Add transactions page with fuzzy search and success dialog for expenses

Features:
- Created TransactionsPage with mobile-optimized layout
  - Card-based transaction items with status indicators
  - Fuzzy search by description, payee, reference, username, and tags
  - Day filter options (5, 30, 60, 90 days)
  - Pagination support
  - Responsive design for mobile and desktop
- Added getUserTransactions API method to ExpensesAPI
  - Supports filtering by days, user ID, and account type
  - Returns paginated transaction data
- Updated AddExpense component with success confirmation
  - Shows success message in same dialog after submission
  - Provides option to navigate to transactions page
  - Clean single-dialog approach
- Added "My Transactions" link to navbar menu
- Added Transaction and TransactionListResponse types
- Added permission management types and API methods (grantPermission, listPermissions, revokePermission)
- Installed alert-dialog component for UI consistency

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
padreug 2025-11-13 09:57:28 +01:00
parent 78fba2a637
commit be00c61c77
20 changed files with 914 additions and 74 deletions

22
package-lock.json generated
View file

@ -25,7 +25,7 @@
"qr-scanner": "^1.4.2",
"qrcode": "^1.5.4",
"radix-vue": "^1.9.13",
"reka-ui": "^2.5.0",
"reka-ui": "^2.6.0",
"tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7",
"unique-names-generator": "^4.7.1",
@ -8890,20 +8890,6 @@
"ms": "^2.0.0"
}
},
"node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/idb": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz",
@ -12167,9 +12153,9 @@
}
},
"node_modules/reka-ui": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/reka-ui/-/reka-ui-2.5.0.tgz",
"integrity": "sha512-81aMAmJeVCy2k0E6x7n1kypDY6aM1ldLis5+zcdV1/JtoAlSDck5OBsyLRJU9CfgbrQp1ImnRnBSmC4fZ2fkZQ==",
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/reka-ui/-/reka-ui-2.6.0.tgz",
"integrity": "sha512-NrGMKrABD97l890mFS3TNUzB0BLUfbL3hh0NjcJRIUSUljb288bx3Mzo31nOyUcdiiW0HqFGXJwyCBh9cWgb0w==",
"license": "MIT",
"dependencies": {
"@floating-ui/dom": "^1.6.13",

View file

@ -34,7 +34,7 @@
"qr-scanner": "^1.4.2",
"qrcode": "^1.5.4",
"radix-vue": "^1.9.13",
"reka-ui": "^2.5.0",
"reka-ui": "^2.6.0",
"tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7",
"unique-names-generator": "^4.7.1",

View file

@ -0,0 +1,15 @@
<script setup lang="ts">
import type { AlertDialogEmits, AlertDialogProps } from "reka-ui"
import { AlertDialogRoot, useForwardPropsEmits } from "reka-ui"
const props = defineProps<AlertDialogProps>()
const emits = defineEmits<AlertDialogEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<AlertDialogRoot v-bind="forwarded">
<slot />
</AlertDialogRoot>
</template>

View file

@ -0,0 +1,18 @@
<script setup lang="ts">
import type { AlertDialogActionProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { AlertDialogAction } from "reka-ui"
import { cn } from "@/lib/utils"
import { buttonVariants } from '@/components/ui/button'
const props = defineProps<AlertDialogActionProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>
<AlertDialogAction v-bind="delegatedProps" :class="cn(buttonVariants(), props.class)">
<slot />
</AlertDialogAction>
</template>

View file

@ -0,0 +1,25 @@
<script setup lang="ts">
import type { AlertDialogCancelProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { AlertDialogCancel } from "reka-ui"
import { cn } from "@/lib/utils"
import { buttonVariants } from '@/components/ui/button'
const props = defineProps<AlertDialogCancelProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>
<AlertDialogCancel
v-bind="delegatedProps"
:class="cn(
buttonVariants({ variant: 'outline' }),
'mt-2 sm:mt-0',
props.class,
)"
>
<slot />
</AlertDialogCancel>
</template>

View file

@ -0,0 +1,39 @@
<script setup lang="ts">
import type { AlertDialogContentEmits, AlertDialogContentProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import {
AlertDialogContent,
AlertDialogOverlay,
AlertDialogPortal,
useForwardPropsEmits,
} from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<AlertDialogContentProps & { class?: HTMLAttributes["class"] }>()
const emits = defineEmits<AlertDialogContentEmits>()
const delegatedProps = reactiveOmit(props, "class")
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<AlertDialogPortal>
<AlertDialogOverlay
class="fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
/>
<AlertDialogContent
v-bind="forwarded"
:class="
cn(
'fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
props.class,
)
"
>
<slot />
</AlertDialogContent>
</AlertDialogPortal>
</template>

View file

@ -0,0 +1,23 @@
<script setup lang="ts">
import type { AlertDialogDescriptionProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import {
AlertDialogDescription,
} from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<AlertDialogDescriptionProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>
<AlertDialogDescription
v-bind="delegatedProps"
:class="cn('text-sm text-muted-foreground', props.class)"
>
<slot />
</AlertDialogDescription>
</template>

View file

@ -0,0 +1,21 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div
:class="
cn(
'flex flex-col-reverse sm:flex-row sm:justify-end sm:gap-x-2',
props.class,
)
"
>
<slot />
</div>
</template>

View file

@ -0,0 +1,16 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div
:class="cn('flex flex-col gap-y-2 text-center sm:text-left', props.class)"
>
<slot />
</div>
</template>

View file

@ -0,0 +1,20 @@
<script setup lang="ts">
import type { AlertDialogTitleProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { AlertDialogTitle } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<AlertDialogTitleProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>
<AlertDialogTitle
v-bind="delegatedProps"
:class="cn('text-lg font-semibold', props.class)"
>
<slot />
</AlertDialogTitle>
</template>

View file

@ -0,0 +1,12 @@
<script setup lang="ts">
import type { AlertDialogTriggerProps } from "reka-ui"
import { AlertDialogTrigger } from "reka-ui"
const props = defineProps<AlertDialogTriggerProps>()
</script>
<template>
<AlertDialogTrigger v-bind="props">
<slot />
</AlertDialogTrigger>
</template>

View file

@ -0,0 +1,9 @@
export { default as AlertDialog } from "./AlertDialog.vue"
export { default as AlertDialogAction } from "./AlertDialogAction.vue"
export { default as AlertDialogCancel } from "./AlertDialogCancel.vue"
export { default as AlertDialogContent } from "./AlertDialogContent.vue"
export { default as AlertDialogDescription } from "./AlertDialogDescription.vue"
export { default as AlertDialogFooter } from "./AlertDialogFooter.vue"
export { default as AlertDialogHeader } from "./AlertDialogHeader.vue"
export { default as AlertDialogTitle } from "./AlertDialogTitle.vue"
export { default as AlertDialogTrigger } from "./AlertDialogTrigger.vue"

View file

@ -1,22 +1,25 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
import { Primitive, type PrimitiveProps } from 'reka-ui'
import { type ButtonVariants, buttonVariants } from '.'
import type { PrimitiveProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import type { ButtonVariants } from "."
import { Primitive } from "reka-ui"
import { cn } from "@/lib/utils"
import { buttonVariants } from "."
interface Props extends PrimitiveProps {
variant?: ButtonVariants['variant']
size?: ButtonVariants['size']
class?: HTMLAttributes['class']
variant?: ButtonVariants["variant"]
size?: ButtonVariants["size"]
class?: HTMLAttributes["class"]
}
const props = withDefaults(defineProps<Props>(), {
as: 'button',
as: "button",
})
</script>
<template>
<Primitive
data-slot="button"
:as="as"
:as-child="asChild"
:class="cn(buttonVariants({ variant, size }), props.class)"

View file

@ -1,33 +1,37 @@
import { cva, type VariantProps } from 'class-variance-authority'
import type { VariantProps } from "class-variance-authority"
import { cva } from "class-variance-authority"
export { default as Button } from './Button.vue'
export { default as Button } from "./Button.vue"
export const buttonVariants = cva(
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground shadow-sm hover:bg-primary/90',
default:
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
destructive:
'bg-destructive text-destructive-foreground shadow-xs hover:bg-destructive/90',
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
'border border-input bg-background shadow-xs hover:bg-accent hover:text-accent-foreground',
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline',
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: 'h-9 px-4 py-2',
xs: 'h-7 rounded px-2',
sm: 'h-8 rounded-md px-3 text-xs',
lg: 'h-10 rounded-md px-8',
icon: 'h-9 w-9',
"default": "h-9 px-4 py-2 has-[>svg]:px-3",
"sm": "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
"lg": "h-10 rounded-md px-6 has-[>svg]:px-4",
"icon": "size-9",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: 'default',
size: 'default',
variant: "default",
size: "default",
},
},
)

View file

@ -61,30 +61,40 @@ export function useModularNavigation() {
// Events module items
if (appConfig.modules.events.enabled) {
items.push({
name: 'My Tickets',
href: '/my-tickets',
items.push({
name: 'My Tickets',
href: '/my-tickets',
icon: 'Ticket',
requiresAuth: true
requiresAuth: true
})
}
// Market module items
// Market module items
if (appConfig.modules.market.enabled) {
items.push({
name: 'Market Dashboard',
href: '/market-dashboard',
items.push({
name: 'Market Dashboard',
href: '/market-dashboard',
icon: 'Store',
requiresAuth: true
requiresAuth: true
})
}
// Expenses module items
if (appConfig.modules.expenses.enabled) {
items.push({
name: 'My Transactions',
href: '/expenses/transactions',
icon: 'Receipt',
requiresAuth: true
})
}
// Base module items (always available)
items.push({
name: 'Relay Hub Status',
href: '/relay-hub-status',
items.push({
name: 'Relay Hub Status',
href: '/relay-hub-status',
icon: 'Activity',
requiresAuth: true
requiresAuth: true
})
return items

View file

@ -1,16 +1,48 @@
<template>
<Dialog :open="true" @update:open="(open) => !open && $emit('close')">
<Dialog :open="true" @update:open="(open) => !open && handleDialogClose()">
<DialogContent class="max-w-2xl max-h-[85vh] my-4 overflow-hidden flex flex-col p-0 gap-0">
<!-- Header -->
<DialogHeader class="px-6 pt-6 pb-4 border-b shrink-0">
<DialogTitle class="flex items-center gap-2">
<DollarSign class="h-5 w-5 text-primary" />
<span>Add Expense</span>
</DialogTitle>
<DialogDescription>
Submit an expense for admin approval
</DialogDescription>
</DialogHeader>
<!-- Success State -->
<div v-if="showSuccessDialog" class="flex flex-col items-center justify-center p-8 space-y-6">
<!-- Success Icon -->
<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>
<!-- Success Message -->
<div class="text-center space-y-2">
<h2 class="text-2xl font-bold">Expense Submitted Successfully!</h2>
<p class="text-muted-foreground">
Your expense has been submitted and is pending admin approval.
</p>
<p class="text-sm text-muted-foreground">
You can track the status in your transactions page.
</p>
</div>
<!-- Action Buttons -->
<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>
<!-- Header -->
<DialogHeader class="px-6 pt-6 pb-4 border-b shrink-0">
<DialogTitle class="flex items-center gap-2">
<DollarSign class="h-5 w-5 text-primary" />
<span>Add Expense</span>
</DialogTitle>
<DialogDescription>
Submit an expense for admin approval
</DialogDescription>
</DialogHeader>
<!-- Scrollable Form Content -->
<div class="flex-1 overflow-y-auto px-6 py-4 space-y-4 min-h-0">
@ -200,12 +232,14 @@
</form>
</div>
</div>
</template>
</DialogContent>
</Dialog>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import * as z from 'zod'
@ -236,7 +270,7 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { DollarSign, ChevronLeft, Loader2 } from 'lucide-vue-next'
import { DollarSign, ChevronLeft, Loader2, CheckCircle2, Receipt } from 'lucide-vue-next'
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import { useAuth } from '@/composables/useAuthService'
import { useToast } from '@/core/composables/useToast'
@ -256,6 +290,7 @@ const emit = defineEmits<Emits>()
const expensesAPI = injectService<ExpensesAPI>(SERVICE_TOKENS.EXPENSES_API)
const { user } = useAuth()
const toast = useToast()
const router = useRouter()
// Component state
const currentStep = ref(1)
@ -264,6 +299,7 @@ const isSubmitting = ref(false)
const availableCurrencies = ref<string[]>([])
const loadingCurrencies = ref(true)
const userInfo = ref<UserInfo | null>(null)
const showSuccessDialog = ref(false)
// Form schema
const formSchema = toTypedSchema(
@ -371,17 +407,16 @@ const onSubmit = form.handleSubmit(async (values) => {
currency: values.currency
})
// Show success message
toast.success('Expense submitted', { description: 'Your expense is pending admin approval' })
// Show success dialog instead of toast
showSuccessDialog.value = true
// Reset form and close
// Reset form for next submission
resetForm()
selectedAccount.value = null
currentStep.value = 1
emit('expense-submitted')
emit('action-complete')
emit('close')
// DON'T emit 'action-complete' yet - wait for user to close success dialog
} catch (error) {
console.error('[AddExpense] Error submitting expense:', error)
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
@ -390,4 +425,36 @@ const onSubmit = form.handleSubmit(async (values) => {
isSubmitting.value = false
}
})
/**
* Handle viewing transactions
*/
function goToTransactions() {
showSuccessDialog.value = false
emit('action-complete')
emit('close')
router.push('/expenses/transactions')
}
/**
* Handle closing success dialog
*/
function closeSuccessDialog() {
showSuccessDialog.value = false
emit('action-complete')
emit('close')
}
/**
* Handle dialog close (from X button or clicking outside)
*/
function handleDialogClose() {
if (showSuccessDialog.value) {
// If in success state, close the whole thing
closeSuccessDialog()
} else {
// If in form state, just close normally
emit('close')
}
}
</script>

View file

@ -11,12 +11,25 @@ import type { ModulePlugin } from '@/core/types'
import { container, SERVICE_TOKENS } from '@/core/di-container'
import { ExpensesAPI } from './services/ExpensesAPI'
import AddExpense from './components/AddExpense.vue'
import TransactionsPage from './views/TransactionsPage.vue'
export const expensesModule: ModulePlugin = {
name: 'expenses',
version: '1.0.0',
dependencies: ['base'],
routes: [
{
path: '/expenses/transactions',
name: 'ExpenseTransactions',
component: TransactionsPage,
meta: {
requiresAuth: true,
title: 'My Transactions'
}
}
],
quickActions: [
{
id: 'add-expense',

View file

@ -8,7 +8,10 @@ import type {
ExpenseEntryRequest,
ExpenseEntry,
AccountNode,
UserInfo
UserInfo,
AccountPermission,
GrantPermissionRequest,
TransactionListResponse
} from '../types'
import { appConfig } from '@/app.config'
@ -302,4 +305,136 @@ export class ExpensesAPI extends BaseService {
}
}
}
/**
* List all account permissions (admin only)
*
* @param adminKey - Admin key for authentication
*/
async listPermissions(adminKey: string): Promise<AccountPermission[]> {
try {
const response = await fetch(`${this.baseUrl}/castle/api/v1/permissions`, {
method: 'GET',
headers: this.getHeaders(adminKey),
signal: AbortSignal.timeout(this.config?.apiConfig?.timeout || 30000)
})
if (!response.ok) {
throw new Error(`Failed to list permissions: ${response.statusText}`)
}
const permissions = await response.json()
return permissions as AccountPermission[]
} catch (error) {
console.error('[ExpensesAPI] Error listing permissions:', error)
throw error
}
}
/**
* Grant account permission to a user (admin only)
*
* @param adminKey - Admin key for authentication
* @param request - Permission grant request
*/
async grantPermission(
adminKey: string,
request: GrantPermissionRequest
): Promise<AccountPermission> {
try {
const response = await fetch(`${this.baseUrl}/castle/api/v1/permissions`, {
method: 'POST',
headers: this.getHeaders(adminKey),
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 grant permission: ${response.statusText}`
throw new Error(errorMessage)
}
const permission = await response.json()
return permission as AccountPermission
} catch (error) {
console.error('[ExpensesAPI] Error granting permission:', error)
throw error
}
}
/**
* Revoke account permission (admin only)
*
* @param adminKey - Admin key for authentication
* @param permissionId - ID of the permission to revoke
*/
async revokePermission(adminKey: string, permissionId: string): Promise<void> {
try {
const response = await fetch(
`${this.baseUrl}/castle/api/v1/permissions/${permissionId}`,
{
method: 'DELETE',
headers: this.getHeaders(adminKey),
signal: AbortSignal.timeout(this.config?.apiConfig?.timeout || 30000)
}
)
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
const errorMessage =
errorData.detail || `Failed to revoke permission: ${response.statusText}`
throw new Error(errorMessage)
}
} catch (error) {
console.error('[ExpensesAPI] Error revoking permission:', error)
throw error
}
}
/**
* Get user's transactions from journal
*
* @param walletKey - Wallet key for authentication (invoice key)
* @param options - Query options for filtering and pagination
*/
async getUserTransactions(
walletKey: string,
options?: {
limit?: number
offset?: number
days?: number // 5, 30, 60, or 90
filter_user_id?: string
filter_account_type?: string
}
): Promise<TransactionListResponse> {
try {
const url = new URL(`${this.baseUrl}/castle/api/v1/entries/user`)
// Add query parameters
if (options?.limit) url.searchParams.set('limit', String(options.limit))
if (options?.offset) url.searchParams.set('offset', String(options.offset))
if (options?.days) url.searchParams.set('days', String(options.days))
if (options?.filter_user_id)
url.searchParams.set('filter_user_id', options.filter_user_id)
if (options?.filter_account_type)
url.searchParams.set('filter_account_type', options.filter_account_type)
const response = await fetch(url.toString(), {
method: 'GET',
headers: this.getHeaders(walletKey),
signal: AbortSignal.timeout(this.config?.apiConfig?.timeout || 30000)
})
if (!response.ok) {
throw new Error(`Failed to fetch transactions: ${response.statusText}`)
}
return await response.json()
} catch (error) {
console.error('[ExpensesAPI] Error fetching transactions:', error)
throw error
}
}
}

View file

@ -95,6 +95,64 @@ export interface UserInfo {
equity_account_name?: string
}
/**
* Account permission for user access control
*/
export interface AccountPermission {
id: string
user_id: string
account_id: string
permission_type: PermissionType
granted_at: string
granted_by: string
expires_at?: string
notes?: string
}
/**
* Grant permission request payload
*/
export interface GrantPermissionRequest {
user_id: string
account_id: string
permission_type: PermissionType
expires_at?: string
notes?: string
}
/**
* Transaction entry from journal (user view)
*/
export interface Transaction {
id: string
date: string
entry_date: string
flag?: string // *, !, #, x for cleared, pending, flagged, voided
description: string
payee?: string
tags: string[]
links: string[]
amount: number // Amount in satoshis
user_id?: string
username?: string
reference?: string
meta?: Record<string, any>
fiat_amount?: number
fiat_currency?: string
}
/**
* Transaction list response with pagination
*/
export interface TransactionListResponse {
entries: Transaction[]
total: number
limit: number
offset: number
has_next: boolean
has_prev: boolean
}
/**
* Module configuration
*/

View file

@ -0,0 +1,366 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useAuth } from '@/composables/useAuthService'
import { useToast } from '@/core/composables/useToast'
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import type { ExpensesAPI } from '../services/ExpensesAPI'
import type { Transaction } from '../types'
import type { FuzzySearchOptions } from '@/composables/useFuzzySearch'
import FuzzySearch from '@/components/ui/fuzzy-search/FuzzySearch.vue'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Separator } from '@/components/ui/separator'
import {
CheckCircle2,
Clock,
Flag,
XCircle,
RefreshCw,
ChevronLeft,
ChevronRight,
Calendar
} from 'lucide-vue-next'
const { user } = useAuth()
const toast = useToast()
const expensesAPI = injectService<ExpensesAPI>(SERVICE_TOKENS.EXPENSES_API)
const transactions = ref<Transaction[]>([])
const isLoading = ref(false)
const selectedDays = ref(5)
const pagination = ref({
total: 0,
limit: 20,
offset: 0,
has_next: false,
has_prev: false
})
const walletKey = computed(() => user.value?.wallets?.[0]?.inkey)
// Fuzzy search state and configuration
const searchResults = ref<Transaction[]>([])
const searchOptions: FuzzySearchOptions<Transaction> = {
fuseOptions: {
keys: [
{ name: 'description', weight: 0.7 }, // Description has highest weight
{ name: 'payee', weight: 0.5 }, // Payee is important
{ name: 'reference', weight: 0.4 }, // Reference helps identification
{ name: 'username', weight: 0.3 }, // Username for filtering
{ name: 'tags', weight: 0.2 } // Tags for categorization
],
threshold: 0.4, // Tolerant of typos
ignoreLocation: true, // Match anywhere in the string
findAllMatches: true, // Find all matches
minMatchCharLength: 2, // Minimum match length
shouldSort: true // Sort by relevance
},
resultLimit: 100, // Show up to 100 results
minSearchLength: 2, // Start searching after 2 characters
matchAllWhenSearchEmpty: true
}
// Transactions to display (search results or all transactions)
const transactionsToDisplay = computed(() => {
return searchResults.value.length > 0 ? searchResults.value : transactions.value
})
// Handle search results
function handleSearchResults(results: Transaction[]) {
searchResults.value = results
}
// Day filter options
const dayOptions = [
{ label: '5 days', value: 5 },
{ label: '30 days', value: 30 },
{ label: '60 days', value: 60 },
{ label: '90 days', value: 90 }
]
// Format date for display
function formatDate(dateString: string): string {
if (!dateString) return '-'
const date = new Date(dateString)
return new Intl.DateTimeFormat('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric'
}).format(date)
}
// Format amount with proper display
function formatAmount(amount: number): string {
return new Intl.NumberFormat('en-US').format(amount)
}
// Get status icon and color based on flag
function getStatusInfo(flag?: string) {
switch (flag) {
case '*':
return { icon: CheckCircle2, color: 'text-green-600', label: 'Cleared' }
case '!':
return { icon: Clock, color: 'text-orange-600', label: 'Pending' }
case '#':
return { icon: Flag, color: 'text-red-600', label: 'Flagged' }
case 'x':
return { icon: XCircle, color: 'text-muted-foreground', label: 'Voided' }
default:
return null
}
}
// Load transactions
async function loadTransactions() {
if (!walletKey.value) {
toast.error('Authentication required', {
description: 'Please log in to view your transactions'
})
return
}
isLoading.value = true
try {
const response = await expensesAPI.getUserTransactions(walletKey.value, {
limit: pagination.value.limit,
offset: pagination.value.offset,
days: selectedDays.value
})
transactions.value = response.entries
pagination.value = {
total: response.total,
limit: response.limit,
offset: response.offset,
has_next: response.has_next,
has_prev: response.has_prev
}
} catch (error) {
console.error('Failed to load transactions:', error)
toast.error('Failed to load transactions', {
description: error instanceof Error ? error.message : 'Unknown error'
})
} finally {
isLoading.value = false
}
}
// Change day filter
function changeDayFilter(days: number) {
selectedDays.value = days
pagination.value.offset = 0 // Reset to first page
loadTransactions()
}
// Next page
function nextPage() {
if (pagination.value.has_next) {
pagination.value.offset += pagination.value.limit
loadTransactions()
}
}
// Previous page
function prevPage() {
if (pagination.value.has_prev) {
pagination.value.offset = Math.max(0, pagination.value.offset - pagination.value.limit)
loadTransactions()
}
}
onMounted(() => {
loadTransactions()
})
</script>
<template>
<div class="container mx-auto p-4 max-w-4xl">
<!-- Header -->
<div class="mb-6">
<h1 class="text-2xl sm:text-3xl font-bold mb-2">My Transactions</h1>
<p class="text-muted-foreground">View your recent transaction history</p>
</div>
<!-- Search Bar -->
<div class="mb-4">
<FuzzySearch
:data="transactions"
:options="searchOptions"
placeholder="Search transactions by description, payee, reference..."
@results="handleSearchResults"
/>
</div>
<!-- Controls -->
<Card class="mb-4">
<CardContent class="pt-6">
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<!-- Day Filter -->
<div class="space-y-2">
<div class="text-sm text-muted-foreground flex items-center gap-2">
<Calendar class="h-4 w-4" />
<span>Show transactions from:</span>
</div>
<div class="flex flex-wrap gap-2">
<Button
v-for="option in dayOptions"
:key="option.value"
:variant="selectedDays === option.value ? 'default' : 'outline'"
size="sm"
@click="changeDayFilter(option.value)"
:disabled="isLoading"
>
{{ option.label }}
</Button>
</div>
</div>
<!-- Refresh Button -->
<Button variant="outline" size="sm" @click="loadTransactions" :disabled="isLoading">
<RefreshCw class="h-4 w-4" :class="{ 'animate-spin': isLoading }" />
</Button>
</div>
</CardContent>
</Card>
<!-- Transactions List -->
<Card>
<CardHeader>
<CardTitle>Recent Transactions</CardTitle>
<CardDescription>
<span v-if="searchResults.length > 0">
Found {{ transactionsToDisplay.length }} matching transaction{{ transactionsToDisplay.length === 1 ? '' : 's' }}
</span>
<span v-else>
Showing {{ pagination.offset + 1 }} -
{{ Math.min(pagination.offset + pagination.limit, pagination.total) }} of
{{ pagination.total }} transactions
</span>
</CardDescription>
</CardHeader>
<CardContent>
<!-- Loading State -->
<div v-if="isLoading" class="text-center py-12">
<RefreshCw class="h-8 w-8 animate-spin mx-auto mb-4 text-muted-foreground" />
<p class="text-muted-foreground">Loading transactions...</p>
</div>
<!-- Empty State -->
<div v-else-if="transactionsToDisplay.length === 0" class="text-center py-12">
<p class="text-muted-foreground">No transactions found</p>
<p class="text-sm text-muted-foreground mt-2">
{{ searchResults.length > 0 ? 'Try a different search term' : 'Try selecting a different time period' }}
</p>
</div>
<!-- Transaction Items (Mobile-Optimized) -->
<div v-else class="space-y-3">
<div
v-for="(transaction, index) in transactionsToDisplay"
:key="transaction.id"
class="border rounded-lg p-4 hover:bg-muted/50 transition-colors"
>
<!-- Transaction Header -->
<div class="flex items-start justify-between gap-3 mb-2">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1">
<!-- Status Icon -->
<component
v-if="getStatusInfo(transaction.flag)"
:is="getStatusInfo(transaction.flag)!.icon"
:class="[
'h-4 w-4 flex-shrink-0',
getStatusInfo(transaction.flag)!.color
]"
/>
<h3 class="font-medium text-sm sm:text-base truncate">
{{ transaction.description }}
</h3>
</div>
<p class="text-xs sm:text-sm text-muted-foreground">
{{ formatDate(transaction.date) }}
</p>
</div>
<!-- Amount -->
<div class="text-right flex-shrink-0">
<p class="font-semibold text-sm sm:text-base">
{{ formatAmount(transaction.amount) }} sats
</p>
<p v-if="transaction.fiat_amount" class="text-xs text-muted-foreground">
{{ transaction.fiat_amount.toFixed(2) }} {{ transaction.fiat_currency }}
</p>
</div>
</div>
<!-- Transaction Details (Collapsible on mobile) -->
<div class="space-y-1 text-xs sm:text-sm">
<!-- Payee -->
<div v-if="transaction.payee" class="text-muted-foreground">
<span class="font-medium">Payee:</span> {{ transaction.payee }}
</div>
<!-- Reference -->
<div v-if="transaction.reference" class="text-muted-foreground">
<span class="font-medium">Ref:</span> {{ transaction.reference }}
</div>
<!-- Username (if available) -->
<div v-if="transaction.username" class="text-muted-foreground">
<span class="font-medium">User:</span> {{ transaction.username }}
</div>
<!-- Tags -->
<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">
{{ tag }}
</Badge>
</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>
<!-- Separator between items (except last) -->
<Separator v-if="index < transactionsToDisplay.length - 1" class="mt-3" />
</div>
</div>
<!-- Pagination (hide when searching) -->
<div
v-if="!isLoading && transactions.length > 0 && searchResults.length === 0 && (pagination.has_next || pagination.has_prev)"
class="flex items-center justify-between mt-6 pt-4 border-t"
>
<Button
variant="outline"
size="sm"
@click="prevPage"
:disabled="!pagination.has_prev || isLoading"
>
<ChevronLeft class="h-4 w-4 mr-1" />
<span class="hidden sm:inline">Previous</span>
</Button>
<span class="text-sm text-muted-foreground">
Page {{ Math.floor(pagination.offset / pagination.limit) + 1 }}
</span>
<Button
variant="outline"
size="sm"
@click="nextPage"
:disabled="!pagination.has_next || isLoading"
>
<span class="hidden sm:inline">Next</span>
<ChevronRight class="h-4 w-4 ml-1" />
</Button>
</div>
</CardContent>
</Card>
</div>
</template>