Compare commits
16 commits
08f1743557
...
67a070e9b3
| Author | SHA1 | Date | |
|---|---|---|---|
| 67a070e9b3 | |||
| 2e96d60f8f | |||
| 9753c4aea4 | |||
| e05f276308 | |||
| e3f665ceea | |||
| 1aeea23296 | |||
| 0382f02b28 | |||
| 0823b0f076 | |||
| f01d5aa581 | |||
| df7ab30dc5 | |||
| a48e3ace5f | |||
| ea1e408801 | |||
| ce7f062760 | |||
| b292cdb2f3 | |||
| 13ece88f82 | |||
| 4924e70fe8 |
6 changed files with 856 additions and 128 deletions
|
|
@ -98,11 +98,8 @@ 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 = '!'), excluding voided ones
|
// Filter for pending transactions (flag = '!')
|
||||||
// (libra convention: voided keeps '!' flag and carries a 'voided' tag).
|
pendingTransactions.value = txData.entries.filter(tx => tx.flag === '!')
|
||||||
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')
|
||||||
|
|
|
||||||
256
src/modules/expenses/components/admin/GrantPermissionDialog.vue
Normal file
256
src/modules/expenses/components/admin/GrantPermissionDialog.vue
Normal file
|
|
@ -0,0 +1,256 @@
|
||||||
|
<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>
|
||||||
399
src/modules/expenses/components/admin/PermissionManager.vue
Normal file
399
src/modules/expenses/components/admin/PermissionManager.vue
Normal file
|
|
@ -0,0 +1,399 @@
|
||||||
|
<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,6 +11,8 @@ 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'
|
||||||
|
|
@ -341,6 +343,93 @@ 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,6 +28,25 @@ 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
|
||||||
*/
|
*/
|
||||||
|
|
@ -106,6 +125,31 @@ 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,6 +12,10 @@ 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
|
||||||
|
|
@ -26,26 +30,14 @@ 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>('')
|
||||||
// Each chip is an inclusion toggle for one bucket of rows. Every row
|
const typeFilter = ref<'all' | 'income' | 'expense'>('all')
|
||||||
// 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 activeCategories = ref<Set<Category>>(new Set<Category>(['income', 'expense']))
|
const typeFilterOptions = [
|
||||||
|
{ label: 'All', value: 'all' as const },
|
||||||
const categoryChips: { label: string; value: Category }[] = [
|
{ label: 'Income', value: 'income' as const },
|
||||||
{ label: 'Income', value: 'income' },
|
{ label: 'Expenses', value: 'expense' as const }
|
||||||
{ 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
|
||||||
}
|
}
|
||||||
|
|
@ -54,22 +46,6 @@ 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
|
||||||
|
|
@ -95,13 +71,12 @@ const searchOptions: FuzzySearchOptions<Transaction> = {
|
||||||
matchAllWhenSearchEmpty: true
|
matchAllWhenSearchEmpty: true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Transactions to display: row passes if its bucket's chip is active.
|
// Transactions to display (search results or all transactions), filtered by type
|
||||||
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
|
||||||
return base.filter(t => {
|
if (typeFilter.value === 'income') return base.filter(isIncome)
|
||||||
const bucket = getBucket(t)
|
if (typeFilter.value === 'expense') return base.filter(isExpense)
|
||||||
return bucket !== null && activeCategories.value.has(bucket)
|
return base
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// Handle search results
|
// Handle search results
|
||||||
|
|
@ -133,28 +108,20 @@ function formatAmount(amount: number): string {
|
||||||
return new Intl.NumberFormat('en-US').format(amount)
|
return new Intl.NumberFormat('en-US').format(amount)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Income gets a leading '+', expense a leading '-'.
|
// Get status icon and color based on flag
|
||||||
function getAmountSign(t: Transaction): string {
|
function getStatusInfo(flag?: string) {
|
||||||
if (isIncome(t)) return '+'
|
switch (flag) {
|
||||||
if (isExpense(t)) return '-'
|
case '*':
|
||||||
return ''
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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
|
||||||
|
|
@ -257,20 +224,19 @@ onMounted(() => {
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Category chips: each chip toggles inclusion of one bucket
|
<!-- Type Filter (All / Income / Expenses) -->
|
||||||
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="chip in categoryChips"
|
v-for="option in typeFilterOptions"
|
||||||
:key="chip.value"
|
:key="option.value"
|
||||||
:variant="activeCategories.has(chip.value) ? 'default' : 'outline'"
|
:variant="typeFilter === option.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="toggleCategory(chip.value)"
|
@click="typeFilter = option.value"
|
||||||
:disabled="isLoading"
|
:disabled="isLoading"
|
||||||
>
|
>
|
||||||
{{ chip.label }}
|
{{ option.label }}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -325,7 +291,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>
|
||||||
{{ transactionsToDisplay.length }} transaction{{ transactionsToDisplay.length === 1 ? '' : 's' }}
|
{{ transactions.length }} transaction{{ transactions.length === 1 ? '' : 's' }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -341,9 +307,7 @@ 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">
|
||||||
<template v-if="searchResults.length > 0">Try a different search term</template>
|
{{ searchResults.length > 0 ? 'Try a different search term' : 'Try selecting a different time period' }}
|
||||||
<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>
|
||||||
|
|
||||||
|
|
@ -352,19 +316,29 @@ onMounted(() => {
|
||||||
<div
|
<div
|
||||||
v-for="transaction in transactionsToDisplay"
|
v-for="transaction in transactionsToDisplay"
|
||||||
:key="transaction.id"
|
:key="transaction.id"
|
||||||
class="border-b md:border md:rounded-lg p-4 hover:bg-muted/50 transition-colors"
|
:class="[
|
||||||
|
'border-b md:border md:rounded-lg p-4 hover:bg-muted/50 transition-colors',
|
||||||
|
isIncome(transaction) && 'border-l-4 border-l-green-600',
|
||||||
|
isExpense(transaction) && 'border-l-4 border-l-red-600'
|
||||||
|
]"
|
||||||
>
|
>
|
||||||
<!-- Transaction Header -->
|
<!-- Transaction Header -->
|
||||||
<div class="flex items-start justify-between gap-3 mb-2">
|
<div class="flex items-start justify-between gap-3 mb-2">
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<h3
|
<div class="flex items-center gap-2 mb-1">
|
||||||
|
<!-- Status Icon -->
|
||||||
|
<component
|
||||||
|
v-if="getStatusInfo(transaction.flag)"
|
||||||
|
:is="getStatusInfo(transaction.flag)!.icon"
|
||||||
:class="[
|
:class="[
|
||||||
'font-medium text-sm sm:text-base truncate mb-1',
|
'h-4 w-4 flex-shrink-0',
|
||||||
isVoided(transaction) && 'line-through text-muted-foreground'
|
getStatusInfo(transaction.flag)!.color
|
||||||
]"
|
]"
|
||||||
>
|
/>
|
||||||
|
<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>
|
||||||
|
|
@ -372,17 +346,11 @@ 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', getAmountColorClass(transaction)]">
|
<p class="font-semibold text-sm sm:text-base">
|
||||||
{{ getAmountSign(transaction) }}{{ formatAmount(transaction.amount) }} sats
|
{{ formatAmount(transaction.amount) }} sats
|
||||||
</p>
|
</p>
|
||||||
<p
|
<p v-if="transaction.fiat_amount" class="text-xs text-muted-foreground">
|
||||||
v-if="transaction.fiat_amount"
|
{{ transaction.fiat_amount.toFixed(2) }} {{ transaction.fiat_currency }}
|
||||||
:class="[
|
|
||||||
'text-xs',
|
|
||||||
getAmountColorClass(transaction) || 'text-muted-foreground'
|
|
||||||
]"
|
|
||||||
>
|
|
||||||
{{ getAmountSign(transaction) }}{{ transaction.fiat_amount.toFixed(2) }} {{ transaction.fiat_currency }}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -399,42 +367,17 @@ onMounted(() => {
|
||||||
<span class="font-medium">Ref:</span> {{ transaction.reference }}
|
<span class="font-medium">Ref:</span> {{ transaction.reference }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Badges: type (Income / Expense) + status (Voided / Pending,
|
<!-- Tags -->
|
||||||
mutually exclusive) + any user-added tags. -->
|
<div v-if="transaction.tags && transaction.tags.length > 0" class="flex flex-wrap gap-1 mt-2">
|
||||||
<div class="flex flex-wrap gap-1 mt-2">
|
|
||||||
<Badge
|
<Badge
|
||||||
v-if="isIncome(transaction)"
|
v-for="tag in transaction.tags"
|
||||||
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="text-xs"
|
:class="[
|
||||||
|
'text-xs',
|
||||||
|
tag === 'income-entry' && 'bg-green-100 dark:bg-green-900/20 text-green-700 dark:text-green-300',
|
||||||
|
tag === 'expense-entry' && 'bg-red-100 dark:bg-red-900/20 text-red-700 dark:text-red-300'
|
||||||
|
]"
|
||||||
>
|
>
|
||||||
{{ tag }}
|
{{ tag }}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue