Compare commits
11 commits
c2e8fca613
...
89145b39cd
| Author | SHA1 | Date | |
|---|---|---|---|
| 89145b39cd | |||
| bfa5118fbe | |||
| 30ad4cf512 | |||
| 26a89c58dd | |||
| 84456a849e | |||
| 0e03a424cb | |||
| 5902aed431 | |||
| 4fee9c015d | |||
| 0390ecd4a0 | |||
| 31cefac183 | |||
| d33359a901 |
8 changed files with 573 additions and 55 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -43,3 +43,4 @@ certs
|
||||||
.obsidian
|
.obsidian
|
||||||
|
|
||||||
.claude/
|
.claude/
|
||||||
|
.playwright-mcp/
|
||||||
|
|
|
||||||
12
.mcp.json
Normal file
12
.mcp.json
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"playwright": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": [
|
||||||
|
"@playwright/mcp@latest",
|
||||||
|
"--caps",
|
||||||
|
"devtools"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,219 @@
|
||||||
|
<template>
|
||||||
|
<Dialog :open="true" @update:open="(open) => !open && handleDialogClose()">
|
||||||
|
<DialogContent class="max-w-2xl max-h-[85vh] overflow-hidden flex flex-col p-0 gap-0 top-[5%] translate-y-0 sm:top-[5%] sm:translate-y-0">
|
||||||
|
<!-- Success State -->
|
||||||
|
<div v-if="showSuccessDialog" class="flex flex-col items-center justify-center p-8 space-y-6">
|
||||||
|
<div class="rounded-full bg-green-100 dark:bg-green-900/20 p-4">
|
||||||
|
<CheckCircle2 class="h-12 w-12 text-green-600 dark:text-green-400" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-center space-y-3">
|
||||||
|
<h2 class="text-2xl font-bold">Income Submitted Successfully!</h2>
|
||||||
|
|
||||||
|
<div class="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-orange-100 dark:bg-orange-900/20 border border-orange-200 dark:border-orange-800">
|
||||||
|
<Clock class="h-4 w-4 text-orange-600 dark:text-orange-400" />
|
||||||
|
<span class="text-sm font-medium text-orange-700 dark:text-orange-300">
|
||||||
|
Pending Admin Approval
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-muted-foreground">
|
||||||
|
Your income entry has been submitted successfully. An administrator will review and approve it shortly.
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-muted-foreground">
|
||||||
|
You can track the approval status in your transactions page.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col sm:flex-row gap-3 w-full max-w-sm">
|
||||||
|
<Button variant="outline" @click="closeSuccessDialog" class="flex-1">
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
<Button @click="goToTransactions" class="flex-1">
|
||||||
|
<Receipt class="h-4 w-4 mr-2" />
|
||||||
|
View My Transactions
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Form State -->
|
||||||
|
<template v-else>
|
||||||
|
<DialogHeader class="px-6 pt-6 pb-4 border-b shrink-0">
|
||||||
|
<DialogTitle class="flex items-center gap-2">
|
||||||
|
<TrendingUp class="h-5 w-5 text-green-600 dark:text-green-400" />
|
||||||
|
<span>{{ t('libra.income.title') }}</span>
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{{ t('libra.income.description') }}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div class="flex-1 overflow-y-auto px-6 py-4 space-y-4 min-h-0">
|
||||||
|
<!-- 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'
|
||||||
|
: selectedRevenueAccount
|
||||||
|
? '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: Revenue Account Selection -->
|
||||||
|
<div v-if="currentStep === 1">
|
||||||
|
<p class="text-sm text-muted-foreground mb-4">
|
||||||
|
{{ t('libra.income.selectAccount') }}
|
||||||
|
</p>
|
||||||
|
<AccountSelector
|
||||||
|
v-model="selectedRevenueAccount"
|
||||||
|
root-account="Income"
|
||||||
|
@account-selected="handleRevenueAccountSelected"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 2: Income Details -->
|
||||||
|
<div v-if="currentStep === 2">
|
||||||
|
<form @submit="onSubmit" class="space-y-4">
|
||||||
|
<FormField v-slot="{ componentField }" name="description">
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Description *</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Textarea
|
||||||
|
placeholder="e.g., Workshop fee, Donation, Service revenue"
|
||||||
|
v-bind="componentField"
|
||||||
|
rows="3"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>Describe the source of this income</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<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 income</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<div class="p-3 rounded-lg bg-muted/50">
|
||||||
|
<div class="flex items-center gap-2 mb-1">
|
||||||
|
<span class="text-sm text-muted-foreground">Revenue account:</span>
|
||||||
|
<Badge variant="secondary" class="font-mono">{{ selectedRevenueAccount?.name }}</Badge>
|
||||||
|
</div>
|
||||||
|
<p v-if="selectedRevenueAccount?.description" class="text-xs text-muted-foreground">
|
||||||
|
{{ selectedRevenueAccount.description }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2 pt-2 pb-6">
|
||||||
|
<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...' : t('libra.income.submitIncome') }}</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { useForm } from 'vee-validate'
|
||||||
|
import { toTypedSchema } from '@vee-validate/zod'
|
||||||
|
import * as z from 'zod'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { TrendingUp, Info } from 'lucide-vue-next'
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import {
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from '@/components/ui/form'
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select'
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
|
|
@ -9,44 +221,131 @@ import {
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@/components/ui/dialog'
|
} from '@/components/ui/dialog'
|
||||||
|
import { TrendingUp, ChevronLeft, Loader2, CheckCircle2, Receipt, Clock } 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 '@/modules/expenses/services/ExpensesAPI'
|
||||||
|
import type { Account } from '@/modules/expenses/types'
|
||||||
|
import AccountSelector from '@/modules/expenses/components/AccountSelector.vue'
|
||||||
|
|
||||||
interface Emits {
|
interface Emits {
|
||||||
(e: 'close'): void
|
(e: 'close'): void
|
||||||
}
|
}
|
||||||
|
|
||||||
const emit = defineEmits<Emits>()
|
const emit = defineEmits<Emits>()
|
||||||
|
|
||||||
|
const expensesAPI = injectService<ExpensesAPI>(SERVICE_TOKENS.EXPENSES_API)
|
||||||
|
const { user } = useAuth()
|
||||||
|
const toast = useToast()
|
||||||
|
const router = useRouter()
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
function handleClose() {
|
const currentStep = ref(1)
|
||||||
|
const selectedRevenueAccount = ref<Account | null>(null)
|
||||||
|
const isSubmitting = ref(false)
|
||||||
|
const availableCurrencies = ref<string[]>([])
|
||||||
|
const showSuccessDialog = ref(false)
|
||||||
|
|
||||||
|
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(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
const form = useForm({
|
||||||
|
validationSchema: formSchema,
|
||||||
|
initialValues: {
|
||||||
|
description: '',
|
||||||
|
amount: 0,
|
||||||
|
currency: '',
|
||||||
|
reference: '',
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const { resetForm, meta } = form
|
||||||
|
const isFormValid = computed(() => meta.value.valid)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
const wallet = user.value?.wallets?.[0]
|
||||||
|
if (!wallet || !wallet.inkey) {
|
||||||
|
console.warn('[AddIncome] No wallet available for loading data')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const currencies = await expensesAPI.getCurrencies()
|
||||||
|
availableCurrencies.value = currencies
|
||||||
|
|
||||||
|
const defaultCurrency = await expensesAPI.getDefaultCurrency()
|
||||||
|
const initialCurrency = defaultCurrency || currencies[0] || 'EUR'
|
||||||
|
form.setFieldValue('currency', initialCurrency)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[AddIncome] Failed to load data:', error)
|
||||||
|
toast.error('Failed to load form data', { description: 'Please check your connection and try again' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleRevenueAccountSelected(account: Account) {
|
||||||
|
selectedRevenueAccount.value = account
|
||||||
|
currentStep.value = 2
|
||||||
|
}
|
||||||
|
|
||||||
|
const onSubmit = form.handleSubmit(async (values) => {
|
||||||
|
if (!selectedRevenueAccount.value) {
|
||||||
|
toast.error('No account selected', { description: 'Please select a revenue account first' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const wallet = user.value?.wallets?.[0]
|
||||||
|
if (!wallet || !wallet.inkey) {
|
||||||
|
toast.error('No wallet available', { description: 'Please log in to submit income' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isSubmitting.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
await expensesAPI.submitIncome(wallet.inkey, {
|
||||||
|
description: values.description,
|
||||||
|
amount: values.amount,
|
||||||
|
revenue_account: selectedRevenueAccount.value.name,
|
||||||
|
currency: values.currency,
|
||||||
|
reference: values.reference,
|
||||||
|
})
|
||||||
|
|
||||||
|
showSuccessDialog.value = true
|
||||||
|
resetForm()
|
||||||
|
selectedRevenueAccount.value = null
|
||||||
|
currentStep.value = 1
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[AddIncome] Error submitting income:', error)
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
toast.error('Submission failed', { description: errorMessage })
|
||||||
|
} finally {
|
||||||
|
isSubmitting.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function goToTransactions() {
|
||||||
|
showSuccessDialog.value = false
|
||||||
|
emit('close')
|
||||||
|
router.push('/expenses/transactions')
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeSuccessDialog() {
|
||||||
|
showSuccessDialog.value = false
|
||||||
emit('close')
|
emit('close')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleDialogClose() {
|
||||||
|
if (showSuccessDialog.value) {
|
||||||
|
closeSuccessDialog()
|
||||||
|
} else {
|
||||||
|
emit('close')
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
|
||||||
<Dialog :open="true" @update:open="(open) => !open && handleClose()">
|
|
||||||
<DialogContent class="max-w-md">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle class="flex items-center gap-2">
|
|
||||||
<TrendingUp class="h-5 w-5 text-green-600 dark:text-green-400" />
|
|
||||||
<span>{{ t('libra.income.title') }}</span>
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
{{ t('libra.income.description') }}
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<!-- Placeholder content -->
|
|
||||||
<div class="flex flex-col items-center py-8 space-y-4">
|
|
||||||
<div class="rounded-full bg-muted p-4">
|
|
||||||
<Info class="h-8 w-8 text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
<p class="text-sm text-muted-foreground text-center max-w-xs">
|
|
||||||
{{ t('libra.income.notAvailable') }}
|
|
||||||
</p>
|
|
||||||
<Button variant="outline" @click="handleClose">
|
|
||||||
Close
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</template>
|
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
||||||
import { useToast } from '@/core/composables/useToast'
|
import { useToast } from '@/core/composables/useToast'
|
||||||
import type { ExpensesAPI } from '@/modules/expenses/services/ExpensesAPI'
|
import type { ExpensesAPI } from '@/modules/expenses/services/ExpensesAPI'
|
||||||
import type { Transaction } from '@/modules/expenses/types'
|
import type { Transaction } from '@/modules/expenses/types'
|
||||||
import { ArrowDown, ArrowUp, Clock, Loader2, PieChart } from 'lucide-vue-next'
|
import { ArrowDown, ArrowUp, Clock, Info, Loader2, PieChart, TrendingUp, TrendingDown } from 'lucide-vue-next'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
@ -17,9 +17,30 @@ const expensesAPI = injectService<ExpensesAPI>(SERVICE_TOKENS.EXPENSES_API)
|
||||||
|
|
||||||
const balance = ref<number | null>(null)
|
const balance = ref<number | null>(null)
|
||||||
const balanceCurrency = ref<string>('sats')
|
const balanceCurrency = ref<string>('sats')
|
||||||
|
const fiatBalances = ref<Record<string, number>>({})
|
||||||
|
const totalExpensesSats = ref<number>(0)
|
||||||
|
const totalExpensesFiat = ref<Record<string, number>>({})
|
||||||
|
const totalIncomeSats = ref<number>(0)
|
||||||
|
const totalIncomeFiat = ref<Record<string, number>>({})
|
||||||
const pendingTransactions = ref<Transaction[]>([])
|
const pendingTransactions = ref<Transaction[]>([])
|
||||||
const isLoading = ref(true)
|
const isLoading = ref(true)
|
||||||
|
|
||||||
|
const fiatBalanceEntries = computed(() =>
|
||||||
|
Object.entries(fiatBalances.value).filter(([, amount]) => Math.abs(amount) > 0.005)
|
||||||
|
)
|
||||||
|
|
||||||
|
const expensesFiatEntries = computed(() =>
|
||||||
|
Object.entries(totalExpensesFiat.value).filter(([, amount]) => Math.abs(amount) > 0.005)
|
||||||
|
)
|
||||||
|
|
||||||
|
const incomeFiatEntries = computed(() =>
|
||||||
|
Object.entries(totalIncomeFiat.value).filter(([, amount]) => Math.abs(amount) > 0.005)
|
||||||
|
)
|
||||||
|
|
||||||
|
const hasBreakdown = computed(() =>
|
||||||
|
totalExpensesSats.value > 0 || totalIncomeSats.value > 0
|
||||||
|
)
|
||||||
|
|
||||||
const walletKey = computed(() => user.value?.wallets?.[0]?.inkey)
|
const walletKey = computed(() => user.value?.wallets?.[0]?.inkey)
|
||||||
const budgetsEnabled = computed(() => import.meta.env.VITE_LIBRA_BUDGETS_ENABLED === 'true')
|
const budgetsEnabled = computed(() => import.meta.env.VITE_LIBRA_BUDGETS_ENABLED === 'true')
|
||||||
|
|
||||||
|
|
@ -55,6 +76,11 @@ async function loadData() {
|
||||||
|
|
||||||
balance.value = balanceData.balance
|
balance.value = balanceData.balance
|
||||||
balanceCurrency.value = balanceData.currency || 'sats'
|
balanceCurrency.value = balanceData.currency || 'sats'
|
||||||
|
fiatBalances.value = balanceData.fiat_balances || {}
|
||||||
|
totalExpensesSats.value = balanceData.total_expenses_sats || 0
|
||||||
|
totalExpensesFiat.value = balanceData.total_expenses_fiat || {}
|
||||||
|
totalIncomeSats.value = balanceData.total_income_sats || 0
|
||||||
|
totalIncomeFiat.value = balanceData.total_income_fiat || {}
|
||||||
|
|
||||||
// Filter for pending transactions (flag = '!')
|
// Filter for pending transactions (flag = '!')
|
||||||
pendingTransactions.value = txData.entries.filter(tx => tx.flag === '!')
|
pendingTransactions.value = txData.entries.filter(tx => tx.flag === '!')
|
||||||
|
|
@ -112,6 +138,15 @@ function formatFiat(amount: number, currency: string): string {
|
||||||
</span>
|
</span>
|
||||||
<span class="text-lg text-muted-foreground">{{ balanceCurrency }}</span>
|
<span class="text-lg text-muted-foreground">{{ balanceCurrency }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="fiatBalanceEntries.length > 0" class="flex flex-wrap items-center gap-x-3 gap-y-1 pl-7">
|
||||||
|
<span
|
||||||
|
v-for="[currency, amount] in fiatBalanceEntries"
|
||||||
|
:key="currency"
|
||||||
|
class="text-base text-muted-foreground"
|
||||||
|
>
|
||||||
|
{{ formatFiat(Math.abs(amount), currency) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<p class="text-sm" :class="libraOwesUser ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'">
|
<p class="text-sm" :class="libraOwesUser ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'">
|
||||||
{{ libraOwesUser ? t('libra.balance.owedToYou') : t('libra.balance.youOwe') }}
|
{{ libraOwesUser ? t('libra.balance.owedToYou') : t('libra.balance.youOwe') }}
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -122,6 +157,63 @@ function formatFiat(amount: number, currency: string): string {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Lifetime Breakdown -->
|
||||||
|
<div v-if="hasBreakdown" class="grid grid-cols-2 gap-3 mb-6">
|
||||||
|
<!-- Income -->
|
||||||
|
<div class="rounded-xl border bg-card p-4">
|
||||||
|
<div class="flex items-center gap-2 mb-2">
|
||||||
|
<TrendingUp class="w-4 h-4 text-muted-foreground" />
|
||||||
|
<h3 class="text-xs font-medium text-muted-foreground uppercase tracking-wide">Income</h3>
|
||||||
|
</div>
|
||||||
|
<p class="text-lg font-semibold text-foreground">
|
||||||
|
{{ formatAmount(totalIncomeSats) }}
|
||||||
|
<span class="text-sm font-normal text-muted-foreground">sats</span>
|
||||||
|
</p>
|
||||||
|
<div v-if="incomeFiatEntries.length > 0" class="mt-1 space-y-0.5">
|
||||||
|
<p
|
||||||
|
v-for="[currency, amount] in incomeFiatEntries"
|
||||||
|
:key="currency"
|
||||||
|
class="text-xs text-muted-foreground"
|
||||||
|
>
|
||||||
|
{{ formatFiat(amount, currency) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3 flex items-start gap-1.5">
|
||||||
|
<Info class="w-3 h-3 mt-0.5 shrink-0 text-primary/70" />
|
||||||
|
<p class="text-[11px] leading-snug text-primary/80">
|
||||||
|
Collected on behalf of the organization — you owe this back.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Expenses -->
|
||||||
|
<div class="rounded-xl border bg-card p-4">
|
||||||
|
<div class="flex items-center gap-2 mb-2">
|
||||||
|
<TrendingDown class="w-4 h-4 text-muted-foreground" />
|
||||||
|
<h3 class="text-xs font-medium text-muted-foreground uppercase tracking-wide">Expenses</h3>
|
||||||
|
</div>
|
||||||
|
<p class="text-lg font-semibold text-foreground">
|
||||||
|
{{ formatAmount(totalExpensesSats) }}
|
||||||
|
<span class="text-sm font-normal text-muted-foreground">sats</span>
|
||||||
|
</p>
|
||||||
|
<div v-if="expensesFiatEntries.length > 0" class="mt-1 space-y-0.5">
|
||||||
|
<p
|
||||||
|
v-for="[currency, amount] in expensesFiatEntries"
|
||||||
|
:key="currency"
|
||||||
|
class="text-xs text-muted-foreground"
|
||||||
|
>
|
||||||
|
{{ formatFiat(amount, currency) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3 flex items-start gap-1.5">
|
||||||
|
<Info class="w-3 h-3 mt-0.5 shrink-0 text-primary/70" />
|
||||||
|
<p class="text-[11px] leading-snug text-primary/80">
|
||||||
|
Paid on the organization's behalf — owed back to you.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Pending Section -->
|
<!-- Pending Section -->
|
||||||
<div v-if="pendingCount > 0" class="rounded-xl border bg-card p-5 mb-6">
|
<div v-if="pendingCount > 0" class="rounded-xl border bg-card p-5 mb-6">
|
||||||
<div class="flex items-center gap-2 mb-3">
|
<div class="flex items-center gap-2 mb-3">
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,8 @@ import type {
|
||||||
Account,
|
Account,
|
||||||
ExpenseEntryRequest,
|
ExpenseEntryRequest,
|
||||||
ExpenseEntry,
|
ExpenseEntry,
|
||||||
|
IncomeEntryRequest,
|
||||||
|
IncomeEntry,
|
||||||
AccountNode,
|
AccountNode,
|
||||||
UserInfo,
|
UserInfo,
|
||||||
AccountPermission,
|
AccountPermission,
|
||||||
|
|
@ -188,6 +190,33 @@ export class ExpensesAPI extends BaseService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Submit income entry to libra
|
||||||
|
*/
|
||||||
|
async submitIncome(walletKey: string, request: IncomeEntryRequest): Promise<IncomeEntry> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${this.baseUrl}/libra/api/v1/entries/income`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: this.getHeaders(walletKey),
|
||||||
|
body: JSON.stringify(request),
|
||||||
|
signal: AbortSignal.timeout(this.config?.apiConfig?.timeout || 30000)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({}))
|
||||||
|
const errorMessage =
|
||||||
|
errorData.detail || `Failed to submit income: ${response.statusText}`
|
||||||
|
throw new Error(errorMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry = await response.json()
|
||||||
|
return entry as IncomeEntry
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[ExpensesAPI] Error submitting income:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get user's expense entries
|
* Get user's expense entries
|
||||||
*/
|
*/
|
||||||
|
|
@ -216,7 +245,15 @@ export class ExpensesAPI extends BaseService {
|
||||||
/**
|
/**
|
||||||
* Get user's balance with libra
|
* Get user's balance with libra
|
||||||
*/
|
*/
|
||||||
async getUserBalance(walletKey: string): Promise<{ balance: number; currency: string }> {
|
async getUserBalance(walletKey: string): Promise<{
|
||||||
|
balance: number
|
||||||
|
currency: string
|
||||||
|
fiat_balances?: Record<string, number>
|
||||||
|
total_expenses_sats?: number
|
||||||
|
total_expenses_fiat?: Record<string, number>
|
||||||
|
total_income_sats?: number
|
||||||
|
total_income_fiat?: Record<string, number>
|
||||||
|
}> {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${this.baseUrl}/libra/api/v1/balance`, {
|
const response = await fetch(`${this.baseUrl}/libra/api/v1/balance`, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,7 @@ export interface AccountWithPermissions extends Account {
|
||||||
export enum PermissionType {
|
export enum PermissionType {
|
||||||
READ = 'read',
|
READ = 'read',
|
||||||
SUBMIT_EXPENSE = 'submit_expense',
|
SUBMIT_EXPENSE = 'submit_expense',
|
||||||
|
SUBMIT_INCOME = 'submit_income',
|
||||||
MANAGE = 'manage'
|
MANAGE = 'manage'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -78,6 +79,35 @@ export interface ExpenseEntry {
|
||||||
status: 'pending' | 'approved' | 'rejected' | 'void'
|
status: 'pending' | 'approved' | 'rejected' | 'void'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Income entry request payload. Income lands on the submitting user as
|
||||||
|
* a receivable (Assets:Receivable:User-{id}) — no payment-method account.
|
||||||
|
*/
|
||||||
|
export interface IncomeEntryRequest {
|
||||||
|
description: string
|
||||||
|
amount: number
|
||||||
|
revenue_account: string
|
||||||
|
currency: string
|
||||||
|
reference?: string
|
||||||
|
entry_date?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Income entry response from libra API
|
||||||
|
*/
|
||||||
|
export interface IncomeEntry {
|
||||||
|
id: string
|
||||||
|
journal_id: string
|
||||||
|
description: string
|
||||||
|
amount: number
|
||||||
|
revenue_account: string
|
||||||
|
currency: string
|
||||||
|
reference?: string
|
||||||
|
entry_date: string
|
||||||
|
created_at: string
|
||||||
|
status: 'pending' | 'approved' | 'rejected' | 'void'
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hierarchical account tree node for UI rendering
|
* Hierarchical account tree node for UI rendering
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,8 @@ import {
|
||||||
Flag,
|
Flag,
|
||||||
XCircle,
|
XCircle,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Calendar
|
Calendar,
|
||||||
|
Filter
|
||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
|
|
||||||
const { user } = useAuth()
|
const { user } = useAuth()
|
||||||
|
|
@ -29,6 +30,21 @@ const isLoading = ref(false)
|
||||||
const dateRangeType = ref<number | 'custom'>(15) // 15, 30, 60, or 'custom'
|
const dateRangeType = ref<number | 'custom'>(15) // 15, 30, 60, or 'custom'
|
||||||
const customStartDate = ref<string>('')
|
const customStartDate = ref<string>('')
|
||||||
const customEndDate = ref<string>('')
|
const customEndDate = ref<string>('')
|
||||||
|
const typeFilter = ref<'all' | 'income' | 'expense'>('all')
|
||||||
|
|
||||||
|
const typeFilterOptions = [
|
||||||
|
{ label: 'All', value: 'all' as const },
|
||||||
|
{ label: 'Income', value: 'income' as const },
|
||||||
|
{ label: 'Expenses', value: 'expense' as const }
|
||||||
|
]
|
||||||
|
|
||||||
|
function isIncome(t: Transaction): boolean {
|
||||||
|
return t.tags?.includes('income-entry') ?? false
|
||||||
|
}
|
||||||
|
|
||||||
|
function isExpense(t: Transaction): boolean {
|
||||||
|
return t.tags?.includes('expense-entry') ?? false
|
||||||
|
}
|
||||||
|
|
||||||
const walletKey = computed(() => user.value?.wallets?.[0]?.inkey)
|
const walletKey = computed(() => user.value?.wallets?.[0]?.inkey)
|
||||||
|
|
||||||
|
|
@ -55,9 +71,12 @@ const searchOptions: FuzzySearchOptions<Transaction> = {
|
||||||
matchAllWhenSearchEmpty: true
|
matchAllWhenSearchEmpty: true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Transactions to display (search results or all transactions)
|
// Transactions to display (search results or all transactions), filtered by type
|
||||||
const transactionsToDisplay = computed(() => {
|
const transactionsToDisplay = computed(() => {
|
||||||
return searchResults.value.length > 0 ? searchResults.value : transactions.value
|
const base = searchResults.value.length > 0 ? searchResults.value : transactions.value
|
||||||
|
if (typeFilter.value === 'income') return base.filter(isIncome)
|
||||||
|
if (typeFilter.value === 'expense') return base.filter(isExpense)
|
||||||
|
return base
|
||||||
})
|
})
|
||||||
|
|
||||||
// Handle search results
|
// Handle search results
|
||||||
|
|
@ -197,7 +216,7 @@ onMounted(() => {
|
||||||
:key="option.value"
|
:key="option.value"
|
||||||
:variant="dateRangeType === option.value ? 'default' : 'outline'"
|
:variant="dateRangeType === option.value ? 'default' : 'outline'"
|
||||||
size="sm"
|
size="sm"
|
||||||
class="h-8 px-3 text-xs"
|
class="h-7 md:h-8 px-3 text-xs"
|
||||||
@click="onDateRangeTypeChange(option.value)"
|
@click="onDateRangeTypeChange(option.value)"
|
||||||
:disabled="isLoading"
|
:disabled="isLoading"
|
||||||
>
|
>
|
||||||
|
|
@ -205,6 +224,22 @@ onMounted(() => {
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Type Filter (All / Income / Expenses) -->
|
||||||
|
<div class="flex items-center gap-2 flex-wrap">
|
||||||
|
<Filter class="h-4 w-4 text-muted-foreground" />
|
||||||
|
<Button
|
||||||
|
v-for="option in typeFilterOptions"
|
||||||
|
:key="option.value"
|
||||||
|
:variant="typeFilter === option.value ? 'default' : 'outline'"
|
||||||
|
size="sm"
|
||||||
|
class="h-7 md:h-8 px-3 text-xs"
|
||||||
|
@click="typeFilter = option.value"
|
||||||
|
:disabled="isLoading"
|
||||||
|
>
|
||||||
|
{{ option.label }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Custom Date Range Inputs -->
|
<!-- Custom Date Range Inputs -->
|
||||||
<div v-if="dateRangeType === 'custom'" class="flex items-end gap-2 flex-wrap">
|
<div v-if="dateRangeType === 'custom'" class="flex items-end gap-2 flex-wrap">
|
||||||
<div class="flex flex-col gap-1">
|
<div class="flex flex-col gap-1">
|
||||||
|
|
@ -212,7 +247,7 @@ onMounted(() => {
|
||||||
<Input
|
<Input
|
||||||
type="date"
|
type="date"
|
||||||
v-model="customStartDate"
|
v-model="customStartDate"
|
||||||
class="h-8 text-xs"
|
class="h-7 md:h-8 text-xs"
|
||||||
:disabled="isLoading"
|
:disabled="isLoading"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -221,13 +256,13 @@ onMounted(() => {
|
||||||
<Input
|
<Input
|
||||||
type="date"
|
type="date"
|
||||||
v-model="customEndDate"
|
v-model="customEndDate"
|
||||||
class="h-8 text-xs"
|
class="h-7 md:h-8 text-xs"
|
||||||
:disabled="isLoading"
|
:disabled="isLoading"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
class="h-8 px-3 text-xs"
|
class="h-7 md:h-8 px-3 text-xs"
|
||||||
@click="applyCustomDateRange"
|
@click="applyCustomDateRange"
|
||||||
:disabled="isLoading || !customStartDate || !customEndDate"
|
:disabled="isLoading || !customStartDate || !customEndDate"
|
||||||
>
|
>
|
||||||
|
|
@ -281,7 +316,11 @@ onMounted(() => {
|
||||||
<div
|
<div
|
||||||
v-for="transaction in transactionsToDisplay"
|
v-for="transaction in transactionsToDisplay"
|
||||||
:key="transaction.id"
|
:key="transaction.id"
|
||||||
class="border-b md:border md:rounded-lg p-4 hover:bg-muted/50 transition-colors"
|
:class="[
|
||||||
|
'border-b md:border md:rounded-lg p-4 hover:bg-muted/50 transition-colors',
|
||||||
|
isIncome(transaction) && 'border-l-4 border-l-green-600',
|
||||||
|
isExpense(transaction) && 'border-l-4 border-l-red-600'
|
||||||
|
]"
|
||||||
>
|
>
|
||||||
<!-- Transaction Header -->
|
<!-- Transaction Header -->
|
||||||
<div class="flex items-start justify-between gap-3 mb-2">
|
<div class="flex items-start justify-between gap-3 mb-2">
|
||||||
|
|
@ -328,22 +367,21 @@ onMounted(() => {
|
||||||
<span class="font-medium">Ref:</span> {{ transaction.reference }}
|
<span class="font-medium">Ref:</span> {{ transaction.reference }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Username (if available) -->
|
|
||||||
<div v-if="transaction.username" class="text-muted-foreground">
|
|
||||||
<span class="font-medium">User:</span> {{ transaction.username }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Tags -->
|
<!-- Tags -->
|
||||||
<div v-if="transaction.tags && transaction.tags.length > 0" class="flex flex-wrap gap-1 mt-2">
|
<div v-if="transaction.tags && transaction.tags.length > 0" class="flex flex-wrap gap-1 mt-2">
|
||||||
<Badge v-for="tag in transaction.tags" :key="tag" variant="secondary" class="text-xs">
|
<Badge
|
||||||
|
v-for="tag in transaction.tags"
|
||||||
|
:key="tag"
|
||||||
|
variant="secondary"
|
||||||
|
:class="[
|
||||||
|
'text-xs',
|
||||||
|
tag === 'income-entry' && 'bg-green-100 dark:bg-green-900/20 text-green-700 dark:text-green-300',
|
||||||
|
tag === 'expense-entry' && 'bg-red-100 dark:bg-red-900/20 text-red-700 dark:text-red-300'
|
||||||
|
]"
|
||||||
|
>
|
||||||
{{ tag }}
|
{{ tag }}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Metadata Source -->
|
|
||||||
<div v-if="transaction.meta?.source" class="text-muted-foreground mt-1">
|
|
||||||
<span class="text-xs">Source: {{ transaction.meta.source }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -236,7 +236,8 @@ export function useCheckout(): UseCheckoutReturn {
|
||||||
|
|
||||||
async function payBolt11Raw(
|
async function payBolt11Raw(
|
||||||
bolt11: string,
|
bolt11: string,
|
||||||
adminkey: string
|
adminkey: string,
|
||||||
|
extra?: Record<string, unknown>
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const response = await fetch(`${apiBaseUrl}/api/v1/payments`, {
|
const response = await fetch(`${apiBaseUrl}/api/v1/payments`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|
@ -244,7 +245,7 @@ export function useCheckout(): UseCheckoutReturn {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'X-Api-Key': adminkey,
|
'X-Api-Key': adminkey,
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ out: true, bolt11 }),
|
body: JSON.stringify({ out: true, bolt11, ...(extra ? { extra } : {}) }),
|
||||||
})
|
})
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
let detail = response.statusText
|
let detail = response.statusText
|
||||||
|
|
@ -272,7 +273,15 @@ export function useCheckout(): UseCheckoutReturn {
|
||||||
state.value.step = 'paying'
|
state.value.step = 'paying'
|
||||||
state.value.currentRestaurantSlug = placed.restaurantSlug
|
state.value.currentRestaurantSlug = placed.restaurantSlug
|
||||||
try {
|
try {
|
||||||
await payBolt11Raw(placed.invoice.bolt11, adminkey)
|
// Tag the outgoing payment so the customer's wallet history
|
||||||
|
// can later surface it as a restaurant order. Mirrors the
|
||||||
|
// `extra={"tag": "restaurant", ...}` the extension stamps on
|
||||||
|
// its incoming invoice.
|
||||||
|
await payBolt11Raw(placed.invoice.bolt11, adminkey, {
|
||||||
|
tag: 'restaurant',
|
||||||
|
restaurant_id: placed.restaurantId,
|
||||||
|
order_id: placed.order.id,
|
||||||
|
})
|
||||||
// Set semantics keeps `paidOrderIds` from re-renders; rebuild
|
// Set semantics keeps `paidOrderIds` from re-renders; rebuild
|
||||||
// it on update so Vue picks up the change.
|
// it on update so Vue picks up the change.
|
||||||
state.value.paidOrderIds = new Set([
|
state.value.paidOrderIds = new Set([
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue