@ngbplatform/ui 1.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/LICENSE +201 -0
- package/README.md +44 -0
- package/package.json +61 -0
- package/public/favicon.svg +6 -0
- package/public/silent-check-sso.html +8 -0
- package/src/assets/ngb_logo.svg +17 -0
- package/src/assets/ngb_logo_color.svg +17 -0
- package/src/index.ts +1023 -0
- package/src/ngb/README.md +27 -0
- package/src/ngb/accounting/NgbAccountingPeriodClosingPage.vue +1157 -0
- package/src/ngb/accounting/NgbChartOfAccountEditor.vue +590 -0
- package/src/ngb/accounting/NgbChartOfAccountsPage.vue +420 -0
- package/src/ngb/accounting/NgbGeneralJournalEntryEditPage.vue +796 -0
- package/src/ngb/accounting/NgbGeneralJournalEntryLinesEditor.vue +454 -0
- package/src/ngb/accounting/NgbGeneralJournalEntryListPage.vue +186 -0
- package/src/ngb/accounting/api.ts +79 -0
- package/src/ngb/accounting/generalJournalEntry.ts +127 -0
- package/src/ngb/accounting/generalJournalEntryApi.ts +112 -0
- package/src/ngb/accounting/generalJournalEntryTypes.ts +155 -0
- package/src/ngb/accounting/navigation.ts +83 -0
- package/src/ngb/accounting/periodClosing.ts +75 -0
- package/src/ngb/accounting/periodClosingApi.ts +60 -0
- package/src/ngb/accounting/periodClosingTypes.ts +73 -0
- package/src/ngb/accounting/types.ts +57 -0
- package/src/ngb/api/audit.ts +23 -0
- package/src/ngb/api/catalogs.ts +50 -0
- package/src/ngb/api/contracts.ts +285 -0
- package/src/ngb/api/documents.ts +170 -0
- package/src/ngb/api/http.ts +446 -0
- package/src/ngb/api/lookups.ts +14 -0
- package/src/ngb/api/types.ts +6 -0
- package/src/ngb/auth/index.ts +13 -0
- package/src/ngb/auth/keycloak.ts +374 -0
- package/src/ngb/auth/router.ts +35 -0
- package/src/ngb/auth/types.ts +12 -0
- package/src/ngb/auth/useAuthStore.ts +209 -0
- package/src/ngb/command-palette/NgbCommandPaletteDialog.vue +342 -0
- package/src/ngb/command-palette/NgbCommandPaletteEmptyState.vue +55 -0
- package/src/ngb/command-palette/NgbCommandPaletteFooterHints.vue +29 -0
- package/src/ngb/command-palette/api.ts +10 -0
- package/src/ngb/command-palette/config.ts +16 -0
- package/src/ngb/command-palette/presentation.ts +85 -0
- package/src/ngb/command-palette/search.ts +130 -0
- package/src/ngb/command-palette/staticItems.ts +243 -0
- package/src/ngb/command-palette/storage.ts +45 -0
- package/src/ngb/command-palette/store.ts +716 -0
- package/src/ngb/command-palette/types.ts +120 -0
- package/src/ngb/command-palette/useCommandPaletteHotkeys.ts +27 -0
- package/src/ngb/command-palette/useCommandPalettePageContext.ts +18 -0
- package/src/ngb/components/NgbConfirmDialog.vue +63 -0
- package/src/ngb/components/NgbDialog.vue +44 -0
- package/src/ngb/components/NgbDrawer.vue +156 -0
- package/src/ngb/components/NgbHeaderActionCluster.vue +140 -0
- package/src/ngb/components/NgbModalShell.vue +99 -0
- package/src/ngb/components/NgbToolbar.vue +14 -0
- package/src/ngb/components/forms/NgbFormLayout.vue +13 -0
- package/src/ngb/components/forms/NgbFormRow.vue +21 -0
- package/src/ngb/components/forms/NgbFormSection.vue +16 -0
- package/src/ngb/components/forms/NgbValidationSummary.vue +18 -0
- package/src/ngb/components/navigation/NgbNavigationTree.vue +68 -0
- package/src/ngb/components/navigation/NgbTreeNode.vue +94 -0
- package/src/ngb/components/register/NgbColumnChooser.vue +101 -0
- package/src/ngb/components/register/NgbRegisterGrid.vue +473 -0
- package/src/ngb/components/register/registerTypes.ts +51 -0
- package/src/ngb/components/register/useRegisterColumnResize.ts +99 -0
- package/src/ngb/components/register/useRegisterColumnState.ts +220 -0
- package/src/ngb/components/register/useRegisterRows.ts +209 -0
- package/src/ngb/components/register/useRegisterViewport.ts +56 -0
- package/src/ngb/editor/NgbDocumentEffectsPage.vue +512 -0
- package/src/ngb/editor/NgbDocumentFlowPage.vue +409 -0
- package/src/ngb/editor/NgbDocumentPrintPage.vue +712 -0
- package/src/ngb/editor/NgbEditorDiscardDialog.vue +40 -0
- package/src/ngb/editor/NgbEntityAuditSidebar.vue +288 -0
- package/src/ngb/editor/NgbEntityEditor.vue +250 -0
- package/src/ngb/editor/NgbEntityEditorDrawerActions.vue +88 -0
- package/src/ngb/editor/NgbEntityEditorHeader.vue +114 -0
- package/src/ngb/editor/catalogNavigation.ts +13 -0
- package/src/ngb/editor/config.ts +252 -0
- package/src/ngb/editor/documentCopyDraft.ts +245 -0
- package/src/ngb/editor/documentEffects.ts +43 -0
- package/src/ngb/editor/documentEffectsDateFormatting.ts +18 -0
- package/src/ngb/editor/documentNavigation.ts +115 -0
- package/src/ngb/editor/documentStatus.ts +45 -0
- package/src/ngb/editor/entityEditorErrors.ts +162 -0
- package/src/ngb/editor/entityEditorPersistence.ts +241 -0
- package/src/ngb/editor/extensions.ts +40 -0
- package/src/ngb/editor/types.ts +295 -0
- package/src/ngb/editor/useConfiguredEntityEditorDocumentActions.ts +183 -0
- package/src/ngb/editor/useDocumentEditorDrawerState.ts +152 -0
- package/src/ngb/editor/useEditorDrawerState.ts +88 -0
- package/src/ngb/editor/useEntityEditorBusinessContext.ts +97 -0
- package/src/ngb/editor/useEntityEditorCapabilities.ts +190 -0
- package/src/ngb/editor/useEntityEditorCommandPalette.ts +154 -0
- package/src/ngb/editor/useEntityEditorCommitHandlers.ts +102 -0
- package/src/ngb/editor/useEntityEditorHeaderActions.ts +253 -0
- package/src/ngb/editor/useEntityEditorLeaveGuard.ts +66 -0
- package/src/ngb/editor/useEntityEditorNavigationActions.ts +235 -0
- package/src/ngb/editor/useEntityEditorOutputs.ts +59 -0
- package/src/ngb/editor/useEntityEditorPageActions.ts +57 -0
- package/src/ngb/editor/useRouteQueryEditorDrawer.ts +173 -0
- package/src/ngb/env/runtimeConfig.ts +25 -0
- package/src/ngb/lookup/config.ts +42 -0
- package/src/ngb/lookup/defaultConfig.ts +136 -0
- package/src/ngb/lookup/navigation.ts +76 -0
- package/src/ngb/lookup/prefetch.ts +40 -0
- package/src/ngb/lookup/store.ts +261 -0
- package/src/ngb/metadata/NgbDocumentListFiltersDrawer.vue +78 -0
- package/src/ngb/metadata/NgbDocumentPeriodFilter.vue +53 -0
- package/src/ngb/metadata/NgbEntityForm.vue +84 -0
- package/src/ngb/metadata/NgbEntityFormFieldsBlock.vue +110 -0
- package/src/ngb/metadata/NgbEntityListPageHeader.vue +80 -0
- package/src/ngb/metadata/NgbFilterFieldControl.vue +104 -0
- package/src/ngb/metadata/NgbMetadataCatalogEditPage.vue +42 -0
- package/src/ngb/metadata/NgbMetadataCatalogListPage.vue +269 -0
- package/src/ngb/metadata/NgbMetadataDocumentEditPage.vue +68 -0
- package/src/ngb/metadata/NgbMetadataDocumentListPage.vue +353 -0
- package/src/ngb/metadata/NgbMetadataFieldRenderer.vue +124 -0
- package/src/ngb/metadata/NgbMetadataLookupControl.vue +92 -0
- package/src/ngb/metadata/NgbRecycleBinFilter.vue +53 -0
- package/src/ngb/metadata/NgbRegisterPageLayout.vue +143 -0
- package/src/ngb/metadata/config.ts +28 -0
- package/src/ngb/metadata/dataTypes.ts +27 -0
- package/src/ngb/metadata/entityForm.ts +114 -0
- package/src/ngb/metadata/entityModel.ts +34 -0
- package/src/ngb/metadata/entityValueFormatting.ts +83 -0
- package/src/ngb/metadata/fieldRendererState.ts +84 -0
- package/src/ngb/metadata/filtering.ts +153 -0
- package/src/ngb/metadata/lookup.ts +25 -0
- package/src/ngb/metadata/monthPagedListQuery.ts +108 -0
- package/src/ngb/metadata/normalization.ts +100 -0
- package/src/ngb/metadata/referenceHydration.ts +99 -0
- package/src/ngb/metadata/register.ts +135 -0
- package/src/ngb/metadata/routePages.ts +86 -0
- package/src/ngb/metadata/store.ts +35 -0
- package/src/ngb/metadata/types.ts +276 -0
- package/src/ngb/metadata/useMetadataListFilters.ts +306 -0
- package/src/ngb/metadata/useMetadataRegisterPageData.ts +246 -0
- package/src/ngb/metadata/useValidationFocus.ts +49 -0
- package/src/ngb/primitives/NgbBadge.vue +31 -0
- package/src/ngb/primitives/NgbButton.vue +53 -0
- package/src/ngb/primitives/NgbCheckbox.vue +33 -0
- package/src/ngb/primitives/NgbDatePicker.vue +141 -0
- package/src/ngb/primitives/NgbIcon.vue +545 -0
- package/src/ngb/primitives/NgbInput.vue +57 -0
- package/src/ngb/primitives/NgbLookup.vue +264 -0
- package/src/ngb/primitives/NgbMonthPicker.vue +132 -0
- package/src/ngb/primitives/NgbMultiSelect.vue +179 -0
- package/src/ngb/primitives/NgbPeriodPicker.vue +125 -0
- package/src/ngb/primitives/NgbPickerNavButton.vue +31 -0
- package/src/ngb/primitives/NgbPickerPopover.vue +79 -0
- package/src/ngb/primitives/NgbRadioGroup.vue +53 -0
- package/src/ngb/primitives/NgbSelect.vue +108 -0
- package/src/ngb/primitives/NgbStatusIcon.vue +63 -0
- package/src/ngb/primitives/NgbSwitch.vue +42 -0
- package/src/ngb/primitives/NgbTabs.vue +116 -0
- package/src/ngb/primitives/NgbToastHost.vue +49 -0
- package/src/ngb/primitives/NgbTooltip.vue +27 -0
- package/src/ngb/primitives/iconNames.ts +83 -0
- package/src/ngb/primitives/toast.ts +66 -0
- package/src/ngb/primitives/useAsyncComboboxQuery.ts +85 -0
- package/src/ngb/primitives/useFloatingDropdownPosition.ts +129 -0
- package/src/ngb/reporting/NgbReportComposerCollectionSection.vue +106 -0
- package/src/ngb/reporting/NgbReportComposerPanel.vue +1187 -0
- package/src/ngb/reporting/NgbReportDateRangeFilter.vue +54 -0
- package/src/ngb/reporting/NgbReportPage.vue +1124 -0
- package/src/ngb/reporting/NgbReportSheet.vue +467 -0
- package/src/ngb/reporting/api.ts +35 -0
- package/src/ngb/reporting/composer.ts +904 -0
- package/src/ngb/reporting/config.ts +120 -0
- package/src/ngb/reporting/defaultConfig.ts +15 -0
- package/src/ngb/reporting/lookupFilters.ts +43 -0
- package/src/ngb/reporting/navigation.ts +181 -0
- package/src/ngb/reporting/pageHelpers.ts +73 -0
- package/src/ngb/reporting/pageSession.ts +92 -0
- package/src/ngb/reporting/paging.ts +66 -0
- package/src/ngb/reporting/types.ts +333 -0
- package/src/ngb/router/backNavigation.ts +122 -0
- package/src/ngb/router/queryParams.ts +129 -0
- package/src/ngb/router/queryState.ts +121 -0
- package/src/ngb/router/routeAliases.ts +66 -0
- package/src/ngb/router/routeParams.ts +15 -0
- package/src/ngb/router/shareLink.ts +39 -0
- package/src/ngb/security/NgbAccessAuditPanel.vue +32 -0
- package/src/ngb/security/NgbAccessDeniedState.vue +27 -0
- package/src/ngb/security/NgbEffectiveAccessPanel.vue +60 -0
- package/src/ngb/security/NgbPermissionMatrix.vue +160 -0
- package/src/ngb/security/NgbRoleEditorPage.vue +319 -0
- package/src/ngb/security/NgbRolesPage.vue +125 -0
- package/src/ngb/security/NgbUserEditorPage.vue +615 -0
- package/src/ngb/security/NgbUsersPage.vue +125 -0
- package/src/ngb/security/api.ts +91 -0
- package/src/ngb/security/audit.ts +21 -0
- package/src/ngb/security/permissions.ts +65 -0
- package/src/ngb/security/types.ts +157 -0
- package/src/ngb/security/useAccessStore.ts +69 -0
- package/src/ngb/site/NgbDashboardAsOfToolbar.vue +100 -0
- package/src/ngb/site/NgbDashboardStatusBanner.vue +91 -0
- package/src/ngb/site/NgbLogo.vue +44 -0
- package/src/ngb/site/NgbPageHeader.vue +49 -0
- package/src/ngb/site/NgbSiteShell.vue +286 -0
- package/src/ngb/site/NgbSiteSidebar.vue +270 -0
- package/src/ngb/site/NgbTopBar.vue +425 -0
- package/src/ngb/site/NgbTrendChart.vue +224 -0
- package/src/ngb/site/dashboardData.ts +324 -0
- package/src/ngb/site/mainMenuStore.ts +49 -0
- package/src/ngb/site/types.ts +31 -0
- package/src/ngb/site/useDashboardPageState.ts +98 -0
- package/src/ngb/site/useTheme.ts +95 -0
- package/src/ngb/utils/clone.ts +43 -0
- package/src/ngb/utils/dateValues.ts +107 -0
- package/src/ngb/utils/errorMessage.ts +14 -0
- package/src/ngb/utils/guid.ts +21 -0
- package/src/ngb/utils/stableValue.ts +21 -0
- package/src/ngb/utils/storage.ts +135 -0
- package/src/styles/tailwind.css +165 -0
- package/src/vite/publicAssets.ts +104 -0
- package/vite-public-assets.js +68 -0
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="w-full">
|
|
3
|
+
<label v-if="label" class="block text-xs font-semibold text-ngb-muted mb-1">{{ label }}</label>
|
|
4
|
+
|
|
5
|
+
<Combobox
|
|
6
|
+
:modelValue="modelValue"
|
|
7
|
+
@update:modelValue="onSelect"
|
|
8
|
+
:disabled="comboboxDisabled"
|
|
9
|
+
by="id"
|
|
10
|
+
>
|
|
11
|
+
<div class="relative" :title="tooltipText || undefined">
|
|
12
|
+
<ComboboxInput
|
|
13
|
+
ref="inputRef"
|
|
14
|
+
:class="[inputClass, comboboxDisabled ? 'opacity-60 cursor-not-allowed' : '']"
|
|
15
|
+
:displayValue="displayValue"
|
|
16
|
+
:placeholder="placeholder"
|
|
17
|
+
:disabled="comboboxDisabled"
|
|
18
|
+
:readonly="readonly"
|
|
19
|
+
:title="tooltipText || undefined"
|
|
20
|
+
@input="onInput(($event.target as HTMLInputElement).value)"
|
|
21
|
+
@mousedown.capture="updatePosition"
|
|
22
|
+
@focus.capture="updatePosition"
|
|
23
|
+
@keydown.arrow-down.capture="updatePosition"
|
|
24
|
+
@keydown.enter.capture="updatePosition"
|
|
25
|
+
/>
|
|
26
|
+
|
|
27
|
+
<div class="absolute inset-y-0 right-0 flex items-center pr-1">
|
|
28
|
+
<button
|
|
29
|
+
v-if="showOpen"
|
|
30
|
+
type="button"
|
|
31
|
+
:class="actionButtonClass"
|
|
32
|
+
:disabled="disabled || !modelValue"
|
|
33
|
+
aria-label="Open"
|
|
34
|
+
title="Open"
|
|
35
|
+
@mousedown.prevent.stop
|
|
36
|
+
@click.prevent.stop="emit('open')"
|
|
37
|
+
>
|
|
38
|
+
<NgbIcon name="open-in-new" :size="actionIconSize" />
|
|
39
|
+
</button>
|
|
40
|
+
|
|
41
|
+
<button
|
|
42
|
+
v-if="showClear"
|
|
43
|
+
type="button"
|
|
44
|
+
:class="actionButtonClass"
|
|
45
|
+
:disabled="disabled || !modelValue"
|
|
46
|
+
aria-label="Clear"
|
|
47
|
+
title="Clear"
|
|
48
|
+
@mousedown.prevent.stop
|
|
49
|
+
@click.prevent.stop="clearSelection"
|
|
50
|
+
>
|
|
51
|
+
<NgbIcon name="x" :size="actionIconSize" />
|
|
52
|
+
</button>
|
|
53
|
+
|
|
54
|
+
<ComboboxButton
|
|
55
|
+
:class="[dropdownButtonClass, comboboxDisabled ? 'pointer-events-none' : '']"
|
|
56
|
+
aria-label="Show options"
|
|
57
|
+
@mousedown.capture="updatePosition"
|
|
58
|
+
>
|
|
59
|
+
▾
|
|
60
|
+
</ComboboxButton>
|
|
61
|
+
</div>
|
|
62
|
+
|
|
63
|
+
<!--
|
|
64
|
+
Teleport dropdown to body to avoid clipping inside tables/scroll regions.
|
|
65
|
+
Position is computed from the input's viewport rect.
|
|
66
|
+
-->
|
|
67
|
+
<Teleport to="body">
|
|
68
|
+
<ComboboxOptions
|
|
69
|
+
v-if="filteredItems.length > 0"
|
|
70
|
+
ref="optionsRef"
|
|
71
|
+
:style="floatingStyle"
|
|
72
|
+
class="fixed z-[1000] max-h-72 overflow-auto rounded-[var(--ngb-radius)] border border-ngb-border bg-ngb-card shadow-card p-1 focus:outline-none"
|
|
73
|
+
>
|
|
74
|
+
<ComboboxOption
|
|
75
|
+
v-for="item in filteredItems"
|
|
76
|
+
:key="item.id"
|
|
77
|
+
:value="item"
|
|
78
|
+
v-slot="{ active, selected }"
|
|
79
|
+
>
|
|
80
|
+
<div
|
|
81
|
+
class="px-3 py-2 rounded-[var(--ngb-radius)] text-sm flex items-start justify-between gap-3"
|
|
82
|
+
:class="active ? 'bg-[var(--ngb-row-hover)]' : ''"
|
|
83
|
+
:title="optionTooltip(item) || undefined"
|
|
84
|
+
>
|
|
85
|
+
<div class="min-w-0">
|
|
86
|
+
<div class="truncate" :class="selected ? 'font-semibold' : ''">{{ item.label }}</div>
|
|
87
|
+
<div v-if="item.meta" class="text-xs text-ngb-muted truncate mt-0.5">{{ item.meta }}</div>
|
|
88
|
+
</div>
|
|
89
|
+
<div v-if="selected" class="text-ngb-blue shrink-0">✓</div>
|
|
90
|
+
</div>
|
|
91
|
+
</ComboboxOption>
|
|
92
|
+
</ComboboxOptions>
|
|
93
|
+
|
|
94
|
+
<div
|
|
95
|
+
v-else-if="query && !comboboxDisabled"
|
|
96
|
+
ref="emptyRef"
|
|
97
|
+
:style="floatingStyle"
|
|
98
|
+
class="fixed z-[1000] rounded-[var(--ngb-radius)] border border-ngb-border bg-ngb-card shadow-card p-3 text-sm text-ngb-muted"
|
|
99
|
+
>
|
|
100
|
+
<span v-if="isSearching">Searching…</span>
|
|
101
|
+
<span v-else>No results</span>
|
|
102
|
+
</div>
|
|
103
|
+
</Teleport>
|
|
104
|
+
</div>
|
|
105
|
+
</Combobox>
|
|
106
|
+
|
|
107
|
+
<div v-if="hint" class="mt-1 text-xs text-ngb-muted">{{ hint }}</div>
|
|
108
|
+
</div>
|
|
109
|
+
</template>
|
|
110
|
+
|
|
111
|
+
<script setup lang="ts">
|
|
112
|
+
import { computed, onBeforeUnmount, ref, watch, type ComponentPublicInstance } from 'vue'
|
|
113
|
+
import { Combobox, ComboboxInput, ComboboxButton, ComboboxOptions, ComboboxOption } from '@headlessui/vue'
|
|
114
|
+
import NgbIcon from './NgbIcon.vue'
|
|
115
|
+
import { useFloatingDropdownPosition } from './useFloatingDropdownPosition'
|
|
116
|
+
import { useAsyncComboboxQuery } from './useAsyncComboboxQuery'
|
|
117
|
+
|
|
118
|
+
export type LookupItem = { id: string; label: string; meta?: string }
|
|
119
|
+
type LookupVariant = 'default' | 'grid' | 'compact'
|
|
120
|
+
type FloatingTarget = HTMLElement | ComponentPublicInstance | null
|
|
121
|
+
|
|
122
|
+
const props = withDefaults(defineProps<{
|
|
123
|
+
modelValue: LookupItem | null
|
|
124
|
+
items: LookupItem[]
|
|
125
|
+
label?: string
|
|
126
|
+
hint?: string
|
|
127
|
+
placeholder?: string
|
|
128
|
+
disabled?: boolean
|
|
129
|
+
readonly?: boolean
|
|
130
|
+
showOpen?: boolean
|
|
131
|
+
showClear?: boolean
|
|
132
|
+
// Visual variant.
|
|
133
|
+
// - default: standard input (bordered)
|
|
134
|
+
// - grid: borderless cell-style input (used inside line grids)
|
|
135
|
+
// - compact: compact bordered input (used in page headers/toolbars)
|
|
136
|
+
variant?: LookupVariant
|
|
137
|
+
}>(), {
|
|
138
|
+
label: '',
|
|
139
|
+
hint: '',
|
|
140
|
+
placeholder: '',
|
|
141
|
+
disabled: false,
|
|
142
|
+
readonly: false,
|
|
143
|
+
showOpen: false,
|
|
144
|
+
showClear: false,
|
|
145
|
+
variant: 'default',
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
const emit = defineEmits<{
|
|
149
|
+
(e: 'update:modelValue', v: LookupItem | null): void
|
|
150
|
+
(e: 'query', v: string): void
|
|
151
|
+
(e: 'open'): void
|
|
152
|
+
}>()
|
|
153
|
+
|
|
154
|
+
const comboboxDisabled = computed(() => !!props.disabled || !!props.readonly)
|
|
155
|
+
|
|
156
|
+
const actionCount = computed(() => 1 + (props.showOpen ? 1 : 0) + (props.showClear ? 1 : 0))
|
|
157
|
+
|
|
158
|
+
function inputPaddingClass(count: number, variant: LookupVariant): string {
|
|
159
|
+
if (variant === 'grid') {
|
|
160
|
+
if (count >= 3) return 'pr-20'
|
|
161
|
+
if (count === 2) return 'pr-14'
|
|
162
|
+
return 'pr-9'
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (variant === 'compact') {
|
|
166
|
+
if (count >= 3) return 'pr-20'
|
|
167
|
+
if (count === 2) return 'pr-14'
|
|
168
|
+
return 'pr-8'
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (count >= 3) return 'pr-24'
|
|
172
|
+
if (count === 2) return 'pr-16'
|
|
173
|
+
return 'pr-10'
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const inputClass = computed(() => {
|
|
177
|
+
if (props.variant === 'grid') {
|
|
178
|
+
return `h-8 w-full rounded-none border border-transparent bg-transparent px-2 text-sm text-ngb-text placeholder:text-ngb-muted/70 ngb-focus ${inputPaddingClass(actionCount.value, props.variant)}`
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (props.variant === 'compact') {
|
|
182
|
+
return `h-[26px] w-full rounded-[var(--ngb-radius)] border border-ngb-border bg-ngb-card px-3 text-xs text-ngb-text placeholder:text-ngb-muted/70 shadow-card ngb-focus ${inputPaddingClass(actionCount.value, props.variant)}`
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return `h-9 w-full rounded-[var(--ngb-radius)] border border-ngb-border bg-ngb-card px-3 text-sm text-ngb-text placeholder:text-ngb-muted/70 ngb-focus ${inputPaddingClass(actionCount.value, props.variant)}`
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
const actionButtonClass = computed(() => {
|
|
189
|
+
const base = 'inline-flex items-center justify-center text-ngb-muted transition-colors hover:text-ngb-text disabled:cursor-not-allowed disabled:opacity-40'
|
|
190
|
+
if (props.variant === 'grid') return `${base} h-8 w-6`
|
|
191
|
+
if (props.variant === 'compact') return `${base} h-[26px] w-6 text-[11px]`
|
|
192
|
+
return `${base} h-9 w-8`
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
const dropdownButtonClass = computed(() => {
|
|
196
|
+
if (props.variant === 'grid') return `${actionButtonClass.value} text-[11px]`
|
|
197
|
+
if (props.variant === 'compact') return `${actionButtonClass.value} text-[10px]`
|
|
198
|
+
return actionButtonClass.value
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
const actionIconSize = computed(() => (props.variant === 'compact' ? 13 : 14))
|
|
202
|
+
const tooltipText = computed(() => {
|
|
203
|
+
const label = String(props.modelValue?.label ?? '').trim()
|
|
204
|
+
return label.length > 0 ? label : ''
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
const {
|
|
208
|
+
query,
|
|
209
|
+
isSearching,
|
|
210
|
+
clearPendingState,
|
|
211
|
+
onInput,
|
|
212
|
+
resetQueryState,
|
|
213
|
+
} = useAsyncComboboxQuery({
|
|
214
|
+
disabled: comboboxDisabled,
|
|
215
|
+
items: computed(() => props.items),
|
|
216
|
+
emitQuery: (value) => emit('query', value),
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
const filteredItems = computed(() => {
|
|
220
|
+
const q = query.value.trim().toLowerCase()
|
|
221
|
+
if (!q) return props.items.slice(0, 50)
|
|
222
|
+
return props.items
|
|
223
|
+
.filter((item) => `${item.label} ${item.meta ?? ''}`.toLowerCase().includes(q))
|
|
224
|
+
.slice(0, 50)
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
function displayValue(item: LookupItem | null) {
|
|
228
|
+
return item?.label ?? ''
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function optionTooltip(item: LookupItem): string {
|
|
232
|
+
const label = String(item.label ?? '').trim()
|
|
233
|
+
const meta = String(item.meta ?? '').trim()
|
|
234
|
+
if (!meta) return label
|
|
235
|
+
if (!label) return meta
|
|
236
|
+
return `${label} - ${meta}`
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
onBeforeUnmount(() => clearPendingState())
|
|
240
|
+
|
|
241
|
+
function onSelect(v: LookupItem | null) {
|
|
242
|
+
emit('update:modelValue', v)
|
|
243
|
+
resetQueryState()
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function clearSelection() {
|
|
247
|
+
if (!props.modelValue) return
|
|
248
|
+
emit('update:modelValue', null)
|
|
249
|
+
resetQueryState({ emitEmptyQuery: true })
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const inputRef = ref<FloatingTarget>(null)
|
|
253
|
+
const optionsRef = ref<FloatingTarget>(null)
|
|
254
|
+
const emptyRef = ref<FloatingTarget>(null)
|
|
255
|
+
|
|
256
|
+
const { floatingStyle, updatePosition } = useFloatingDropdownPosition(inputRef, [optionsRef, emptyRef])
|
|
257
|
+
|
|
258
|
+
watch(
|
|
259
|
+
() => [filteredItems.value.length, query.value, isSearching.value] as const,
|
|
260
|
+
() => {
|
|
261
|
+
updatePosition()
|
|
262
|
+
},
|
|
263
|
+
)
|
|
264
|
+
</script>
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, ref, watch } from 'vue'
|
|
3
|
+
import NgbPickerPopover from './NgbPickerPopover.vue'
|
|
4
|
+
import NgbPickerNavButton from './NgbPickerNavButton.vue'
|
|
5
|
+
import {
|
|
6
|
+
currentMonthValue,
|
|
7
|
+
formatMonthValue,
|
|
8
|
+
normalizeMonthValue,
|
|
9
|
+
parseMonthValue,
|
|
10
|
+
toMonthValue,
|
|
11
|
+
} from '../utils/dateValues'
|
|
12
|
+
|
|
13
|
+
const props = defineProps<{
|
|
14
|
+
modelValue: string | null | undefined
|
|
15
|
+
placeholder?: string
|
|
16
|
+
disabled?: boolean
|
|
17
|
+
readonly?: boolean
|
|
18
|
+
grouped?: boolean
|
|
19
|
+
displayFormat?: 'short' | 'long'
|
|
20
|
+
}>()
|
|
21
|
+
|
|
22
|
+
const emit = defineEmits<{
|
|
23
|
+
(e: 'update:modelValue', v: string | null): void
|
|
24
|
+
}>()
|
|
25
|
+
|
|
26
|
+
function toDisplay(v: string | null | undefined, format: 'short' | 'long'): string {
|
|
27
|
+
if (!v) return ''
|
|
28
|
+
return formatMonthValue(v, { month: format, year: 'numeric' }) ?? String(v)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const months = [
|
|
32
|
+
{ value: 1, label: 'Jan' },
|
|
33
|
+
{ value: 2, label: 'Feb' },
|
|
34
|
+
{ value: 3, label: 'Mar' },
|
|
35
|
+
{ value: 4, label: 'Apr' },
|
|
36
|
+
{ value: 5, label: 'May' },
|
|
37
|
+
{ value: 6, label: 'Jun' },
|
|
38
|
+
{ value: 7, label: 'Jul' },
|
|
39
|
+
{ value: 8, label: 'Aug' },
|
|
40
|
+
{ value: 9, label: 'Sep' },
|
|
41
|
+
{ value: 10, label: 'Oct' },
|
|
42
|
+
{ value: 11, label: 'Nov' },
|
|
43
|
+
{ value: 12, label: 'Dec' },
|
|
44
|
+
] as const
|
|
45
|
+
|
|
46
|
+
const selected = computed(() => parseMonthValue(props.modelValue ?? null))
|
|
47
|
+
const viewYear = ref<number>(selected.value?.year ?? parseMonthValue(currentMonthValue())?.year ?? new Date().getFullYear())
|
|
48
|
+
|
|
49
|
+
watch(
|
|
50
|
+
() => props.modelValue,
|
|
51
|
+
(v) => {
|
|
52
|
+
const ym = parseMonthValue(normalizeMonthValue(v) ?? null)
|
|
53
|
+
if (!ym) return
|
|
54
|
+
viewYear.value = ym.year
|
|
55
|
+
}
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
const displayValue = computed(() => toDisplay(props.modelValue ?? null, props.displayFormat ?? 'long'))
|
|
59
|
+
|
|
60
|
+
function prevYear() {
|
|
61
|
+
viewYear.value -= 1
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function nextYear() {
|
|
65
|
+
viewYear.value += 1
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function pick(month: number, close: () => void) {
|
|
69
|
+
emit('update:modelValue', toMonthValue(viewYear.value, month))
|
|
70
|
+
close()
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function clear(close: () => void) {
|
|
74
|
+
emit('update:modelValue', null)
|
|
75
|
+
close()
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function setCurrent(close: () => void) {
|
|
79
|
+
emit('update:modelValue', currentMonthValue())
|
|
80
|
+
close()
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function isSelected(month: number): boolean {
|
|
84
|
+
return selected.value?.year === viewYear.value && selected.value?.month === month
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function isCurrent(month: number): boolean {
|
|
88
|
+
const ym = parseMonthValue(currentMonthValue())
|
|
89
|
+
return ym?.year === viewYear.value && ym.month === month
|
|
90
|
+
}
|
|
91
|
+
</script>
|
|
92
|
+
|
|
93
|
+
<template>
|
|
94
|
+
<NgbPickerPopover
|
|
95
|
+
:display-value="displayValue"
|
|
96
|
+
:placeholder="placeholder ?? 'Select month'"
|
|
97
|
+
:disabled="disabled"
|
|
98
|
+
:readonly="readonly"
|
|
99
|
+
:grouped="grouped"
|
|
100
|
+
>
|
|
101
|
+
<template #header>
|
|
102
|
+
<NgbPickerNavButton direction="prev" @click="prevYear" />
|
|
103
|
+
<div class="text-sm font-semibold text-ngb-text">{{ viewYear }}</div>
|
|
104
|
+
<NgbPickerNavButton direction="next" @click="nextYear" />
|
|
105
|
+
</template>
|
|
106
|
+
|
|
107
|
+
<template #default="{ close }">
|
|
108
|
+
<div class="grid grid-cols-4 gap-2">
|
|
109
|
+
<button
|
|
110
|
+
v-for="month in months"
|
|
111
|
+
:key="month.value"
|
|
112
|
+
type="button"
|
|
113
|
+
class="h-10 rounded-md border text-sm transition-colors"
|
|
114
|
+
:class="[
|
|
115
|
+
isSelected(month.value)
|
|
116
|
+
? 'border-ngb-primary/50 bg-ngb-primary/20 text-ngb-text'
|
|
117
|
+
: 'border-ngb-border bg-transparent text-ngb-text hover:bg-ngb-muted/10',
|
|
118
|
+
isCurrent(month.value) ? 'ring-1 ring-ngb-primary/40' : ''
|
|
119
|
+
]"
|
|
120
|
+
@click="pick(month.value, close)"
|
|
121
|
+
>
|
|
122
|
+
{{ month.label }}
|
|
123
|
+
</button>
|
|
124
|
+
</div>
|
|
125
|
+
</template>
|
|
126
|
+
|
|
127
|
+
<template #footer="{ close }">
|
|
128
|
+
<button type="button" class="text-ngb-primary hover:underline" @click="clear(close)">Clear</button>
|
|
129
|
+
<button type="button" class="text-ngb-primary hover:underline" @click="setCurrent(close)">This month</button>
|
|
130
|
+
</template>
|
|
131
|
+
</NgbPickerPopover>
|
|
132
|
+
</template>
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="w-full">
|
|
3
|
+
<label v-if="label" class="block text-xs font-semibold text-ngb-muted mb-1">{{ label }}</label>
|
|
4
|
+
|
|
5
|
+
<Combobox
|
|
6
|
+
multiple
|
|
7
|
+
:modelValue="modelValue"
|
|
8
|
+
@update:modelValue="onSelect"
|
|
9
|
+
:disabled="disabled"
|
|
10
|
+
by="id"
|
|
11
|
+
>
|
|
12
|
+
<div ref="anchorRef" class="relative">
|
|
13
|
+
<div
|
|
14
|
+
class="min-h-9 w-full rounded-[var(--ngb-radius)] border border-ngb-border bg-ngb-card px-2 py-1 flex flex-wrap items-center gap-1 ngb-focus"
|
|
15
|
+
:class="disabled ? 'opacity-60 cursor-not-allowed' : ''"
|
|
16
|
+
>
|
|
17
|
+
<span
|
|
18
|
+
v-for="item in modelValue"
|
|
19
|
+
:key="item.id"
|
|
20
|
+
class="inline-flex items-center gap-1 rounded-full border border-ngb-border bg-[var(--ngb-row-hover)] px-2 py-0.5 text-[11px] font-semibold"
|
|
21
|
+
:title="multiSelectItemTooltip(item) || undefined"
|
|
22
|
+
>
|
|
23
|
+
<span class="truncate max-w-[180px]">{{ item.label }}</span>
|
|
24
|
+
<button
|
|
25
|
+
v-if="!disabled"
|
|
26
|
+
type="button"
|
|
27
|
+
class="text-ngb-muted hover:text-ngb-text"
|
|
28
|
+
aria-label="Remove"
|
|
29
|
+
@click.stop="remove(item)"
|
|
30
|
+
>
|
|
31
|
+
×
|
|
32
|
+
</button>
|
|
33
|
+
</span>
|
|
34
|
+
|
|
35
|
+
<ComboboxInput
|
|
36
|
+
class="h-7 flex-1 min-w-[120px] bg-transparent px-2 text-sm text-ngb-text placeholder:text-ngb-muted/70 outline-none"
|
|
37
|
+
:displayValue="() => ''"
|
|
38
|
+
:placeholder="modelValue.length === 0 ? placeholder : ''"
|
|
39
|
+
@input="onQueryInput(($event.target as HTMLInputElement).value)"
|
|
40
|
+
@mousedown.capture="updatePosition"
|
|
41
|
+
@focus.capture="updatePosition"
|
|
42
|
+
@keydown.arrow-down.capture="updatePosition"
|
|
43
|
+
@keydown.enter.capture="updatePosition"
|
|
44
|
+
@keydown.backspace.capture="onBackspace"
|
|
45
|
+
/>
|
|
46
|
+
</div>
|
|
47
|
+
|
|
48
|
+
<Teleport to="body">
|
|
49
|
+
<ComboboxOptions
|
|
50
|
+
v-if="filteredItems.length > 0"
|
|
51
|
+
ref="optionsRef"
|
|
52
|
+
:style="floatingStyle"
|
|
53
|
+
class="fixed z-[1000] max-h-72 overflow-auto rounded-[var(--ngb-radius)] border border-ngb-border bg-ngb-card shadow-card p-1 focus:outline-none"
|
|
54
|
+
>
|
|
55
|
+
<ComboboxOption
|
|
56
|
+
v-for="item in filteredItems"
|
|
57
|
+
:key="item.id"
|
|
58
|
+
:value="item"
|
|
59
|
+
v-slot="{ active, selected }"
|
|
60
|
+
>
|
|
61
|
+
<div
|
|
62
|
+
class="px-3 py-2 rounded-[var(--ngb-radius)] text-sm flex items-center justify-between gap-3"
|
|
63
|
+
:class="active ? 'bg-[var(--ngb-row-hover)]' : ''"
|
|
64
|
+
:title="multiSelectItemTooltip(item) || undefined"
|
|
65
|
+
>
|
|
66
|
+
<div class="min-w-0">
|
|
67
|
+
<div class="truncate" :class="selected ? 'font-semibold' : ''">{{ item.label }}</div>
|
|
68
|
+
<div v-if="item.meta" class="text-xs text-ngb-muted truncate mt-0.5">{{ item.meta }}</div>
|
|
69
|
+
</div>
|
|
70
|
+
<div v-if="selected" class="text-ngb-blue shrink-0">✓</div>
|
|
71
|
+
</div>
|
|
72
|
+
</ComboboxOption>
|
|
73
|
+
</ComboboxOptions>
|
|
74
|
+
|
|
75
|
+
<div
|
|
76
|
+
v-else-if="query.trim() && !disabled"
|
|
77
|
+
ref="emptyRef"
|
|
78
|
+
:style="floatingStyle"
|
|
79
|
+
class="fixed z-[1000] rounded-[var(--ngb-radius)] border border-ngb-border bg-ngb-card shadow-card p-3 text-sm text-ngb-muted"
|
|
80
|
+
>
|
|
81
|
+
<span v-if="isSearching">Searching…</span>
|
|
82
|
+
<span v-else>No results</span>
|
|
83
|
+
</div>
|
|
84
|
+
</Teleport>
|
|
85
|
+
</div>
|
|
86
|
+
</Combobox>
|
|
87
|
+
|
|
88
|
+
<div v-if="hint" class="mt-1 text-xs text-ngb-muted">{{ hint }}</div>
|
|
89
|
+
</div>
|
|
90
|
+
</template>
|
|
91
|
+
|
|
92
|
+
<script setup lang="ts">
|
|
93
|
+
import { computed, onBeforeUnmount, ref, watch, type ComponentPublicInstance } from 'vue'
|
|
94
|
+
import { Combobox, ComboboxInput, ComboboxOptions, ComboboxOption } from '@headlessui/vue'
|
|
95
|
+
|
|
96
|
+
import { useFloatingDropdownPosition } from './useFloatingDropdownPosition'
|
|
97
|
+
import { useAsyncComboboxQuery } from './useAsyncComboboxQuery'
|
|
98
|
+
|
|
99
|
+
export type MultiSelectItem = { id: string; label: string; meta?: string }
|
|
100
|
+
type FloatingTarget = HTMLElement | ComponentPublicInstance | null
|
|
101
|
+
|
|
102
|
+
const props = defineProps<{
|
|
103
|
+
modelValue: MultiSelectItem[]
|
|
104
|
+
items: MultiSelectItem[]
|
|
105
|
+
label?: string
|
|
106
|
+
hint?: string
|
|
107
|
+
placeholder?: string
|
|
108
|
+
disabled?: boolean
|
|
109
|
+
}>()
|
|
110
|
+
|
|
111
|
+
const emit = defineEmits<{
|
|
112
|
+
(e: 'update:modelValue', v: MultiSelectItem[]): void
|
|
113
|
+
(e: 'query', v: string): void
|
|
114
|
+
}>()
|
|
115
|
+
|
|
116
|
+
const {
|
|
117
|
+
query,
|
|
118
|
+
isSearching,
|
|
119
|
+
resetQueryState,
|
|
120
|
+
onInput,
|
|
121
|
+
} = useAsyncComboboxQuery({
|
|
122
|
+
disabled: computed(() => !!props.disabled),
|
|
123
|
+
items: computed(() => props.items),
|
|
124
|
+
emitQuery: (value) => emit('query', value),
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
const filteredItems = computed(() => {
|
|
128
|
+
const normalizedQuery = query.value.trim().toLowerCase()
|
|
129
|
+
const selectedIds = new Set(props.modelValue.map((item) => item.id))
|
|
130
|
+
const source = props.items.filter((item) => !selectedIds.has(item.id))
|
|
131
|
+
if (!normalizedQuery) return source.slice(0, 50)
|
|
132
|
+
|
|
133
|
+
return source
|
|
134
|
+
.filter((item) => `${item.label} ${item.meta ?? ''}`.toLowerCase().includes(normalizedQuery))
|
|
135
|
+
.slice(0, 50)
|
|
136
|
+
})
|
|
137
|
+
const anchorRef = ref<FloatingTarget>(null)
|
|
138
|
+
const optionsRef = ref<FloatingTarget>(null)
|
|
139
|
+
const emptyRef = ref<FloatingTarget>(null)
|
|
140
|
+
|
|
141
|
+
const { floatingStyle, updatePosition } = useFloatingDropdownPosition(anchorRef, [optionsRef, emptyRef])
|
|
142
|
+
|
|
143
|
+
function remove(item: MultiSelectItem) {
|
|
144
|
+
emit('update:modelValue', props.modelValue.filter((entry) => entry.id !== item.id))
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function multiSelectItemTooltip(item: MultiSelectItem): string {
|
|
148
|
+
const label = String(item.label ?? '').trim()
|
|
149
|
+
const meta = String(item.meta ?? '').trim()
|
|
150
|
+
if (!meta) return label
|
|
151
|
+
if (!label) return meta
|
|
152
|
+
return `${label} - ${meta}`
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function onQueryInput(value: string) {
|
|
156
|
+
onInput(value)
|
|
157
|
+
updatePosition()
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function onSelect(value: MultiSelectItem[]) {
|
|
161
|
+
emit('update:modelValue', value)
|
|
162
|
+
resetQueryState({ emitEmptyQuery: true })
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function onBackspace(event: KeyboardEvent) {
|
|
166
|
+
if (query.value.trim().length > 0 || props.modelValue.length === 0) return
|
|
167
|
+
event.stopPropagation()
|
|
168
|
+
remove(props.modelValue[props.modelValue.length - 1]!)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
watch(
|
|
172
|
+
() => filteredItems.value.length,
|
|
173
|
+
() => {
|
|
174
|
+
updatePosition()
|
|
175
|
+
},
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
onBeforeUnmount(() => resetQueryState())
|
|
179
|
+
</script>
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="w-full">
|
|
3
|
+
<label v-if="label" class="block text-xs font-semibold text-ngb-muted mb-1">{{ label }}</label>
|
|
4
|
+
|
|
5
|
+
<div class="flex items-center gap-3 flex-wrap">
|
|
6
|
+
<NgbTabs v-model="kind" :tabs="kindTabs" />
|
|
7
|
+
|
|
8
|
+
<div class="flex items-center gap-2">
|
|
9
|
+
<span class="text-xs font-semibold text-ngb-muted">Year</span>
|
|
10
|
+
<div class="w-[112px]">
|
|
11
|
+
<NgbSelect
|
|
12
|
+
:model-value="year"
|
|
13
|
+
:options="yearOptions"
|
|
14
|
+
@update:model-value="onYear(String($event ?? ''))"
|
|
15
|
+
/>
|
|
16
|
+
</div>
|
|
17
|
+
</div>
|
|
18
|
+
|
|
19
|
+
<div class="flex items-center gap-2">
|
|
20
|
+
<span class="text-xs font-semibold text-ngb-muted">{{ periodLabel }}</span>
|
|
21
|
+
<div class="w-[112px]">
|
|
22
|
+
<NgbSelect
|
|
23
|
+
:model-value="period"
|
|
24
|
+
:options="periodOptions"
|
|
25
|
+
@update:model-value="onPeriod(String($event ?? ''))"
|
|
26
|
+
/>
|
|
27
|
+
</div>
|
|
28
|
+
</div>
|
|
29
|
+
</div>
|
|
30
|
+
|
|
31
|
+
<div v-if="hint" class="mt-1 text-xs text-ngb-muted">{{ hint }}</div>
|
|
32
|
+
</div>
|
|
33
|
+
</template>
|
|
34
|
+
|
|
35
|
+
<script setup lang="ts">
|
|
36
|
+
import { computed } from 'vue'
|
|
37
|
+
|
|
38
|
+
import NgbSelect from './NgbSelect.vue'
|
|
39
|
+
import NgbTabs from './NgbTabs.vue'
|
|
40
|
+
|
|
41
|
+
export type PeriodKind = 'month' | 'quarter' | 'year'
|
|
42
|
+
export type PeriodValue = {
|
|
43
|
+
kind: PeriodKind
|
|
44
|
+
year: number
|
|
45
|
+
period: number
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const props = defineProps<{
|
|
49
|
+
modelValue: PeriodValue
|
|
50
|
+
label?: string
|
|
51
|
+
hint?: string
|
|
52
|
+
minYear?: number
|
|
53
|
+
maxYear?: number
|
|
54
|
+
}>()
|
|
55
|
+
|
|
56
|
+
const emit = defineEmits<{
|
|
57
|
+
(e: 'update:modelValue', value: PeriodValue): void
|
|
58
|
+
}>()
|
|
59
|
+
|
|
60
|
+
const kindTabs = [
|
|
61
|
+
{ key: 'month', label: 'Month' },
|
|
62
|
+
{ key: 'quarter', label: 'Quarter' },
|
|
63
|
+
{ key: 'year', label: 'Year' },
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
const years = computed(() => {
|
|
67
|
+
const now = new Date()
|
|
68
|
+
const min = props.minYear ?? (now.getUTCFullYear() - 5)
|
|
69
|
+
const max = props.maxYear ?? (now.getUTCFullYear() + 1)
|
|
70
|
+
const values: number[] = []
|
|
71
|
+
|
|
72
|
+
for (let year = max; year >= min; year -= 1) values.push(year)
|
|
73
|
+
return values
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
const yearOptions = computed(() => years.value.map((year) => ({ value: year, label: String(year) })))
|
|
77
|
+
|
|
78
|
+
const kind = computed({
|
|
79
|
+
get: () => props.modelValue.kind,
|
|
80
|
+
set: (value: string) => {
|
|
81
|
+
const nextKind = value as PeriodKind
|
|
82
|
+
emit('update:modelValue', {
|
|
83
|
+
...props.modelValue,
|
|
84
|
+
kind: nextKind,
|
|
85
|
+
period: 1,
|
|
86
|
+
})
|
|
87
|
+
},
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
const year = computed(() => props.modelValue.year)
|
|
91
|
+
const period = computed(() => props.modelValue.period)
|
|
92
|
+
|
|
93
|
+
const periodLabel = computed(() => {
|
|
94
|
+
if (props.modelValue.kind === 'month') return 'Month'
|
|
95
|
+
if (props.modelValue.kind === 'quarter') return 'Quarter'
|
|
96
|
+
return 'Period'
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
const periodOptions = computed(() => {
|
|
100
|
+
if (props.modelValue.kind === 'month') {
|
|
101
|
+
return Array.from({ length: 12 }, (_, index) => ({
|
|
102
|
+
value: index + 1,
|
|
103
|
+
label: String(index + 1).padStart(2, '0'),
|
|
104
|
+
}))
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (props.modelValue.kind === 'quarter') {
|
|
108
|
+
return [1, 2, 3, 4].map((quarter) => ({ value: quarter, label: `Q${quarter}` }))
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return [{ value: 1, label: 'FY' }]
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
function onYear(value: string) {
|
|
115
|
+
const nextYear = Number(value)
|
|
116
|
+
if (!Number.isFinite(nextYear)) return
|
|
117
|
+
emit('update:modelValue', { ...props.modelValue, year: nextYear })
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function onPeriod(value: string) {
|
|
121
|
+
const nextPeriod = Number(value)
|
|
122
|
+
if (!Number.isFinite(nextPeriod)) return
|
|
123
|
+
emit('update:modelValue', { ...props.modelValue, period: nextPeriod })
|
|
124
|
+
}
|
|
125
|
+
</script>
|