
This commit includes the following changes: - Deleted the `package-lock.json` file to streamline dependency management. - Updated the `financials.py` endpoint to return a comprehensive user financial summary, including net balance, total group spending, debts, and credits. - Enhanced the `expense.py` CRUD operations to handle enum values and improve error handling during expense deletion. - Introduced new schemas in `financials.py` for user financial summaries and debt/credit tracking. - Refactored the costs service to improve group balance summary calculations. These changes aim to improve the application's financial tracking capabilities and maintain cleaner dependency management.
1090 lines
38 KiB
Vue
1090 lines
38 KiB
Vue
<template>
|
|
<Dialog v-model="open" class="z-50">
|
|
<div class="expense-creation-sheet">
|
|
<!-- Enhanced Header -->
|
|
<div class="sheet-header">
|
|
<div class="header-content">
|
|
<div class="header-text">
|
|
<Heading :level="2" class="sheet-title">
|
|
{{ isEditing ? 'Edit Expense' : 'Add New Expense' }}
|
|
</Heading>
|
|
<p class="sheet-subtitle">
|
|
{{ isEditing ? 'Update expense details' : 'Split costs fairly with your household' }}
|
|
</p>
|
|
</div>
|
|
<Button variant="ghost" size="sm" @click="open = false" class="close-button">
|
|
<BaseIcon name="heroicons:x-mark-20-solid" class="w-5 h-5" />
|
|
</Button>
|
|
</div>
|
|
|
|
<!-- Enhanced Smart Actions Bar -->
|
|
<div class="smart-actions-bar">
|
|
<Button variant="soft" color="primary" size="sm" @click="showReceiptScanner = true"
|
|
:disabled="!isCameraAvailable" class="smart-action">
|
|
<template #icon-left>
|
|
<BaseIcon name="heroicons:camera-20-solid" />
|
|
</template>
|
|
<span class="action-text">Scan Receipt</span>
|
|
</Button>
|
|
<Button variant="soft" color="success" size="sm" @click="useSmartDefaults" class="smart-action">
|
|
<template #icon-left>
|
|
<BaseIcon name="heroicons:sparkles-20-solid" />
|
|
</template>
|
|
<span class="action-text">Smart Fill</span>
|
|
</Button>
|
|
<Button variant="soft" color="neutral" size="sm" @click="toggleEqualSplit" class="smart-action">
|
|
<template #icon-left>
|
|
<BaseIcon name="heroicons:arrows-right-left-20-solid" />
|
|
</template>
|
|
<span class="action-text">Equal Split</span>
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Progress Indicator -->
|
|
<div class="progress-indicator">
|
|
<div class="progress-track">
|
|
<div class="progress-fill" :style="{ width: `${progressPercentage}%` }"></div>
|
|
</div>
|
|
<span class="progress-text">Step {{ currentStep }} of {{ totalSteps }}</span>
|
|
</div>
|
|
|
|
<!-- Main Form -->
|
|
<form @submit.prevent="handleSubmit" class="sheet-form">
|
|
<!-- Step 1: Basic Details -->
|
|
<div v-if="currentStep === 1" class="form-step">
|
|
<div class="step-header">
|
|
<div class="step-icon">
|
|
<BaseIcon name="heroicons:document-text-20-solid" />
|
|
</div>
|
|
<div class="step-content">
|
|
<h3 class="step-title">What did you spend on?</h3>
|
|
<p class="step-description">Add a clear description to help others understand the expense
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="form-section">
|
|
<Input v-model="form.description" label="Description"
|
|
placeholder="e.g., Weekly groceries, Dinner at Olive Garden..."
|
|
:suggestions="descriptionSuggestions" @focus="loadDescriptionSuggestions" required size="lg"
|
|
class="description-input" />
|
|
|
|
<!-- Quick Suggestions -->
|
|
<div v-if="commonExpenses.length > 0" class="quick-suggestions">
|
|
<h4 class="suggestions-title">Common expenses</h4>
|
|
<div class="suggestion-chips">
|
|
<button v-for="expense in commonExpenses" :key="expense.name" type="button"
|
|
@click="selectCommonExpense(expense)" class="suggestion-chip">
|
|
<BaseIcon :name="expense.icon" class="chip-icon" />
|
|
<span>{{ expense.name }}</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Step 2: Amount & Currency -->
|
|
<div v-if="currentStep === 2" class="form-step">
|
|
<div class="step-header">
|
|
<div class="step-icon">
|
|
<BaseIcon name="heroicons:banknotes-20-solid" />
|
|
</div>
|
|
<div class="step-content">
|
|
<h3 class="step-title">How much did it cost?</h3>
|
|
<p class="step-description">Enter the total amount you paid</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="form-section">
|
|
<div class="amount-input-group">
|
|
<div class="amount-field">
|
|
<Input type="number" step="0.01" min="0.01" v-model="form.total_amount"
|
|
label="Total Amount" placeholder="0.00" :loading="isCalculatingTotal" size="xl"
|
|
required class="amount-input">
|
|
<template #prefix>
|
|
<span class="currency-symbol">{{ currencySymbol }}</span>
|
|
</template>
|
|
</Input>
|
|
</div>
|
|
<div class="currency-field">
|
|
<Listbox v-model="form.currency" class="currency-selector">
|
|
<label class="field-label">Currency</label>
|
|
<ListboxButton class="currency-button">
|
|
<span class="currency-code">{{ form.currency }}</span>
|
|
<BaseIcon name="heroicons:chevron-up-down-20-solid" class="chevron-icon" />
|
|
</ListboxButton>
|
|
<ListboxOptions class="currency-options">
|
|
<ListboxOption v-for="currency in supportedCurrencies" :key="currency.code"
|
|
:value="currency.code" class="currency-option"
|
|
v-slot="{ active, selected }">
|
|
<div class="currency-option-content">
|
|
<div class="currency-main">
|
|
<span class="currency-code">{{ currency.code }}</span>
|
|
<span class="currency-symbol">{{ currency.symbol }}</span>
|
|
</div>
|
|
<span class="currency-name">{{ currency.name }}</span>
|
|
<BaseIcon v-if="selected" name="heroicons:check-20-solid"
|
|
class="selected-icon" />
|
|
</div>
|
|
</ListboxOption>
|
|
</ListboxOptions>
|
|
</Listbox>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Amount Suggestions -->
|
|
<div v-if="suggestedAmounts.length > 0" class="amount-suggestions">
|
|
<h4 class="suggestions-title">Recent amounts</h4>
|
|
<div class="suggestion-amounts">
|
|
<button v-for="amount in suggestedAmounts" :key="amount" type="button"
|
|
@click="form.total_amount = amount" class="amount-suggestion">
|
|
{{ currencySymbol }}{{ amount }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Step 3: Who Paid -->
|
|
<div v-if="currentStep === 3" class="form-step">
|
|
<div class="step-header">
|
|
<div class="step-icon">
|
|
<BaseIcon name="heroicons:user-20-solid" />
|
|
</div>
|
|
<div class="step-content">
|
|
<h3 class="step-title">Who paid for this?</h3>
|
|
<p class="step-description">Select the person who made the payment</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="form-section">
|
|
<div class="user-selection-grid">
|
|
<button v-for="user in availableUsers" :key="user.id" type="button"
|
|
@click="form.paid_by_user_id = user.id" :class="[
|
|
'user-selection-card',
|
|
{ 'selected': form.paid_by_user_id === user.id }
|
|
]">
|
|
<div class="user-avatar">
|
|
{{ user.email.charAt(0).toUpperCase() }}
|
|
</div>
|
|
<div class="user-details">
|
|
<span class="user-name">{{ user.full_name || user.email }}</span>
|
|
<span v-if="user.id === currentUserId" class="user-badge">You</span>
|
|
</div>
|
|
<div class="selection-indicator">
|
|
<BaseIcon v-if="form.paid_by_user_id === user.id"
|
|
name="heroicons:check-circle-20-solid" class="selected-icon" />
|
|
<div v-else class="unselected-circle"></div>
|
|
</div>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Step 4: Split Configuration -->
|
|
<div v-if="currentStep === 4" class="form-step">
|
|
<div class="step-header">
|
|
<div class="step-icon">
|
|
<BaseIcon name="heroicons:calculator-20-solid" />
|
|
</div>
|
|
<div class="step-content">
|
|
<h3 class="step-title">How should this be split?</h3>
|
|
<p class="step-description">Choose how to divide the expense among household members</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="form-section">
|
|
<div class="split-type-grid">
|
|
<Card v-for="splitType in splitTypes" :key="splitType.value"
|
|
:variant="form.split_type === splitType.value ? 'elevated' : 'outlined'"
|
|
:color="form.split_type === splitType.value ? 'primary' : 'neutral'" padding="lg"
|
|
:interactive="true" class="split-type-card"
|
|
:class="{ 'selected': form.split_type === splitType.value }"
|
|
@click="selectSplitType(splitType.value)">
|
|
<div class="split-type-content">
|
|
<div class="split-type-header">
|
|
<div class="split-type-icon">
|
|
<BaseIcon :name="`heroicons:${splitType.icon}-20-solid`" />
|
|
</div>
|
|
<div class="split-type-text">
|
|
<h5 class="split-type-title">{{ splitType.title }}</h5>
|
|
<p class="split-type-description">{{ splitType.description }}</p>
|
|
</div>
|
|
</div>
|
|
<div v-if="splitType.recommended" class="split-type-footer">
|
|
<span class="recommended-badge">
|
|
<BaseIcon name="heroicons:star-20-solid" class="star-icon" />
|
|
Recommended
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
|
|
<!-- Split Preview -->
|
|
<div v-if="splitPreview" class="split-preview">
|
|
<h4 class="preview-title">Split preview</h4>
|
|
<div class="preview-content">
|
|
<p class="preview-text">{{ splitPreview }}</p>
|
|
<div v-if="showAdvancedSplit" class="preview-breakdown">
|
|
<div v-for="split in form.splits_in" :key="split.user_id"
|
|
class="split-item-preview">
|
|
<span class="split-user">{{ getUserById(split.user_id)?.full_name ||
|
|
getUserById(split.user_id)?.email }}</span>
|
|
<span class="split-amount">{{ currencySymbol }}{{ split.amount }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Advanced Split Configuration (if needed) -->
|
|
<div v-if="showAdvancedSplit && currentStep === 4" class="advanced-split-section">
|
|
<Card variant="soft" color="neutral" padding="lg" class="split-config-card">
|
|
<div class="config-header">
|
|
<h4 class="config-title">Customize split amounts</h4>
|
|
<p class="config-description">Adjust individual amounts as needed</p>
|
|
</div>
|
|
|
|
<div class="splits-list">
|
|
<div v-for="(split, index) in form.splits_in" :key="index" class="split-item">
|
|
<div class="split-user-info">
|
|
<div class="user-avatar-mini">
|
|
{{ getUserById(split.user_id)?.email.charAt(0).toUpperCase() }}
|
|
</div>
|
|
<span class="user-name-mini">{{ getUserById(split.user_id)?.full_name ||
|
|
getUserById(split.user_id)?.email }}</span>
|
|
</div>
|
|
<div class="split-amount-field">
|
|
<Input type="number" step="0.01" min="0" v-model="split.amount"
|
|
:placeholder="equalSplitAmount.toFixed(2)" size="sm" class="amount-input-mini">
|
|
<template #prefix>
|
|
<span class="currency-symbol-mini">{{ currencySymbol }}</span>
|
|
</template>
|
|
</Input>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="split-validation">
|
|
<div :class="['validation-status', { 'valid': isSplitValid, 'invalid': !isSplitValid }]">
|
|
<BaseIcon
|
|
:name="isSplitValid ? 'heroicons:check-circle-20-solid' : 'heroicons:exclamation-triangle-20-solid'" />
|
|
<span>{{ splitValidationMessage }}</span>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
|
|
<!-- Navigation Buttons -->
|
|
<div class="form-navigation">
|
|
<Button v-if="currentStep > 1" variant="outline" color="neutral" size="lg" @click="previousStep"
|
|
class="nav-button">
|
|
<template #icon-left>
|
|
<BaseIcon name="heroicons:arrow-left-20-solid" />
|
|
</template>
|
|
Back
|
|
</Button>
|
|
|
|
<div class="nav-spacer"></div>
|
|
|
|
<Button v-if="currentStep < totalSteps" variant="solid" color="primary" size="lg" @click="nextStep"
|
|
:disabled="!canProceedToNextStep" class="nav-button">
|
|
Continue
|
|
<template #icon-right>
|
|
<BaseIcon name="heroicons:arrow-right-20-solid" />
|
|
</template>
|
|
</Button>
|
|
|
|
<Button v-else type="submit" variant="solid" color="success" size="lg" :loading="isSubmitting"
|
|
:disabled="!canSubmit" class="nav-button">
|
|
<template #icon-left>
|
|
<BaseIcon name="heroicons:check-20-solid" />
|
|
</template>
|
|
{{ isEditing ? 'Update Expense' : 'Create Expense' }}
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
|
|
<!-- Receipt Scanner Modal -->
|
|
<Dialog v-model="showReceiptScanner">
|
|
<Card variant="elevated" padding="xl" class="receipt-scanner-modal">
|
|
<div class="scanner-header">
|
|
<h3 class="scanner-title">Scan Your Receipt</h3>
|
|
<Button variant="ghost" size="sm" @click="showReceiptScanner = false">
|
|
<BaseIcon name="heroicons:x-mark-20-solid" />
|
|
</Button>
|
|
</div>
|
|
|
|
<div class="scanner-content">
|
|
<div class="scanner-preview">
|
|
<div class="camera-placeholder">
|
|
<BaseIcon name="heroicons:camera-20-solid" class="camera-icon" />
|
|
<p>Camera preview will appear here</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="scanner-actions">
|
|
<Button variant="solid" color="primary" size="lg" class="capture-button">
|
|
<template #icon-left>
|
|
<BaseIcon name="heroicons:camera-20-solid" />
|
|
</template>
|
|
Capture Receipt
|
|
</Button>
|
|
<p class="scanner-help">Position your receipt clearly in the camera view</p>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
</Dialog>
|
|
</div>
|
|
</Dialog>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { reactive, ref, watch, computed, onMounted } from 'vue'
|
|
import { Listbox, ListboxButton, ListboxOption, ListboxOptions } from '@headlessui/vue'
|
|
import { Dialog, Heading, Input, Button, Card, Switch } from '@/components/ui'
|
|
import BaseIcon from '@/components/BaseIcon.vue'
|
|
import { useExpenses } from '@/composables/useExpenses'
|
|
import { useAuthStore } from '@/stores/auth'
|
|
import type { Expense, ExpenseSplitCreate } from '@/types/expense'
|
|
import type { UserPublic } from '@/types/user'
|
|
|
|
interface ExpenseForm {
|
|
description: string
|
|
total_amount: string
|
|
currency: string
|
|
split_type: string
|
|
paid_by_user_id: number
|
|
isRecurring: boolean
|
|
recurrence_pattern: {
|
|
type: 'daily' | 'weekly' | 'monthly' | 'yearly'
|
|
interval: string
|
|
}
|
|
splits_in: ExpenseSplitCreate[]
|
|
}
|
|
|
|
const open = defineModel<boolean>('modelValue', { default: false })
|
|
|
|
// Props
|
|
const props = defineProps<{ expense?: Expense | null }>()
|
|
|
|
const authStore = useAuthStore()
|
|
const { createExpense, updateExpense } = useExpenses()
|
|
|
|
// State
|
|
const submitting = ref(false)
|
|
const showReceiptScanner = ref(false)
|
|
const isCalculatingTotal = ref(false)
|
|
const availableUsers = ref<UserPublic[]>([])
|
|
const descriptionSuggestions = ref<string[]>([])
|
|
|
|
const form = reactive<ExpenseForm>({
|
|
description: '',
|
|
total_amount: '',
|
|
currency: 'USD',
|
|
split_type: 'EQUAL',
|
|
paid_by_user_id: authStore.user?.id || 0,
|
|
isRecurring: false,
|
|
recurrence_pattern: {
|
|
type: 'monthly',
|
|
interval: '1'
|
|
},
|
|
splits_in: []
|
|
})
|
|
|
|
// Computed
|
|
const isEditing = computed(() => !!props.expense)
|
|
const currentUserId = computed(() => authStore.user?.id)
|
|
|
|
const supportedCurrencies = computed(() => [
|
|
{ code: 'USD', name: 'US Dollar', symbol: '$' },
|
|
{ code: 'EUR', name: 'Euro', symbol: '€' },
|
|
{ code: 'GBP', name: 'British Pound', symbol: '£' },
|
|
{ code: 'CAD', name: 'Canadian Dollar', symbol: 'C$' }
|
|
])
|
|
|
|
const currencySymbol = computed(() => {
|
|
return supportedCurrencies.value.find(c => c.code === form.currency)?.symbol || '$'
|
|
})
|
|
|
|
const splitTypes = computed(() => [
|
|
{
|
|
value: 'EQUAL',
|
|
title: 'Equal Split',
|
|
description: 'Split equally among all participants',
|
|
icon: 'pie_chart',
|
|
recommended: true
|
|
},
|
|
{
|
|
value: 'EXACT_AMOUNTS',
|
|
title: 'Exact Amounts',
|
|
description: 'Specify exact amount for each person',
|
|
icon: 'calculate',
|
|
recommended: false
|
|
},
|
|
{
|
|
value: 'PERCENTAGE',
|
|
title: 'Percentage',
|
|
description: 'Split by percentage for each person',
|
|
icon: 'percent',
|
|
recommended: false
|
|
},
|
|
{
|
|
value: 'SHARES',
|
|
title: 'Shares',
|
|
description: 'Split by number of shares',
|
|
icon: 'share',
|
|
recommended: false
|
|
}
|
|
])
|
|
|
|
const showAdvancedSplit = computed(() =>
|
|
['EXACT_AMOUNTS', 'PERCENTAGE', 'SHARES'].includes(form.split_type)
|
|
)
|
|
|
|
const selectedPayer = computed(() =>
|
|
availableUsers.value.find(user => user.id === form.paid_by_user_id)
|
|
)
|
|
|
|
const recurrenceOptions = computed(() => [
|
|
{ value: 'daily', label: 'Daily' },
|
|
{ value: 'weekly', label: 'Weekly' },
|
|
{ value: 'monthly', label: 'Monthly' },
|
|
{ value: 'yearly', label: 'Yearly' }
|
|
])
|
|
|
|
const splitPreview = computed(() => {
|
|
if (form.split_type === 'EQUAL') {
|
|
return `Split equally among ${availableUsers.value.length} people`
|
|
}
|
|
if (form.splits_in.length === 0) return 'No splits configured'
|
|
|
|
const validSplits = form.splits_in.filter(s => s.user_id > 0)
|
|
return `${validSplits.length} people configured`
|
|
})
|
|
|
|
const splitValidationError = computed(() => {
|
|
if (!showAdvancedSplit.value) return null
|
|
|
|
const totalAmount = parseFloat(form.total_amount) || 0
|
|
|
|
if (form.split_type === 'EXACT_AMOUNTS') {
|
|
const totalSplit = form.splits_in.reduce((sum, split) => sum + (parseFloat(split.owed_amount as string) || 0), 0)
|
|
if (Math.abs(totalSplit - totalAmount) > 0.01) {
|
|
return `Split amounts ($${totalSplit.toFixed(2)}) must equal total expense ($${totalAmount.toFixed(2)})`
|
|
}
|
|
}
|
|
|
|
if (form.split_type === 'PERCENTAGE') {
|
|
const totalPercentage = form.splits_in.reduce((sum, split) => sum + (parseFloat(split.share_percentage as string) || 0), 0)
|
|
if (Math.abs(totalPercentage - 100) > 0.1) {
|
|
return `Percentages must total 100% (currently ${totalPercentage.toFixed(1)}%)`
|
|
}
|
|
}
|
|
|
|
return null
|
|
})
|
|
|
|
const isFormValid = computed(() => {
|
|
return form.description.trim() &&
|
|
form.total_amount &&
|
|
parseFloat(form.total_amount) > 0 &&
|
|
form.paid_by_user_id > 0 &&
|
|
!splitValidationError.value
|
|
})
|
|
|
|
const isCameraAvailable = computed(() => {
|
|
return 'mediaDevices' in navigator && 'getUserMedia' in navigator.mediaDevices
|
|
})
|
|
|
|
// Methods
|
|
const getUserById = (id: number) => {
|
|
return availableUsers.value.find(user => user.id === id)
|
|
}
|
|
|
|
const getRecurrenceLabel = (type: string) => {
|
|
return recurrenceOptions.value.find(option => option.value === type)?.label || type
|
|
}
|
|
|
|
const loadDescriptionSuggestions = () => {
|
|
// Mock suggestions - would be replaced with real data
|
|
descriptionSuggestions.value = [
|
|
'Grocery shopping',
|
|
'Dinner at restaurant',
|
|
'Gas for car',
|
|
'Utility bill',
|
|
'Internet bill',
|
|
'Rent payment'
|
|
]
|
|
}
|
|
|
|
const useSmartDefaults = () => {
|
|
// Smart defaults based on context
|
|
if (!form.description) {
|
|
const hour = new Date().getHours()
|
|
if (hour >= 6 && hour < 11) {
|
|
form.description = 'Breakfast'
|
|
} else if (hour >= 11 && hour < 16) {
|
|
form.description = 'Lunch'
|
|
} else if (hour >= 16 && hour < 22) {
|
|
form.description = 'Dinner'
|
|
} else {
|
|
form.description = 'Late night snacks'
|
|
}
|
|
}
|
|
|
|
// Set default split to equal if not already configured
|
|
if (!form.split_type || form.split_type === 'EQUAL') {
|
|
form.split_type = 'EQUAL'
|
|
}
|
|
|
|
// Set default currency based on user locale
|
|
if (!form.currency) {
|
|
form.currency = 'USD' // Could be detected from locale
|
|
}
|
|
}
|
|
|
|
const addSplit = () => {
|
|
form.splits_in.push({
|
|
user_id: 0,
|
|
owed_amount: form.split_type === 'EXACT_AMOUNTS' ? 0 : undefined,
|
|
share_percentage: form.split_type === 'PERCENTAGE' ? 0 : undefined,
|
|
share_units: form.split_type === 'SHARES' ? 0 : undefined
|
|
})
|
|
}
|
|
|
|
const removeSplit = (index: number) => {
|
|
if (form.splits_in.length > 1) {
|
|
form.splits_in.splice(index, 1)
|
|
}
|
|
}
|
|
|
|
const initializeSplits = () => {
|
|
if (showAdvancedSplit.value && form.splits_in.length === 0) {
|
|
addSplit()
|
|
}
|
|
}
|
|
|
|
// Watch for split type changes
|
|
watch(() => form.split_type, () => {
|
|
form.splits_in = []
|
|
if (showAdvancedSplit.value) {
|
|
initializeSplits()
|
|
}
|
|
})
|
|
|
|
// Sync form when editing
|
|
watch(
|
|
() => props.expense,
|
|
(val) => {
|
|
if (val) {
|
|
form.description = val.description
|
|
form.total_amount = val.total_amount
|
|
form.currency = val.currency
|
|
form.split_type = val.split_type
|
|
form.paid_by_user_id = val.paid_by_user_id
|
|
form.isRecurring = val.isRecurring || false
|
|
// Initialize splits if needed
|
|
if (showAdvancedSplit.value) {
|
|
initializeSplits()
|
|
}
|
|
} else {
|
|
// Reset form
|
|
form.description = ''
|
|
form.total_amount = ''
|
|
form.currency = 'USD'
|
|
form.split_type = 'EQUAL'
|
|
form.paid_by_user_id = authStore.user?.id || 0
|
|
form.isRecurring = false
|
|
form.splits_in = []
|
|
}
|
|
},
|
|
{ immediate: true }
|
|
)
|
|
|
|
const handleSubmit = async () => {
|
|
if (!isFormValid.value || submitting.value) return
|
|
|
|
submitting.value = true
|
|
try {
|
|
const expenseData = {
|
|
...form,
|
|
total_amount: parseFloat(form.total_amount)
|
|
}
|
|
|
|
if (isEditing.value && props.expense) {
|
|
await updateExpense(props.expense.id, {
|
|
...expenseData,
|
|
version: props.expense.version
|
|
})
|
|
} else {
|
|
await createExpense(expenseData as any)
|
|
}
|
|
|
|
open.value = false
|
|
} catch (e) {
|
|
console.error('Failed to save expense', e)
|
|
} finally {
|
|
submitting.value = false
|
|
}
|
|
}
|
|
|
|
// Mock function to load available users
|
|
const loadAvailableUsers = async () => {
|
|
// This would be replaced with actual API call
|
|
availableUsers.value = [
|
|
{ id: 1, email: 'user1@example.com', full_name: 'John Doe' },
|
|
{ id: 2, email: 'user2@example.com', full_name: 'Jane Smith' }
|
|
]
|
|
|
|
if (authStore.user) {
|
|
const currentUserExists = availableUsers.value.some(user => user.id === authStore.user?.id)
|
|
if (!currentUserExists) {
|
|
availableUsers.value.unshift(authStore.user)
|
|
}
|
|
}
|
|
}
|
|
|
|
onMounted(() => {
|
|
loadAvailableUsers()
|
|
loadDescriptionSuggestions()
|
|
})
|
|
</script>
|
|
|
|
<style scoped>
|
|
.expense-creation-sheet {
|
|
@apply bg-surface-primary rounded-radius-lg shadow-elevation-high w-full max-w-2xl max-h-[90vh] overflow-hidden;
|
|
}
|
|
|
|
.sheet-header {
|
|
@apply p-spacing-lg border-b border-border-secondary space-y-spacing-md;
|
|
}
|
|
|
|
.header-content {
|
|
@apply flex items-center justify-between;
|
|
}
|
|
|
|
.header-text {
|
|
@apply space-y-spacing-xs;
|
|
}
|
|
|
|
.sheet-title {
|
|
@apply text-heading-lg font-semibold text-text-primary;
|
|
}
|
|
|
|
.sheet-subtitle {
|
|
@apply text-body-sm text-text-secondary;
|
|
}
|
|
|
|
.close-button {
|
|
@apply flex-shrink-0;
|
|
}
|
|
|
|
.smart-actions-bar {
|
|
@apply flex items-center gap-spacing-sm;
|
|
}
|
|
|
|
.smart-action {
|
|
@apply transition-all duration-micro hover:scale-105;
|
|
}
|
|
|
|
.sheet-form {
|
|
@apply p-6 space-y-6 overflow-y-auto max-h-[calc(90vh-200px)];
|
|
}
|
|
|
|
.form-section {
|
|
@apply space-y-3;
|
|
}
|
|
|
|
.section-header {
|
|
@apply space-y-2;
|
|
}
|
|
|
|
.section-title {
|
|
@apply font-semibold text-neutral-900 dark:text-neutral-100;
|
|
}
|
|
|
|
.section-description {
|
|
@apply text-sm text-neutral-500 dark:text-neutral-400;
|
|
}
|
|
|
|
.field-label {
|
|
@apply block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2;
|
|
}
|
|
|
|
.amount-input-group {
|
|
@apply flex gap-3;
|
|
}
|
|
|
|
.amount-field {
|
|
@apply flex-1;
|
|
}
|
|
|
|
.currency-field {
|
|
@apply w-24;
|
|
}
|
|
|
|
.currency-selector {
|
|
@apply relative;
|
|
}
|
|
|
|
.currency-button {
|
|
@apply flex items-center justify-between w-full px-3 py-2 bg-neutral-100 dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-lg;
|
|
@apply text-sm font-medium text-neutral-700 dark:text-neutral-300;
|
|
@apply hover:bg-neutral-200 dark:hover:bg-neutral-700 transition-colors duration-micro;
|
|
}
|
|
|
|
.currency-options {
|
|
@apply absolute top-full left-0 right-0 mt-1 bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-lg shadow-medium z-50;
|
|
@apply max-h-48 overflow-y-auto;
|
|
}
|
|
|
|
.currency-option {
|
|
@apply flex items-center justify-between px-3 py-2 cursor-pointer transition-colors duration-micro;
|
|
@apply hover:bg-neutral-50 dark:hover:bg-neutral-700;
|
|
@apply ui-selected:bg-primary-50 ui-selected:text-primary-900 dark:ui-selected:bg-primary-900 dark:ui-selected:text-primary-100;
|
|
}
|
|
|
|
.currency-option-content {
|
|
@apply flex items-center gap-2;
|
|
}
|
|
|
|
.currency-code {
|
|
@apply font-medium text-neutral-900 dark:text-neutral-100;
|
|
}
|
|
|
|
.currency-name {
|
|
@apply text-xs text-neutral-500 dark:text-neutral-400;
|
|
}
|
|
|
|
.selected-icon {
|
|
@apply w-4 h-4 text-primary-500;
|
|
}
|
|
|
|
.paid-by-selector {
|
|
@apply relative;
|
|
}
|
|
|
|
.paid-by-button {
|
|
@apply flex items-center justify-between w-full px-4 py-3 bg-neutral-50 dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-lg;
|
|
@apply hover:bg-neutral-100 dark:hover:bg-neutral-700 transition-colors duration-micro;
|
|
}
|
|
|
|
.selected-user {
|
|
@apply flex items-center gap-3;
|
|
}
|
|
|
|
.user-info {
|
|
@apply flex items-center gap-3;
|
|
}
|
|
|
|
.user-avatar {
|
|
@apply w-8 h-8 bg-primary-100 dark:bg-primary-900 text-primary-800 dark:text-primary-200 rounded-full flex items-center justify-center;
|
|
@apply text-sm font-semibold;
|
|
}
|
|
|
|
.user-name {
|
|
@apply font-medium text-neutral-900 dark:text-neutral-100;
|
|
}
|
|
|
|
.placeholder {
|
|
@apply text-neutral-500 dark:text-neutral-400;
|
|
}
|
|
|
|
.paid-by-options {
|
|
@apply absolute top-full left-0 right-0 mt-1 bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-lg shadow-medium z-50;
|
|
@apply max-h-64 overflow-y-auto;
|
|
}
|
|
|
|
.user-option {
|
|
@apply flex items-center gap-3 px-4 py-3 cursor-pointer transition-colors duration-micro;
|
|
@apply hover:bg-neutral-50 dark:hover:bg-neutral-700;
|
|
@apply ui-selected:bg-primary-50 ui-selected:text-primary-900 dark:ui-selected:bg-primary-900 dark:ui-selected:text-primary-100;
|
|
}
|
|
|
|
.user-option-content {
|
|
@apply flex items-center gap-2;
|
|
}
|
|
|
|
.user-details {
|
|
@apply flex items-center gap-2;
|
|
}
|
|
|
|
.user-badge {
|
|
@apply px-2 py-0.5 bg-primary-100 dark:bg-primary-900 text-primary-800 dark:text-primary-200 rounded-full text-xs font-medium;
|
|
}
|
|
|
|
.split-type-grid {
|
|
@apply grid grid-cols-1 sm:grid-cols-2 gap-3;
|
|
}
|
|
|
|
.split-type-card {
|
|
@apply p-4 border-2 border-neutral-200 dark:border-neutral-700 rounded-lg cursor-pointer transition-all duration-micro;
|
|
@apply hover:border-primary-300 dark:hover:border-primary-600 hover:shadow-soft;
|
|
}
|
|
|
|
.split-type-card.selected {
|
|
@apply border-primary-500 dark:border-primary-400 bg-primary-50 dark:bg-primary-950;
|
|
}
|
|
|
|
.split-type-content {
|
|
@apply space-y-1;
|
|
}
|
|
|
|
.split-type-header {
|
|
@apply flex items-center gap-3;
|
|
}
|
|
|
|
.split-type-icon {
|
|
@apply w-10 h-10 bg-neutral-100 dark:bg-neutral-800 rounded-lg flex items-center justify-center mb-3;
|
|
}
|
|
|
|
.split-type-text {
|
|
@apply space-y-1;
|
|
}
|
|
|
|
.split-type-title {
|
|
@apply font-semibold text-neutral-900 dark:text-neutral-100;
|
|
}
|
|
|
|
.split-type-description {
|
|
@apply text-sm text-neutral-600 dark:text-neutral-400;
|
|
}
|
|
|
|
.split-type-footer {
|
|
@apply flex items-center gap-2;
|
|
}
|
|
|
|
.recommended-badge {
|
|
@apply inline-block px-2 py-1 bg-success-100 dark:bg-success-900 text-success-800 dark:text-success-200 rounded-full text-xs font-medium mt-2;
|
|
}
|
|
|
|
.split-config-card {
|
|
@apply space-y-4;
|
|
}
|
|
|
|
.splits-list {
|
|
@apply space-y-3;
|
|
}
|
|
|
|
.split-item {
|
|
@apply flex items-center gap-3;
|
|
}
|
|
|
|
.split-user-field {
|
|
@apply flex-1;
|
|
}
|
|
|
|
.split-user-button {
|
|
@apply flex items-center justify-between w-full px-3 py-2 bg-neutral-50 dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-lg;
|
|
@apply text-sm hover:bg-neutral-100 dark:hover:bg-neutral-700 transition-colors duration-micro;
|
|
}
|
|
|
|
.selected-user-mini {
|
|
@apply flex items-center gap-2;
|
|
}
|
|
|
|
.user-avatar-mini {
|
|
@apply w-6 h-6 bg-primary-100 dark:bg-primary-900 text-primary-800 dark:text-primary-200 rounded-full flex items-center justify-center;
|
|
@apply text-xs font-semibold;
|
|
}
|
|
|
|
.user-name-mini {
|
|
@apply font-medium text-neutral-900 dark:text-neutral-100;
|
|
}
|
|
|
|
.split-user-options {
|
|
@apply absolute top-full left-0 right-0 mt-1 bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-lg shadow-medium z-50;
|
|
}
|
|
|
|
.user-option-mini {
|
|
@apply flex items-center gap-2 px-3 py-2 cursor-pointer transition-colors duration-micro;
|
|
@apply hover:bg-neutral-50 dark:hover:bg-neutral-700;
|
|
@apply ui-selected:bg-primary-50 ui-selected:text-primary-900 dark:ui-selected:bg-primary-900 dark:ui-selected:text-primary-100;
|
|
}
|
|
|
|
.user-option-mini-content {
|
|
@apply flex items-center gap-2;
|
|
}
|
|
|
|
.split-amount-field {
|
|
@apply w-32;
|
|
}
|
|
|
|
.split-amount-input {
|
|
@apply w-full;
|
|
}
|
|
|
|
.remove-split-button {
|
|
@apply flex-shrink-0;
|
|
}
|
|
|
|
.add-split-button {
|
|
@apply w-full;
|
|
}
|
|
|
|
.split-validation-error {
|
|
@apply flex items-center gap-2 p-3 bg-error-50 dark:bg-error-950 border border-error-200 dark:border-error-800 rounded-lg;
|
|
@apply text-sm text-error-800 dark:text-error-200;
|
|
}
|
|
|
|
.recurring-toggle {
|
|
@apply space-y-2;
|
|
}
|
|
|
|
.recurring-config {
|
|
@apply space-y-4 mt-4;
|
|
}
|
|
|
|
.recurring-options {
|
|
@apply grid grid-cols-1 sm:grid-cols-2 gap-4;
|
|
}
|
|
|
|
.recurrence-field {
|
|
@apply relative;
|
|
}
|
|
|
|
.recurrence-button {
|
|
@apply flex items-center justify-between w-full px-3 py-2 bg-neutral-100 dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-lg;
|
|
@apply text-sm font-medium text-neutral-700 dark:text-neutral-300;
|
|
@apply hover:bg-neutral-200 dark:hover:bg-neutral-700 transition-colors duration-micro;
|
|
}
|
|
|
|
.recurrence-options {
|
|
@apply absolute top-full left-0 right-0 mt-1 bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-lg shadow-medium z-50;
|
|
}
|
|
|
|
.recurrence-option {
|
|
@apply px-3 py-2 cursor-pointer transition-colors duration-micro;
|
|
@apply hover:bg-neutral-50 dark:hover:bg-neutral-700;
|
|
@apply ui-selected:bg-primary-50 ui-selected:text-primary-900 dark:ui-selected:bg-primary-900 dark:ui-selected:text-primary-100;
|
|
}
|
|
|
|
.interval-field {
|
|
@apply w-full;
|
|
}
|
|
|
|
.interval-input {
|
|
@apply w-full;
|
|
}
|
|
|
|
.form-actions {
|
|
@apply flex items-center justify-end gap-3 pt-6 border-t border-neutral-200 dark:border-neutral-700;
|
|
}
|
|
|
|
.receipt-scanner-modal {
|
|
@apply z-60;
|
|
}
|
|
|
|
.scanner-card {
|
|
@apply bg-white dark:bg-neutral-800 rounded-lg shadow-xl w-full max-w-2xl max-h-[90vh] overflow-hidden;
|
|
}
|
|
|
|
.scanner-header {
|
|
@apply p-6 border-b border-neutral-200 dark:border-neutral-700 space-y-4;
|
|
}
|
|
|
|
.scanner-title {
|
|
@apply text-xl font-semibold text-neutral-900 dark:text-neutral-100;
|
|
}
|
|
|
|
.scanner-content {
|
|
@apply p-6 space-y-6 overflow-y-auto max-h-[calc(90vh-200px)];
|
|
}
|
|
|
|
.scanner-placeholder {
|
|
@apply text-center py-12 space-y-4;
|
|
}
|
|
|
|
.scanner-icon {
|
|
@apply w-16 h-16 bg-neutral-100 dark:bg-neutral-800 rounded-full flex items-center justify-center mx-auto;
|
|
}
|
|
|
|
.scanner-placeholder-title {
|
|
@apply text-2xl font-semibold text-neutral-900 dark:text-neutral-100;
|
|
}
|
|
|
|
.scanner-placeholder-description {
|
|
@apply text-sm text-neutral-600 dark:text-neutral-400;
|
|
}
|
|
|
|
.scanner-features {
|
|
@apply flex items-center justify-center gap-4;
|
|
}
|
|
|
|
.scanner-feature {
|
|
@apply flex items-center gap-2;
|
|
}
|
|
|
|
.feature-icon {
|
|
@apply w-4 h-4 text-neutral-500 dark:text-neutral-400;
|
|
}
|
|
|
|
.scanner-actions {
|
|
@apply flex justify-end mt-6;
|
|
}
|
|
|
|
.currency-symbol,
|
|
.percentage-symbol {
|
|
@apply text-neutral-500 dark:text-neutral-400 font-medium;
|
|
}
|
|
|
|
.progress-indicator {
|
|
@apply flex items-center gap-4;
|
|
}
|
|
|
|
.progress-track {
|
|
@apply h-2 bg-neutral-200 dark:bg-neutral-700 rounded-full overflow-hidden;
|
|
}
|
|
|
|
.progress-fill {
|
|
@apply h-2 bg-primary-500 rounded-full;
|
|
}
|
|
|
|
.progress-text {
|
|
@apply text-sm text-neutral-500 dark:text-neutral-400;
|
|
}
|
|
|
|
.receipt-scanner-modal {
|
|
@apply z-60;
|
|
}
|
|
|
|
.scanner-preview {
|
|
@apply flex items-center justify-center;
|
|
}
|
|
|
|
.camera-placeholder {
|
|
@apply text-center py-12 space-y-4;
|
|
}
|
|
|
|
.capture-button {
|
|
@apply bg-primary-500 text-white px-4 py-2 rounded-lg hover:bg-primary-600 transition-colors duration-micro;
|
|
}
|
|
|
|
.scanner-help {
|
|
@apply text-sm text-neutral-500 dark:text-neutral-400;
|
|
}
|
|
|
|
.receipt-scanner-modal {
|
|
@apply z-60;
|
|
}
|
|
|
|
.scanner-preview {
|
|
@apply flex items-center justify-center;
|
|
}
|
|
|
|
.camera-placeholder {
|
|
@apply text-center py-12 space-y-4;
|
|
}
|
|
|
|
.capture-button {
|
|
@apply bg-primary-500 text-white px-4 py-2 rounded-lg hover:bg-primary-600 transition-colors duration-micro;
|
|
}
|
|
|
|
.scanner-help {
|
|
@apply text-sm text-neutral-500 dark:text-neutral-400;
|
|
}
|
|
</style> |