@bagelink/vue 1.15.253 → 1.15.261
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/dist/components/FilterQuery.vue.d.ts +6 -0
- package/dist/components/FilterQuery.vue.d.ts.map +1 -1
- package/dist/components/charts/ChartTooltip.vue.d.ts.map +1 -1
- package/dist/components/form/inputs/Upload/attachments.d.ts +27 -0
- package/dist/components/form/inputs/Upload/attachments.d.ts.map +1 -0
- package/dist/components/form/inputs/Upload/upload.d.ts +7 -3
- package/dist/components/form/inputs/Upload/upload.d.ts.map +1 -1
- package/dist/components/form/inputs/Upload/upload.types.d.ts +13 -0
- package/dist/components/form/inputs/Upload/upload.types.d.ts.map +1 -1
- package/dist/components/form/inputs/Upload/useAttachments.d.ts +97 -0
- package/dist/components/form/inputs/Upload/useAttachments.d.ts.map +1 -0
- package/dist/components/form/inputs/index.d.ts +2 -0
- package/dist/components/form/inputs/index.d.ts.map +1 -1
- package/dist/form-flow/FormFlowPlayer.vue.d.ts +8 -10
- package/dist/form-flow/FormFlowPlayer.vue.d.ts.map +1 -1
- package/dist/form-flow/structure.d.ts +11 -7
- package/dist/form-flow/structure.d.ts.map +1 -1
- package/dist/index.cjs +92 -92
- package/dist/index.mjs +9546 -9424
- package/dist/style.css +1 -1
- package/package.json +1 -1
- package/src/components/Dropdown.vue +2 -2
- package/src/components/FilterQuery.vue +99 -12
- package/src/components/ListItem.vue +1 -1
- package/src/components/charts/ChartTooltip.vue +39 -4
- package/src/components/dataTable/DataTable.vue +1 -1
- package/src/components/form/inputs/Upload/attachments.ts +41 -0
- package/src/components/form/inputs/Upload/upload.ts +19 -10
- package/src/components/form/inputs/Upload/upload.types.ts +18 -0
- package/src/components/form/inputs/Upload/useAttachments.ts +175 -0
- package/src/components/form/inputs/index.ts +2 -0
- package/src/form-flow/FormFlowPlayer.vue +28 -26
- package/src/form-flow/structure.ts +12 -7
- package/src/styles/dark.css +3 -2
package/package.json
CHANGED
|
@@ -407,14 +407,14 @@ defineExpose({ show, hide, shown })
|
|
|
407
407
|
<div
|
|
408
408
|
:id="bglId" ref="popoverRef" popover="manual" class="bgl-dropdown m-0 p-0 border-none"
|
|
409
409
|
role="menu"
|
|
410
|
-
style="border-radius: var(--bgl-card-border-radius);"
|
|
410
|
+
style="border-radius: calc(var(--bgl-card-border-radius) * 0.7);"
|
|
411
411
|
:class="[props.popupClass, { 'bgl-dropdown--no-positioning': shouldDisablePositioning, 'bg-white shadow': props.card }]"
|
|
412
412
|
@toggle="onPopoverToggle" @mouseenter="onPopoverMouseenter" @mouseleave="onPopoverMouseleave"
|
|
413
413
|
@focus="onPopoverMouseenter" @blur="onPopoverMouseleave" @keydown="onPopoverKeydown"
|
|
414
414
|
>
|
|
415
415
|
<div class="bgl-dropdown__backdrop" />
|
|
416
416
|
<div class="bgl-dropdown__content overflow-hidden" :class="{ 'display-flex column bgl-dropdown__content--card': props.card }"
|
|
417
|
-
style="border-radius: var(--bgl-card-border-radius);">
|
|
417
|
+
style="border-radius: calc(var(--bgl-card-border-radius) * 0.7);">
|
|
418
418
|
<slot :hide :show :shown />
|
|
419
419
|
</div>
|
|
420
420
|
</div>
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import type { FilterField, QueryOption, OptionsSource } from './FilterQuery.types'
|
|
3
|
+
import type { AlignedPlacement } from './Dropdown.vue'
|
|
3
4
|
export type { FilterField, QueryOption, OptionsSource }
|
|
4
5
|
</script>
|
|
5
6
|
|
|
@@ -17,9 +18,18 @@ interface UICondition {
|
|
|
17
18
|
connector: LogicalOperator | ''
|
|
18
19
|
}
|
|
19
20
|
|
|
20
|
-
const props = defineProps<{
|
|
21
|
+
const props = withDefaults(defineProps<{
|
|
21
22
|
fields: FilterField[]
|
|
22
|
-
|
|
23
|
+
placement?: AlignedPlacement
|
|
24
|
+
/** Render the filter controls directly instead of inside a dropdown. */
|
|
25
|
+
inline?: boolean
|
|
26
|
+
/** Keep the desktop row layout and controls on mobile screens. */
|
|
27
|
+
desktopLayoutOnMobile?: boolean
|
|
28
|
+
}>(), {
|
|
29
|
+
placement: 'bottom-end',
|
|
30
|
+
inline: false,
|
|
31
|
+
desktopLayoutOnMobile: false,
|
|
32
|
+
})
|
|
23
33
|
|
|
24
34
|
const emit = defineEmits<{
|
|
25
35
|
change: [value: QueryConditions<T>]
|
|
@@ -35,6 +45,20 @@ const internalModel = ref<QueryConditions<T>>(collapseToPresets([...model.value]
|
|
|
35
45
|
const { $t } = useI18n()
|
|
36
46
|
const instance = getCurrentInstance()
|
|
37
47
|
const hasSaveListener = computed(() => !!instance?.vnode.props?.onSave)
|
|
48
|
+
const filterLabel = computed(() =>
|
|
49
|
+
activeFiltersCount.value > 0
|
|
50
|
+
? `${currentTexts.value.filter} (${activeFiltersCount.value})`
|
|
51
|
+
: currentTexts.value.filter,
|
|
52
|
+
)
|
|
53
|
+
const dropdownProps = computed(() => props.inline
|
|
54
|
+
? {}
|
|
55
|
+
: {
|
|
56
|
+
flat: true,
|
|
57
|
+
placement: props.placement,
|
|
58
|
+
icon: 'filter_alt',
|
|
59
|
+
value: filterLabel.value,
|
|
60
|
+
thin: true,
|
|
61
|
+
})
|
|
38
62
|
|
|
39
63
|
function isComplete(c: { field: string, op: ComparisonOperator, value?: any }) {
|
|
40
64
|
if (!c.field || !c.op) return false
|
|
@@ -369,11 +393,16 @@ function onConnectorChange(id: string, connector: LogicalOperator) {
|
|
|
369
393
|
</script>
|
|
370
394
|
|
|
371
395
|
<template>
|
|
372
|
-
<
|
|
373
|
-
|
|
374
|
-
|
|
396
|
+
<component
|
|
397
|
+
:is="props.inline ? 'div' : Dropdown"
|
|
398
|
+
v-bind="dropdownProps"
|
|
399
|
+
class="filter-query-wrap"
|
|
400
|
+
:class="{
|
|
401
|
+
'filter-query-inline': props.inline,
|
|
402
|
+
'filter-query-desktop-mobile': props.inline || props.desktopLayoutOnMobile,
|
|
403
|
+
}"
|
|
375
404
|
>
|
|
376
|
-
<Card class="p-05">
|
|
405
|
+
<component :is="props.inline ? 'div' : Card" :class="{ 'p-05': !props.inline }">
|
|
377
406
|
<div class="p-025 bg-bg radius-1 mb-05 m_max-h-300px m_overflow m_border">
|
|
378
407
|
<!-- Empty state -->
|
|
379
408
|
<div v-if="conditions.length === 0" class="opacity-3 flex gap-05 p-025 min-w-450px m_min-0">
|
|
@@ -402,12 +431,14 @@ function onConnectorChange(id: string, connector: LogicalOperator) {
|
|
|
402
431
|
@update:model-value="(v: LogicalOperator) => onConnectorChange(condition.id, v)"
|
|
403
432
|
/>
|
|
404
433
|
</div>
|
|
405
|
-
<!-- <div v-else /> -->
|
|
406
434
|
|
|
407
435
|
<!-- Field selector -->
|
|
408
436
|
<SelectInput
|
|
409
437
|
:model-value="condition.field" :options="fields"
|
|
410
|
-
|
|
438
|
+
:placeholder="currentTexts.placeholders.selectField"
|
|
439
|
+
class="m-0 light-input borderHover"
|
|
440
|
+
:class="{ 'first-filter-field': index === 0 }"
|
|
441
|
+
searchable
|
|
411
442
|
@update:model-value="(v: string) => onFieldChange(condition.id, v)"
|
|
412
443
|
/>
|
|
413
444
|
|
|
@@ -486,7 +517,8 @@ function onConnectorChange(id: string, connector: LogicalOperator) {
|
|
|
486
517
|
|
|
487
518
|
<!-- Remove button -->
|
|
488
519
|
<Btn
|
|
489
|
-
icon="close" thin flat size="sm"
|
|
520
|
+
icon="close" thin flat size="sm"
|
|
521
|
+
:class="{ 'm_bg-gray-40 m_px-3 m_mx-auto': !props.inline && !props.desktopLayoutOnMobile }"
|
|
490
522
|
@click="removeCondition(index)"
|
|
491
523
|
/>
|
|
492
524
|
</div>
|
|
@@ -507,12 +539,15 @@ function onConnectorChange(id: string, connector: LogicalOperator) {
|
|
|
507
539
|
/>
|
|
508
540
|
</div>
|
|
509
541
|
</div>
|
|
510
|
-
</
|
|
511
|
-
</
|
|
542
|
+
</component>
|
|
543
|
+
</component>
|
|
512
544
|
</template>
|
|
513
545
|
|
|
514
546
|
<style>
|
|
515
|
-
|
|
547
|
+
.filter-query-wrap{
|
|
548
|
+
font-size: var(--bgl-input-font-size);
|
|
549
|
+
--bgl-radius:var(--bgl-input-border-radius)
|
|
550
|
+
}
|
|
516
551
|
.and-or-select {
|
|
517
552
|
--bgl-input-font-size: 12px !important;
|
|
518
553
|
--input-background-color: transparent !important;
|
|
@@ -536,6 +571,44 @@ padding-top: 0 !important;
|
|
|
536
571
|
grid-template-columns: 50px minmax(90px, 0.5fr) minmax(170px, 0.75fr) minmax(120px, 0.5fr) auto;
|
|
537
572
|
}
|
|
538
573
|
|
|
574
|
+
.filter-query-inline {
|
|
575
|
+
width: 100%;
|
|
576
|
+
max-width: 100%;
|
|
577
|
+
min-width: 0;
|
|
578
|
+
box-sizing: border-box;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
.filter-query-inline > *,
|
|
582
|
+
.filter-query-inline .m_overflow,
|
|
583
|
+
.filter-query-inline .first-filter-row > *,
|
|
584
|
+
.filter-query-inline .filter-row > * {
|
|
585
|
+
max-width: 100%;
|
|
586
|
+
min-width: 0;
|
|
587
|
+
box-sizing: border-box;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
.filter-query-inline .m_overflow {
|
|
591
|
+
overflow-x: hidden;
|
|
592
|
+
overflow-y: visible;
|
|
593
|
+
max-height: none;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
.filter-query-inline .filter-row {
|
|
597
|
+
grid-template-columns: 50px minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr) auto;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
.filter-query-inline .first-filter-row {
|
|
601
|
+
grid-template-columns: 50px minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr) auto;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
.filter-query-inline .first-filter-field {
|
|
605
|
+
grid-column: span 2;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
.filter-query-inline .min-w-450px {
|
|
609
|
+
min-width: 0 !important;
|
|
610
|
+
}
|
|
611
|
+
|
|
539
612
|
/* Transition animations */
|
|
540
613
|
.condition-enter-active,
|
|
541
614
|
.condition-leave-active {
|
|
@@ -566,6 +639,20 @@ transform: translateX(-10px);
|
|
|
566
639
|
.first-filter-row {
|
|
567
640
|
grid-template-columns: auto;
|
|
568
641
|
}
|
|
642
|
+
|
|
643
|
+
.filter-query-desktop-mobile .filter-row,
|
|
644
|
+
.filter-query-desktop-mobile .first-filter-row {
|
|
645
|
+
grid-template-columns: 50px minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr) auto;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
.filter-query-desktop-mobile .first-filter-row > *,
|
|
649
|
+
.filter-query-desktop-mobile .filter-row > * {
|
|
650
|
+
min-width: 0;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
.filter-query-desktop-mobile .first-filter-field {
|
|
654
|
+
grid-column: span 2;
|
|
655
|
+
}
|
|
569
656
|
}
|
|
570
657
|
|
|
571
658
|
</style>
|
|
@@ -344,7 +344,7 @@ content: '';
|
|
|
344
344
|
position: absolute;
|
|
345
345
|
inset-inline: 0;
|
|
346
346
|
bottom: 0;
|
|
347
|
-
border-bottom: 1px solid var(--bgl-border-color);
|
|
347
|
+
border-bottom: 1px solid color-mix(in srgb, var(--bgl-border-color) 50%, transparent);
|
|
348
348
|
}
|
|
349
349
|
.list-item-row:last-child::after,
|
|
350
350
|
.list-item-row.no-border-list::after {
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* - `prefer="top"`: pinned to the top of the chart (Line, Bar) — host sets
|
|
14
14
|
* `{ left }` only; no vertical flip.
|
|
15
15
|
*/
|
|
16
|
-
import { nextTick, ref, watch } from 'vue'
|
|
16
|
+
import { computed, nextTick, onMounted, onUpdated, ref, watch } from 'vue'
|
|
17
17
|
import { useI18n } from '@bagelink/vue'
|
|
18
18
|
|
|
19
19
|
export interface TipRow { name: string; color?: string; value: string }
|
|
@@ -56,6 +56,40 @@ function preferredSide(): 'above' | 'below' | 'top' {
|
|
|
56
56
|
const el = ref<HTMLElement>()
|
|
57
57
|
// The side actually used after measuring (may differ from the preferred one).
|
|
58
58
|
const side = ref<'above' | 'below' | 'top'>(preferredSide())
|
|
59
|
+
const shiftX = ref(0)
|
|
60
|
+
const shiftStyle = computed(() => ({ '--bgl-tip-shift-x': `${shiftX.value}px` }))
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Keep the tooltip inside its chart. Positioning starts centered on the cursor,
|
|
64
|
+
* then this correction nudges it away from either horizontal edge.
|
|
65
|
+
*/
|
|
66
|
+
function clampHorizontal() {
|
|
67
|
+
const node = el.value
|
|
68
|
+
const container = node?.parentElement
|
|
69
|
+
if (!node || !container) return
|
|
70
|
+
|
|
71
|
+
const tip = node.getBoundingClientRect()
|
|
72
|
+
const bounds = container.getBoundingClientRect()
|
|
73
|
+
const padding = 8
|
|
74
|
+
const naturalLeft = tip.left - shiftX.value
|
|
75
|
+
const naturalRight = tip.right - shiftX.value
|
|
76
|
+
let nextShift = 0
|
|
77
|
+
|
|
78
|
+
if (naturalLeft < bounds.left + padding) {
|
|
79
|
+
nextShift = bounds.left + padding - naturalLeft
|
|
80
|
+
} else if (naturalRight > bounds.right - padding) {
|
|
81
|
+
nextShift = bounds.right - padding - naturalRight
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (Math.abs(nextShift - shiftX.value) > 0.5) shiftX.value = nextShift
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function queueHorizontalClamp() {
|
|
88
|
+
nextTick(clampHorizontal)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
onMounted(queueHorizontalClamp)
|
|
92
|
+
onUpdated(queueHorizontalClamp)
|
|
59
93
|
|
|
60
94
|
/**
|
|
61
95
|
* Place the tooltip by the anchor's position in the viewport:
|
|
@@ -93,6 +127,7 @@ watch(
|
|
|
93
127
|
ref="el"
|
|
94
128
|
class="bgl-tip absolute top-0 pointer-events-none nowrap z-5 color-white line-height-16"
|
|
95
129
|
:class="{ 'bgl-tip--above': side === 'above', 'bgl-tip--below': side === 'below' }"
|
|
130
|
+
:style="shiftStyle"
|
|
96
131
|
>
|
|
97
132
|
<p v-if="label" class="opacity-7 txt-075rem m-0 pb-025 ">{{ label }}</p>
|
|
98
133
|
<p v-if="hero" class="m-0 tabular-nums bold pb-025 txt-1-25rem">{{ hero }}</p>
|
|
@@ -119,12 +154,12 @@ watch(
|
|
|
119
154
|
|
|
120
155
|
<style scoped>
|
|
121
156
|
.bgl-tip {
|
|
122
|
-
transform: translateX(-50%); transform-origin: center;
|
|
157
|
+
transform: translateX(calc(-50% + var(--bgl-tip-shift-x, 0px))); transform-origin: center;
|
|
123
158
|
background: var(--bgl-black); border-radius: 0.5rem; padding: 0.4rem 0.6rem;
|
|
124
159
|
font-size: 0.75rem; box-shadow: 0 4px 16px rgba(0, 0, 0, 0.18); min-width: 7rem;
|
|
125
160
|
}
|
|
126
|
-
.bgl-tip--above { transform: translate(-50
|
|
127
|
-
.bgl-tip--below { transform: translate(-50
|
|
161
|
+
.bgl-tip--above { transform: translate(calc(-50% + var(--bgl-tip-shift-x, 0px)), calc(-100% - 0.6rem)); }
|
|
162
|
+
.bgl-tip--below { transform: translate(calc(-50% + var(--bgl-tip-shift-x, 0px)), 0.6rem); }
|
|
128
163
|
.bgl-tip__dot { width: 8px; height: 8px; border-radius: 2px; }
|
|
129
164
|
.bgl-tip__name { opacity: 0.7; }
|
|
130
165
|
.bgl-tip__total,
|
|
@@ -180,7 +180,7 @@ const headerFontSize = computed(() => {
|
|
|
180
180
|
|
|
181
181
|
<template>
|
|
182
182
|
<div class="table-list-wrap h-100p" v-bind="containerProps" :class="{ overflowHiddenLoading: showLoading }">
|
|
183
|
-
<Loading v-if="showLoading" class="
|
|
183
|
+
<Loading v-if="showLoading" class="absolute inset-1" />
|
|
184
184
|
<div v-if="!isEmpty" v-bind="wrapperProps" :class="{ 'pointer-events-none': showLoading }">
|
|
185
185
|
<table class="infinite-wrapper" :class="{ selecting: computedSelectedItems.length }">
|
|
186
186
|
<thead class="row first-row">
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { AxiosResponse } from 'axios'
|
|
2
|
+
import type { FileAttachment } from './upload.types'
|
|
3
|
+
import { getUploadApi } from './upload'
|
|
4
|
+
|
|
5
|
+
export interface AttachRequest {
|
|
6
|
+
file_id: string
|
|
7
|
+
record_id: string
|
|
8
|
+
record_type: string
|
|
9
|
+
slot?: string
|
|
10
|
+
label?: string | null
|
|
11
|
+
sort_order?: number
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface AttachManyRequest {
|
|
15
|
+
file_ids: string[]
|
|
16
|
+
record_id: string
|
|
17
|
+
record_type: string
|
|
18
|
+
slot?: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const attachments = {
|
|
22
|
+
list: async (params: {
|
|
23
|
+
record_id: string
|
|
24
|
+
record_type?: string
|
|
25
|
+
slot?: string
|
|
26
|
+
}): Promise<AxiosResponse<FileAttachment[]>> => {
|
|
27
|
+
return getUploadApi().get('/files/attachments', { params })
|
|
28
|
+
},
|
|
29
|
+
|
|
30
|
+
attach: async (body: AttachRequest): Promise<AxiosResponse<FileAttachment>> => {
|
|
31
|
+
return getUploadApi().post('/files/attachments', body)
|
|
32
|
+
},
|
|
33
|
+
|
|
34
|
+
attachMany: async (body: AttachManyRequest): Promise<AxiosResponse<FileAttachment[]>> => {
|
|
35
|
+
return getUploadApi().post('/files/attachments/batch', body)
|
|
36
|
+
},
|
|
37
|
+
|
|
38
|
+
detach: async (attachmentId: string): Promise<AxiosResponse<void>> => {
|
|
39
|
+
return getUploadApi().delete(`/files/attachments/${attachmentId}`)
|
|
40
|
+
},
|
|
41
|
+
}
|
|
@@ -10,24 +10,33 @@ export function createUploadApi(options: { baseURL?: string } = {}) {
|
|
|
10
10
|
}
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
+
/** @internal — shared with attachments.ts */
|
|
14
|
+
export function getUploadApi(): AxiosInstance {
|
|
15
|
+
return api
|
|
16
|
+
}
|
|
17
|
+
|
|
13
18
|
export const files = {
|
|
14
|
-
get: async (pathKey
|
|
15
|
-
return api.get(
|
|
19
|
+
get: async (pathKey: string): Promise<AxiosResponse<BglFile>> => {
|
|
20
|
+
return api.get(`/files/${pathKey}`)
|
|
21
|
+
},
|
|
22
|
+
|
|
23
|
+
patch: async (
|
|
24
|
+
pathKey: string,
|
|
25
|
+
bglFilePatch: BglFilePatch,
|
|
26
|
+
): Promise<AxiosResponse<BglFile>> => {
|
|
27
|
+
return api.patch(`/files/${pathKey}`, bglFilePatch)
|
|
16
28
|
},
|
|
17
29
|
|
|
30
|
+
/** @deprecated use patch() */
|
|
18
31
|
put: async (
|
|
19
32
|
bglFilePatch: BglFilePatch,
|
|
20
33
|
pathKey?: string
|
|
21
34
|
): Promise<AxiosResponse<BglFile>> => {
|
|
22
|
-
return api.
|
|
23
|
-
params: { path_key: pathKey },
|
|
24
|
-
})
|
|
35
|
+
return api.patch(`/files/${pathKey}`, bglFilePatch)
|
|
25
36
|
},
|
|
26
37
|
|
|
27
|
-
delete: async (pathKey
|
|
28
|
-
return api.delete(`/files/${pathKey}
|
|
29
|
-
params: { path_key: pathKey },
|
|
30
|
-
})
|
|
38
|
+
delete: async (pathKey: string) => {
|
|
39
|
+
return api.delete(`/files/${pathKey}`)
|
|
31
40
|
},
|
|
32
41
|
|
|
33
42
|
upload: async (
|
|
@@ -46,7 +55,7 @@ export const files = {
|
|
|
46
55
|
},
|
|
47
56
|
|
|
48
57
|
list: async (dirPath?: string): Promise<AxiosResponse<BglFile[]>> => {
|
|
49
|
-
return api.get('/files
|
|
58
|
+
return api.get('/files', {
|
|
50
59
|
params: { dir_path: dirPath },
|
|
51
60
|
})
|
|
52
61
|
},
|
|
@@ -43,3 +43,21 @@ export interface QueueFile {
|
|
|
43
43
|
uploaded?: boolean
|
|
44
44
|
namespace?: string
|
|
45
45
|
}
|
|
46
|
+
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
// Attachments
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
export interface FileAttachment {
|
|
52
|
+
id: string
|
|
53
|
+
file_id: string
|
|
54
|
+
record_id: string
|
|
55
|
+
record_type: string
|
|
56
|
+
slot: string
|
|
57
|
+
label?: string | null
|
|
58
|
+
sort_order: number
|
|
59
|
+
created_by?: string | null
|
|
60
|
+
created_at?: string
|
|
61
|
+
updated_at?: string
|
|
62
|
+
file?: BglFile | null
|
|
63
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import type { MaybeRefOrGetter } from 'vue'
|
|
2
|
+
import type { FileAttachment } from './upload.types'
|
|
3
|
+
import { ref, watch, toValue } from 'vue'
|
|
4
|
+
import { attachments } from './attachments'
|
|
5
|
+
import { files } from './upload'
|
|
6
|
+
|
|
7
|
+
export interface UseAttachmentsProps {
|
|
8
|
+
/** UUID of the domain record */
|
|
9
|
+
recordId: MaybeRefOrGetter<string | undefined>
|
|
10
|
+
/** Polymorphic type, e.g. "deal_product", "person" */
|
|
11
|
+
recordType: MaybeRefOrGetter<string>
|
|
12
|
+
/** Semantic slot filter, e.g. "image", "certificate" */
|
|
13
|
+
slot?: MaybeRefOrGetter<string | undefined>
|
|
14
|
+
/** Allow multiple attachments (default true) */
|
|
15
|
+
multiple?: boolean
|
|
16
|
+
/** Auto-load on mount / when recordId changes */
|
|
17
|
+
autoLoad?: boolean
|
|
18
|
+
/** Accepted file types for browse() */
|
|
19
|
+
accept?: string
|
|
20
|
+
/** Callback after an attachment is added or removed */
|
|
21
|
+
onChange?: () => void
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function useAttachments(props: UseAttachmentsProps) {
|
|
25
|
+
const items = ref<FileAttachment[]>([])
|
|
26
|
+
const loading = ref(false)
|
|
27
|
+
const uploading = ref(false)
|
|
28
|
+
|
|
29
|
+
// -----------------------------------------------------------------------
|
|
30
|
+
// Load
|
|
31
|
+
// -----------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
async function load() {
|
|
34
|
+
const rid = toValue(props.recordId)
|
|
35
|
+
if (!rid) { items.value = []; return }
|
|
36
|
+
|
|
37
|
+
loading.value = true
|
|
38
|
+
try {
|
|
39
|
+
const params: { record_id: string; record_type?: string; slot?: string } = {
|
|
40
|
+
record_id: rid,
|
|
41
|
+
record_type: toValue(props.recordType),
|
|
42
|
+
}
|
|
43
|
+
const slot = toValue(props.slot)
|
|
44
|
+
if (slot) { params.slot = slot }
|
|
45
|
+
|
|
46
|
+
const { data } = await attachments.list(params)
|
|
47
|
+
items.value = data
|
|
48
|
+
} catch (e) {
|
|
49
|
+
console.error('[useAttachments] load failed', e)
|
|
50
|
+
} finally {
|
|
51
|
+
loading.value = false
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Auto-reload when recordId changes
|
|
56
|
+
if (props.autoLoad !== false) {
|
|
57
|
+
watch(() => toValue(props.recordId), load, { immediate: true })
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// -----------------------------------------------------------------------
|
|
61
|
+
// Attach
|
|
62
|
+
// -----------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
/** Attach an already-uploaded file by its path_key */
|
|
65
|
+
async function attach(fileId: string, opts?: { label?: string; sortOrder?: number }) {
|
|
66
|
+
const rid = toValue(props.recordId)
|
|
67
|
+
if (!rid) { return }
|
|
68
|
+
|
|
69
|
+
const { data } = await attachments.attach({
|
|
70
|
+
file_id: fileId,
|
|
71
|
+
record_id: rid,
|
|
72
|
+
record_type: toValue(props.recordType),
|
|
73
|
+
slot: toValue(props.slot) || 'file',
|
|
74
|
+
label: opts?.label,
|
|
75
|
+
sort_order: opts?.sortOrder,
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
if (props.multiple !== false) {
|
|
79
|
+
items.value.push(data)
|
|
80
|
+
} else {
|
|
81
|
+
items.value = [data]
|
|
82
|
+
}
|
|
83
|
+
props.onChange?.()
|
|
84
|
+
return data
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// -----------------------------------------------------------------------
|
|
88
|
+
// Upload + Attach in one shot
|
|
89
|
+
// -----------------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
async function uploadAndAttach(
|
|
92
|
+
file: File,
|
|
93
|
+
opts?: { label?: string; dirPath?: string; tags?: string[]; onProgress?: (pct: number) => void }
|
|
94
|
+
) {
|
|
95
|
+
const rid = toValue(props.recordId)
|
|
96
|
+
if (!rid) { return }
|
|
97
|
+
|
|
98
|
+
uploading.value = true
|
|
99
|
+
try {
|
|
100
|
+
// 1. Upload
|
|
101
|
+
const { data: uploaded } = await files.upload(file, {
|
|
102
|
+
dirPath: opts?.dirPath,
|
|
103
|
+
tags: opts?.tags,
|
|
104
|
+
onUploadProgress: (e: ProgressEvent) => {
|
|
105
|
+
opts?.onProgress?.((e.loaded / e.total) * 100)
|
|
106
|
+
},
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
// 2. Attach
|
|
110
|
+
return await attach(uploaded.path_key, { label: opts?.label })
|
|
111
|
+
} finally {
|
|
112
|
+
uploading.value = false
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// -----------------------------------------------------------------------
|
|
117
|
+
// Detach
|
|
118
|
+
// -----------------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
async function detach(attachmentId: string) {
|
|
121
|
+
await attachments.detach(attachmentId)
|
|
122
|
+
items.value = items.value.filter(a => a.id !== attachmentId)
|
|
123
|
+
props.onChange?.()
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Detach all items (e.g. before replacing in single mode) */
|
|
127
|
+
async function detachAll() {
|
|
128
|
+
await Promise.all(items.value.map(a => attachments.detach(a.id)))
|
|
129
|
+
items.value = []
|
|
130
|
+
props.onChange?.()
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// -----------------------------------------------------------------------
|
|
134
|
+
// Browse (open file picker → upload → attach)
|
|
135
|
+
// -----------------------------------------------------------------------
|
|
136
|
+
|
|
137
|
+
function browse(opts?: { label?: string; dirPath?: string; tags?: string[] }) {
|
|
138
|
+
const input = document.createElement('input')
|
|
139
|
+
input.type = 'file'
|
|
140
|
+
input.multiple = props.multiple !== false
|
|
141
|
+
input.accept = props.accept || ''
|
|
142
|
+
|
|
143
|
+
input.onchange = async (e: Event) => {
|
|
144
|
+
const picked = (e.target as HTMLInputElement).files
|
|
145
|
+
if (!picked) { return }
|
|
146
|
+
|
|
147
|
+
for (const file of Array.from(picked)) {
|
|
148
|
+
await uploadAndAttach(file, opts)
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
input.click()
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
/** Reactive list of current attachments */
|
|
157
|
+
items,
|
|
158
|
+
/** True while loading attachments from server */
|
|
159
|
+
loading,
|
|
160
|
+
/** True while a file is being uploaded */
|
|
161
|
+
uploading,
|
|
162
|
+
/** Reload attachments from server */
|
|
163
|
+
load,
|
|
164
|
+
/** Attach an already-uploaded file */
|
|
165
|
+
attach,
|
|
166
|
+
/** Upload a file then attach it */
|
|
167
|
+
uploadAndAttach,
|
|
168
|
+
/** Remove an attachment (keeps the file) */
|
|
169
|
+
detach,
|
|
170
|
+
/** Remove all attachments */
|
|
171
|
+
detachAll,
|
|
172
|
+
/** Open file picker → upload → attach */
|
|
173
|
+
browse,
|
|
174
|
+
}
|
|
175
|
+
}
|
|
@@ -25,3 +25,5 @@ export { createUploadApi } from './Upload/upload'
|
|
|
25
25
|
|
|
26
26
|
export { default as UploadInput } from './Upload/UploadInput.vue'
|
|
27
27
|
export { useFileUpload } from './Upload/useFileUpload'
|
|
28
|
+
export { useAttachments } from './Upload/useAttachments'
|
|
29
|
+
export type { FileAttachment } from './Upload/upload.types'
|