Compare commits
22 commits
67a070e9b3
...
08f1743557
| Author | SHA1 | Date | |
|---|---|---|---|
| 08f1743557 | |||
| 148e92b7aa | |||
| 2ea6afe487 | |||
| d029660ef0 | |||
| ef1acc17c3 | |||
| 933a166d05 | |||
| a12ed8dd6a | |||
| 5517aebb6a | |||
| 0f133119c4 | |||
| 5aced3f2c9 | |||
| 78e3d56d76 | |||
| a605b31c5f | |||
| 00959074da | |||
| b4dd7f2bbc | |||
| b19fa98d5b | |||
| fa1dff9de4 | |||
| 1f20d5f00c | |||
| 75dfd8a541 | |||
| 4af220adda | |||
| 1fbf7b3d26 | |||
| e9195978c1 | |||
| 4c704e5a41 |
6 changed files with 128 additions and 856 deletions
|
|
@ -98,8 +98,11 @@ async function loadData() {
|
||||||
totalIncomeSats.value = balanceData.total_income_sats || 0
|
totalIncomeSats.value = balanceData.total_income_sats || 0
|
||||||
totalIncomeFiat.value = balanceData.total_income_fiat || {}
|
totalIncomeFiat.value = balanceData.total_income_fiat || {}
|
||||||
|
|
||||||
// Filter for pending transactions (flag = '!')
|
// Filter for pending transactions (flag = '!'), excluding voided ones
|
||||||
pendingTransactions.value = txData.entries.filter(tx => tx.flag === '!')
|
// (libra convention: voided keeps '!' flag and carries a 'voided' tag).
|
||||||
|
pendingTransactions.value = txData.entries.filter(
|
||||||
|
tx => tx.flag === '!' && !tx.tags?.includes('voided')
|
||||||
|
)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[BalancePage] Error loading data:', error)
|
console.error('[BalancePage] Error loading data:', error)
|
||||||
toast.error('Failed to load balance data')
|
toast.error('Failed to load balance data')
|
||||||
|
|
|
||||||
|
|
@ -1,256 +0,0 @@
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, computed } from 'vue'
|
|
||||||
import { useForm } from 'vee-validate'
|
|
||||||
import { toTypedSchema } from '@vee-validate/zod'
|
|
||||||
import * as z from 'zod'
|
|
||||||
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 { Account } from '../../types'
|
|
||||||
import { PermissionType } from '../../types'
|
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
|
||||||
import {
|
|
||||||
FormControl,
|
|
||||||
FormDescription,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage
|
|
||||||
} from '@/components/ui/form'
|
|
||||||
import { Input } from '@/components/ui/input'
|
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
|
||||||
import { Loader2 } from 'lucide-vue-next'
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
isOpen: boolean
|
|
||||||
accounts: Account[]
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = defineProps<Props>()
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
close: []
|
|
||||||
permissionGranted: []
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const { user } = useAuth()
|
|
||||||
const toast = useToast()
|
|
||||||
const expensesAPI = injectService<ExpensesAPI>(SERVICE_TOKENS.EXPENSES_API)
|
|
||||||
|
|
||||||
const isGranting = ref(false)
|
|
||||||
|
|
||||||
const adminKey = computed(() => user.value?.wallets?.[0]?.adminkey)
|
|
||||||
|
|
||||||
// Form schema
|
|
||||||
const formSchema = toTypedSchema(
|
|
||||||
z.object({
|
|
||||||
user_id: z.string().min(1, 'User ID is required'),
|
|
||||||
account_id: z.string().min(1, 'Account is required'),
|
|
||||||
permission_type: z.nativeEnum(PermissionType, {
|
|
||||||
errorMap: () => ({ message: 'Permission type is required' })
|
|
||||||
}),
|
|
||||||
notes: z.string().optional(),
|
|
||||||
expires_at: z.string().optional()
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
// Setup form
|
|
||||||
const form = useForm({
|
|
||||||
validationSchema: formSchema,
|
|
||||||
initialValues: {
|
|
||||||
user_id: '',
|
|
||||||
account_id: '',
|
|
||||||
permission_type: PermissionType.READ,
|
|
||||||
notes: '',
|
|
||||||
expires_at: ''
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const { resetForm, meta } = form
|
|
||||||
const isFormValid = computed(() => meta.value.valid)
|
|
||||||
|
|
||||||
// Permission type options
|
|
||||||
const permissionTypes = [
|
|
||||||
{ value: PermissionType.READ, label: 'Read', description: 'View account and balance' },
|
|
||||||
{
|
|
||||||
value: PermissionType.SUBMIT_EXPENSE,
|
|
||||||
label: 'Submit Expense',
|
|
||||||
description: 'Submit expenses to this account'
|
|
||||||
},
|
|
||||||
{ value: PermissionType.MANAGE, label: 'Manage', description: 'Full account management' }
|
|
||||||
]
|
|
||||||
|
|
||||||
// Submit form
|
|
||||||
const onSubmit = form.handleSubmit(async (values) => {
|
|
||||||
if (!adminKey.value) {
|
|
||||||
toast.error('Admin access required')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
isGranting.value = true
|
|
||||||
|
|
||||||
try {
|
|
||||||
await expensesAPI.grantPermission(adminKey.value, {
|
|
||||||
user_id: values.user_id,
|
|
||||||
account_id: values.account_id,
|
|
||||||
permission_type: values.permission_type,
|
|
||||||
notes: values.notes || undefined,
|
|
||||||
expires_at: values.expires_at || undefined
|
|
||||||
})
|
|
||||||
|
|
||||||
emit('permissionGranted')
|
|
||||||
resetForm()
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to grant permission:', error)
|
|
||||||
toast.error('Failed to grant permission', {
|
|
||||||
description: error instanceof Error ? error.message : 'Unknown error'
|
|
||||||
})
|
|
||||||
} finally {
|
|
||||||
isGranting.value = false
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Handle dialog close
|
|
||||||
function handleClose() {
|
|
||||||
if (!isGranting.value) {
|
|
||||||
resetForm()
|
|
||||||
emit('close')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<Dialog :open="props.isOpen" @update:open="handleClose">
|
|
||||||
<DialogContent class="sm:max-w-[500px]">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Grant Account Permission</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Grant a user permission to access an expense account. Permissions on parent accounts
|
|
||||||
cascade to children.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<form @submit="onSubmit" class="space-y-4">
|
|
||||||
<!-- User ID -->
|
|
||||||
<FormField v-slot="{ componentField }" name="user_id">
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>User ID *</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
placeholder="Enter user wallet ID"
|
|
||||||
v-bind="componentField"
|
|
||||||
:disabled="isGranting"
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>The wallet ID of the user to grant permission to</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
<!-- Account -->
|
|
||||||
<FormField v-slot="{ componentField }" name="account_id">
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Account *</FormLabel>
|
|
||||||
<Select v-bind="componentField" :disabled="isGranting">
|
|
||||||
<FormControl>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="Select account" />
|
|
||||||
</SelectTrigger>
|
|
||||||
</FormControl>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem
|
|
||||||
v-for="account in props.accounts"
|
|
||||||
:key="account.id"
|
|
||||||
:value="account.id"
|
|
||||||
>
|
|
||||||
{{ account.name }}
|
|
||||||
</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<FormDescription>Account to grant access to</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
<!-- Permission Type -->
|
|
||||||
<FormField v-slot="{ componentField }" name="permission_type">
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Permission Type *</FormLabel>
|
|
||||||
<Select v-bind="componentField" :disabled="isGranting">
|
|
||||||
<FormControl>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="Select permission type" />
|
|
||||||
</SelectTrigger>
|
|
||||||
</FormControl>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem
|
|
||||||
v-for="type in permissionTypes"
|
|
||||||
:key="type.value"
|
|
||||||
:value="type.value"
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<div class="font-medium">{{ type.label }}</div>
|
|
||||||
<div class="text-sm text-muted-foreground">{{ type.description }}</div>
|
|
||||||
</div>
|
|
||||||
</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<FormDescription>Type of permission to grant</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
<!-- Expiration Date (Optional) -->
|
|
||||||
<FormField v-slot="{ componentField }" name="expires_at">
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Expiration Date (Optional)</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
type="datetime-local"
|
|
||||||
v-bind="componentField"
|
|
||||||
:disabled="isGranting"
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>Leave empty for permanent access</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
<!-- Notes (Optional) -->
|
|
||||||
<FormField v-slot="{ componentField }" name="notes">
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Notes (Optional)</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Textarea
|
|
||||||
placeholder="Add notes about this permission..."
|
|
||||||
v-bind="componentField"
|
|
||||||
:disabled="isGranting"
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>Optional notes for admin reference</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
@click="handleClose"
|
|
||||||
:disabled="isGranting"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button type="submit" :disabled="isGranting || !isFormValid">
|
|
||||||
<Loader2 v-if="isGranting" class="mr-2 h-4 w-4 animate-spin" />
|
|
||||||
{{ isGranting ? 'Granting...' : 'Grant Permission' }}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</form>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</template>
|
|
||||||
|
|
@ -1,399 +0,0 @@
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, onMounted, computed } 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 { AccountPermission, Account } from '../../types'
|
|
||||||
import { PermissionType } from '../../types'
|
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
|
||||||
import { Badge } from '@/components/ui/badge'
|
|
||||||
import { Loader2, Plus, Trash2, Users, Shield } from 'lucide-vue-next'
|
|
||||||
import {
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow
|
|
||||||
} from '@/components/ui/table'
|
|
||||||
import {
|
|
||||||
AlertDialog,
|
|
||||||
AlertDialogAction,
|
|
||||||
AlertDialogCancel,
|
|
||||||
AlertDialogContent,
|
|
||||||
AlertDialogDescription,
|
|
||||||
AlertDialogFooter,
|
|
||||||
AlertDialogHeader,
|
|
||||||
AlertDialogTitle
|
|
||||||
} from '@/components/ui/alert-dialog'
|
|
||||||
|
|
||||||
import GrantPermissionDialog from './GrantPermissionDialog.vue'
|
|
||||||
|
|
||||||
const { user } = useAuth()
|
|
||||||
const toast = useToast()
|
|
||||||
const expensesAPI = injectService<ExpensesAPI>(SERVICE_TOKENS.EXPENSES_API)
|
|
||||||
|
|
||||||
const permissions = ref<AccountPermission[]>([])
|
|
||||||
const accounts = ref<Account[]>([])
|
|
||||||
const isLoading = ref(false)
|
|
||||||
const showGrantDialog = ref(false)
|
|
||||||
const permissionToRevoke = ref<AccountPermission | null>(null)
|
|
||||||
const showRevokeDialog = ref(false)
|
|
||||||
|
|
||||||
const adminKey = computed(() => user.value?.wallets?.[0]?.adminkey)
|
|
||||||
|
|
||||||
// Get permission type badge variant
|
|
||||||
function getPermissionBadge(type: PermissionType) {
|
|
||||||
switch (type) {
|
|
||||||
case PermissionType.READ:
|
|
||||||
return 'default'
|
|
||||||
case PermissionType.SUBMIT_EXPENSE:
|
|
||||||
return 'secondary'
|
|
||||||
case PermissionType.MANAGE:
|
|
||||||
return 'destructive'
|
|
||||||
default:
|
|
||||||
return 'outline'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get permission type label
|
|
||||||
function getPermissionLabel(type: PermissionType): string {
|
|
||||||
switch (type) {
|
|
||||||
case PermissionType.READ:
|
|
||||||
return 'Read'
|
|
||||||
case PermissionType.SUBMIT_EXPENSE:
|
|
||||||
return 'Submit Expense'
|
|
||||||
case PermissionType.MANAGE:
|
|
||||||
return 'Manage'
|
|
||||||
default:
|
|
||||||
return type
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get account name by ID
|
|
||||||
function getAccountName(accountId: string): string {
|
|
||||||
const account = accounts.value.find((a) => a.id === accountId)
|
|
||||||
return account?.name || accountId
|
|
||||||
}
|
|
||||||
|
|
||||||
// Format date
|
|
||||||
function formatDate(dateString: string): string {
|
|
||||||
const date = new Date(dateString)
|
|
||||||
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load accounts
|
|
||||||
async function loadAccounts() {
|
|
||||||
if (!adminKey.value) return
|
|
||||||
|
|
||||||
try {
|
|
||||||
accounts.value = await expensesAPI.getAccounts(adminKey.value)
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load accounts:', error)
|
|
||||||
toast.error('Failed to load accounts', {
|
|
||||||
description: error instanceof Error ? error.message : 'Unknown error'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load all permissions
|
|
||||||
async function loadPermissions() {
|
|
||||||
if (!adminKey.value) {
|
|
||||||
toast.error('Admin access required', {
|
|
||||||
description: 'You need admin privileges to manage permissions'
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
isLoading.value = true
|
|
||||||
try {
|
|
||||||
permissions.value = await expensesAPI.listPermissions(adminKey.value)
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load permissions:', error)
|
|
||||||
toast.error('Failed to load permissions', {
|
|
||||||
description: error instanceof Error ? error.message : 'Unknown error'
|
|
||||||
})
|
|
||||||
} finally {
|
|
||||||
isLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle permission granted
|
|
||||||
function handlePermissionGranted() {
|
|
||||||
showGrantDialog.value = false
|
|
||||||
loadPermissions()
|
|
||||||
toast.success('Permission granted', {
|
|
||||||
description: 'User permission has been successfully granted'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Confirm revoke permission
|
|
||||||
function confirmRevoke(permission: AccountPermission) {
|
|
||||||
permissionToRevoke.value = permission
|
|
||||||
showRevokeDialog.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Revoke permission
|
|
||||||
async function revokePermission() {
|
|
||||||
if (!adminKey.value || !permissionToRevoke.value) return
|
|
||||||
|
|
||||||
try {
|
|
||||||
await expensesAPI.revokePermission(adminKey.value, permissionToRevoke.value.id)
|
|
||||||
toast.success('Permission revoked', {
|
|
||||||
description: 'User permission has been successfully revoked'
|
|
||||||
})
|
|
||||||
loadPermissions()
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to revoke permission:', error)
|
|
||||||
toast.error('Failed to revoke permission', {
|
|
||||||
description: error instanceof Error ? error.message : 'Unknown error'
|
|
||||||
})
|
|
||||||
} finally {
|
|
||||||
showRevokeDialog.value = false
|
|
||||||
permissionToRevoke.value = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Group permissions by user
|
|
||||||
const permissionsByUser = computed(() => {
|
|
||||||
const grouped = new Map<string, AccountPermission[]>()
|
|
||||||
|
|
||||||
for (const permission of permissions.value) {
|
|
||||||
const existing = grouped.get(permission.user_id) || []
|
|
||||||
existing.push(permission)
|
|
||||||
grouped.set(permission.user_id, existing)
|
|
||||||
}
|
|
||||||
|
|
||||||
return grouped
|
|
||||||
})
|
|
||||||
|
|
||||||
// Group permissions by account
|
|
||||||
const permissionsByAccount = computed(() => {
|
|
||||||
const grouped = new Map<string, AccountPermission[]>()
|
|
||||||
|
|
||||||
for (const permission of permissions.value) {
|
|
||||||
const existing = grouped.get(permission.account_id) || []
|
|
||||||
existing.push(permission)
|
|
||||||
grouped.set(permission.account_id, existing)
|
|
||||||
}
|
|
||||||
|
|
||||||
return grouped
|
|
||||||
})
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
loadAccounts()
|
|
||||||
loadPermissions()
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="container mx-auto p-6 space-y-6">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h1 class="text-3xl font-bold">Permission Management</h1>
|
|
||||||
<p class="text-muted-foreground">
|
|
||||||
Manage user access to expense accounts
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button @click="showGrantDialog = true" :disabled="isLoading">
|
|
||||||
<Plus class="mr-2 h-4 w-4" />
|
|
||||||
Grant Permission
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle class="flex items-center gap-2">
|
|
||||||
<Shield class="h-5 w-5" />
|
|
||||||
Account Permissions
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
View and manage all account permissions. Permissions on parent accounts cascade to
|
|
||||||
children.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<Tabs default-value="by-user" class="w-full">
|
|
||||||
<TabsList class="grid w-full grid-cols-2">
|
|
||||||
<TabsTrigger value="by-user">
|
|
||||||
<Users class="mr-2 h-4 w-4" />
|
|
||||||
By User
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="by-account">
|
|
||||||
<Shield class="mr-2 h-4 w-4" />
|
|
||||||
By Account
|
|
||||||
</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
|
|
||||||
<!-- By User View -->
|
|
||||||
<TabsContent value="by-user" class="space-y-4">
|
|
||||||
<div v-if="isLoading" class="flex items-center justify-center py-8">
|
|
||||||
<Loader2 class="h-8 w-8 animate-spin text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else-if="permissionsByUser.size === 0" class="text-center py-8">
|
|
||||||
<p class="text-muted-foreground">No permissions granted yet</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else class="space-y-4">
|
|
||||||
<div
|
|
||||||
v-for="[userId, userPermissions] in permissionsByUser"
|
|
||||||
:key="userId"
|
|
||||||
class="border rounded-lg p-4"
|
|
||||||
>
|
|
||||||
<h3 class="font-semibold mb-2">User: {{ userId }}</h3>
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead>Account</TableHead>
|
|
||||||
<TableHead>Permission</TableHead>
|
|
||||||
<TableHead>Granted</TableHead>
|
|
||||||
<TableHead>Expires</TableHead>
|
|
||||||
<TableHead>Notes</TableHead>
|
|
||||||
<TableHead class="text-right">Actions</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
<TableRow v-for="permission in userPermissions" :key="permission.id">
|
|
||||||
<TableCell class="font-medium">
|
|
||||||
{{ getAccountName(permission.account_id) }}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Badge :variant="getPermissionBadge(permission.permission_type)">
|
|
||||||
{{ getPermissionLabel(permission.permission_type) }}
|
|
||||||
</Badge>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>{{ formatDate(permission.granted_at) }}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
{{ permission.expires_at ? formatDate(permission.expires_at) : 'Never' }}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<span class="text-sm text-muted-foreground">
|
|
||||||
{{ permission.notes || '-' }}
|
|
||||||
</span>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell class="text-right">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
@click="confirmRevoke(permission)"
|
|
||||||
:disabled="isLoading"
|
|
||||||
>
|
|
||||||
<Trash2 class="h-4 w-4 text-destructive" />
|
|
||||||
</Button>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<!-- By Account View -->
|
|
||||||
<TabsContent value="by-account" class="space-y-4">
|
|
||||||
<div v-if="isLoading" class="flex items-center justify-center py-8">
|
|
||||||
<Loader2 class="h-8 w-8 animate-spin text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else-if="permissionsByAccount.size === 0" class="text-center py-8">
|
|
||||||
<p class="text-muted-foreground">No permissions granted yet</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else class="space-y-4">
|
|
||||||
<div
|
|
||||||
v-for="[accountId, accountPermissions] in permissionsByAccount"
|
|
||||||
:key="accountId"
|
|
||||||
class="border rounded-lg p-4"
|
|
||||||
>
|
|
||||||
<h3 class="font-semibold mb-2">Account: {{ getAccountName(accountId) }}</h3>
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead>User</TableHead>
|
|
||||||
<TableHead>Permission</TableHead>
|
|
||||||
<TableHead>Granted</TableHead>
|
|
||||||
<TableHead>Expires</TableHead>
|
|
||||||
<TableHead>Notes</TableHead>
|
|
||||||
<TableHead class="text-right">Actions</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
<TableRow v-for="permission in accountPermissions" :key="permission.id">
|
|
||||||
<TableCell class="font-medium">{{ permission.user_id }}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Badge :variant="getPermissionBadge(permission.permission_type)">
|
|
||||||
{{ getPermissionLabel(permission.permission_type) }}
|
|
||||||
</Badge>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>{{ formatDate(permission.granted_at) }}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
{{ permission.expires_at ? formatDate(permission.expires_at) : 'Never' }}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<span class="text-sm text-muted-foreground">
|
|
||||||
{{ permission.notes || '-' }}
|
|
||||||
</span>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell class="text-right">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
@click="confirmRevoke(permission)"
|
|
||||||
:disabled="isLoading"
|
|
||||||
>
|
|
||||||
<Trash2 class="h-4 w-4 text-destructive" />
|
|
||||||
</Button>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<!-- Grant Permission Dialog -->
|
|
||||||
<GrantPermissionDialog
|
|
||||||
:is-open="showGrantDialog"
|
|
||||||
:accounts="accounts"
|
|
||||||
@close="showGrantDialog = false"
|
|
||||||
@permission-granted="handlePermissionGranted"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Revoke Confirmation Dialog -->
|
|
||||||
<AlertDialog :open="showRevokeDialog" @update:open="showRevokeDialog = $event">
|
|
||||||
<AlertDialogContent>
|
|
||||||
<AlertDialogHeader>
|
|
||||||
<AlertDialogTitle>Revoke Permission?</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription>
|
|
||||||
Are you sure you want to revoke this permission? The user will immediately lose access.
|
|
||||||
<div v-if="permissionToRevoke" class="mt-4 p-4 bg-muted rounded-md">
|
|
||||||
<p class="font-medium">Permission Details:</p>
|
|
||||||
<p class="text-sm mt-2">
|
|
||||||
<strong>User:</strong> {{ permissionToRevoke.user_id }}
|
|
||||||
</p>
|
|
||||||
<p class="text-sm">
|
|
||||||
<strong>Account:</strong> {{ getAccountName(permissionToRevoke.account_id) }}
|
|
||||||
</p>
|
|
||||||
<p class="text-sm">
|
|
||||||
<strong>Type:</strong> {{ getPermissionLabel(permissionToRevoke.permission_type) }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
||||||
<AlertDialogAction @click="revokePermission" class="bg-destructive text-destructive-foreground hover:bg-destructive/90">
|
|
||||||
Revoke
|
|
||||||
</AlertDialogAction>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
@ -11,8 +11,6 @@ import type {
|
||||||
IncomeEntry,
|
IncomeEntry,
|
||||||
AccountNode,
|
AccountNode,
|
||||||
UserInfo,
|
UserInfo,
|
||||||
AccountPermission,
|
|
||||||
GrantPermissionRequest,
|
|
||||||
TransactionListResponse
|
TransactionListResponse
|
||||||
} from '../types'
|
} from '../types'
|
||||||
import { appConfig } from '@/app.config'
|
import { appConfig } from '@/app.config'
|
||||||
|
|
@ -343,93 +341,6 @@ 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}/libra/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}/libra/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}/libra/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
|
* Get user's transactions from journal
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -28,25 +28,6 @@ export interface Account {
|
||||||
has_children?: boolean
|
has_children?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Account with user-specific permission metadata
|
|
||||||
* (Will be available once libra API implements permissions)
|
|
||||||
*/
|
|
||||||
export interface AccountWithPermissions extends Account {
|
|
||||||
user_permissions?: PermissionType[]
|
|
||||||
inherited_from?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Permission types for account access control
|
|
||||||
*/
|
|
||||||
export enum PermissionType {
|
|
||||||
READ = 'read',
|
|
||||||
SUBMIT_EXPENSE = 'submit_expense',
|
|
||||||
SUBMIT_INCOME = 'submit_income',
|
|
||||||
MANAGE = 'manage'
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Expense entry request payload
|
* Expense entry request payload
|
||||||
*/
|
*/
|
||||||
|
|
@ -125,31 +106,6 @@ 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)
|
* Transaction entry from journal (user view)
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -12,10 +12,6 @@ import { Button } from '@/components/ui/button'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import {
|
import {
|
||||||
CheckCircle2,
|
|
||||||
Clock,
|
|
||||||
Flag,
|
|
||||||
XCircle,
|
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Calendar,
|
Calendar,
|
||||||
Filter
|
Filter
|
||||||
|
|
@ -30,14 +26,26 @@ const isLoading = ref(false)
|
||||||
const dateRangeType = ref<number | 'custom'>(15) // 15, 30, 60, or 'custom'
|
const dateRangeType = ref<number | 'custom'>(15) // 15, 30, 60, or 'custom'
|
||||||
const customStartDate = ref<string>('')
|
const customStartDate = ref<string>('')
|
||||||
const customEndDate = ref<string>('')
|
const customEndDate = ref<string>('')
|
||||||
const typeFilter = ref<'all' | 'income' | 'expense'>('all')
|
// Each chip is an inclusion toggle for one bucket of rows. Every row
|
||||||
|
// belongs to exactly one bucket (voided rows go to 'voided' regardless
|
||||||
|
// of their income/expense type). Default hides voided.
|
||||||
|
type Category = 'income' | 'expense' | 'voided'
|
||||||
|
|
||||||
const typeFilterOptions = [
|
const activeCategories = ref<Set<Category>>(new Set<Category>(['income', 'expense']))
|
||||||
{ label: 'All', value: 'all' as const },
|
|
||||||
{ label: 'Income', value: 'income' as const },
|
const categoryChips: { label: string; value: Category }[] = [
|
||||||
{ label: 'Expenses', value: 'expense' as const }
|
{ label: 'Income', value: 'income' },
|
||||||
|
{ label: 'Expenses', value: 'expense' },
|
||||||
|
{ label: 'Voided', value: 'voided' }
|
||||||
]
|
]
|
||||||
|
|
||||||
|
function toggleCategory(cat: Category) {
|
||||||
|
const next = new Set(activeCategories.value)
|
||||||
|
if (next.has(cat)) next.delete(cat)
|
||||||
|
else next.add(cat)
|
||||||
|
activeCategories.value = next
|
||||||
|
}
|
||||||
|
|
||||||
function isIncome(t: Transaction): boolean {
|
function isIncome(t: Transaction): boolean {
|
||||||
return t.tags?.includes('income-entry') ?? false
|
return t.tags?.includes('income-entry') ?? false
|
||||||
}
|
}
|
||||||
|
|
@ -46,6 +54,22 @@ function isExpense(t: Transaction): boolean {
|
||||||
return t.tags?.includes('expense-entry') ?? false
|
return t.tags?.includes('expense-entry') ?? false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isVoided(t: Transaction): boolean {
|
||||||
|
return t.tags?.includes('voided') ?? false
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPending(t: Transaction): boolean {
|
||||||
|
return t.flag === '!' && !isVoided(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Which chip bucket a row falls into. Voided always wins over type.
|
||||||
|
function getBucket(t: Transaction): Category | null {
|
||||||
|
if (isVoided(t)) return 'voided'
|
||||||
|
if (isIncome(t)) return 'income'
|
||||||
|
if (isExpense(t)) return 'expense'
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
const walletKey = computed(() => user.value?.wallets?.[0]?.inkey)
|
const walletKey = computed(() => user.value?.wallets?.[0]?.inkey)
|
||||||
|
|
||||||
// Fuzzy search state and configuration
|
// Fuzzy search state and configuration
|
||||||
|
|
@ -71,12 +95,13 @@ const searchOptions: FuzzySearchOptions<Transaction> = {
|
||||||
matchAllWhenSearchEmpty: true
|
matchAllWhenSearchEmpty: true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Transactions to display (search results or all transactions), filtered by type
|
// Transactions to display: row passes if its bucket's chip is active.
|
||||||
const transactionsToDisplay = computed(() => {
|
const transactionsToDisplay = computed(() => {
|
||||||
const base = searchResults.value.length > 0 ? searchResults.value : transactions.value
|
const base = searchResults.value.length > 0 ? searchResults.value : transactions.value
|
||||||
if (typeFilter.value === 'income') return base.filter(isIncome)
|
return base.filter(t => {
|
||||||
if (typeFilter.value === 'expense') return base.filter(isExpense)
|
const bucket = getBucket(t)
|
||||||
return base
|
return bucket !== null && activeCategories.value.has(bucket)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// Handle search results
|
// Handle search results
|
||||||
|
|
@ -108,20 +133,28 @@ function formatAmount(amount: number): string {
|
||||||
return new Intl.NumberFormat('en-US').format(amount)
|
return new Intl.NumberFormat('en-US').format(amount)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get status icon and color based on flag
|
// Income gets a leading '+', expense a leading '-'.
|
||||||
function getStatusInfo(flag?: string) {
|
function getAmountSign(t: Transaction): string {
|
||||||
switch (flag) {
|
if (isIncome(t)) return '+'
|
||||||
case '*':
|
if (isExpense(t)) return '-'
|
||||||
return { icon: CheckCircle2, color: 'text-green-600', label: 'Cleared' }
|
return ''
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Color tint for the amount text. Voided entries drop to muted regardless
|
||||||
|
// of type since the strike-through carries the "ignore this" signal.
|
||||||
|
function getAmountColorClass(t: Transaction): string {
|
||||||
|
if (isVoided(t)) return 'line-through text-muted-foreground'
|
||||||
|
if (isIncome(t)) return 'text-green-600 dark:text-green-400'
|
||||||
|
if (isExpense(t)) return 'text-red-600 dark:text-red-400'
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tags that drive other visual channels (border / sign / strike-through) —
|
||||||
|
// suppressed from the badge row so it only carries user-added tags.
|
||||||
|
const TYPE_TAGS = new Set(['income-entry', 'expense-entry', 'voided'])
|
||||||
|
|
||||||
|
function getDisplayTags(t: Transaction): string[] {
|
||||||
|
return (t.tags ?? []).filter(tag => !TYPE_TAGS.has(tag))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load transactions
|
// Load transactions
|
||||||
|
|
@ -224,19 +257,20 @@ onMounted(() => {
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Type Filter (All / Income / Expenses) -->
|
<!-- Category chips: each chip toggles inclusion of one bucket
|
||||||
|
of rows. Defaults: Income + Expenses on, Voided off. -->
|
||||||
<div class="flex items-center gap-2 flex-wrap">
|
<div class="flex items-center gap-2 flex-wrap">
|
||||||
<Filter class="h-4 w-4 text-muted-foreground" />
|
<Filter class="h-4 w-4 text-muted-foreground" />
|
||||||
<Button
|
<Button
|
||||||
v-for="option in typeFilterOptions"
|
v-for="chip in categoryChips"
|
||||||
:key="option.value"
|
:key="chip.value"
|
||||||
:variant="typeFilter === option.value ? 'default' : 'outline'"
|
:variant="activeCategories.has(chip.value) ? 'default' : 'outline'"
|
||||||
size="sm"
|
size="sm"
|
||||||
class="h-7 md:h-8 px-3 text-xs"
|
class="h-7 md:h-8 px-3 text-xs"
|
||||||
@click="typeFilter = option.value"
|
@click="toggleCategory(chip.value)"
|
||||||
:disabled="isLoading"
|
:disabled="isLoading"
|
||||||
>
|
>
|
||||||
{{ option.label }}
|
{{ chip.label }}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -291,7 +325,7 @@ onMounted(() => {
|
||||||
Found {{ transactionsToDisplay.length }} matching transaction{{ transactionsToDisplay.length === 1 ? '' : 's' }}
|
Found {{ transactionsToDisplay.length }} matching transaction{{ transactionsToDisplay.length === 1 ? '' : 's' }}
|
||||||
</span>
|
</span>
|
||||||
<span v-else>
|
<span v-else>
|
||||||
{{ transactions.length }} transaction{{ transactions.length === 1 ? '' : 's' }}
|
{{ transactionsToDisplay.length }} transaction{{ transactionsToDisplay.length === 1 ? '' : 's' }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -307,7 +341,9 @@ onMounted(() => {
|
||||||
<div v-else-if="transactionsToDisplay.length === 0" class="text-center py-12 px-4">
|
<div v-else-if="transactionsToDisplay.length === 0" class="text-center py-12 px-4">
|
||||||
<p class="text-muted-foreground">No transactions found</p>
|
<p class="text-muted-foreground">No transactions found</p>
|
||||||
<p class="text-sm text-muted-foreground mt-2">
|
<p class="text-sm text-muted-foreground mt-2">
|
||||||
{{ searchResults.length > 0 ? 'Try a different search term' : 'Try selecting a different time period' }}
|
<template v-if="searchResults.length > 0">Try a different search term</template>
|
||||||
|
<template v-else-if="activeCategories.size === 0">Select a category above to see transactions</template>
|
||||||
|
<template v-else>Try selecting a different time period or toggling more categories</template>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -316,29 +352,19 @@ onMounted(() => {
|
||||||
<div
|
<div
|
||||||
v-for="transaction in transactionsToDisplay"
|
v-for="transaction in transactionsToDisplay"
|
||||||
:key="transaction.id"
|
:key="transaction.id"
|
||||||
:class="[
|
class="border-b md:border md:rounded-lg p-4 hover:bg-muted/50 transition-colors"
|
||||||
'border-b md:border md:rounded-lg p-4 hover:bg-muted/50 transition-colors',
|
|
||||||
isIncome(transaction) && 'border-l-4 border-l-green-600',
|
|
||||||
isExpense(transaction) && 'border-l-4 border-l-red-600'
|
|
||||||
]"
|
|
||||||
>
|
>
|
||||||
<!-- Transaction Header -->
|
<!-- Transaction Header -->
|
||||||
<div class="flex items-start justify-between gap-3 mb-2">
|
<div class="flex items-start justify-between gap-3 mb-2">
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<div class="flex items-center gap-2 mb-1">
|
<h3
|
||||||
<!-- Status Icon -->
|
|
||||||
<component
|
|
||||||
v-if="getStatusInfo(transaction.flag)"
|
|
||||||
:is="getStatusInfo(transaction.flag)!.icon"
|
|
||||||
:class="[
|
:class="[
|
||||||
'h-4 w-4 flex-shrink-0',
|
'font-medium text-sm sm:text-base truncate mb-1',
|
||||||
getStatusInfo(transaction.flag)!.color
|
isVoided(transaction) && 'line-through text-muted-foreground'
|
||||||
]"
|
]"
|
||||||
/>
|
>
|
||||||
<h3 class="font-medium text-sm sm:text-base truncate">
|
|
||||||
{{ transaction.description }}
|
{{ transaction.description }}
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
|
||||||
<p class="text-xs sm:text-sm text-muted-foreground">
|
<p class="text-xs sm:text-sm text-muted-foreground">
|
||||||
{{ formatDate(transaction.date) }}
|
{{ formatDate(transaction.date) }}
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -346,11 +372,17 @@ onMounted(() => {
|
||||||
|
|
||||||
<!-- Amount -->
|
<!-- Amount -->
|
||||||
<div class="text-right flex-shrink-0">
|
<div class="text-right flex-shrink-0">
|
||||||
<p class="font-semibold text-sm sm:text-base">
|
<p :class="['font-semibold text-sm sm:text-base', getAmountColorClass(transaction)]">
|
||||||
{{ formatAmount(transaction.amount) }} sats
|
{{ getAmountSign(transaction) }}{{ formatAmount(transaction.amount) }} sats
|
||||||
</p>
|
</p>
|
||||||
<p v-if="transaction.fiat_amount" class="text-xs text-muted-foreground">
|
<p
|
||||||
{{ transaction.fiat_amount.toFixed(2) }} {{ transaction.fiat_currency }}
|
v-if="transaction.fiat_amount"
|
||||||
|
:class="[
|
||||||
|
'text-xs',
|
||||||
|
getAmountColorClass(transaction) || 'text-muted-foreground'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
{{ getAmountSign(transaction) }}{{ transaction.fiat_amount.toFixed(2) }} {{ transaction.fiat_currency }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -367,17 +399,42 @@ onMounted(() => {
|
||||||
<span class="font-medium">Ref:</span> {{ transaction.reference }}
|
<span class="font-medium">Ref:</span> {{ transaction.reference }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Tags -->
|
<!-- Badges: type (Income / Expense) + status (Voided / Pending,
|
||||||
<div v-if="transaction.tags && transaction.tags.length > 0" class="flex flex-wrap gap-1 mt-2">
|
mutually exclusive) + any user-added tags. -->
|
||||||
|
<div class="flex flex-wrap gap-1 mt-2">
|
||||||
<Badge
|
<Badge
|
||||||
v-for="tag in transaction.tags"
|
v-if="isIncome(transaction)"
|
||||||
|
variant="secondary"
|
||||||
|
class="text-xs bg-green-100 dark:bg-green-900/20 text-green-700 dark:text-green-300"
|
||||||
|
>
|
||||||
|
Income
|
||||||
|
</Badge>
|
||||||
|
<Badge
|
||||||
|
v-else-if="isExpense(transaction)"
|
||||||
|
variant="secondary"
|
||||||
|
class="text-xs bg-orange-100 dark:bg-orange-900/20 text-orange-700 dark:text-orange-300"
|
||||||
|
>
|
||||||
|
Expense
|
||||||
|
</Badge>
|
||||||
|
<Badge
|
||||||
|
v-if="isVoided(transaction)"
|
||||||
|
variant="outline"
|
||||||
|
class="text-xs text-muted-foreground"
|
||||||
|
>
|
||||||
|
Voided
|
||||||
|
</Badge>
|
||||||
|
<Badge
|
||||||
|
v-else-if="isPending(transaction)"
|
||||||
|
variant="secondary"
|
||||||
|
class="text-xs bg-yellow-100 dark:bg-yellow-900/20 text-yellow-700 dark:text-yellow-300"
|
||||||
|
>
|
||||||
|
Pending approval
|
||||||
|
</Badge>
|
||||||
|
<Badge
|
||||||
|
v-for="tag in getDisplayTags(transaction)"
|
||||||
:key="tag"
|
:key="tag"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
:class="[
|
class="text-xs"
|
||||||
'text-xs',
|
|
||||||
tag === 'income-entry' && 'bg-green-100 dark:bg-green-900/20 text-green-700 dark:text-green-300',
|
|
||||||
tag === 'expense-entry' && 'bg-red-100 dark:bg-red-900/20 text-red-700 dark:text-red-300'
|
|
||||||
]"
|
|
||||||
>
|
>
|
||||||
{{ tag }}
|
{{ tag }}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue