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",
|
||||
"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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
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">
|
||||
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)"
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
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