@nuxt-customer-portal/ui 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +7 -0
- package/LICENSE +21 -0
- package/README.md +5 -0
- package/app/app.config.ts +29 -0
- package/app/components/AppCard.vue +22 -0
- package/app/components/AppFooter.vue +22 -0
- package/app/components/AppHeader.vue +398 -0
- package/app/components/AppLogo.vue +65 -0
- package/app/components/AppUserMenu.vue +128 -0
- package/app/components/ConfirmationModal.vue +78 -0
- package/app/components/CustomPageCard.vue +39 -0
- package/app/components/DashboardContribution.vue +31 -0
- package/app/components/InvitationActions.vue +97 -0
- package/app/components/NotificationsSlideover.vue +41 -0
- package/app/components/PortalListToolbar.vue +122 -0
- package/app/components/home/HomeDateRangePicker.vue +119 -0
- package/app/components/home/HomePeriodSelect.vue +45 -0
- package/app/composables/useDashboard.ts +28 -0
- package/app/composables/useInvitationManagement.ts +4 -0
- package/app/composables/useModuleNavigation.ts +126 -0
- package/app/composables/useNavigationLinks.ts +83 -0
- package/app/layouts/auth.vue +32 -0
- package/app/layouts/centerform.vue +5 -0
- package/app/layouts/default.vue +91 -0
- package/app/layouts/portal.vue +22 -0
- package/app/pages/dashboard.vue +68 -0
- package/nuxt.config.ts +5 -0
- package/package.json +42 -0
- package/portal.manifest.mjs +6 -0
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { authClient } from '@nuxt-customer-portal/core/app/utils/auth-client'
|
|
3
|
+
import type { DropdownMenuItem } from '@nuxt/ui'
|
|
4
|
+
|
|
5
|
+
withDefaults(
|
|
6
|
+
defineProps<{
|
|
7
|
+
size?: 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | 'xs'
|
|
8
|
+
inline?: boolean
|
|
9
|
+
}>(),
|
|
10
|
+
{
|
|
11
|
+
size: 'sm',
|
|
12
|
+
inline: false
|
|
13
|
+
}
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
const emit = defineEmits<{ navigate: [] }>()
|
|
17
|
+
|
|
18
|
+
const { t } = useI18n()
|
|
19
|
+
const userStore = useUserStore()
|
|
20
|
+
const { currentUser, userInitials } = storeToRefs(userStore)
|
|
21
|
+
|
|
22
|
+
// Logic preserved from AppHeader
|
|
23
|
+
const isOrgAdmin = ref(false)
|
|
24
|
+
|
|
25
|
+
const signOut = async () => {
|
|
26
|
+
await authClient.signOut()
|
|
27
|
+
userStore.clearUserData()
|
|
28
|
+
emit('navigate')
|
|
29
|
+
await navigateTo('/')
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const userMenuItems = computed(() => {
|
|
33
|
+
const menuItems: DropdownMenuItem[][] = [
|
|
34
|
+
[
|
|
35
|
+
{
|
|
36
|
+
label: currentUser.value?.name || currentUser.value?.email || 'User',
|
|
37
|
+
avatar: {
|
|
38
|
+
src: currentUser.value?.image || undefined,
|
|
39
|
+
alt: currentUser.value?.name || currentUser.value?.email || 'User'
|
|
40
|
+
},
|
|
41
|
+
type: 'label' as const
|
|
42
|
+
}
|
|
43
|
+
],
|
|
44
|
+
[
|
|
45
|
+
{
|
|
46
|
+
label: t('menu.settings.title'),
|
|
47
|
+
icon: 'i-lucide-cog',
|
|
48
|
+
to: '/settings'
|
|
49
|
+
}
|
|
50
|
+
]
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
// Add organization menu items for admins/owners
|
|
54
|
+
if (isOrgAdmin.value && menuItems[1]) {
|
|
55
|
+
menuItems[1].push(
|
|
56
|
+
{
|
|
57
|
+
label: 'Create Organization',
|
|
58
|
+
icon: 'i-lucide-plus-circle',
|
|
59
|
+
to: '/organizations/create'
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
label: 'Invite User',
|
|
63
|
+
icon: 'i-lucide-user-plus',
|
|
64
|
+
onSelect: () => {
|
|
65
|
+
// Navigate to organization page with invite modal
|
|
66
|
+
navigateTo('/organization?invite=true')
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
menuItems.push([
|
|
73
|
+
{
|
|
74
|
+
label: t('menu.logout'),
|
|
75
|
+
icon: 'i-lucide-log-out',
|
|
76
|
+
onSelect: signOut
|
|
77
|
+
}
|
|
78
|
+
])
|
|
79
|
+
|
|
80
|
+
return menuItems
|
|
81
|
+
})
|
|
82
|
+
</script>
|
|
83
|
+
|
|
84
|
+
<template>
|
|
85
|
+
<div v-if="currentUser && inline" class="space-y-2">
|
|
86
|
+
<div class="flex min-w-0 items-center gap-3 px-3 py-2">
|
|
87
|
+
<UAvatar
|
|
88
|
+
:src="currentUser.image ?? undefined"
|
|
89
|
+
:alt="currentUser.name || currentUser.email || 'User'"
|
|
90
|
+
:text="userInitials"
|
|
91
|
+
:size="size"
|
|
92
|
+
class="shrink-0"
|
|
93
|
+
/>
|
|
94
|
+
<div class="min-w-0">
|
|
95
|
+
<div class="truncate text-sm font-semibold text-highlighted">{{ currentUser.name || currentUser.email }}</div>
|
|
96
|
+
<div v-if="currentUser.name" class="truncate text-xs text-muted">{{ currentUser.email }}</div>
|
|
97
|
+
</div>
|
|
98
|
+
</div>
|
|
99
|
+
<UButton
|
|
100
|
+
:label="t('menu.settings.title')"
|
|
101
|
+
icon="i-lucide-cog"
|
|
102
|
+
to="/settings"
|
|
103
|
+
color="neutral"
|
|
104
|
+
variant="ghost"
|
|
105
|
+
block
|
|
106
|
+
class="min-h-11 justify-start"
|
|
107
|
+
@click="emit('navigate')"
|
|
108
|
+
/>
|
|
109
|
+
<UButton
|
|
110
|
+
:label="t('menu.logout')"
|
|
111
|
+
icon="i-lucide-log-out"
|
|
112
|
+
color="neutral"
|
|
113
|
+
variant="ghost"
|
|
114
|
+
block
|
|
115
|
+
class="min-h-11 justify-start"
|
|
116
|
+
@click="signOut"
|
|
117
|
+
/>
|
|
118
|
+
</div>
|
|
119
|
+
<UDropdownMenu v-else-if="currentUser" :items="userMenuItems" :ui="{ content: 'w-48' }">
|
|
120
|
+
<UAvatar
|
|
121
|
+
:src="currentUser.image ?? undefined"
|
|
122
|
+
:alt="currentUser.name || currentUser.email || 'User'"
|
|
123
|
+
:text="userInitials"
|
|
124
|
+
:size="size"
|
|
125
|
+
class="cursor-pointer transition-[box-shadow] hover:ring-2 hover:ring-primary/20"
|
|
126
|
+
/>
|
|
127
|
+
</UDropdownMenu>
|
|
128
|
+
</template>
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
interface Props {
|
|
3
|
+
title: string
|
|
4
|
+
message: string
|
|
5
|
+
messageParams?: Record<string, string | number>
|
|
6
|
+
confirmText?: string
|
|
7
|
+
cancelText?: string
|
|
8
|
+
confirmColor?: 'primary' | 'error' | 'warning' | 'success' | 'info'
|
|
9
|
+
confirmVariant?: 'solid' | 'outline' | 'ghost' | 'soft'
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const props = withDefaults(defineProps<Props>(), {
|
|
13
|
+
confirmText: undefined,
|
|
14
|
+
cancelText: undefined,
|
|
15
|
+
confirmColor: 'primary',
|
|
16
|
+
confirmVariant: 'solid',
|
|
17
|
+
messageParams: () => ({})
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
const emit = defineEmits<{
|
|
21
|
+
confirm: []
|
|
22
|
+
cancel: []
|
|
23
|
+
}>()
|
|
24
|
+
|
|
25
|
+
const open = defineModel<boolean>('open', { default: false })
|
|
26
|
+
const { t } = useI18n()
|
|
27
|
+
|
|
28
|
+
const handleConfirm = () => {
|
|
29
|
+
open.value = false
|
|
30
|
+
emit('confirm')
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const handleCancel = () => {
|
|
34
|
+
open.value = false
|
|
35
|
+
emit('cancel')
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Helper to check if a string is an i18n key (contains a dot)
|
|
39
|
+
const isI18nKey = (str: string): boolean => {
|
|
40
|
+
return str.includes('.')
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Get translated or raw text
|
|
44
|
+
const getText = (key: string | undefined, fallback: string): string => {
|
|
45
|
+
if (!key) {
|
|
46
|
+
return fallback
|
|
47
|
+
}
|
|
48
|
+
if (isI18nKey(key)) {
|
|
49
|
+
return t(key)
|
|
50
|
+
}
|
|
51
|
+
return key
|
|
52
|
+
}
|
|
53
|
+
</script>
|
|
54
|
+
|
|
55
|
+
<template>
|
|
56
|
+
<UModal v-model:open="open" :title="getText(title, title)" :ui="{ footer: 'justify-end' }">
|
|
57
|
+
<template #body>
|
|
58
|
+
<div class="space-y-4">
|
|
59
|
+
<p class="text-sm text-gray-600 dark:text-gray-400">
|
|
60
|
+
<template v-if="isI18nKey(props.message)">
|
|
61
|
+
{{ t(props.message, props.messageParams) }}
|
|
62
|
+
</template>
|
|
63
|
+
<template v-else>
|
|
64
|
+
{{ props.message }}
|
|
65
|
+
</template>
|
|
66
|
+
</p>
|
|
67
|
+
<div class="flex gap-4 justify-end pt-4">
|
|
68
|
+
<UButton type="button" variant="outline" @click="handleCancel">
|
|
69
|
+
{{ getText(props.cancelText, t('common.cancel')) }}
|
|
70
|
+
</UButton>
|
|
71
|
+
<UButton type="button" :color="confirmColor" :variant="confirmVariant" @click="handleConfirm">
|
|
72
|
+
{{ getText(props.confirmText, t('common.confirm')) }}
|
|
73
|
+
</UButton>
|
|
74
|
+
</div>
|
|
75
|
+
</div>
|
|
76
|
+
</template>
|
|
77
|
+
</UModal>
|
|
78
|
+
</template>
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
const props = withDefaults(
|
|
3
|
+
defineProps<{
|
|
4
|
+
title?: string
|
|
5
|
+
description?: string
|
|
6
|
+
success?: string
|
|
7
|
+
error?: string
|
|
8
|
+
showLogo?: boolean
|
|
9
|
+
}>(),
|
|
10
|
+
{
|
|
11
|
+
title: '',
|
|
12
|
+
description: '',
|
|
13
|
+
success: '',
|
|
14
|
+
error: '',
|
|
15
|
+
showLogo: true
|
|
16
|
+
}
|
|
17
|
+
)
|
|
18
|
+
</script>
|
|
19
|
+
|
|
20
|
+
<template>
|
|
21
|
+
<div>
|
|
22
|
+
<UAlert v-if="props.success" color="success" :description="props.success" variant="outline" />
|
|
23
|
+
<UAlert v-if="props.error" color="error" :description="props.error" variant="outline" />
|
|
24
|
+
<UPageCard variant="subtle" class="max-w-sm w-full mt-10 space-y-6 text-center">
|
|
25
|
+
<template v-if="props.title" #title>
|
|
26
|
+
<div class="flex justify-center mb-10">
|
|
27
|
+
<AppLogo class="w-auto h-6 shrink-0" />
|
|
28
|
+
</div>
|
|
29
|
+
<h2 class="text-2xl text-center">{{ props.title }}</h2>
|
|
30
|
+
</template>
|
|
31
|
+
<template v-if="props.description" #description>
|
|
32
|
+
<div class="text-center text-sm text-gray-500 mb-5">
|
|
33
|
+
{{ props.description }}
|
|
34
|
+
</div>
|
|
35
|
+
</template>
|
|
36
|
+
<slot />
|
|
37
|
+
</UPageCard>
|
|
38
|
+
</div>
|
|
39
|
+
</template>
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
const props = defineProps<{ component: string }>()
|
|
3
|
+
// Component resolution depends on the active Vue instance. Resolve during setup;
|
|
4
|
+
// deferring it through a computed getter runs outside that context during render
|
|
5
|
+
// and turns registered Nuxt components into empty custom elements.
|
|
6
|
+
const resolvedComponent = resolveComponent(props.component)
|
|
7
|
+
const componentMissing = typeof resolvedComponent === 'string'
|
|
8
|
+
</script>
|
|
9
|
+
|
|
10
|
+
<template>
|
|
11
|
+
<NuxtErrorBoundary>
|
|
12
|
+
<component :is="resolvedComponent" v-if="!componentMissing" />
|
|
13
|
+
<UCard v-else>
|
|
14
|
+
<p class="font-medium">{{ $t('dashboard.error.title') }}</p>
|
|
15
|
+
<p class="mt-1 text-sm text-muted">{{ $t('dashboard.error.description') }}</p>
|
|
16
|
+
</UCard>
|
|
17
|
+
<template #error="{ clearError }">
|
|
18
|
+
<UCard>
|
|
19
|
+
<div class="flex items-center justify-between gap-4">
|
|
20
|
+
<div>
|
|
21
|
+
<p class="font-medium">{{ $t('dashboard.error.title') }}</p>
|
|
22
|
+
<p class="mt-1 text-sm text-muted">{{ $t('dashboard.error.description') }}</p>
|
|
23
|
+
</div>
|
|
24
|
+
<UButton color="neutral" variant="outline" icon="i-lucide-refresh-cw" @click="clearError()">
|
|
25
|
+
{{ $t('dashboard.error.retry') }}
|
|
26
|
+
</UButton>
|
|
27
|
+
</div>
|
|
28
|
+
</UCard>
|
|
29
|
+
</template>
|
|
30
|
+
</NuxtErrorBoundary>
|
|
31
|
+
</template>
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { invitationRoleSchema, type InvitationRole } from '@nuxt-customer-portal/core/shared/invitation-validation'
|
|
3
|
+
|
|
4
|
+
const props = defineProps<{
|
|
5
|
+
endpoint: string
|
|
6
|
+
email: string
|
|
7
|
+
role: string | null
|
|
8
|
+
canEdit?: boolean
|
|
9
|
+
canRevoke?: boolean
|
|
10
|
+
}>()
|
|
11
|
+
const emit = defineEmits<{ refresh: [] }>()
|
|
12
|
+
const { t } = useI18n()
|
|
13
|
+
const api = useInvitationManagement()
|
|
14
|
+
const toast = useToast()
|
|
15
|
+
const editing = ref(false)
|
|
16
|
+
const revoking = ref(false)
|
|
17
|
+
const busy = ref(false)
|
|
18
|
+
const currentRole = (): InvitationRole => (props.role === 'admin' || props.role === 'owner' ? props.role : 'member')
|
|
19
|
+
const state = reactive({ role: currentRole() })
|
|
20
|
+
const openEditor = () => {
|
|
21
|
+
state.role = currentRole()
|
|
22
|
+
editing.value = true
|
|
23
|
+
}
|
|
24
|
+
const save = async () => {
|
|
25
|
+
busy.value = true
|
|
26
|
+
try {
|
|
27
|
+
await api.changeRole(props.endpoint, state.role)
|
|
28
|
+
editing.value = false
|
|
29
|
+
toast.add({ title: t('invitationManagement.updated'), color: 'success' })
|
|
30
|
+
emit('refresh')
|
|
31
|
+
} catch {
|
|
32
|
+
toast.add({ title: t('invitationManagement.failed'), color: 'error' })
|
|
33
|
+
} finally {
|
|
34
|
+
busy.value = false
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
const revoke = async () => {
|
|
38
|
+
busy.value = true
|
|
39
|
+
try {
|
|
40
|
+
await api.revoke(props.endpoint)
|
|
41
|
+
toast.add({ title: t('invitationManagement.revoked'), color: 'success' })
|
|
42
|
+
emit('refresh')
|
|
43
|
+
} catch {
|
|
44
|
+
toast.add({ title: t('invitationManagement.failed'), color: 'error' })
|
|
45
|
+
} finally {
|
|
46
|
+
busy.value = false
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
</script>
|
|
50
|
+
|
|
51
|
+
<template>
|
|
52
|
+
<div class="flex items-center gap-1" @click.stop @keydown.stop>
|
|
53
|
+
<UButton
|
|
54
|
+
v-if="canEdit"
|
|
55
|
+
icon="i-lucide-pencil"
|
|
56
|
+
color="neutral"
|
|
57
|
+
variant="ghost"
|
|
58
|
+
size="sm"
|
|
59
|
+
:disabled="busy"
|
|
60
|
+
:aria-label="t('invitationManagement.edit')"
|
|
61
|
+
@click="openEditor"
|
|
62
|
+
/>
|
|
63
|
+
<UButton
|
|
64
|
+
v-if="canRevoke"
|
|
65
|
+
icon="i-lucide-x"
|
|
66
|
+
color="error"
|
|
67
|
+
variant="ghost"
|
|
68
|
+
size="sm"
|
|
69
|
+
:disabled="busy"
|
|
70
|
+
:aria-label="t('invitationManagement.revoke')"
|
|
71
|
+
@click="revoking = true"
|
|
72
|
+
/>
|
|
73
|
+
<UModal v-model:open="editing" :title="t('invitationManagement.edit')">
|
|
74
|
+
<template #body>
|
|
75
|
+
<UForm :state="state" :schema="invitationRoleSchema" novalidate class="space-y-4" @submit="save">
|
|
76
|
+
<p>{{ email }}</p>
|
|
77
|
+
<UFormField name="role" :label="t('invitationManagement.role')" required>
|
|
78
|
+
<USelect v-model="state.role" :items="['member', 'admin', 'owner']" class="w-full" />
|
|
79
|
+
</UFormField>
|
|
80
|
+
<div class="flex justify-end gap-2">
|
|
81
|
+
<UButton color="neutral" variant="outline" @click="editing = false">{{ t('common.cancel') }}</UButton>
|
|
82
|
+
<UButton type="submit" :loading="busy">{{ t('invitationManagement.save') }}</UButton>
|
|
83
|
+
</div>
|
|
84
|
+
</UForm>
|
|
85
|
+
</template>
|
|
86
|
+
</UModal>
|
|
87
|
+
<ConfirmationModal
|
|
88
|
+
v-model:open="revoking"
|
|
89
|
+
title="invitationManagement.revoke"
|
|
90
|
+
message="invitationManagement.confirmRevoke"
|
|
91
|
+
:message-params="{ email }"
|
|
92
|
+
confirm-text="invitationManagement.revoke"
|
|
93
|
+
confirm-color="error"
|
|
94
|
+
@confirm="revoke"
|
|
95
|
+
/>
|
|
96
|
+
</div>
|
|
97
|
+
</template>
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { formatTimeAgo } from '@vueuse/core'
|
|
3
|
+
import type { Notification } from '@nuxt-customer-portal/core/app/types'
|
|
4
|
+
|
|
5
|
+
const { isNotificationsSlideoverOpen } = useDashboard()
|
|
6
|
+
|
|
7
|
+
const { data: notifications } = await useFetch<Notification[]>('/api/notifications')
|
|
8
|
+
</script>
|
|
9
|
+
|
|
10
|
+
<template>
|
|
11
|
+
<USlideover v-model:open="isNotificationsSlideoverOpen" title="Notifications">
|
|
12
|
+
<template #body>
|
|
13
|
+
<NuxtLink
|
|
14
|
+
v-for="notification in notifications"
|
|
15
|
+
:key="notification.id"
|
|
16
|
+
:to="`/inbox?id=${notification.id}`"
|
|
17
|
+
class="px-3 py-2.5 rounded-md hover:bg-elevated/50 flex items-center gap-3 relative -mx-3 first:-mt-3 last:-mb-3"
|
|
18
|
+
>
|
|
19
|
+
<UChip color="error" :show="!!notification.unread" inset>
|
|
20
|
+
<UAvatar v-bind="notification.sender?.avatar" :alt="notification.sender.name" size="md" />
|
|
21
|
+
</UChip>
|
|
22
|
+
|
|
23
|
+
<div class="text-sm flex-1">
|
|
24
|
+
<p class="flex items-center justify-between">
|
|
25
|
+
<span class="text-highlighted font-medium">{{ notification.sender.name }}</span>
|
|
26
|
+
|
|
27
|
+
<time
|
|
28
|
+
:datetime="notification.date"
|
|
29
|
+
class="text-muted text-xs"
|
|
30
|
+
v-text="formatTimeAgo(new Date(notification.date))"
|
|
31
|
+
/>
|
|
32
|
+
</p>
|
|
33
|
+
|
|
34
|
+
<p class="text-dimmed">
|
|
35
|
+
{{ notification.body }}
|
|
36
|
+
</p>
|
|
37
|
+
</div>
|
|
38
|
+
</NuxtLink>
|
|
39
|
+
</template>
|
|
40
|
+
</USlideover>
|
|
41
|
+
</template>
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
type Option = { label: string; value: string | undefined; badgeText?: string; badgeColor?: string }
|
|
3
|
+
const props = defineProps<{
|
|
4
|
+
searchPlaceholder?: string
|
|
5
|
+
filters: Array<{ key: string; placeholder: string; items: Option[] }>
|
|
6
|
+
filterValues: Record<string, string | undefined>
|
|
7
|
+
sortOptions: Array<{ label: string; value: string }>
|
|
8
|
+
sortBy: string
|
|
9
|
+
sortDir: 'asc' | 'desc'
|
|
10
|
+
}>()
|
|
11
|
+
const emit = defineEmits<{
|
|
12
|
+
filter: [key: string, value: string | undefined]
|
|
13
|
+
sort: [value: string]
|
|
14
|
+
toggleDirection: []
|
|
15
|
+
}>()
|
|
16
|
+
const search = defineModel<string>('search', { required: true })
|
|
17
|
+
const { t } = useI18n()
|
|
18
|
+
const showFilters = ref(false)
|
|
19
|
+
const showSort = ref(false)
|
|
20
|
+
const toolbar = ref<HTMLElement | null>(null)
|
|
21
|
+
const { width } = useElementSize(toolbar)
|
|
22
|
+
const isMobile = computed(() => width.value < Math.max(660, 160 + props.filters.length * 120 + 232))
|
|
23
|
+
</script>
|
|
24
|
+
|
|
25
|
+
<template>
|
|
26
|
+
<div ref="toolbar" class="shrink-0 border-b border-default pb-4">
|
|
27
|
+
<div class="flex items-center gap-2">
|
|
28
|
+
<UInput
|
|
29
|
+
v-model="search"
|
|
30
|
+
:placeholder="searchPlaceholder || t('common.searchPlaceholder')"
|
|
31
|
+
icon="i-lucide-search"
|
|
32
|
+
:class="isMobile ? 'min-w-0 flex-1' : 'min-w-40 flex-1 md:max-w-xs'"
|
|
33
|
+
/>
|
|
34
|
+
<template v-if="!isMobile">
|
|
35
|
+
<USelect
|
|
36
|
+
v-for="filter in filters"
|
|
37
|
+
:key="filter.key"
|
|
38
|
+
:model-value="filterValues[filter.key]"
|
|
39
|
+
:items="filter.items"
|
|
40
|
+
value-key="value"
|
|
41
|
+
:placeholder="filter.placeholder"
|
|
42
|
+
:aria-label="filter.placeholder"
|
|
43
|
+
class="w-44 min-w-28 shrink"
|
|
44
|
+
@update:model-value="emit('filter', filter.key, $event)"
|
|
45
|
+
/>
|
|
46
|
+
<div class="ml-auto flex shrink-0 items-center gap-2">
|
|
47
|
+
<USelect
|
|
48
|
+
:model-value="sortBy"
|
|
49
|
+
:items="sortOptions"
|
|
50
|
+
value-key="value"
|
|
51
|
+
icon="i-lucide-arrow-down-up"
|
|
52
|
+
:aria-label="t('common.sortBy')"
|
|
53
|
+
class="w-44"
|
|
54
|
+
@update:model-value="emit('sort', $event)"
|
|
55
|
+
/>
|
|
56
|
+
<UButton
|
|
57
|
+
color="neutral"
|
|
58
|
+
variant="outline"
|
|
59
|
+
:icon="sortDir === 'asc' ? 'i-lucide-arrow-up-narrow-wide' : 'i-lucide-arrow-down-wide-narrow'"
|
|
60
|
+
:aria-label="t('common.direction')"
|
|
61
|
+
@click="emit('toggleDirection')"
|
|
62
|
+
/>
|
|
63
|
+
</div>
|
|
64
|
+
</template>
|
|
65
|
+
<template v-else>
|
|
66
|
+
<UButton
|
|
67
|
+
v-if="filters.length"
|
|
68
|
+
color="neutral"
|
|
69
|
+
variant="outline"
|
|
70
|
+
icon="i-lucide-filter"
|
|
71
|
+
:aria-label="t('common.filters')"
|
|
72
|
+
@click="showFilters = true"
|
|
73
|
+
/>
|
|
74
|
+
<UButton
|
|
75
|
+
color="neutral"
|
|
76
|
+
variant="outline"
|
|
77
|
+
icon="i-lucide-arrow-down-up"
|
|
78
|
+
:aria-label="t('common.sort')"
|
|
79
|
+
@click="showSort = true"
|
|
80
|
+
/>
|
|
81
|
+
</template>
|
|
82
|
+
</div>
|
|
83
|
+
<UModal v-model:open="showFilters" :title="t('common.filters')">
|
|
84
|
+
<template #body>
|
|
85
|
+
<div class="space-y-4">
|
|
86
|
+
<UFormField v-for="filter in filters" :key="filter.key" :label="filter.placeholder">
|
|
87
|
+
<USelect
|
|
88
|
+
:model-value="filterValues[filter.key]"
|
|
89
|
+
:items="filter.items"
|
|
90
|
+
value-key="value"
|
|
91
|
+
class="w-full"
|
|
92
|
+
@update:model-value="emit('filter', filter.key, $event)"
|
|
93
|
+
/>
|
|
94
|
+
</UFormField>
|
|
95
|
+
</div>
|
|
96
|
+
</template>
|
|
97
|
+
</UModal>
|
|
98
|
+
<UModal v-model:open="showSort" :title="t('common.sort')">
|
|
99
|
+
<template #body>
|
|
100
|
+
<div class="space-y-4">
|
|
101
|
+
<USelect
|
|
102
|
+
:model-value="sortBy"
|
|
103
|
+
:items="sortOptions"
|
|
104
|
+
icon="i-lucide-arrow-down-up"
|
|
105
|
+
:aria-label="t('common.sortBy')"
|
|
106
|
+
value-key="value"
|
|
107
|
+
class="w-full"
|
|
108
|
+
@update:model-value="emit('sort', $event)"
|
|
109
|
+
/><UButton
|
|
110
|
+
block
|
|
111
|
+
color="neutral"
|
|
112
|
+
variant="outline"
|
|
113
|
+
:icon="sortDir === 'asc' ? 'i-lucide-arrow-up-narrow-wide' : 'i-lucide-arrow-down-wide-narrow'"
|
|
114
|
+
@click="emit('toggleDirection')"
|
|
115
|
+
>
|
|
116
|
+
{{ t('common.direction') }}
|
|
117
|
+
</UButton>
|
|
118
|
+
</div>
|
|
119
|
+
</template>
|
|
120
|
+
</UModal>
|
|
121
|
+
</div>
|
|
122
|
+
</template>
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { DateFormatter, getLocalTimeZone, CalendarDate, today } from '@internationalized/date'
|
|
3
|
+
import type { Range } from '@nuxt-customer-portal/core/app/types'
|
|
4
|
+
|
|
5
|
+
const df = new DateFormatter('en-US', {
|
|
6
|
+
dateStyle: 'medium'
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
const selected = defineModel<Range>({ required: true })
|
|
10
|
+
|
|
11
|
+
const ranges = [
|
|
12
|
+
{ label: 'Last 7 days', days: 7 },
|
|
13
|
+
{ label: 'Last 14 days', days: 14 },
|
|
14
|
+
{ label: 'Last 30 days', days: 30 },
|
|
15
|
+
{ label: 'Last 3 months', months: 3 },
|
|
16
|
+
{ label: 'Last 6 months', months: 6 },
|
|
17
|
+
{ label: 'Last year', years: 1 }
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
const toCalendarDate = (date: Date) => {
|
|
21
|
+
return new CalendarDate(date.getFullYear(), date.getMonth() + 1, date.getDate())
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const calendarRange = computed({
|
|
25
|
+
get: () => ({
|
|
26
|
+
start: selected.value.start ? toCalendarDate(selected.value.start) : undefined,
|
|
27
|
+
end: selected.value.end ? toCalendarDate(selected.value.end) : undefined
|
|
28
|
+
}),
|
|
29
|
+
set: (newValue: { start: CalendarDate | null; end: CalendarDate | null }) => {
|
|
30
|
+
selected.value = {
|
|
31
|
+
start: newValue.start ? newValue.start.toDate(getLocalTimeZone()) : new Date(),
|
|
32
|
+
end: newValue.end ? newValue.end.toDate(getLocalTimeZone()) : new Date()
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
const isRangeSelected = (range: { days?: number; months?: number; years?: number }) => {
|
|
38
|
+
if (!selected.value.start || !selected.value.end) {
|
|
39
|
+
return false
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const currentDate = today(getLocalTimeZone())
|
|
43
|
+
let startDate = currentDate.copy()
|
|
44
|
+
|
|
45
|
+
if (range.days) {
|
|
46
|
+
startDate = startDate.subtract({ days: range.days })
|
|
47
|
+
} else if (range.months) {
|
|
48
|
+
startDate = startDate.subtract({ months: range.months })
|
|
49
|
+
} else if (range.years) {
|
|
50
|
+
startDate = startDate.subtract({ years: range.years })
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const selectedStart = toCalendarDate(selected.value.start)
|
|
54
|
+
const selectedEnd = toCalendarDate(selected.value.end)
|
|
55
|
+
|
|
56
|
+
return selectedStart.compare(startDate) === 0 && selectedEnd.compare(currentDate) === 0
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const selectRange = (range: { days?: number; months?: number; years?: number }) => {
|
|
60
|
+
const endDate = today(getLocalTimeZone())
|
|
61
|
+
let startDate = endDate.copy()
|
|
62
|
+
|
|
63
|
+
if (range.days) {
|
|
64
|
+
startDate = startDate.subtract({ days: range.days })
|
|
65
|
+
} else if (range.months) {
|
|
66
|
+
startDate = startDate.subtract({ months: range.months })
|
|
67
|
+
} else if (range.years) {
|
|
68
|
+
startDate = startDate.subtract({ years: range.years })
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
selected.value = {
|
|
72
|
+
start: startDate.toDate(getLocalTimeZone()),
|
|
73
|
+
end: endDate.toDate(getLocalTimeZone())
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
</script>
|
|
77
|
+
|
|
78
|
+
<template>
|
|
79
|
+
<UPopover :content="{ align: 'start' }" :modal="true">
|
|
80
|
+
<UButton color="neutral" variant="ghost" icon="i-lucide-calendar" class="data-[state=open]:bg-elevated group">
|
|
81
|
+
<span class="truncate">
|
|
82
|
+
<template v-if="selected.start">
|
|
83
|
+
<template v-if="selected.end"> {{ df.format(selected.start) }} - {{ df.format(selected.end) }} </template>
|
|
84
|
+
<template v-else>
|
|
85
|
+
{{ df.format(selected.start) }}
|
|
86
|
+
</template>
|
|
87
|
+
</template>
|
|
88
|
+
<template v-else> Pick a date </template>
|
|
89
|
+
</span>
|
|
90
|
+
|
|
91
|
+
<template #trailing>
|
|
92
|
+
<UIcon
|
|
93
|
+
name="i-lucide-chevron-down"
|
|
94
|
+
class="shrink-0 text-dimmed size-5 group-data-[state=open]:rotate-180 transition-transform duration-200"
|
|
95
|
+
/>
|
|
96
|
+
</template>
|
|
97
|
+
</UButton>
|
|
98
|
+
|
|
99
|
+
<template #content>
|
|
100
|
+
<div class="flex items-stretch sm:divide-x divide-default">
|
|
101
|
+
<div class="hidden sm:flex flex-col justify-center">
|
|
102
|
+
<UButton
|
|
103
|
+
v-for="(range, index) in ranges"
|
|
104
|
+
:key="index"
|
|
105
|
+
:label="range.label"
|
|
106
|
+
color="neutral"
|
|
107
|
+
variant="ghost"
|
|
108
|
+
class="rounded-none px-4"
|
|
109
|
+
:class="[isRangeSelected(range) ? 'bg-elevated' : 'hover:bg-elevated/50']"
|
|
110
|
+
truncate
|
|
111
|
+
@click="selectRange(range)"
|
|
112
|
+
/>
|
|
113
|
+
</div>
|
|
114
|
+
|
|
115
|
+
<UCalendar v-model="calendarRange" class="p-2" :number-of-months="2" range />
|
|
116
|
+
</div>
|
|
117
|
+
</template>
|
|
118
|
+
</UPopover>
|
|
119
|
+
</template>
|