shared-ritm 1.2.37 → 1.2.38

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.
@@ -0,0 +1,368 @@
1
+ <script setup lang="ts">
2
+ import { ref, watch, defineProps, defineEmits, nextTick, computed } from 'vue'
3
+
4
+ type ModalMode = 'view' | 'edit' | 'create'
5
+ type FieldType = 'text' | 'select'
6
+
7
+ interface FieldOption {
8
+ label: string
9
+ value: string
10
+ }
11
+
12
+ interface FieldSchema {
13
+ key: string
14
+ label: string
15
+ type: FieldType
16
+ rules?: ((val: any) => boolean | string)[]
17
+ options?: FieldOption[]
18
+ placeholder?: string
19
+ }
20
+
21
+ const props = defineProps<{
22
+ modelValue: boolean
23
+ title: string
24
+ mode: ModalMode
25
+ fields: FieldSchema[]
26
+ initialData?: Record<string, any>
27
+ }>()
28
+
29
+ const emit = defineEmits<{
30
+ (e: 'update:modelValue', val: boolean): void
31
+ (e: 'submit', data: Record<string, any>): void
32
+ (e: 'edit'): void
33
+ (e: 'delete'): void
34
+ }>()
35
+
36
+ const formData = ref<Record<string, any>>({})
37
+ const formRef = ref()
38
+
39
+ watch(
40
+ () => props.modelValue,
41
+ val => {
42
+ if (val) {
43
+ formData.value = { ...(props.initialData ?? {}) }
44
+ }
45
+ },
46
+ { immediate: true },
47
+ )
48
+ function isEqual(a: any, b: any): boolean {
49
+ if (Array.isArray(a) && Array.isArray(b)) {
50
+ if (a.length !== b.length) return false
51
+ return a.every((item, i) => isEqual(item, b[i]))
52
+ }
53
+ if (typeof a === 'object' && typeof b === 'object') {
54
+ return JSON.stringify(a) === JSON.stringify(b)
55
+ }
56
+ return a === b
57
+ }
58
+
59
+ function normalizeValue(val: any): any {
60
+ if (Array.isArray(val)) {
61
+ return val.map(v => (typeof v === 'object' && v !== null ? v.value ?? v : v))
62
+ }
63
+ return typeof val === 'object' && val !== null ? val.value ?? val : val
64
+ }
65
+
66
+ function submit() {
67
+ formRef.value?.validate().then(ok => {
68
+ if (!ok) return
69
+
70
+ const changed: Record<string, any> = {}
71
+
72
+ for (const field of props.fields) {
73
+ const key = field.key
74
+ const current = formData.value[key]
75
+ const initial = props.initialData?.[key]
76
+
77
+ const normalizedCurrent = field.type === 'select' ? normalizeValue(current) : current
78
+ const normalizedInitial = field.type === 'select' ? normalizeValue(initial) : initial
79
+
80
+ if (!isEqual(normalizedCurrent, normalizedInitial)) {
81
+ changed[key] = normalizedCurrent
82
+ }
83
+ }
84
+
85
+ emit('submit', changed)
86
+ })
87
+ }
88
+
89
+ function handleClear(key: string) {
90
+ const field = props.fields.find(f => f.key === key)
91
+ formData.value[key] = field?.type === 'select' ? [] : ''
92
+
93
+ nextTick(() => {
94
+ formRef.value?.validate()
95
+ })
96
+ }
97
+ const isSubmitDisabled = computed(() => {
98
+ if (props.mode === 'view') return false
99
+
100
+ const hasEmptyRequired = props.fields.some(field => {
101
+ const isRequired = field.rules?.some(rule => rule('') !== true)
102
+ const val = formData.value[field.key]
103
+ const normalized = normalizeValue(val)
104
+
105
+ const empty =
106
+ normalized === null ||
107
+ normalized === undefined ||
108
+ (typeof normalized === 'string' && normalized.trim() === '') ||
109
+ (Array.isArray(normalized) && normalized.length === 0)
110
+
111
+ return isRequired && empty
112
+ })
113
+
114
+ if (hasEmptyRequired) return true
115
+
116
+ const hasChanges = props.fields.some(field => {
117
+ const key = field.key
118
+ const current = normalizeValue(formData.value[key])
119
+ const initial = normalizeValue(props.initialData?.[key])
120
+ return !isEqual(current, initial)
121
+ })
122
+
123
+ return !hasChanges
124
+ })
125
+ function uuidv4() {
126
+ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
127
+ const r = (Math.random() * 16) | 0
128
+ const v = c === 'x' ? r : (r & 0x3) | 0x8
129
+ return v.toString(16)
130
+ })
131
+ }
132
+
133
+ function generateUuid() {
134
+ formData.value.uuid = uuidv4()
135
+ }
136
+ const filteredOptions = ref<Record<string, FieldOption[]>>({})
137
+
138
+ function onFilter(key: string) {
139
+ return (val: string, update: (cb: () => void) => void) => {
140
+ update(() => {
141
+ const field = props.fields.find(f => f.key === key)
142
+ if (!field?.options) return
143
+ const lower = val.toLowerCase()
144
+ filteredOptions.value[key] = field.options.filter(opt => opt.label.toLowerCase().includes(lower))
145
+ })
146
+ }
147
+ }
148
+ </script>
149
+
150
+ <template>
151
+ <q-dialog :model-value="modelValue" @update:model-value="val => emit('update:modelValue', val)">
152
+ <q-card style="min-width: 700px">
153
+ <q-card-section>
154
+ <div class="modal-title">{{ title }}</div>
155
+ </q-card-section>
156
+
157
+ <q-card-section>
158
+ <q-form ref="formRef" @submit.prevent="submit">
159
+ <div v-for="field in fields" :key="field.key" class="field-wrapper">
160
+ <label class="field-label">
161
+ {{ field.label }}
162
+ <span v-if="field.rules?.length && mode !== 'view'" class="required">*</span>
163
+ </label>
164
+
165
+ <q-input
166
+ v-if="field.type === 'text'"
167
+ v-model="formData[field.key]"
168
+ :rules="field.rules"
169
+ :readonly="mode === 'view' || (field.key === 'uuid' && mode === 'edit')"
170
+ filled
171
+ :placeholder="field.placeholder"
172
+ >
173
+ <template v-if="mode !== 'view'" #append>
174
+ <q-icon
175
+ v-if="formData[field.key] && !(field.key === 'uuid' && mode === 'edit')"
176
+ name="close"
177
+ class="cursor-pointer clear-input"
178
+ @click="() => handleClear(field.key)"
179
+ />
180
+ <q-btn
181
+ v-if="field.key === 'uuid' && mode === 'create'"
182
+ flat
183
+ no-caps
184
+ label="UUID"
185
+ size="sm"
186
+ class="q-ml-sm uuid-btn"
187
+ @click="generateUuid"
188
+ />
189
+ </template>
190
+ </q-input>
191
+
192
+ <q-select
193
+ v-else-if="field.type === 'select'"
194
+ v-model="formData[field.key]"
195
+ :options="filteredOptions[field.key] || field.options"
196
+ :rules="field.rules"
197
+ :readonly="mode === 'view'"
198
+ :placeholder="mode === 'view' ? '' : field.placeholder"
199
+ filled
200
+ multiple
201
+ use-input
202
+ input-debounce="0"
203
+ emit-value
204
+ map-options
205
+ use-chips
206
+ stack-label
207
+ popup-content-class="custom-select-menu"
208
+ @filter="(val, update) => onFilter(field.key)(val, update)"
209
+ >
210
+ <template v-if="mode !== 'view'" #append>
211
+ <q-icon
212
+ v-if="formData[field.key]?.length"
213
+ name="close"
214
+ class="cursor-pointer clear-input"
215
+ @click.stop="
216
+ () => {
217
+ handleClear(field.key)
218
+ nextTick(() => formRef.value?.validate())
219
+ }
220
+ "
221
+ />
222
+ </template>
223
+ </q-select>
224
+ </div>
225
+ </q-form>
226
+ </q-card-section>
227
+
228
+ <q-card-actions align="center">
229
+ <q-btn v-if="mode === 'view'" class="remove" flat label="Удалить" @click="emit('delete')" />
230
+ <q-btn
231
+ v-if="mode !== 'view'"
232
+ class="confirm"
233
+ flat
234
+ :label="mode === 'edit' ? 'Сохранить' : 'Создать'"
235
+ :disable="isSubmitDisabled"
236
+ @click="submit"
237
+ />
238
+ <q-btn v-else class="confirm" flat label="Редактировать" @click="$emit('edit')" />
239
+ <q-btn v-close-popup class="cancel" flat label="Закрыть" />
240
+ </q-card-actions>
241
+ </q-card>
242
+ </q-dialog>
243
+ </template>
244
+ <style lang="scss">
245
+ .custom-select-menu {
246
+ max-height: 250px !important;
247
+ overflow-y: auto !important;
248
+ }
249
+ </style>
250
+ <style scoped lang="scss">
251
+ .uuid-btn {
252
+ height: 32px;
253
+ padding: 0 10px;
254
+ border: 1px solid #3f8cff;
255
+ color: #3f8cff;
256
+ font-weight: 700;
257
+ font-size: 14px;
258
+ background: white;
259
+ border-radius: 6px;
260
+ }
261
+
262
+ .q-card {
263
+ border-radius: 12px;
264
+ background: #fff;
265
+ box-shadow: 0 4px 4px 0 rgba(0, 0, 0, 0.25);
266
+ font-family: NunitoSansFont, sans-serif;
267
+
268
+ .modal-title {
269
+ color: #1d425d;
270
+ text-align: center;
271
+ font-size: 32px;
272
+ font-weight: 700;
273
+ padding: 18px 29px;
274
+ }
275
+
276
+ .q-card__section {
277
+ padding: 0;
278
+ }
279
+
280
+ .q-form {
281
+ padding: 0 29px;
282
+ }
283
+
284
+ .field-wrapper {
285
+ display: flex;
286
+ flex-direction: column;
287
+ margin-bottom: 15px;
288
+ }
289
+
290
+ .field-label {
291
+ font-size: 14px;
292
+ font-weight: 700;
293
+ color: #7d8592;
294
+ }
295
+ ::v-deep(.q-placeholder) {
296
+ color: #7d8592;
297
+ }
298
+ ::v-deep(.q-field__control) {
299
+ border-radius: 8px;
300
+ border: 1px solid #d8e0f0;
301
+ background: #fff;
302
+ box-shadow: 0px 1px 2px 0px rgba(184, 200, 224, 0.22);
303
+ }
304
+
305
+ ::v-deep(.q-field--filled .q-field__control:before) {
306
+ background: #fff !important;
307
+ border: none;
308
+ }
309
+
310
+ ::v-deep(.q-field--with-bottom) {
311
+ padding-bottom: 0;
312
+ }
313
+
314
+ ::v-deep(.q-field__bottom) {
315
+ padding: 0;
316
+ }
317
+
318
+ .clear-input {
319
+ color: #d8e0f0;
320
+ }
321
+ ::v-deep(.q-chip) {
322
+ border-radius: 4px;
323
+ background: #e9eff9;
324
+ color: #1d425d;
325
+ font-family: NunitoSansFont, sans-serif;
326
+ font-size: 14px;
327
+ height: 30px;
328
+ line-height: 30px;
329
+ padding: 0 10px;
330
+ .q-chip__icon {
331
+ color: #3f8cff;
332
+ margin-left: 5px;
333
+ }
334
+ }
335
+ .required {
336
+ color: #f65160;
337
+ font-weight: bold;
338
+ }
339
+
340
+ &__actions {
341
+ padding: 18px 0;
342
+ border-radius: 0px 0px 16px 16px;
343
+ background: #f4f9fd;
344
+
345
+ button {
346
+ padding: 13px 37px;
347
+ border-radius: 4px;
348
+ font-size: 16px;
349
+ font-weight: 700;
350
+ text-transform: none;
351
+
352
+ &.remove {
353
+ border: 1px solid #f65160;
354
+ color: #f65160;
355
+ }
356
+ &.confirm {
357
+ background: #3f8cff;
358
+ color: #fff;
359
+ }
360
+
361
+ &.cancel {
362
+ color: #3f8cff;
363
+ border: 1px solid #3f8cff;
364
+ }
365
+ }
366
+ }
367
+ }
368
+ </style>
@@ -0,0 +1,151 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue'
3
+ import { defineProps, defineEmits } from 'vue'
4
+
5
+ const props = defineProps<{
6
+ modelValue: number
7
+ totalPages: number
8
+ }>()
9
+
10
+ const emit = defineEmits<{
11
+ (e: 'update:modelValue', value: number): void
12
+ (e: 'page-change', value: number): void
13
+ }>()
14
+
15
+ const currentPage = computed({
16
+ get: () => props.modelValue,
17
+ set: (val: number) => {
18
+ emit('update:modelValue', val)
19
+ emit('page-change', val)
20
+ },
21
+ })
22
+
23
+ const pageArray = computed(() => {
24
+ const maxPagesToShow = 5
25
+ let startPage = 1
26
+ let endPage = props.totalPages
27
+ const pages: (number | string)[] = []
28
+
29
+ if (props.totalPages <= maxPagesToShow) {
30
+ for (let i = 1; i <= props.totalPages; i++) pages.push(i)
31
+ } else {
32
+ if (props.modelValue <= 3) {
33
+ startPage = 1
34
+ endPage = maxPagesToShow - 1
35
+ } else if (props.modelValue + 2 >= props.totalPages) {
36
+ startPage = props.totalPages - (maxPagesToShow - 2)
37
+ endPage = props.totalPages
38
+ } else {
39
+ startPage = props.modelValue - 2
40
+ endPage = props.modelValue + 2
41
+ }
42
+
43
+ for (let i = startPage; i <= endPage; i++) pages.push(i)
44
+
45
+ if (startPage > 1) {
46
+ pages.unshift('...')
47
+ pages.unshift(1)
48
+ }
49
+ if (endPage < props.totalPages) {
50
+ pages.push('...')
51
+ pages.push(props.totalPages)
52
+ }
53
+ }
54
+
55
+ return pages
56
+ })
57
+
58
+ function changePage(page: number | string) {
59
+ if (typeof page === 'number' && page !== props.modelValue) {
60
+ currentPage.value = page
61
+ }
62
+ }
63
+
64
+ function prevPage() {
65
+ if (props.modelValue > 1) currentPage.value = props.modelValue - 1
66
+ }
67
+
68
+ function nextPage() {
69
+ if (props.modelValue < props.totalPages) currentPage.value = props.modelValue + 1
70
+ }
71
+ </script>
72
+
73
+ <template>
74
+ <div class="table-pagination">
75
+ <button class="arrow-button" :disabled="modelValue <= 1" @click="prevPage">
76
+ <q-icon name="mdi-chevron-left" :color="modelValue <= 1 ? 'grey-4' : 'primary'" />
77
+ </button>
78
+
79
+ <div class="pages">
80
+ <button
81
+ v-for="page in pageArray"
82
+ :key="page"
83
+ class="page-button"
84
+ :class="{ selected: page === modelValue, ellipsis: page === '...' }"
85
+ :disabled="page === modelValue || page === '...'"
86
+ @click="changePage(page)"
87
+ >
88
+ {{ page }}
89
+ </button>
90
+ </div>
91
+
92
+ <button class="arrow-button" :disabled="modelValue >= totalPages" @click="nextPage">
93
+ <q-icon name="mdi-chevron-right" :color="modelValue >= totalPages ? 'grey-4' : 'primary'" />
94
+ </button>
95
+ </div>
96
+ </template>
97
+
98
+ <style scoped lang="scss">
99
+ .pages {
100
+ display: flex;
101
+ gap: 20px;
102
+ }
103
+ .table-pagination {
104
+ display: flex;
105
+ justify-content: start;
106
+ align-items: center;
107
+ gap: 20px;
108
+ flex-wrap: wrap;
109
+ margin-top: 17px;
110
+ margin-bottom: 15px;
111
+ .arrow-button,
112
+ .page-button {
113
+ width: 34px;
114
+ height: 34px;
115
+ border-radius: 4px;
116
+ background: white;
117
+ border: none;
118
+ cursor: pointer;
119
+ font-weight: 500;
120
+ color: #3f8cff;
121
+ font-size: 16px;
122
+ transition: all 0.2s ease;
123
+ }
124
+
125
+ .page-button.selected {
126
+ background: #3f8cff;
127
+ color: white;
128
+ cursor: not-allowed;
129
+ }
130
+
131
+ .page-button.ellipsis {
132
+ cursor: default;
133
+ color: #aab2c8;
134
+ font-weight: 400;
135
+ }
136
+
137
+ .arrow-button:disabled {
138
+ cursor: not-allowed;
139
+ background: white;
140
+ }
141
+
142
+ @media (max-width: 768px) {
143
+ .arrow-button,
144
+ .page-button {
145
+ width: 32px;
146
+ height: 32px;
147
+ font-size: 14px;
148
+ }
149
+ }
150
+ }
151
+ </style>
@@ -0,0 +1,78 @@
1
+ <script setup lang="ts">
2
+ import { defineProps, defineEmits, ref, watch } from 'vue'
3
+
4
+ const props = defineProps<{
5
+ modelValue: string
6
+ placeholder: string
7
+ }>()
8
+
9
+ const emit = defineEmits<{
10
+ (e: 'update:modelValue', value: string): void
11
+ (e: 'search', value: string): void
12
+ }>()
13
+
14
+ const input = ref(props.modelValue)
15
+ let debounceTimer: ReturnType<typeof setTimeout>
16
+
17
+ watch(
18
+ () => props.modelValue,
19
+ val => {
20
+ input.value = val
21
+ },
22
+ )
23
+
24
+ watch(input, val => {
25
+ emit('update:modelValue', val)
26
+ clearTimeout(debounceTimer)
27
+ debounceTimer = setTimeout(() => {
28
+ emit('search', val)
29
+ }, 400)
30
+ })
31
+
32
+ const clearInput = () => {
33
+ input.value = ''
34
+ emit('search', '')
35
+ }
36
+ </script>
37
+
38
+ <template>
39
+ <q-input v-model="input" :placeholder="placeholder" dense outlined>
40
+ <template v-if="input" #append>
41
+ <q-icon name="✕" class="cursor-pointer" @click.stop="clearInput" />
42
+ </template>
43
+ </q-input>
44
+ </template>
45
+
46
+ <style scoped lang="scss">
47
+ .q-field {
48
+ background: #fff;
49
+ border-radius: 4px;
50
+ outline: none;
51
+ border-color: #f2f7fb;
52
+
53
+ ::v-deep(.q-field__control) {
54
+ height: 52px;
55
+ display: flex;
56
+ align-items: center;
57
+ }
58
+
59
+ ::v-deep(.q-field__native) {
60
+ font-family: NunitoSansFont, sans-serif;
61
+ color: #1d425d;
62
+ padding-left: 8px;
63
+ }
64
+
65
+ ::v-deep(.q-field__native::placeholder) {
66
+ color: rgba(184, 184, 184, 0.87);
67
+ }
68
+
69
+ ::v-deep(.q-icon.cursor-pointer) {
70
+ color: #1d425d;
71
+ font-size: 20px;
72
+ line-height: 15px;
73
+ }
74
+ ::v-deep(.q-field__control:hover:before) {
75
+ border-color: #3f8cff;
76
+ }
77
+ }
78
+ </style>
@@ -0,0 +1,104 @@
1
+ import { Ref, computed, ref } from 'vue'
2
+
3
+ export interface TableColumn {
4
+ name: string
5
+ label: string
6
+ style?: string
7
+ headerStyle?: string
8
+ field: string | ((row: any) => any)
9
+ sortable?: boolean
10
+ filterType: 'single' | 'multi' | null
11
+ align?: 'left' | 'center' | 'right'
12
+ }
13
+
14
+ export interface FilterOption {
15
+ id: string
16
+ name: string
17
+ }
18
+
19
+ export interface TableModel<T = any> {
20
+ columns: TableColumn[]
21
+ rows: T[] | Ref<T[]>
22
+ filtersOptions?: Ref<Record<string, FilterOption[]>>
23
+ }
24
+
25
+ export const useTableModel = <T = any>(model: TableModel<T>) => {
26
+ const columnFilters = ref<Record<string, string | string[] | undefined>>({})
27
+ const filterMenus = ref<Record<string, boolean>>({})
28
+
29
+ model.columns.forEach(({ name, filterType }) => {
30
+ if (filterType) {
31
+ columnFilters.value[name] = filterType === 'multi' ? [] : undefined
32
+ filterMenus.value[name] = false
33
+ }
34
+ })
35
+
36
+ const getFieldValue = (row: any, col: TableColumn) =>
37
+ typeof col.field === 'function' ? col.field(row) : row[col.field]
38
+
39
+ const resolvedRows = computed(() => (Array.isArray(model.rows) ? model.rows : (model.rows as Ref<T[]>).value))
40
+
41
+ const filteredRows = computed(() =>
42
+ resolvedRows.value.filter(row =>
43
+ model.columns.every(col => {
44
+ const filter = columnFilters.value[col.name]
45
+ if (!col.filterType || filter == null || (Array.isArray(filter) && filter.length === 0)) return true
46
+ const value = getFieldValue(row, col)
47
+ return col.filterType === 'single'
48
+ ? value === filter
49
+ : Array.isArray(value)
50
+ ? value.some(v => (filter as string[]).includes(v))
51
+ : (filter as string[]).includes(value)
52
+ }),
53
+ ),
54
+ )
55
+
56
+ const toggleFilterValue = (colName: string, value: string) => {
57
+ const col = model.columns.find(c => c.name === colName)
58
+ if (col?.filterType === 'multi') {
59
+ const current = columnFilters.value[colName] as string[]
60
+ const index = current.indexOf(value)
61
+ index > -1 ? current.splice(index, 1) : current.push(value)
62
+ columnFilters.value[colName] = [...current]
63
+ } else {
64
+ columnFilters.value[colName] = value
65
+ filterMenus.value[colName] = false
66
+ }
67
+ }
68
+
69
+ const selectedFilters = computed(() => {
70
+ const result: Record<string, string[]> = {}
71
+ for (const col of model.columns) {
72
+ const filter = columnFilters.value[col.name]
73
+ const options = model.filtersOptions?.value[col.name] || []
74
+
75
+ if (filter) {
76
+ result[col.name] = Array.isArray(filter)
77
+ ? options.filter(opt => filter.includes(opt.name)).map(opt => opt.id)
78
+ : options.filter(opt => opt.name === filter).map(opt => opt.id)
79
+ }
80
+ }
81
+ return result
82
+ })
83
+
84
+ const clearFilter = (colName: string) => {
85
+ const col = model.columns.find(c => c.name === colName)
86
+ columnFilters.value[colName] = col?.filterType === 'multi' ? [] : undefined
87
+ }
88
+
89
+ const openFilterMenu = (colName: string, isOpen: boolean) => {
90
+ filterMenus.value[colName] = isOpen
91
+ }
92
+
93
+ return {
94
+ rows: resolvedRows,
95
+ columns: computed(() => model.columns),
96
+ columnFilters,
97
+ filterMenus,
98
+ filteredRows,
99
+ toggleFilterValue,
100
+ clearFilter,
101
+ openFilterMenu,
102
+ selectedFilters,
103
+ }
104
+ }