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.
361 lines
11 KiB
Vue
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>
|