webapp/src/modules/expenses/components/AddExpense.vue
padreug 8dad92f0e5 Enables currency selection for expenses
Allows users to select a currency when adding an expense.

Fetches available currencies from the backend and displays them
in a dropdown menu, defaulting to EUR if the fetch fails.
The expense submission process now includes the selected currency.
2025-11-07 21:39:16 +01:00

361 lines
11 KiB
Vue

<template>
<div class="bg-card border border-border rounded-lg shadow-lg overflow-hidden">
<!-- Header -->
<div class="flex items-center justify-between p-4 border-b border-border">
<div class="flex items-center gap-2">
<DollarSign class="h-5 w-5 text-primary" />
<h3 class="font-semibold text-foreground">Add Expense</h3>
</div>
<Button
variant="ghost"
size="icon"
@click="$emit('close')"
>
<X class="h-4 w-4" />
</Button>
</div>
<!-- Form -->
<div class="p-4 space-y-4">
<!-- Step indicator -->
<div class="flex items-center justify-center gap-2 mb-4">
<div
:class="[
'flex items-center justify-center w-8 h-8 rounded-full font-medium text-sm transition-colors',
currentStep === 1
? 'bg-primary text-primary-foreground'
: selectedAccount
? 'bg-primary/20 text-primary'
: 'bg-muted text-muted-foreground'
]"
>
1
</div>
<div class="w-12 h-px bg-border" />
<div
:class="[
'flex items-center justify-center w-8 h-8 rounded-full font-medium text-sm transition-colors',
currentStep === 2
? 'bg-primary text-primary-foreground'
: 'bg-muted text-muted-foreground'
]"
>
2
</div>
</div>
<!-- Step 1: Account Selection -->
<div v-if="currentStep === 1">
<p class="text-sm text-muted-foreground mb-4">
Select the account for this expense
</p>
<AccountSelector
v-model="selectedAccount"
root-account="Expenses"
@account-selected="handleAccountSelected"
/>
</div>
<!-- Step 2: Expense Details -->
<div v-if="currentStep === 2">
<form @submit="onSubmit" class="space-y-4">
<!-- Description -->
<FormField v-slot="{ componentField }" name="description">
<FormItem>
<FormLabel>Description *</FormLabel>
<FormControl>
<Textarea
placeholder="e.g., Groceries for community dinner"
v-bind="componentField"
rows="3"
/>
</FormControl>
<FormDescription>
Describe what this expense was for
</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<!-- Amount -->
<FormField v-slot="{ componentField }" name="amount">
<FormItem>
<FormLabel>Amount *</FormLabel>
<FormControl>
<Input
type="number"
placeholder="0.00"
v-bind="componentField"
min="0.01"
step="0.01"
/>
</FormControl>
<FormDescription>
Amount in selected currency
</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<!-- Currency -->
<FormField v-slot="{ componentField }" name="currency">
<FormItem>
<FormLabel>Currency *</FormLabel>
<Select v-bind="componentField">
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select currency" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem
v-for="currency in availableCurrencies"
:key="currency"
:value="currency"
>
{{ currency }}
</SelectItem>
</SelectContent>
</Select>
<FormDescription>
Currency for this expense
</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<!-- Reference (optional) -->
<FormField v-slot="{ componentField }" name="reference">
<FormItem>
<FormLabel>Reference</FormLabel>
<FormControl>
<Input
placeholder="e.g., Invoice #123, Receipt #456"
v-bind="componentField"
/>
</FormControl>
<FormDescription>
Optional reference number or note
</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<!-- Convert to equity checkbox -->
<FormField v-slot="{ value, handleChange }" name="isEquity">
<FormItem>
<div class="flex items-center space-x-2">
<FormControl>
<Checkbox
:model-value="value"
@update:model-value="handleChange"
:disabled="isSubmitting"
/>
</FormControl>
<div class="space-y-1 leading-none">
<FormLabel>Convert to equity contribution</FormLabel>
<FormDescription>
Instead of cash reimbursement, increase your equity stake by this amount
</FormDescription>
</div>
</div>
</FormItem>
</FormField>
<!-- Selected account info -->
<div class="p-3 rounded-lg bg-muted/50">
<p class="text-sm font-medium text-foreground mb-1">
Account: {{ selectedAccount?.name }}
</p>
<p
v-if="selectedAccount?.description"
class="text-xs text-muted-foreground"
>
{{ selectedAccount.description }}
</p>
</div>
<!-- Action buttons -->
<div class="flex items-center gap-2 pt-2">
<Button
type="button"
variant="outline"
@click="currentStep = 1"
:disabled="isSubmitting"
class="flex-1"
>
<ChevronLeft class="h-4 w-4 mr-1" />
Back
</Button>
<Button
type="submit"
:disabled="isSubmitting || !isFormValid"
class="flex-1"
>
<Loader2
v-if="isSubmitting"
class="h-4 w-4 mr-2 animate-spin"
/>
<span>{{ isSubmitting ? 'Submitting...' : 'Submit Expense' }}</span>
</Button>
</div>
</form>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import * as z from 'zod'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Checkbox } from '@/components/ui/checkbox'
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { DollarSign, X, ChevronLeft, Loader2 } from 'lucide-vue-next'
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import { useAuth } from '@/composables/useAuthService'
import { useToast } from '@/core/composables/useToast'
import type { ExpensesAPI } from '../services/ExpensesAPI'
import type { Account } from '../types'
import AccountSelector from './AccountSelector.vue'
interface Emits {
(e: 'close'): void
(e: 'expense-submitted'): void
(e: 'action-complete'): void
}
const emit = defineEmits<Emits>()
// Inject services and composables
const expensesAPI = injectService<ExpensesAPI>(SERVICE_TOKENS.EXPENSES_API)
const { user } = useAuth()
const toast = useToast()
// Component state
const currentStep = ref(1)
const selectedAccount = ref<Account | null>(null)
const isSubmitting = ref(false)
const availableCurrencies = ref<string[]>([])
const loadingCurrencies = ref(true)
// Form schema
const formSchema = toTypedSchema(
z.object({
description: z.string().min(1, 'Description is required').max(500, 'Description too long'),
amount: z.coerce.number().min(0.01, 'Amount must be at least 0.01'),
currency: z.string().min(1, 'Currency is required'),
reference: z.string().max(100, 'Reference too long').optional(),
isEquity: z.boolean().default(false)
})
)
// Set up form
const form = useForm({
validationSchema: formSchema,
initialValues: {
description: '',
amount: 0,
currency: 'EUR',
reference: '',
isEquity: false
}
})
const { resetForm, meta } = form
const isFormValid = computed(() => meta.value.valid)
/**
* Fetch available currencies on component mount
*/
onMounted(async () => {
try {
loadingCurrencies.value = true
const currencies = await expensesAPI.getCurrencies()
availableCurrencies.value = currencies
console.log('[AddExpense] Loaded currencies:', currencies)
} catch (error) {
console.error('[AddExpense] Failed to load currencies:', error)
toast.error('Failed to load currencies', { description: 'Please check your connection and try again' })
availableCurrencies.value = []
} finally {
loadingCurrencies.value = false
}
})
/**
* Handle account selection
*/
function handleAccountSelected(account: Account) {
selectedAccount.value = account
currentStep.value = 2
}
/**
* Submit expense
*/
const onSubmit = form.handleSubmit(async (values) => {
if (!selectedAccount.value) {
console.error('[AddExpense] No account selected')
toast.error('No account selected', { description: 'Please select an account first' })
return
}
// Get wallet key from first wallet (invoice key for submission)
const wallet = user.value?.wallets?.[0]
if (!wallet || !wallet.inkey) {
toast.error('No wallet available', { description: 'Please log in to submit expenses' })
return
}
isSubmitting.value = true
try {
await expensesAPI.submitExpense(wallet.inkey, {
description: values.description,
amount: values.amount,
expense_account: selectedAccount.value.name,
is_equity: values.isEquity,
user_wallet: wallet.id,
reference: values.reference,
currency: values.currency
})
// Show success message
toast.success('Expense submitted', { description: 'Your expense is pending admin approval' })
// Reset form and close
resetForm()
selectedAccount.value = null
currentStep.value = 1
emit('expense-submitted')
emit('action-complete')
emit('close')
} catch (error) {
console.error('[AddExpense] Error submitting expense:', error)
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
toast.error('Submission failed', { description: errorMessage })
} finally {
isSubmitting.value = false
}
})
</script>