@kinetis/vector-ui 0.0.0-stage → 0.9.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.
Files changed (282) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/LICENSE +18 -0
  3. package/README.md +72 -2
  4. package/app/app.config.ts +63 -0
  5. package/app/app.vue +11 -0
  6. package/app/components/Vx/Action/Dialog.vue +107 -0
  7. package/app/components/Vx/Cell/Boolean.vue +10 -0
  8. package/app/components/Vx/Cell/Color.vue +12 -0
  9. package/app/components/Vx/Cell/Count.vue +12 -0
  10. package/app/components/Vx/Cell/Date.vue +10 -0
  11. package/app/components/Vx/Cell/DateTime.vue +10 -0
  12. package/app/components/Vx/Cell/Email.vue +9 -0
  13. package/app/components/Vx/Cell/Enum.vue +14 -0
  14. package/app/components/Vx/Cell/File.vue +30 -0
  15. package/app/components/Vx/Cell/Image.vue +38 -0
  16. package/app/components/Vx/Cell/Json.vue +18 -0
  17. package/app/components/Vx/Cell/KeyValue.vue +25 -0
  18. package/app/components/Vx/Cell/Markdown.vue +18 -0
  19. package/app/components/Vx/Cell/Money.vue +16 -0
  20. package/app/components/Vx/Cell/Number.vue +16 -0
  21. package/app/components/Vx/Cell/Relation.vue +16 -0
  22. package/app/components/Vx/Cell/RichText.vue +41 -0
  23. package/app/components/Vx/Cell/Text.vue +13 -0
  24. package/app/components/Vx/Cell/Url.vue +11 -0
  25. package/app/components/Vx/Cell.vue +25 -0
  26. package/app/components/Vx/CommandPalette.vue +118 -0
  27. package/app/components/Vx/Confirm.vue +19 -0
  28. package/app/components/Vx/Dashboard.vue +27 -0
  29. package/app/components/Vx/FieldsForm.vue +46 -0
  30. package/app/components/Vx/Import/Dialog.vue +131 -0
  31. package/app/components/Vx/Inbox.vue +49 -0
  32. package/app/components/Vx/Input/Boolean.vue +10 -0
  33. package/app/components/Vx/Input/Currency.vue +25 -0
  34. package/app/components/Vx/Input/DateTime.vue +38 -0
  35. package/app/components/Vx/Input/Enum.vue +21 -0
  36. package/app/components/Vx/Input/KeyValue.vue +78 -0
  37. package/app/components/Vx/Input/Markdown.vue +120 -0
  38. package/app/components/Vx/Input/Money.vue +47 -0
  39. package/app/components/Vx/Input/Number.vue +24 -0
  40. package/app/components/Vx/Input/Relation.vue +127 -0
  41. package/app/components/Vx/Input/Repeater.vue +134 -0
  42. package/app/components/Vx/Input/RichText.vue +182 -0
  43. package/app/components/Vx/Input/Text.vue +21 -0
  44. package/app/components/Vx/Input/Textarea.vue +20 -0
  45. package/app/components/Vx/Input/Upload.vue +89 -0
  46. package/app/components/Vx/Layout.vue +111 -0
  47. package/app/components/Vx/List/BulkBar.vue +44 -0
  48. package/app/components/Vx/List/BulkEdit.vue +91 -0
  49. package/app/components/Vx/List/ColumnManager.vue +74 -0
  50. package/app/components/Vx/List/FilterBar.vue +224 -0
  51. package/app/components/Vx/List/ViewMenu.vue +115 -0
  52. package/app/components/Vx/Notifications.vue +9 -0
  53. package/app/components/Vx/Resource/Audit.vue +82 -0
  54. package/app/components/Vx/Resource/Form.vue +351 -0
  55. package/app/components/Vx/Resource/RelationPanel.vue +217 -0
  56. package/app/components/Vx/Resource/Show.vue +35 -0
  57. package/app/components/Vx/Resource/Table.vue +513 -0
  58. package/app/components/Vx/Widget/Card.vue +80 -0
  59. package/app/components/Vx/Widget/Partition.vue +21 -0
  60. package/app/components/Vx/Widget/Progress.vue +22 -0
  61. package/app/components/Vx/Widget/Stat.vue +22 -0
  62. package/app/components/Vx/Widget/Table.vue +31 -0
  63. package/app/components/Vx/Widget/Trend.vue +37 -0
  64. package/app/composables/useConfirm.ts +34 -0
  65. package/app/composables/useLeaveGuard.ts +21 -0
  66. package/app/composables/useVectorApi.ts +74 -0
  67. package/app/composables/useVectorAuthStore.ts +252 -0
  68. package/app/composables/useVectorBranding.ts +45 -0
  69. package/app/composables/useVectorDashboards.ts +15 -0
  70. package/app/composables/useVectorExtensions.ts +44 -0
  71. package/app/composables/useVectorFields.ts +68 -0
  72. package/app/composables/useVectorForm.ts +36 -0
  73. package/app/composables/useVectorIdle.ts +39 -0
  74. package/app/composables/useVectorImpersonation.ts +78 -0
  75. package/app/composables/useVectorInputs.ts +61 -0
  76. package/app/composables/useVectorJobs.ts +62 -0
  77. package/app/composables/useVectorLocale.ts +70 -0
  78. package/app/composables/useVectorNotifications.ts +24 -0
  79. package/app/composables/useVectorNotificationsInbox.ts +26 -0
  80. package/app/composables/useVectorPreferences.ts +30 -0
  81. package/app/composables/useVectorResource.ts +305 -0
  82. package/app/composables/useVectorRuntime.ts +42 -0
  83. package/app/composables/useVectorSchema.ts +56 -0
  84. package/app/composables/useVectorT.ts +9 -0
  85. package/app/composables/useVectorViews.ts +25 -0
  86. package/app/i18n/ar/actions.ts +11 -0
  87. package/app/i18n/ar/common.ts +23 -0
  88. package/app/i18n/ar/dashboard.ts +28 -0
  89. package/app/i18n/ar/fields.ts +77 -0
  90. package/app/i18n/ar/index.ts +12 -0
  91. package/app/i18n/ar/jobs.ts +36 -0
  92. package/app/i18n/ar/list.ts +160 -0
  93. package/app/i18n/ar/record.ts +62 -0
  94. package/app/i18n/ar/settings.ts +10 -0
  95. package/app/i18n/ar/shell.ts +54 -0
  96. package/app/i18n/de/actions.ts +11 -0
  97. package/app/i18n/de/common.ts +23 -0
  98. package/app/i18n/de/dashboard.ts +28 -0
  99. package/app/i18n/de/fields.ts +77 -0
  100. package/app/i18n/de/index.ts +12 -0
  101. package/app/i18n/de/jobs.ts +36 -0
  102. package/app/i18n/de/list.ts +160 -0
  103. package/app/i18n/de/record.ts +62 -0
  104. package/app/i18n/de/settings.ts +10 -0
  105. package/app/i18n/de/shell.ts +54 -0
  106. package/app/i18n/en/actions.ts +11 -0
  107. package/app/i18n/en/common.ts +23 -0
  108. package/app/i18n/en/dashboard.ts +28 -0
  109. package/app/i18n/en/fields.ts +77 -0
  110. package/app/i18n/en/index.ts +12 -0
  111. package/app/i18n/en/jobs.ts +36 -0
  112. package/app/i18n/en/list.ts +160 -0
  113. package/app/i18n/en/record.ts +62 -0
  114. package/app/i18n/en/settings.ts +10 -0
  115. package/app/i18n/en/shell.ts +54 -0
  116. package/app/i18n/es/actions.ts +11 -0
  117. package/app/i18n/es/common.ts +23 -0
  118. package/app/i18n/es/dashboard.ts +28 -0
  119. package/app/i18n/es/fields.ts +77 -0
  120. package/app/i18n/es/index.ts +12 -0
  121. package/app/i18n/es/jobs.ts +36 -0
  122. package/app/i18n/es/list.ts +160 -0
  123. package/app/i18n/es/record.ts +62 -0
  124. package/app/i18n/es/settings.ts +10 -0
  125. package/app/i18n/es/shell.ts +54 -0
  126. package/app/i18n/fr/actions.ts +11 -0
  127. package/app/i18n/fr/common.ts +23 -0
  128. package/app/i18n/fr/dashboard.ts +28 -0
  129. package/app/i18n/fr/fields.ts +77 -0
  130. package/app/i18n/fr/index.ts +12 -0
  131. package/app/i18n/fr/jobs.ts +36 -0
  132. package/app/i18n/fr/list.ts +160 -0
  133. package/app/i18n/fr/record.ts +62 -0
  134. package/app/i18n/fr/settings.ts +10 -0
  135. package/app/i18n/fr/shell.ts +54 -0
  136. package/app/i18n/he/actions.ts +11 -0
  137. package/app/i18n/he/common.ts +23 -0
  138. package/app/i18n/he/dashboard.ts +28 -0
  139. package/app/i18n/he/fields.ts +77 -0
  140. package/app/i18n/he/index.ts +12 -0
  141. package/app/i18n/he/jobs.ts +36 -0
  142. package/app/i18n/he/list.ts +160 -0
  143. package/app/i18n/he/record.ts +62 -0
  144. package/app/i18n/he/settings.ts +10 -0
  145. package/app/i18n/he/shell.ts +54 -0
  146. package/app/i18n/index.ts +111 -0
  147. package/app/i18n/it/actions.ts +11 -0
  148. package/app/i18n/it/common.ts +23 -0
  149. package/app/i18n/it/dashboard.ts +28 -0
  150. package/app/i18n/it/fields.ts +77 -0
  151. package/app/i18n/it/index.ts +12 -0
  152. package/app/i18n/it/jobs.ts +36 -0
  153. package/app/i18n/it/list.ts +160 -0
  154. package/app/i18n/it/record.ts +62 -0
  155. package/app/i18n/it/settings.ts +10 -0
  156. package/app/i18n/it/shell.ts +54 -0
  157. package/app/i18n/ja/actions.ts +11 -0
  158. package/app/i18n/ja/common.ts +23 -0
  159. package/app/i18n/ja/dashboard.ts +28 -0
  160. package/app/i18n/ja/fields.ts +77 -0
  161. package/app/i18n/ja/index.ts +12 -0
  162. package/app/i18n/ja/jobs.ts +36 -0
  163. package/app/i18n/ja/list.ts +160 -0
  164. package/app/i18n/ja/record.ts +62 -0
  165. package/app/i18n/ja/settings.ts +10 -0
  166. package/app/i18n/ja/shell.ts +54 -0
  167. package/app/i18n/ko/actions.ts +11 -0
  168. package/app/i18n/ko/common.ts +23 -0
  169. package/app/i18n/ko/dashboard.ts +28 -0
  170. package/app/i18n/ko/fields.ts +77 -0
  171. package/app/i18n/ko/index.ts +12 -0
  172. package/app/i18n/ko/jobs.ts +36 -0
  173. package/app/i18n/ko/list.ts +160 -0
  174. package/app/i18n/ko/record.ts +62 -0
  175. package/app/i18n/ko/settings.ts +10 -0
  176. package/app/i18n/ko/shell.ts +54 -0
  177. package/app/i18n/nl/actions.ts +11 -0
  178. package/app/i18n/nl/common.ts +23 -0
  179. package/app/i18n/nl/dashboard.ts +28 -0
  180. package/app/i18n/nl/fields.ts +77 -0
  181. package/app/i18n/nl/index.ts +12 -0
  182. package/app/i18n/nl/jobs.ts +36 -0
  183. package/app/i18n/nl/list.ts +160 -0
  184. package/app/i18n/nl/record.ts +62 -0
  185. package/app/i18n/nl/settings.ts +10 -0
  186. package/app/i18n/nl/shell.ts +54 -0
  187. package/app/i18n/pl/actions.ts +11 -0
  188. package/app/i18n/pl/common.ts +23 -0
  189. package/app/i18n/pl/dashboard.ts +28 -0
  190. package/app/i18n/pl/fields.ts +77 -0
  191. package/app/i18n/pl/index.ts +12 -0
  192. package/app/i18n/pl/jobs.ts +36 -0
  193. package/app/i18n/pl/list.ts +160 -0
  194. package/app/i18n/pl/record.ts +62 -0
  195. package/app/i18n/pl/settings.ts +10 -0
  196. package/app/i18n/pl/shell.ts +54 -0
  197. package/app/i18n/pt/actions.ts +11 -0
  198. package/app/i18n/pt/common.ts +23 -0
  199. package/app/i18n/pt/dashboard.ts +28 -0
  200. package/app/i18n/pt/fields.ts +77 -0
  201. package/app/i18n/pt/index.ts +12 -0
  202. package/app/i18n/pt/jobs.ts +36 -0
  203. package/app/i18n/pt/list.ts +160 -0
  204. package/app/i18n/pt/record.ts +62 -0
  205. package/app/i18n/pt/settings.ts +10 -0
  206. package/app/i18n/pt/shell.ts +54 -0
  207. package/app/i18n/pt-BR/actions.ts +11 -0
  208. package/app/i18n/pt-BR/common.ts +23 -0
  209. package/app/i18n/pt-BR/dashboard.ts +28 -0
  210. package/app/i18n/pt-BR/fields.ts +77 -0
  211. package/app/i18n/pt-BR/index.ts +12 -0
  212. package/app/i18n/pt-BR/jobs.ts +36 -0
  213. package/app/i18n/pt-BR/list.ts +160 -0
  214. package/app/i18n/pt-BR/record.ts +62 -0
  215. package/app/i18n/pt-BR/settings.ts +10 -0
  216. package/app/i18n/pt-BR/shell.ts +54 -0
  217. package/app/i18n/ru/actions.ts +11 -0
  218. package/app/i18n/ru/common.ts +23 -0
  219. package/app/i18n/ru/dashboard.ts +28 -0
  220. package/app/i18n/ru/fields.ts +77 -0
  221. package/app/i18n/ru/index.ts +12 -0
  222. package/app/i18n/ru/jobs.ts +36 -0
  223. package/app/i18n/ru/list.ts +160 -0
  224. package/app/i18n/ru/record.ts +62 -0
  225. package/app/i18n/ru/settings.ts +10 -0
  226. package/app/i18n/ru/shell.ts +54 -0
  227. package/app/i18n/tr/actions.ts +11 -0
  228. package/app/i18n/tr/common.ts +23 -0
  229. package/app/i18n/tr/dashboard.ts +28 -0
  230. package/app/i18n/tr/fields.ts +77 -0
  231. package/app/i18n/tr/index.ts +12 -0
  232. package/app/i18n/tr/jobs.ts +36 -0
  233. package/app/i18n/tr/list.ts +160 -0
  234. package/app/i18n/tr/record.ts +62 -0
  235. package/app/i18n/tr/settings.ts +10 -0
  236. package/app/i18n/tr/shell.ts +54 -0
  237. package/app/i18n/uk/actions.ts +11 -0
  238. package/app/i18n/uk/common.ts +23 -0
  239. package/app/i18n/uk/dashboard.ts +28 -0
  240. package/app/i18n/uk/fields.ts +77 -0
  241. package/app/i18n/uk/index.ts +12 -0
  242. package/app/i18n/uk/jobs.ts +36 -0
  243. package/app/i18n/uk/list.ts +160 -0
  244. package/app/i18n/uk/record.ts +62 -0
  245. package/app/i18n/uk/settings.ts +10 -0
  246. package/app/i18n/uk/shell.ts +54 -0
  247. package/app/i18n/zh-Hans/actions.ts +11 -0
  248. package/app/i18n/zh-Hans/common.ts +23 -0
  249. package/app/i18n/zh-Hans/dashboard.ts +28 -0
  250. package/app/i18n/zh-Hans/fields.ts +77 -0
  251. package/app/i18n/zh-Hans/index.ts +12 -0
  252. package/app/i18n/zh-Hans/jobs.ts +36 -0
  253. package/app/i18n/zh-Hans/list.ts +160 -0
  254. package/app/i18n/zh-Hans/record.ts +62 -0
  255. package/app/i18n/zh-Hans/settings.ts +10 -0
  256. package/app/i18n/zh-Hans/shell.ts +54 -0
  257. package/app/layouts/default.vue +8 -0
  258. package/app/layouts/vector.vue +238 -0
  259. package/app/middleware/vector-auth.ts +16 -0
  260. package/app/pages/d/[name].vue +12 -0
  261. package/app/pages/index.vue +22 -0
  262. package/app/pages/jobs/[id].vue +52 -0
  263. package/app/pages/jobs/index.vue +42 -0
  264. package/app/pages/login.vue +109 -0
  265. package/app/pages/p/[name].vue +37 -0
  266. package/app/pages/r/[resource]/[id]/edit.vue +54 -0
  267. package/app/pages/r/[resource]/[id]/index.vue +143 -0
  268. package/app/pages/r/[resource]/create.vue +53 -0
  269. package/app/pages/r/[resource]/index.vue +29 -0
  270. package/app/pages/settings.vue +104 -0
  271. package/app/plugins/vector-csp.client.ts +14 -0
  272. package/app/plugins/vector-extensions.client.ts +84 -0
  273. package/app/plugins/vector-i18n.ts +76 -0
  274. package/app/types/vector.ts +438 -0
  275. package/app/utils/files.ts +14 -0
  276. package/app/utils/format.ts +100 -0
  277. package/app/utils/richtext.ts +305 -0
  278. package/app/utils/rules.ts +113 -0
  279. package/app/utils/translate.ts +45 -0
  280. package/app/utils/widgets.ts +11 -0
  281. package/nuxt.config.ts +83 -0
  282. package/package.json +82 -5
