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:
parent
78fba2a637
commit
be00c61c77
20 changed files with 914 additions and 74 deletions
22
package-lock.json
generated
22
package-lock.json
generated
|
|
@ -25,7 +25,7 @@
|
||||||
"qr-scanner": "^1.4.2",
|
"qr-scanner": "^1.4.2",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"radix-vue": "^1.9.13",
|
"radix-vue": "^1.9.13",
|
||||||
"reka-ui": "^2.5.0",
|
"reka-ui": "^2.6.0",
|
||||||
"tailwind-merge": "^2.6.0",
|
"tailwind-merge": "^2.6.0",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"unique-names-generator": "^4.7.1",
|
"unique-names-generator": "^4.7.1",
|
||||||
|
|
@ -8890,20 +8890,6 @@
|
||||||
"ms": "^2.0.0"
|
"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": {
|
"node_modules/idb": {
|
||||||
"version": "7.1.1",
|
"version": "7.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz",
|
||||||
|
|
@ -12167,9 +12153,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/reka-ui": {
|
"node_modules/reka-ui": {
|
||||||
"version": "2.5.0",
|
"version": "2.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/reka-ui/-/reka-ui-2.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/reka-ui/-/reka-ui-2.6.0.tgz",
|
||||||
"integrity": "sha512-81aMAmJeVCy2k0E6x7n1kypDY6aM1ldLis5+zcdV1/JtoAlSDck5OBsyLRJU9CfgbrQp1ImnRnBSmC4fZ2fkZQ==",
|
"integrity": "sha512-NrGMKrABD97l890mFS3TNUzB0BLUfbL3hh0NjcJRIUSUljb288bx3Mzo31nOyUcdiiW0HqFGXJwyCBh9cWgb0w==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@floating-ui/dom": "^1.6.13",
|
"@floating-ui/dom": "^1.6.13",
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@
|
||||||
"qr-scanner": "^1.4.2",
|
"qr-scanner": "^1.4.2",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"radix-vue": "^1.9.13",
|
"radix-vue": "^1.9.13",
|
||||||
"reka-ui": "^2.5.0",
|
"reka-ui": "^2.6.0",
|
||||||
"tailwind-merge": "^2.6.0",
|
"tailwind-merge": "^2.6.0",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"unique-names-generator": "^4.7.1",
|
"unique-names-generator": "^4.7.1",
|
||||||
|
|
|
||||||
15
src/components/ui/alert-dialog/AlertDialog.vue
Normal file
15
src/components/ui/alert-dialog/AlertDialog.vue
Normal 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>
|
||||||
18
src/components/ui/alert-dialog/AlertDialogAction.vue
Normal file
18
src/components/ui/alert-dialog/AlertDialogAction.vue
Normal 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>
|
||||||
25
src/components/ui/alert-dialog/AlertDialogCancel.vue
Normal file
25
src/components/ui/alert-dialog/AlertDialogCancel.vue
Normal 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>
|
||||||
39
src/components/ui/alert-dialog/AlertDialogContent.vue
Normal file
39
src/components/ui/alert-dialog/AlertDialogContent.vue
Normal 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>
|
||||||
23
src/components/ui/alert-dialog/AlertDialogDescription.vue
Normal file
23
src/components/ui/alert-dialog/AlertDialogDescription.vue
Normal 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>
|
||||||
21
src/components/ui/alert-dialog/AlertDialogFooter.vue
Normal file
21
src/components/ui/alert-dialog/AlertDialogFooter.vue
Normal 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>
|
||||||
16
src/components/ui/alert-dialog/AlertDialogHeader.vue
Normal file
16
src/components/ui/alert-dialog/AlertDialogHeader.vue
Normal 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>
|
||||||
20
src/components/ui/alert-dialog/AlertDialogTitle.vue
Normal file
20
src/components/ui/alert-dialog/AlertDialogTitle.vue
Normal 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>
|
||||||
12
src/components/ui/alert-dialog/AlertDialogTrigger.vue
Normal file
12
src/components/ui/alert-dialog/AlertDialogTrigger.vue
Normal 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>
|
||||||
9
src/components/ui/alert-dialog/index.ts
Normal file
9
src/components/ui/alert-dialog/index.ts
Normal 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"
|
||||||
|
|
@ -1,22 +1,25 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { HTMLAttributes } from 'vue'
|
import type { PrimitiveProps } from "reka-ui"
|
||||||
import { cn } from '@/lib/utils'
|
import type { HTMLAttributes } from "vue"
|
||||||
import { Primitive, type PrimitiveProps } from 'reka-ui'
|
import type { ButtonVariants } from "."
|
||||||
import { type ButtonVariants, buttonVariants } from '.'
|
import { Primitive } from "reka-ui"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { buttonVariants } from "."
|
||||||
|
|
||||||
interface Props extends PrimitiveProps {
|
interface Props extends PrimitiveProps {
|
||||||
variant?: ButtonVariants['variant']
|
variant?: ButtonVariants["variant"]
|
||||||
size?: ButtonVariants['size']
|
size?: ButtonVariants["size"]
|
||||||
class?: HTMLAttributes['class']
|
class?: HTMLAttributes["class"]
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
as: 'button',
|
as: "button",
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Primitive
|
<Primitive
|
||||||
|
data-slot="button"
|
||||||
:as="as"
|
:as="as"
|
||||||
:as-child="asChild"
|
:as-child="asChild"
|
||||||
:class="cn(buttonVariants({ variant, size }), props.class)"
|
:class="cn(buttonVariants({ variant, size }), props.class)"
|
||||||
|
|
|
||||||
|
|
@ -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(
|
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: {
|
variants: {
|
||||||
variant: {
|
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:
|
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:
|
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:
|
secondary:
|
||||||
'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
|
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
|
||||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
ghost:
|
||||||
link: 'text-primary underline-offset-4 hover:underline',
|
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
default: 'h-9 px-4 py-2',
|
"default": "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||||
xs: 'h-7 rounded px-2',
|
"sm": "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||||
sm: 'h-8 rounded-md px-3 text-xs',
|
"lg": "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||||
lg: 'h-10 rounded-md px-8',
|
"icon": "size-9",
|
||||||
icon: 'h-9 w-9',
|
"icon-sm": "size-8",
|
||||||
|
"icon-lg": "size-10",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
variant: 'default',
|
variant: "default",
|
||||||
size: 'default',
|
size: "default",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -61,30 +61,40 @@ export function useModularNavigation() {
|
||||||
|
|
||||||
// Events module items
|
// Events module items
|
||||||
if (appConfig.modules.events.enabled) {
|
if (appConfig.modules.events.enabled) {
|
||||||
items.push({
|
items.push({
|
||||||
name: 'My Tickets',
|
name: 'My Tickets',
|
||||||
href: '/my-tickets',
|
href: '/my-tickets',
|
||||||
icon: 'Ticket',
|
icon: 'Ticket',
|
||||||
requiresAuth: true
|
requiresAuth: true
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Market module items
|
// Market module items
|
||||||
if (appConfig.modules.market.enabled) {
|
if (appConfig.modules.market.enabled) {
|
||||||
items.push({
|
items.push({
|
||||||
name: 'Market Dashboard',
|
name: 'Market Dashboard',
|
||||||
href: '/market-dashboard',
|
href: '/market-dashboard',
|
||||||
icon: 'Store',
|
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)
|
// Base module items (always available)
|
||||||
items.push({
|
items.push({
|
||||||
name: 'Relay Hub Status',
|
name: 'Relay Hub Status',
|
||||||
href: '/relay-hub-status',
|
href: '/relay-hub-status',
|
||||||
icon: 'Activity',
|
icon: 'Activity',
|
||||||
requiresAuth: true
|
requiresAuth: true
|
||||||
})
|
})
|
||||||
|
|
||||||
return items
|
return items
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,48 @@
|
||||||
<template>
|
<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">
|
<DialogContent class="max-w-2xl max-h-[85vh] my-4 overflow-hidden flex flex-col p-0 gap-0">
|
||||||
<!-- Header -->
|
<!-- Success State -->
|
||||||
<DialogHeader class="px-6 pt-6 pb-4 border-b shrink-0">
|
<div v-if="showSuccessDialog" class="flex flex-col items-center justify-center p-8 space-y-6">
|
||||||
<DialogTitle class="flex items-center gap-2">
|
<!-- Success Icon -->
|
||||||
<DollarSign class="h-5 w-5 text-primary" />
|
<div class="rounded-full bg-green-100 dark:bg-green-900/20 p-4">
|
||||||
<span>Add Expense</span>
|
<CheckCircle2 class="h-12 w-12 text-green-600 dark:text-green-400" />
|
||||||
</DialogTitle>
|
</div>
|
||||||
<DialogDescription>
|
|
||||||
Submit an expense for admin approval
|
<!-- Success Message -->
|
||||||
</DialogDescription>
|
<div class="text-center space-y-2">
|
||||||
</DialogHeader>
|
<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 -->
|
<!-- Scrollable Form Content -->
|
||||||
<div class="flex-1 overflow-y-auto px-6 py-4 space-y-4 min-h-0">
|
<div class="flex-1 overflow-y-auto px-6 py-4 space-y-4 min-h-0">
|
||||||
|
|
@ -200,12 +232,14 @@
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
import { useForm } from 'vee-validate'
|
import { useForm } from 'vee-validate'
|
||||||
import { toTypedSchema } from '@vee-validate/zod'
|
import { toTypedSchema } from '@vee-validate/zod'
|
||||||
import * as z from 'zod'
|
import * as z from 'zod'
|
||||||
|
|
@ -236,7 +270,7 @@ import {
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@/components/ui/dialog'
|
} 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 { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
||||||
import { useAuth } from '@/composables/useAuthService'
|
import { useAuth } from '@/composables/useAuthService'
|
||||||
import { useToast } from '@/core/composables/useToast'
|
import { useToast } from '@/core/composables/useToast'
|
||||||
|
|
@ -256,6 +290,7 @@ const emit = defineEmits<Emits>()
|
||||||
const expensesAPI = injectService<ExpensesAPI>(SERVICE_TOKENS.EXPENSES_API)
|
const expensesAPI = injectService<ExpensesAPI>(SERVICE_TOKENS.EXPENSES_API)
|
||||||
const { user } = useAuth()
|
const { user } = useAuth()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
// Component state
|
// Component state
|
||||||
const currentStep = ref(1)
|
const currentStep = ref(1)
|
||||||
|
|
@ -264,6 +299,7 @@ const isSubmitting = ref(false)
|
||||||
const availableCurrencies = ref<string[]>([])
|
const availableCurrencies = ref<string[]>([])
|
||||||
const loadingCurrencies = ref(true)
|
const loadingCurrencies = ref(true)
|
||||||
const userInfo = ref<UserInfo | null>(null)
|
const userInfo = ref<UserInfo | null>(null)
|
||||||
|
const showSuccessDialog = ref(false)
|
||||||
|
|
||||||
// Form schema
|
// Form schema
|
||||||
const formSchema = toTypedSchema(
|
const formSchema = toTypedSchema(
|
||||||
|
|
@ -371,17 +407,16 @@ const onSubmit = form.handleSubmit(async (values) => {
|
||||||
currency: values.currency
|
currency: values.currency
|
||||||
})
|
})
|
||||||
|
|
||||||
// Show success message
|
// Show success dialog instead of toast
|
||||||
toast.success('Expense submitted', { description: 'Your expense is pending admin approval' })
|
showSuccessDialog.value = true
|
||||||
|
|
||||||
// Reset form and close
|
// Reset form for next submission
|
||||||
resetForm()
|
resetForm()
|
||||||
selectedAccount.value = null
|
selectedAccount.value = null
|
||||||
currentStep.value = 1
|
currentStep.value = 1
|
||||||
|
|
||||||
emit('expense-submitted')
|
emit('expense-submitted')
|
||||||
emit('action-complete')
|
// DON'T emit 'action-complete' yet - wait for user to close success dialog
|
||||||
emit('close')
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[AddExpense] Error submitting expense:', error)
|
console.error('[AddExpense] Error submitting expense:', error)
|
||||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
|
@ -390,4 +425,36 @@ const onSubmit = form.handleSubmit(async (values) => {
|
||||||
isSubmitting.value = false
|
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>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -11,12 +11,25 @@ import type { ModulePlugin } from '@/core/types'
|
||||||
import { container, SERVICE_TOKENS } from '@/core/di-container'
|
import { container, SERVICE_TOKENS } from '@/core/di-container'
|
||||||
import { ExpensesAPI } from './services/ExpensesAPI'
|
import { ExpensesAPI } from './services/ExpensesAPI'
|
||||||
import AddExpense from './components/AddExpense.vue'
|
import AddExpense from './components/AddExpense.vue'
|
||||||
|
import TransactionsPage from './views/TransactionsPage.vue'
|
||||||
|
|
||||||
export const expensesModule: ModulePlugin = {
|
export const expensesModule: ModulePlugin = {
|
||||||
name: 'expenses',
|
name: 'expenses',
|
||||||
version: '1.0.0',
|
version: '1.0.0',
|
||||||
dependencies: ['base'],
|
dependencies: ['base'],
|
||||||
|
|
||||||
|
routes: [
|
||||||
|
{
|
||||||
|
path: '/expenses/transactions',
|
||||||
|
name: 'ExpenseTransactions',
|
||||||
|
component: TransactionsPage,
|
||||||
|
meta: {
|
||||||
|
requiresAuth: true,
|
||||||
|
title: 'My Transactions'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
|
||||||
quickActions: [
|
quickActions: [
|
||||||
{
|
{
|
||||||
id: 'add-expense',
|
id: 'add-expense',
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,10 @@ import type {
|
||||||
ExpenseEntryRequest,
|
ExpenseEntryRequest,
|
||||||
ExpenseEntry,
|
ExpenseEntry,
|
||||||
AccountNode,
|
AccountNode,
|
||||||
UserInfo
|
UserInfo,
|
||||||
|
AccountPermission,
|
||||||
|
GrantPermissionRequest,
|
||||||
|
TransactionListResponse
|
||||||
} from '../types'
|
} from '../types'
|
||||||
import { appConfig } from '@/app.config'
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -95,6 +95,64 @@ export interface UserInfo {
|
||||||
equity_account_name?: string
|
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
|
* Module configuration
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
366
src/modules/expenses/views/TransactionsPage.vue
Normal file
366
src/modules/expenses/views/TransactionsPage.vue
Normal 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>
|
||||||
Loading…
Add table
Add a link
Reference in a new issue