@@ -0,0 +1,513 @@
1
+ <script setup lang="ts">
2
+ import { fieldProps, type VectorAction, type VectorExportFormat, type VectorField, type VectorFilterGroup, type VectorFilterQuery, type VectorFilters, type VectorListState, type VectorPage, type VectorRecord, type VectorResource, type VectorSelection, type VectorSort, type VectorSavedView } from '#vector/app/types/vector'
3
+
4
+ // The list view: a server-driven table whose every option — page, size,
5
+ // sort, filters, search, grouping, columns — becomes one list request,
6
+ // with saved views, per-user column state, summaries, bulk operations,
7
+ // actions and export on top.
8
+ const props = defineProps<{ resource: VectorResource }>()
9
+
10
+ const appConfig = useAppConfig()
11
+ const { t, noun } = useVectorT()
12
+ const notifications = useVectorNotifications()
13
+ const { schema } = useVectorSchema()
14
+ const { confirm } = useConfirm()
15
+ const client = useVectorResource(() => props.resource.slug)
16
+ const preferences = useVectorPreferences()
17
+ await preferences.load()
18
+
19
+ // --- list state -------------------------------------------------------
20
+ const columns = ref<string[]>(preferences.get<string[]>(`columns.${props.resource.slug}`)?.filter(c => props.resource.table.columns.includes(c)) ?? [...props.resource.table.columns])
21
+ // The filter: conditions that must all hold, "any of" groups of
22
+ // alternatives, and whatever a saved view holds that the bar cannot edit.
23
+ const filters = ref<VectorFilters>({})
24
+ const groups = ref<VectorFilterGroup[]>([])
25
+ const advanced = ref<VectorFilterQuery[]>([])
26
+ const search = ref<string | null>('')
27
+ const term = computed(() => (search.value ?? '').trim())
28
+ const group = ref<string | null>(null)
29
+ const trashed = ref<'without' | 'only' | 'with'>('without')
30
+ const summaries = ref(props.resource.table.summaries.length > 0)
31
+ const page = ref(1)
32
+ const itemsPerPage = ref(props.resource.table.perPage)
33
+ const sortBy = ref<{ key: string, order?: boolean | 'asc' | 'desc' }[]>(props.resource.table.defaultSort.map(s => ({ key: s.field, order: s.order })))
34
+ const activeView = ref<number | null>(null)
35
+
36
+ const fields = computed<VectorField[]>(() => columns.value
37
+ .map(name => props.resource.fields.find(f => f.name === name))
38
+ .filter((f): f is VectorField => f !== undefined))
39
+
40
+ /** Numbers and money align right, a computed one shown as either too. */
41
+ const numeric = (field: VectorField) => ['number', 'money'].includes(field.type === 'computed' ? field.computed?.as ?? '' : field.type)
42
+
43
+ const headers = computed(() => fields.value.map(field => ({
44
+ key: field.name,
45
+ title: field.label,
46
+ sortable: field.sortable,
47
+ align: (numeric(field) ? 'end' : 'start') as 'start' | 'end',
48
+ nowrap: true,
49
+ })))
50
+
51
+ /** The filter as the list grammar reads it. */
52
+ const wireFilters = computed<VectorFilterQuery>(() => {
53
+ const all: VectorFilterQuery = {}
54
+ for (const [name, ops] of Object.entries(filters.value)) all[name] = { ...ops }
55
+ const extra: VectorFilterQuery[] = [
56
+ ...groups.value.filter(g => g.length > 0).map(g => ({ or: g.map(alternative => ({ ...alternative })) })),
57
+ ...advanced.value,
58
+ ]
59
+ if (extra.length === 1 && Array.isArray(extra[0]!.or)) all.or = extra[0]!.or
60
+ else if (extra.length > 0) all.and = extra
61
+ if (props.resource.softDelete && trashed.value !== 'without') all.trashed = trashed.value
62
+ return all
63
+ })
64
+
65
+ /** The list grammar as a view stores it and a bulk selection sends it. */
66
+ const state = computed<VectorListState>(() => ({
67
+ filter: wireFilters.value,
68
+ q: term.value || undefined,
69
+ sort: sortBy.value.map(s => (s.order === 'desc' ? '-' : '') + s.key).join(',') || undefined,
70
+ group: group.value ?? undefined,
71
+ columns: columns.value.join(','),
72
+ perPage: itemsPerPage.value,
73
+ summaries: summaries.value,
74
+ }))
75
+
76
+ // --- loading ----------------------------------------------------------
77
+ const items = ref<VectorRecord[]>([])
78
+ const total = ref(0)
79
+ const summaryValues = ref<Record<string, number | null>>({})
80
+ const loading = ref(false)
81
+ let requestId = 0
82
+
83
+ async function load() {
84
+ const id = ++requestId
85
+ loading.value = true
86
+
87
+ try {
88
+ const sort: VectorSort[] = sortBy.value.map(s => ({ field: s.key, order: s.order === 'desc' ? 'desc' : 'asc' }))
89
+ const result: VectorPage = await client.list({
90
+ page: page.value, perPage: itemsPerPage.value, sort, filter: rawFilters(), q: term.value || undefined,
91
+ group: group.value ?? undefined, summaries: summaries.value, columns: columns.value,
92
+ })
93
+
94
+ if (id !== requestId) return
95
+
96
+ items.value = result.data
97
+ total.value = result.total
98
+ summaryValues.value = result.summaries ?? {}
99
+ } catch (error) {
100
+ notifications.error(vectorErrorMessage(error))
101
+ } finally {
102
+ if (id === requestId) loading.value = false
103
+ }
104
+ }
105
+
106
+ /** The wire form of the filters, `trashed` as the server reads it. */
107
+ function rawFilters(): VectorFilterQuery {
108
+ return wireFilters.value
109
+ }
110
+
111
+ const debouncedLoad = useDebounceFn(() => { page.value = 1; load() }, 300)
112
+ watch(search, () => debouncedLoad())
113
+ watch([filters, groups, advanced, group, trashed, summaries], () => { page.value = 1; load() }, { deep: true })
114
+ watch(columns, (c) => { preferences.put({ [`columns.${props.resource.slug}`]: c }).catch(() => undefined); load() })
115
+
116
+ function onOptions() {
117
+ load()
118
+ }
119
+
120
+ // --- views ------------------------------------------------------------
121
+ const viewMenu = ref<{ views: VectorSavedView[] } | null>(null)
122
+
123
+ /**
124
+ * A stored filter back into what the bar edits: its conditions, its
125
+ * "any of" groups of plain alternatives, and anything deeper kept as it
126
+ * is. Conditions on fields this account cannot see are dropped.
127
+ */
128
+ function decodeFilter(filter: VectorFilterQuery, known: Set<string>) {
129
+ const out = { filters: {} as VectorFilters, groups: [] as VectorFilterGroup[], advanced: [] as VectorFilterQuery[], trashed: 'without' as 'without' | 'only' | 'with' }
130
+ const plain = (node: VectorFilterQuery): node is VectorFilters => Object.entries(node).every(([k, v]) => k !== 'or' && k !== 'and' && k !== 'trashed' && typeof v === 'object' && !Array.isArray(v))
131
+
132
+ for (const [key, spec] of Object.entries(filter)) {
133
+ if (key === 'trashed') {
134
+ const value = typeof spec === 'string' ? spec : (spec as Record<string, string> | undefined)?.eq ?? (spec as Record<string, string> | undefined)?.['']
135
+ out.trashed = (value === 'only' || value === 'with' ? value : 'without')
136
+ } else if (key === 'or' && Array.isArray(spec)) {
137
+ if (spec.every(plain)) out.groups.push(spec.map(a => Object.fromEntries(Object.entries(a).filter(([name]) => known.has(name)))).filter(a => Object.keys(a).length > 0))
138
+ else out.advanced.push({ or: spec })
139
+ } else if (key === 'and' && Array.isArray(spec)) {
140
+ for (const child of spec) {
141
+ const inner = decodeFilter(child, known)
142
+ for (const [name, ops] of Object.entries(inner.filters)) out.filters[name] = { ...(out.filters[name] ?? {}), ...ops }
143
+ out.groups.push(...inner.groups)
144
+ out.advanced.push(...inner.advanced)
145
+ }
146
+ } else if (known.has(key) && spec !== undefined && !Array.isArray(spec)) {
147
+ out.filters[key] = typeof spec === 'string' ? { eq: spec } : { ...spec }
148
+ }
149
+ }
150
+
151
+ out.groups = out.groups.filter(g => g.length > 0)
152
+ return out
153
+ }
154
+
155
+ function applyView(view: VectorSavedView | null) {
156
+ activeView.value = view?.id ?? null
157
+ const s = view?.state ?? {}
158
+ // A shared view may name fields this account cannot see; those parts are dropped, not sent.
159
+ const known = new Set(props.resource.fields.map(f => f.name))
160
+ const decoded = decodeFilter(s.filter ?? {}, known)
161
+ trashed.value = decoded.trashed
162
+ filters.value = decoded.filters
163
+ groups.value = decoded.groups
164
+ advanced.value = decoded.advanced
165
+ search.value = s.q ?? ''
166
+ group.value = s.group && known.has(s.group) ? s.group : null
167
+ const sorts = s.sort ? s.sort.split(',').map(e => ({ key: e.replace(/^-/, ''), order: e.startsWith('-') ? 'desc' as const : 'asc' as const })).filter(x => known.has(x.key)) : []
168
+ sortBy.value = sorts.length ? sorts : props.resource.table.defaultSort.map(x => ({ key: x.field, order: x.order }))
169
+ if (s.columns) columns.value = s.columns.split(',').filter(c => props.resource.table.columns.includes(c))
170
+ itemsPerPage.value = s.perPage ?? props.resource.table.perPage
171
+ summaries.value = s.summaries ?? summaries.value
172
+ page.value = 1
173
+ }
174
+
175
+ onMounted(() => {
176
+ // your own default, else the one set for everyone
177
+ const preset = viewMenu.value?.views?.find(v => v.isDefault) ?? viewMenu.value?.views?.find(v => v.resourceDefault)
178
+ if (preset) applyView(preset)
179
+ })
180
+
181
+ // --- selection, bulk operations, actions -----------------------------
182
+ const selected = ref<(number | string)[]>([])
183
+ const allMatching = ref(false)
184
+ watch(items, () => { selected.value = selected.value.filter(id => items.value.some(i => i.id === id)); if (!selected.value.length) allMatching.value = false })
185
+
186
+ const selection = computed<VectorSelection>(() => allMatching.value
187
+ ? { all: { filter: rawFilters(), q: term.value || undefined } }
188
+ : { ids: selected.value })
189
+ const selectionCount = computed(() => allMatching.value ? total.value : selected.value.length)
190
+ const indexActions = computed(() => props.resource.actions.filter(a => a.showOnIndex && !a.standalone))
191
+ const standaloneActions = computed(() => props.resource.actions.filter(a => a.showOnIndex && a.standalone))
192
+ const dialogAction = ref<VectorAction | null>(null)
193
+ const bulkEditing = ref(false)
194
+
195
+ async function operation(op: 'delete' | 'restore' | 'forceDelete') {
196
+ const n = selectionCount.value
197
+ const subject = noun(n === 1 ? props.resource.labelSingular : props.resource.label)
198
+ const titles = { delete: t('list.operation.deleteTitle', { count: n, noun: subject }), restore: t('list.operation.restoreTitle', { count: n, noun: subject }), forceDelete: t('list.operation.forceDeleteTitle', { count: n, noun: subject }) }
199
+
200
+ if (!await confirm({ title: titles[op], text: op === 'restore' ? undefined : (props.resource.softDelete && op === 'delete' ? t('list.operation.toTrash') : t('list.operation.irreversible')), confirmText: op === 'restore' ? t('common.restore') : t('common.delete'), destructive: op !== 'restore' })) return
201
+
202
+ try {
203
+ const { affected } = await client.batch(op, selection.value)
204
+ notifications.success(t(op === 'restore' ? 'list.operation.restored' : 'list.operation.deleted', { count: affected, noun: affected === 1 ? noun(props.resource.labelSingular) : noun(props.resource.label) }))
205
+ selected.value = []
206
+ allMatching.value = false
207
+ await load()
208
+ } catch (error) {
209
+ notifications.error(vectorErrorMessage(error))
210
+ }
211
+ }
212
+
213
+ // one format is a button; more are a menu
214
+ const exportFormats = computed<VectorExportFormat[]>(() => schema.value?.capabilities.exportFormats ?? ['csv'])
215
+
216
+ async function exportAs(format: VectorExportFormat) {
217
+ try {
218
+ const queued = await client.exportFile({ sort: sortBy.value.map(s => ({ field: s.key, order: s.order === 'desc' ? 'desc' : 'asc' })), filter: rawFilters(), q: term.value || undefined, columns: columns.value }, format)
219
+
220
+ if (queued) {
221
+ // an XLSX is always prepared in the background; a CSV only when it is large
222
+ notifications.success(t(format === 'xlsx' ? 'list.exportPrepared' : 'list.exportQueued'))
223
+ await navigateTo(`${appConfig.vector.basePath}/jobs/${queued.jobId}`)
224
+ }
225
+ } catch (error) {
226
+ notifications.error(vectorErrorMessage(error))
227
+ }
228
+ }
229
+
230
+ const importing = ref(false)
231
+
232
+ function actionDone() {
233
+ dialogAction.value = null
234
+ selected.value = []
235
+ allMatching.value = false
236
+ load()
237
+ }
238
+
239
+ // --- rendering helpers ------------------------------------------------
240
+ const groupField = computed(() => props.resource.fields.find(f => f.name === group.value) ?? null)
241
+
242
+ function formatSummary(field: VectorField, value: number | null): string {
243
+ if (value === null) return t('common.none')
244
+ if (field.type === 'money') {
245
+ const p = fieldProps(field)
246
+ return formatMoney(Math.round(value), typeof p.currency === 'string' ? p.currency : (typeof p.currencyField === 'string' ? String(items.value[0]?.[p.currencyField] ?? 'EUR') : 'EUR'))
247
+ }
248
+ return formatNumber(value, field.summary === 'avg' ? 2 : undefined)
249
+ }
250
+
251
+ function open(record: VectorRecord) {
252
+ navigateTo(`${appConfig.vector.basePath}/r/${props.resource.slug}/${encodeURIComponent(String(record.id))}`)
253
+ }
254
+
255
+ // --- inline editing ---------------------------------------------------
256
+ // A click on an inline-editable cell opens its editor; a changed cell keeps
257
+ // its editor (highlighted) until the changeset is saved or discarded, so
258
+ // nothing has to render a value the list has not loaded.
259
+ const { inputFor } = useVectorInputs()
260
+ const pending = reactive<Record<string, Record<string, unknown>>>({})
261
+ const opened = reactive<Record<string, true>>({})
262
+ const cellErrors = reactive<Record<string, string>>({})
263
+ const savingChanges = ref(false)
264
+ const cellKey = (id: string | number, field: string) => `${id}\u0000${field}`
265
+ const pendingCount = computed(() => Object.values(pending).reduce((n, values) => n + Object.keys(values).length, 0))
266
+
267
+ function editValue(item: VectorRecord, field: VectorField): unknown {
268
+ const value = item[field.name]
269
+ return field.type === 'belongsTo' && value && typeof value === 'object' && 'id' in value ? (value as { id: unknown }).id : value
270
+ }
271
+
272
+ function startEdit(item: VectorRecord, field: VectorField) {
273
+ if (item._trashed === true) return
274
+ closeUnchanged()
275
+ opened[cellKey(item.id, field.name)] = true
276
+ }
277
+
278
+ /** Closes every open editor whose value was left as loaded. */
279
+ function closeUnchanged() {
280
+ for (const key of Object.keys(opened)) {
281
+ const [id, name] = key.split('\u0000') as [string, string]
282
+ if (!(pending[id] && name in pending[id])) delete opened[key]
283
+ }
284
+ }
285
+
286
+ function isEditing(item: VectorRecord, field: VectorField): boolean {
287
+ return opened[cellKey(item.id, field.name)] === true
288
+ }
289
+
290
+ function setCell(item: VectorRecord, field: VectorField, value: unknown) {
291
+ delete cellErrors[cellKey(item.id, field.name)]
292
+ const row = (pending[String(item.id)] ??= {})
293
+ if (value === editValue(item, field)) delete row[field.name]
294
+ else row[field.name] = value
295
+ if (!Object.keys(row).length) delete pending[String(item.id)]
296
+ }
297
+
298
+ function discardChanges() {
299
+ for (const key of Object.keys(pending)) delete pending[key]
300
+ for (const key of Object.keys(opened)) delete opened[key]
301
+ for (const key of Object.keys(cellErrors)) delete cellErrors[key]
302
+ }
303
+
304
+ async function saveChanges() {
305
+ const ids = Object.keys(pending)
306
+ const list = ids.map((id) => {
307
+ const item = items.value.find(i => String(i.id) === id)
308
+ return { id: item?.id ?? id, version: item?._version as number | undefined, values: pending[id]! }
309
+ })
310
+ savingChanges.value = true
311
+ for (const key of Object.keys(cellErrors)) delete cellErrors[key]
312
+
313
+ try {
314
+ await client.changes(list)
315
+ notifications.success(t('list.inline.saved', { count: list.length }, list.length))
316
+ discardChanges()
317
+ await load()
318
+ } catch (error) {
319
+ const failure = error as { status?: number, statusCode?: number, data?: { errors?: { path: (string | number)[], message: string }[] } } | null
320
+ const status = failure?.status ?? failure?.statusCode
321
+
322
+ if (status === 422 && Array.isArray(failure?.data?.errors)) {
323
+ for (const violation of failure.data.errors) {
324
+ const [, index, field] = violation.path
325
+ const change = typeof index === 'number' ? list[index] : undefined
326
+ if (change && typeof field === 'string' && field in change.values) cellErrors[cellKey(change.id, field)] = violation.message
327
+ else notifications.error(field ? `${String(field)}: ${violation.message}` : violation.message)
328
+ }
329
+ } else if (status === 409) {
330
+ notifications.error(t('list.inline.conflict'))
331
+ } else {
332
+ notifications.error(vectorErrorMessage(error))
333
+ }
334
+ } finally {
335
+ savingChanges.value = false
336
+ }
337
+ }
338
+
339
+ async function mayLeave(): Promise<boolean> {
340
+ if (!pendingCount.value) return true
341
+ return await confirm({ title: t('list.inline.leaveTitle', { count: pendingCount.value }, pendingCount.value), text: t('list.inline.leaveText'), confirmText: t('list.inline.discard'), destructive: true })
342
+ }
343
+
344
+ // Another resource's list is the same route with another slug, so an update guards too.
345
+ onBeforeRouteLeave(mayLeave)
346
+ onBeforeRouteUpdate((to, from) => to.path === from.path || mayLeave())
347
+
348
+ function warnBeforeUnload(event: BeforeUnloadEvent) {
349
+ if (pendingCount.value) event.preventDefault()
350
+ }
351
+ onMounted(() => window.addEventListener('beforeunload', warnBeforeUnload))
352
+ onBeforeUnmount(() => window.removeEventListener('beforeunload', warnBeforeUnload))
353
+ </script>
354
+
355
+ <template>
356
+ <div>
357
+ <v-row v-if="resource.widgets.length" density="compact" class="mb-2">
358
+ <v-col v-for="widget in resource.widgets" :key="widget.name" cols="12" :md="widget.width">
359
+ <VxWidgetCard :widget="widget" :load="(range: string | undefined) => client.widget(widget.name, range).then(r => r.data)" />
360
+ </v-col>
361
+ </v-row>
362
+ <div class="d-flex align-center flex-wrap ga-2 mb-2">
363
+ <v-text-field v-model="search" :placeholder="resource.table.search.length ? t('list.search', { label: noun(resource.label) }) : t('list.noSearch')" :disabled="resource.table.search.length === 0" prepend-inner-icon="mdi-magnify" density="compact" hide-details clearable :aria-label="t('common.search')" style="max-width: 320px" />
364
+ <VxListViewMenu ref="viewMenu" :resource="resource" :current="state" :active-id="activeView" @apply="applyView" @update:active-id="(id: number | null) => activeView = id" />
365
+ <v-select v-if="resource.table.groupable.length" v-model="group" :items="[{ title: t('list.noGrouping'), value: null }, ...resource.table.groupable.map(g => ({ title: resource.fields.find(f => f.name === g)?.label ?? g, value: g }))]" :label="t('list.groupBy')" density="compact" hide-details style="max-width: 200px" />
366
+ <v-select v-if="resource.softDelete" v-model="trashed" :items="[{ title: t('list.trash.active'), value: 'without' }, { title: t('list.trash.only'), value: 'only' }, { title: t('list.trash.with'), value: 'with' }]" density="compact" hide-details :aria-label="t('list.trash.label')" style="max-width: 180px" prepend-inner-icon="mdi-delete-clock-outline" />
367
+ <v-spacer />
368
+ <v-btn v-for="action in standaloneActions" :key="action.name" size="small" variant="tonal" :prepend-icon="action.icon ?? undefined" @click="dialogAction = action">{{ action.label }}</v-btn>
369
+ <template v-if="resource.permissions.export">
370
+ <v-btn v-if="exportFormats.length === 1" size="small" variant="text" icon="mdi-download-outline" :aria-label="t('list.exportCsv')" @click="exportAs('csv')" />
371
+ <v-menu v-else>
372
+ <template #activator="{ props: menu }">
373
+ <v-btn v-bind="menu" size="small" variant="text" icon="mdi-download-outline" :aria-label="t('list.export')" />
374
+ </template>
375
+ <v-list density="compact">
376
+ <v-list-item v-for="format in exportFormats" :key="format" :prepend-icon="format === 'xlsx' ? 'mdi-file-excel-outline' : 'mdi-file-delimited-outline'" :title="t(`list.exportFormats.${format}`)" @click="exportAs(format)" />
377
+ </v-list>
378
+ </v-menu>
379
+ </template>
380
+ <v-btn v-if="(resource.permissions.create || resource.permissions.update) && schema?.capabilities.queue" size="small" variant="text" icon="mdi-upload-outline" :aria-label="t('list.importCsv')" @click="importing = true" />
381
+ <VxListColumnManager v-model="columns" :resource="resource" />
382
+ </div>
383
+ <VxListFilterBar v-model="filters" v-model:groups="groups" v-model:advanced="advanced" :resource="resource" class="mb-2" />
384
+
385
+ <VxListBulkBar
386
+ v-if="selected.length"
387
+ :resource="resource"
388
+ :selected="selected.length"
389
+ :total="total"
390
+ :all-matching="allMatching"
391
+ :trashed-only="trashed === 'only'"
392
+ :actions="indexActions"
393
+ @update:all-matching="(v: boolean) => allMatching = v"
394
+ @clear="selected = []; allMatching = false"
395
+ @operation="operation"
396
+ @action="(a: VectorAction) => dialogAction = a"
397
+ @edit="bulkEditing = true"
398
+ />
399
+
400
+ <VxListBulkEdit v-if="bulkEditing" :resource="resource" :selection="selection" :count="selectionCount" @done="bulkEditing = false; actionDone()" @cancel="bulkEditing = false" />
401
+
402
+ <v-card v-if="pendingCount" color="warning" variant="tonal" flat class="d-flex align-center flex-wrap ga-2 px-4 py-2 mb-2" role="status">
403
+ <v-icon icon="mdi-pencil-outline" />
404
+ <span class="font-weight-medium">{{ t('list.inline.pending', { count: pendingCount }, pendingCount) }}</span>
405
+ <v-spacer />
406
+ <v-btn size="small" variant="text" :disabled="savingChanges" @click="discardChanges">{{ t('list.inline.discard') }}</v-btn>
407
+ <v-btn size="small" color="primary" variant="flat" :loading="savingChanges" @click="saveChanges">{{ t('list.inline.save') }}</v-btn>
408
+ </v-card>
409
+
410
+ <v-data-table-server
411
+ v-model="selected"
412
+ v-model:page="page"
413
+ v-model:items-per-page="itemsPerPage"
414
+ v-model:sort-by="sortBy"
415
+ :headers="headers"
416
+ :items="items"
417
+ :items-length="total"
418
+ :loading="loading"
419
+ :items-per-page-options="resource.table.perPageOptions"
420
+ :group-by="group ? [{ key: group, order: 'asc' }] : []"
421
+ item-value="id"
422
+ show-select
423
+ hover
424
+ fixed-header
425
+ density="comfortable"
426
+ @update:options="onOptions"
427
+ @click:row="(_: Event, { item }: { item: VectorRecord }) => open(item)"
428
+ >
429
+ <template v-for="field in fields" :key="field.name" #[`item.${field.name}`]="{ item }">
430
+ <div v-if="field.inline && isEditing(item, field)" class="vx-inline-editor" :class="{ 'vx-inline-changed': pending[String(item.id)] && field.name in pending[String(item.id)]! }" @click.stop @keydown.esc="closeUnchanged">
431
+ <component
432
+ :is="inputFor(field)"
433
+ :model-value="pending[String(item.id)]?.[field.name] ?? editValue(item, field)"
434
+ :field="field"
435
+ :rules="rulesFor(field, 'update')"
436
+ :error-messages="cellErrors[cellKey(item.id, field.name)] ? [cellErrors[cellKey(item.id, field.name)]!] : []"
437
+ :record="item"
438
+ :resource="resource.slug"
439
+ :aria-label="t('list.inline.editLabel', { label: field.label })"
440
+ label=""
441
+ density="compact"
442
+ hide-details="auto"
443
+ autofocus
444
+ @update:model-value="(v: unknown) => setCell(item, field, v)"
445
+ />
446
+ </div>
447
+ <span
448
+ v-else-if="field.inline && item._trashed !== true && resource.permissions.update"
449
+ class="vx-inline-cell"
450
+ role="button"
451
+ tabindex="0"
452
+ :aria-label="t('list.inline.editLabel', { label: field.label })"
453
+ @click.stop="startEdit(item, field)"
454
+ @keydown.enter.stop.prevent="startEdit(item, field)"
455
+ >
456
+ <VxCell :field="field" :value="item[field.name]" :record="item" :resource="resource" context="index" />
457
+ <v-icon icon="mdi-pencil-outline" size="x-small" class="vx-inline-pencil" />
458
+ </span>
459
+ <span v-else :class="{ 'text-disabled': item._trashed === true }">
460
+ <VxCell :field="field" :value="item[field.name]" :record="item" :resource="resource" context="index" />
461
+ </span>
462
+ </template>
463
+
464
+ <template #group-header="{ item, columns: cols, toggleGroup, isGroupOpen }">
465
+ <tr class="bg-surface-light">
466
+ <td :colspan="cols.length" class="font-weight-medium">
467
+ <v-btn size="small" variant="text" :icon="isGroupOpen(item) ? 'mdi-chevron-down' : 'mdi-chevron-right'" :aria-label="isGroupOpen(item) ? t('list.collapseGroup') : t('list.expandGroup')" :aria-expanded="isGroupOpen(item)" @click="toggleGroup(item)" />
468
+ <span class="text-medium-emphasis me-2">{{ t('list.groupHeader', { label: groupField?.label ?? '' }) }}</span>
469
+ <VxCell v-if="groupField" :field="groupField" :value="item.value" :record="{}" :resource="resource" context="index" />
470
+ <v-chip size="x-small" class="ms-2" variant="tonal">{{ item.items.length }}</v-chip>
471
+ </td>
472
+ </tr>
473
+ </template>
474
+
475
+ <template v-if="summaries && Object.keys(summaryValues).length" #body.append>
476
+ <tr class="font-weight-medium bg-surface-light">
477
+ <td />
478
+ <td v-if="group" />
479
+ <td v-for="field in fields" :key="field.name" :class="numeric(field) ? 'text-end' : ''">
480
+ <template v-if="field.summary && field.name in summaryValues">
481
+ <span class="text-caption text-medium-emphasis me-1">{{ t(`list.summary.${field.summary}`) }}</span>{{ formatSummary(field, summaryValues[field.name] ?? null) }}
482
+ </template>
483
+ </td>
484
+ </tr>
485
+ </template>
486
+
487
+ <template #no-data>
488
+ <v-empty-state icon="mdi-database-off-outline" :title="Object.keys(filters).length || groups.length || advanced.length || term ? t('list.nothingMatches') : t('list.empty', { label: noun(resource.label) })" />
489
+ </template>
490
+ </v-data-table-server>
491
+
492
+ <VxImportDialog v-if="importing" :resource="resource" @queued="importing = false" @cancel="importing = false" />
493
+
494
+ <VxActionDialog
495
+ v-if="dialogAction"
496
+ :resource="resource"
497
+ :action="dialogAction"
498
+ :selection="dialogAction.standalone ? null : selection"
499
+ :count="selectionCount"
500
+ @done="actionDone"
501
+ @cancel="dialogAction = null"
502
+ />
503
+ </div>
504
+ </template>
505
+
506
+ <style scoped>
507
+ .vx-inline-cell { display: inline-flex; align-items: center; gap: 4px; cursor: text; border-radius: 4px; padding: 2px 4px; margin: -2px -4px; }
508
+ .vx-inline-cell:hover, .vx-inline-cell:focus-visible { outline: 1px dashed rgba(var(--v-theme-primary), 0.6); }
509
+ .vx-inline-pencil { opacity: 0; }
510
+ .vx-inline-cell:hover .vx-inline-pencil, .vx-inline-cell:focus-visible .vx-inline-pencil { opacity: 0.6; }
511
+ .vx-inline-editor { min-width: 140px; padding-block: 4px; }
512
+ .vx-inline-changed { background: rgba(var(--v-theme-warning), 0.12); border-radius: 4px; }
513
+ </style>
@@ -0,0 +1,80 @@
1
+ <script setup lang="ts">
2
+ import type { VectorWidget, VectorWidgetData } from '#vector/app/types/vector'
3
+
4
+ // One widget card: loads its data for the selected range — and again in a
5
+ // new language, whose labels the server translates — and renders the shape
6
+ // its type declares. A consuming app replaces a type through
7
+ // app.config.vector.widgets, like fields and inputs.
8
+ const props = defineProps<{
9
+ widget: VectorWidget
10
+ load: (range: string | undefined) => Promise<VectorWidgetData>
11
+ }>()
12
+
13
+ const appConfig = useAppConfig()
14
+ const { t } = useVectorT()
15
+ const range = ref<string | undefined>(Object.keys(props.widget.ranges)[0])
16
+ const data = ref<VectorWidgetData | null>(null)
17
+ const error = ref<string | null>(null)
18
+ const loading = ref(false)
19
+ let requestId = 0
20
+
21
+ async function refresh() {
22
+ const id = ++requestId
23
+ loading.value = true
24
+ error.value = null
25
+
26
+ try {
27
+ const result = await props.load(range.value)
28
+ if (id === requestId) data.value = result
29
+ } catch (caught) {
30
+ if (id === requestId) error.value = vectorErrorMessage(caught)
31
+ } finally {
32
+ if (id === requestId) loading.value = false
33
+ }
34
+ }
35
+
36
+ const { current: locale } = useVectorLocale()
37
+ watch([range, locale], refresh)
38
+ onMounted(refresh)
39
+
40
+ const builtIn: Record<string, Component> = {
41
+ stat: defineAsyncComponent(() => import('#vector/app/components/Vx/Widget/Stat.vue')),
42
+ trend: defineAsyncComponent(() => import('#vector/app/components/Vx/Widget/Trend.vue')),
43
+ partition: defineAsyncComponent(() => import('#vector/app/components/Vx/Widget/Partition.vue')),
44
+ progress: defineAsyncComponent(() => import('#vector/app/components/Vx/Widget/Progress.vue')),
45
+ table: defineAsyncComponent(() => import('#vector/app/components/Vx/Widget/Table.vue')),
46
+ }
47
+
48
+ const component = computed(() => {
49
+ const override = appConfig.vector.widgets[props.widget.type] ?? useVectorExtensions().widgets.get(props.widget.type)
50
+ return override ? (typeof override === 'string' ? resolveComponent(override) : override) : builtIn[props.widget.type] ?? builtIn.stat!
51
+ })
52
+ </script>
53
+
54
+ <template>
55
+ <v-card class="h-100 d-flex flex-column">
56
+ <v-card-item class="pb-0">
57
+ <template v-if="widget.icon" #prepend>
58
+ <v-icon :icon="widget.icon" color="primary" />
59
+ </template>
60
+ <v-card-title class="text-subtitle-1">{{ widget.label }}</v-card-title>
61
+ <template #append>
62
+ <v-select
63
+ v-if="Object.keys(widget.ranges).length > 1"
64
+ v-model="range"
65
+ :items="Object.entries(widget.ranges).map(([value, title]) => ({ value, title }))"
66
+ density="compact"
67
+ variant="plain"
68
+ hide-details
69
+ :aria-label="t('dashboard.range')"
70
+ style="min-width: 110px"
71
+ />
72
+ </template>
73
+ </v-card-item>
74
+ <v-card-text class="flex-grow-1 d-flex flex-column justify-center">
75
+ <v-progress-linear v-if="loading && !data" indeterminate color="primary" :aria-label="t('dashboard.loading')" />
76
+ <v-alert v-else-if="error" type="error" variant="tonal" density="compact" :text="error" />
77
+ <component :is="component" v-else-if="data" :data="data" :widget="widget" />
78
+ </v-card-text>
79
+ </v-card>
80
+ </template>
@@ -0,0 +1,21 @@
1
+ <script setup lang="ts">
2
+ import type { VectorWidgetData } from '#vector/app/types/vector'
3
+
4
+ const props = defineProps<{ data: VectorWidgetData }>()
5
+ const { t } = useVectorT()
6
+ const slices = computed(() => props.data.slices ?? [])
7
+ const total = computed(() => slices.value.reduce((sum, s) => sum + s.value, 0))
8
+ </script>
9
+
10
+ <template>
11
+ <div>
12
+ <div class="d-flex rounded overflow-hidden mb-3" style="height: 12px">
13
+ <div v-for="slice in slices" :key="slice.label" :class="`bg-${slice.color ?? 'primary'}`" :style="{ width: `${total ? (slice.value / total) * 100 : 0}%` }" :title="slice.label" />
14
+ </div>
15
+ <div v-for="slice in slices" :key="slice.label" class="d-flex justify-space-between text-body-2 py-1">
16
+ <span class="d-flex align-center ga-2"><v-icon size="x-small" icon="mdi-circle" :color="slice.color ?? 'primary'" />{{ slice.label }}</span>
17
+ <span class="font-weight-medium">{{ formatWidgetValue(slice.value, data.format) }} <span class="text-medium-emphasis">({{ t('dashboard.percent', { value: total ? Math.round((slice.value / total) * 100) : 0 }) }})</span></span>
18
+ </div>
19
+ <div v-if="slices.length === 0" class="text-medium-emphasis text-body-2">{{ t('dashboard.partition.empty') }}</div>
20
+ </div>
21
+ </template>
@@ -0,0 +1,22 @@
1
+ <script setup lang="ts">
2
+ import type { VectorWidgetData } from '#vector/app/types/vector'
3
+
4
+ const props = defineProps<{ data: VectorWidgetData }>()
5
+ const { t } = useVectorT()
6
+ const percent = computed(() => {
7
+ const value = props.data.value ?? 0
8
+ const target = props.data.target ?? 0
9
+ return target > 0 ? Math.min(100, (value / target) * 100) : 0
10
+ })
11
+ </script>
12
+
13
+ <template>
14
+ <div>
15
+ <div class="d-flex justify-space-between align-baseline mb-2">
16
+ <span class="text-h5 font-weight-medium">{{ formatWidgetValue(data.value, data.format) }}</span>
17
+ <span class="text-caption text-medium-emphasis">{{ t('dashboard.progress.of', { target: formatWidgetValue(data.target, data.format) }) }}</span>
18
+ </div>
19
+ <v-progress-linear :model-value="percent" color="primary" height="10" rounded :aria-label="data.caption ?? t('dashboard.progress.label')" />
20
+ <div v-if="data.caption" class="text-caption text-medium-emphasis mt-2">{{ data.caption }}</div>
21
+ </div>
22
+ </template>