@amirjalili1374/ui-kit 1.2.0 → 1.2.2
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/README.md +11 -11
- package/package.json +3 -3
- package/src/assets/fonts/A Massir Spray.ttf +0 -0
- package/src/assets/fonts/BYekan.ttf +0 -0
- package/src/assets/fonts/BYekan.woff +0 -0
- package/src/assets/fonts/BYekan.woff2 +0 -0
- package/src/assets/fonts/Dima Shekasteh 2 Free.ttf +0 -0
- package/src/assets/fonts/Dima Shekasteh Free Regular.ttf +0 -0
- package/src/assets/fonts/IRANSansWeb.ts +0 -1
- package/src/assets/fonts/IRANSansWeb.ttf +0 -0
- package/src/assets/fonts/IRANSansXBlack.ttf +0 -0
- package/src/assets/fonts/IRANSansXBold.ttf +0 -0
- package/src/assets/fonts/IRANSansXDemiBold.ttf +0 -0
- package/src/assets/fonts/IRANSansXExtraBold.ttf +0 -0
- package/src/assets/fonts/IRANSansXLight.ttf +0 -0
- package/src/assets/fonts/IRANSansXMedium.ttf +0 -0
- package/src/assets/fonts/IRANSansXRegular.ttf +0 -0
- package/src/assets/fonts/IRANSansXThin.ttf +0 -0
- package/src/assets/fonts/IRANSansXUltraLight.ttf +0 -0
- package/src/assets/fonts/IranNastaliq.ttf +0 -0
- package/src/assets/fonts/Vazir-Medium-FD.ttf +0 -0
- package/src/assets/fonts/Vazir-Medium-FD.woff +0 -0
- package/src/assets/fonts/Vazir-Medium-FD.woff2 +0 -0
- package/src/assets/fonts/Vazir-Regular-FD.eot +0 -0
- package/src/assets/fonts/kalamehBold.woff +0 -0
- package/src/assets/fonts/kalamehBold.woff2 +0 -0
- package/src/assets/fonts/kalamehHeavy.woff +0 -0
- package/src/assets/fonts/kalamehHeavy.woff2 +0 -0
- package/src/assets/fonts/kalamehLight.woff +0 -0
- package/src/assets/fonts/kalamehLight.woff2 +0 -0
- package/src/assets/fonts/kalamehRegular.woff +0 -0
- package/src/assets/fonts/kalamehRegular.woff2 +0 -0
- package/src/assets/images/auth/social-google.svg +0 -6
- package/src/assets/images/favicon.svg +0 -18
- package/src/assets/images/icons/icon-card.svg +0 -5
- package/src/assets/images/logos/logo.svg +0 -12
- package/src/assets/images/logos/logolight.svg +0 -12
- package/src/assets/images/maintenance/img-error-bg.svg +0 -34
- package/src/assets/images/maintenance/img-error-blue.svg +0 -43
- package/src/assets/images/maintenance/img-error-purple.svg +0 -42
- package/src/assets/images/maintenance/img-error-text.svg +0 -27
- package/src/assets/images/profile/profile-user-account-svgrepo-com.svg +0 -12
- package/src/assets/images/profile/user-round.svg +0 -15
- package/src/assets/images/template/template-01.ts +0 -1
- package/src/assets/images/vectors/colorized-bg.svg +0 -40
- package/src/assets/images/vectors/logo_stroke_1px.svg +0 -26
- package/src/assets/images/vectors/logo_stroke_2px.svg +0 -26
- package/src/assets/scss/components/_approval-sections.scss +0 -75
- package/src/assets/styles/fonts.scss +0 -77
- package/src/components/Loading.vue +0 -88
- package/src/components/common/AppStepper.vue +0 -139
- package/src/components/shared/BaseBreadcrumb.vue +0 -55
- package/src/components/shared/BaseIcon.vue +0 -27
- package/src/components/shared/ConfirmDialog.vue +0 -72
- package/src/components/shared/CustomAutocomplete.vue +0 -306
- package/src/components/shared/CustomDataTable.vue +0 -1859
- package/src/components/shared/DescriptionInput.vue +0 -204
- package/src/components/shared/DownloadButton.vue +0 -169
- package/src/components/shared/MoneyInput.vue +0 -105
- package/src/components/shared/PdfViewer.vue +0 -645
- package/src/components/shared/ShamsiDatePicker.vue +0 -444
- package/src/components/shared/UiChildCard.vue +0 -17
- package/src/components/shared/UiParentCard.vue +0 -21
- package/src/components/shared/VPriceTextField.vue +0 -136
- package/src/composables/useDataTable.ts +0 -152
- package/src/composables/usePermissions.ts +0 -90
- package/src/composables/useRouteGuard.ts +0 -36
- package/src/composables/useTableActions.ts +0 -207
- package/src/composables/useTableHeaders.ts +0 -172
- package/src/composables/useTableSelection.ts +0 -201
- package/src/constants/enums/approval.ts +0 -13
- package/src/constants/enums/booleanEnum.ts +0 -11
- package/src/constants/enums/contractType.ts +0 -11
- package/src/constants/enums/lcProductType.ts +0 -21
- package/src/constants/enums/repaymentType.ts +0 -11
- package/src/directives/v-digit-limit.ts +0 -15
- package/src/directives/v-permission.ts +0 -31
- package/src/features/index.ts +0 -48
- package/src/index.ts +0 -119
- package/src/plugins/key-clock.ts +0 -39
- package/src/plugins/mdi-icon.ts +0 -31
- package/src/plugins/vuetify.ts +0 -74
- package/src/scss/_override.scss +0 -72
- package/src/scss/_variables.scss +0 -124
- package/src/scss/components/_VButtons.scss +0 -23
- package/src/scss/components/_VCard.scss +0 -20
- package/src/scss/components/_VCustomDataTable.scss +0 -282
- package/src/scss/components/_VField.scss +0 -9
- package/src/scss/components/_VInput.scss +0 -17
- package/src/scss/components/_VNavigationDrawer.scss +0 -3
- package/src/scss/components/_VShadow.scss +0 -3
- package/src/scss/components/_VStepper.scss +0 -235
- package/src/scss/components/_VTabs.scss +0 -11
- package/src/scss/components/_VTextField.scss +0 -40
- package/src/scss/components/_approval.scss +0 -128
- package/src/scss/layout/_container.scss +0 -147
- package/src/scss/layout/_sidebar.scss +0 -138
- package/src/scss/layout/_topbar.scss +0 -39
- package/src/scss/pages/_dashboards.scss +0 -97
- package/src/scss/style.scss +0 -21
- package/src/services/apiService.ts +0 -59
- package/src/services/axiosInstance.ts +0 -14
- package/src/stores/customizer.ts +0 -55
- package/src/stores/permissions.ts +0 -237
- package/src/theme/darkThemes/DarkModernTheme.ts +0 -54
- package/src/theme/darkThemes/DarkOrangeTheme.ts +0 -53
- package/src/theme/darkThemes/DarkPurpleTheme.ts +0 -54
- package/src/theme/darkThemes/DarkRedTheme.ts +0 -54
- package/src/theme/darkThemes/DarkSilverTheme.ts +0 -53
- package/src/theme/darkThemes/DarkSteelTealGreen.ts +0 -53
- package/src/theme/darkThemes/DarkTealTheme.ts +0 -52
- package/src/theme/lightThemes/ModernTheme.ts +0 -55
- package/src/theme/lightThemes/OrangeTheme.ts +0 -54
- package/src/theme/lightThemes/PurpleTheme.ts +0 -54
- package/src/theme/lightThemes/RedTheme.ts +0 -55
- package/src/theme/lightThemes/SilverTheme.ts +0 -55
- package/src/theme/lightThemes/SteelTealGreen.ts +0 -54
- package/src/theme/lightThemes/TealTheme.ts +0 -54
- package/src/types/approval/approvalType.ts +0 -473
- package/src/types/cartable/cartableTypes.ts +0 -169
- package/src/types/componentTypes/DataTableType.ts +0 -14
- package/src/types/componentTypes/DataTableTypes.ts +0 -130
- package/src/types/enums/global.ts +0 -267
- package/src/types/jalaali-js.d.ts +0 -6
- package/src/types/models/Base.ts +0 -4
- package/src/types/models/env.d.ts +0 -10
- package/src/types/models/person.ts +0 -13
- package/src/types/models/userInfo.ts +0 -29
- package/src/types/preApproval/preApprovalTypes.ts +0 -67
- package/src/types/shims-tabler-icons.d.ts +0 -58
- package/src/types/themeTypes/ThemeType.ts +0 -47
- package/src/types/vue-apexcharts.d.ts +0 -1
- package/src/types/vue3-print-nb.d.ts +0 -1
- package/src/types/vue_tabler_icon.d.ts +0 -10
- package/src/utils/NationalCodeValidator.ts +0 -33
- package/src/utils/date-convertor.ts +0 -40
- package/src/utils/greetingUtils.ts +0 -97
- package/src/utils/helpers/fake-backend.ts +0 -68
- package/src/utils/helpers/fetch-wrapper.ts +0 -86
- package/src/utils/number-formatter.ts +0 -33
- package/src/validators/nationalCodeRule.ts +0 -6
|
@@ -1,1859 +0,0 @@
|
|
|
1
|
-
<script setup lang="ts">
|
|
2
|
-
/**
|
|
3
|
-
* CustomDataTable.vue
|
|
4
|
-
*
|
|
5
|
-
* A feature-rich data table component with server-side pagination, filtering,
|
|
6
|
-
* grouping, selection, CRUD actions, custom actions, downloads, and dialogs.
|
|
7
|
-
*
|
|
8
|
-
* Key features:
|
|
9
|
-
* - Server-side pagination and infinite scroll
|
|
10
|
-
* - Optional grouping with expand/collapse
|
|
11
|
-
* - Row selection (single/multi) with external v-model
|
|
12
|
-
* - Dynamic actions column with CRUD/routes/downloads/custom buttons
|
|
13
|
-
* - Date conversion between Shamsi and Gregorian with optional timezone
|
|
14
|
-
*
|
|
15
|
-
* Accessibility:
|
|
16
|
-
* - Adds ARIA attributes to group headers and busy regions
|
|
17
|
-
* - Keyboard support for toggling groups and activating selection on rows
|
|
18
|
-
*/
|
|
19
|
-
import MoneyInput from '@/components/shared/MoneyInput.vue';
|
|
20
|
-
import ShamsiDatePicker from '@/components/shared/ShamsiDatePicker.vue';
|
|
21
|
-
import { useTableSelection } from '@/composables/useTableSelection';
|
|
22
|
-
import apiService from '@/services/apiService';
|
|
23
|
-
import axiosInstance from '@/services/axiosInstance';
|
|
24
|
-
import { DateConverter } from '@/utils/date-convertor';
|
|
25
|
-
import { formatNumberWithCommas } from '@/utils/number-formatter';
|
|
26
|
-
import { IconChevronDown, IconChevronRight } from '@tabler/icons-vue';
|
|
27
|
-
import { useDebounceFn } from '@vueuse/core';
|
|
28
|
-
import type { Component, Ref } from 'vue';
|
|
29
|
-
import { computed, isRef, onBeforeUnmount, onMounted, ref, shallowRef, unref, watch } from 'vue';
|
|
30
|
-
import { useRouter } from 'vue-router';
|
|
31
|
-
|
|
32
|
-
import type {
|
|
33
|
-
ApiResponse,
|
|
34
|
-
CustomAction,
|
|
35
|
-
DataTableProps,
|
|
36
|
-
Header,
|
|
37
|
-
TableItem
|
|
38
|
-
} from '@/types/componentTypes/DataTableTypes';
|
|
39
|
-
|
|
40
|
-
type AutocompleteItemsSource =
|
|
41
|
-
| any[]
|
|
42
|
-
| Ref<any[] | undefined>
|
|
43
|
-
| ((context?: Record<string, any>) => any[] | undefined);
|
|
44
|
-
|
|
45
|
-
type EnhancedHeader = Header & {
|
|
46
|
-
autocompleteItems?: AutocompleteItemsSource;
|
|
47
|
-
autocompleteItemTitle?: string;
|
|
48
|
-
autocompleteItemValue?: string;
|
|
49
|
-
autocompleteReturnObject?: boolean;
|
|
50
|
-
autocompleteMultiple?: boolean;
|
|
51
|
-
};
|
|
52
|
-
|
|
53
|
-
/**
|
|
54
|
-
* Component props - using proper types from DataTableTypes
|
|
55
|
-
*/
|
|
56
|
-
interface Props extends Omit<DataTableProps, 'routes'> {
|
|
57
|
-
routes?: Record<string, string> | ((item: TableItem) => Record<string, string>);
|
|
58
|
-
enableGroupDelete?: boolean; // Enable group delete functionality for bulk mode
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
const props = withDefaults(defineProps<Props>(), {
|
|
62
|
-
autoFetch: true,
|
|
63
|
-
showPagination: true,
|
|
64
|
-
showRefreshButton: false,
|
|
65
|
-
selectable: false,
|
|
66
|
-
multiSelect: false,
|
|
67
|
-
selectedItems: () => [],
|
|
68
|
-
uniqueKey: 'id',
|
|
69
|
-
pageSize: 10,
|
|
70
|
-
defaultExpanded: false,
|
|
71
|
-
dateWithTimezone: false,
|
|
72
|
-
bulkMode: false
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
const emit = defineEmits<{
|
|
76
|
-
(e: 'update:selectedItems', items: TableItem[]): void;
|
|
77
|
-
(e: 'selection-change', items: TableItem[]): void;
|
|
78
|
-
}>();
|
|
79
|
-
|
|
80
|
-
defineOptions({ inheritAttrs: false });
|
|
81
|
-
|
|
82
|
-
const items = ref<TableItem[]>([]);
|
|
83
|
-
const originalServerData = ref<TableItem[]>([]); // Store original server data
|
|
84
|
-
const loading = ref(false);
|
|
85
|
-
const error = ref<string | null>(null);
|
|
86
|
-
const dialog = ref(false);
|
|
87
|
-
const deleteDialog = ref(false);
|
|
88
|
-
const isEditing = ref(false);
|
|
89
|
-
const editedItem = ref<TableItem | null>(null);
|
|
90
|
-
const formModel = ref<Record<string, any>>({});
|
|
91
|
-
const itemToDelete = ref<TableItem | null>(null);
|
|
92
|
-
const snackbar = ref(false);
|
|
93
|
-
const snackbarMessage = ref('');
|
|
94
|
-
const router = useRouter();
|
|
95
|
-
const itemsPerPage = ref(props.pageSize);
|
|
96
|
-
const totalSize = ref(0);
|
|
97
|
-
const totalPages = ref(0);
|
|
98
|
-
const currentPage = ref(1);
|
|
99
|
-
const sortBy = ref<{ key: string; order: 'asc' | 'desc' } | null>(null);
|
|
100
|
-
const filterDialog = ref(false);
|
|
101
|
-
const filterModel = ref<Record<string, any>>({});
|
|
102
|
-
const tableRef = ref<HTMLElement | null>(null);
|
|
103
|
-
const isLoadingMore = ref(false);
|
|
104
|
-
const hasMore = ref(true);
|
|
105
|
-
|
|
106
|
-
// Selection & grouping using composable (minimal-risk wiring)
|
|
107
|
-
const selection = useTableSelection(items, {
|
|
108
|
-
multiSelect: props.bulkMode ? false : props.multiSelect, // Force single select in bulk mode
|
|
109
|
-
uniqueKey: props.uniqueKey as any,
|
|
110
|
-
groupBy: props.groupBy as any,
|
|
111
|
-
defaultExpanded: props.defaultExpanded
|
|
112
|
-
});
|
|
113
|
-
|
|
114
|
-
// Override toggleSelection in bulk mode to ensure single selection
|
|
115
|
-
const originalToggleSelection = selection.toggleSelection;
|
|
116
|
-
selection.toggleSelection = (item: any) => {
|
|
117
|
-
if (props.bulkMode) {
|
|
118
|
-
// In bulk mode, always clear and select only this item
|
|
119
|
-
selection.clearSelection();
|
|
120
|
-
selection.selectedItems.value = [item];
|
|
121
|
-
} else {
|
|
122
|
-
// Normal behavior for non-bulk mode
|
|
123
|
-
originalToggleSelection(item);
|
|
124
|
-
}
|
|
125
|
-
};
|
|
126
|
-
const selectedItems = selection.selectedItems;
|
|
127
|
-
const selectAll = computed({
|
|
128
|
-
get: () => selection.allSelected.value,
|
|
129
|
-
set: (val: boolean) => {
|
|
130
|
-
if (val) {
|
|
131
|
-
selection.toggleSelectAll();
|
|
132
|
-
} else {
|
|
133
|
-
selection.clearSelection();
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
});
|
|
137
|
-
const groupedItems = selection.groupedItems as unknown as Ref<
|
|
138
|
-
Array<{ groupKey: string | number; groupLabel: string; items: any[]; isExpanded: boolean; count: number }>
|
|
139
|
-
>;
|
|
140
|
-
const expandedGroups = selection.expandedGroups;
|
|
141
|
-
|
|
142
|
-
// Computed flag to determine if any actions should be shown
|
|
143
|
-
const hasAnyActions = computed(() => {
|
|
144
|
-
// If bulkMode is true, don't show actions column in table
|
|
145
|
-
if (props.bulkMode) {
|
|
146
|
-
return false;
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
const hasCrudActions = Array.isArray(props.actions) && props.actions.length > 0;
|
|
150
|
-
const hasRoutes = !!props.routes && (typeof props.routes === 'function' || Object.keys(props.routes).length > 0);
|
|
151
|
-
const hasDownloadLinks = !!props.downloadLink && Object.keys(props.downloadLink).length > 0;
|
|
152
|
-
const hasCustomActions = Array.isArray(props.customActions) && props.customActions.length > 0;
|
|
153
|
-
const hasCustomButtons = (Array.isArray(props.customButtons) && props.customButtons.length > 0) || !!props.customButtonsFn;
|
|
154
|
-
return hasCrudActions || hasRoutes || hasDownloadLinks || hasCustomActions || hasCustomButtons;
|
|
155
|
-
});
|
|
156
|
-
|
|
157
|
-
// Function to get routes for a specific item
|
|
158
|
-
const getRoutesForItem = (item: any): Record<string, string> => {
|
|
159
|
-
if (!props.routes) return {};
|
|
160
|
-
if (typeof props.routes === 'function') {
|
|
161
|
-
return props.routes(item);
|
|
162
|
-
}
|
|
163
|
-
return props.routes;
|
|
164
|
-
};
|
|
165
|
-
|
|
166
|
-
// Estimate auto width based on header title and type when width is not provided
|
|
167
|
-
const estimateColumnWidth = (header: Header): number => {
|
|
168
|
-
const title = header.title || '';
|
|
169
|
-
const basePadding = 32; // left/right padding
|
|
170
|
-
const avgCharWidth = 10; // heuristic average per character
|
|
171
|
-
const typeExtra = header.type && String(header.type).toLowerCase() === 'money' ? 40 : 0;
|
|
172
|
-
const computed = basePadding + title.length * avgCharWidth + typeExtra;
|
|
173
|
-
const min = 100;
|
|
174
|
-
const max = 300;
|
|
175
|
-
return Math.min(Math.max(computed, min), max);
|
|
176
|
-
};
|
|
177
|
-
|
|
178
|
-
// Headers with auto width applied when not specified
|
|
179
|
-
const autoHeaders = computed(() => {
|
|
180
|
-
return props.headers.map((h) => ({
|
|
181
|
-
...h,
|
|
182
|
-
width: h.width ?? estimateColumnWidth(h)
|
|
183
|
-
}));
|
|
184
|
-
});
|
|
185
|
-
|
|
186
|
-
const selectionHeader = { title: '', key: 'selection', sortable: false, width: 50 } as const;
|
|
187
|
-
|
|
188
|
-
const groupedHeaders = computed(() => {
|
|
189
|
-
const base = [...(props.selectable ? [selectionHeader] : []), ...autoHeaders.value];
|
|
190
|
-
|
|
191
|
-
if (!hasAnyActions.value) {
|
|
192
|
-
return base;
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
// Calculate dynamic width based on actual button sizes (same logic as normalHeaders)
|
|
196
|
-
let totalWidth = 0;
|
|
197
|
-
|
|
198
|
-
// CRUD actions (edit, delete, view, create)
|
|
199
|
-
if (props.actions) {
|
|
200
|
-
props.actions.forEach((action) => {
|
|
201
|
-
switch (action) {
|
|
202
|
-
case 'edit':
|
|
203
|
-
totalWidth += 140; // "ویرایش ✏️" button width
|
|
204
|
-
break;
|
|
205
|
-
case 'delete':
|
|
206
|
-
totalWidth += 120; // "حذف ❌" button width
|
|
207
|
-
break;
|
|
208
|
-
case 'view':
|
|
209
|
-
totalWidth += 140; // "🔍 نمایش" button width
|
|
210
|
-
break;
|
|
211
|
-
case 'create':
|
|
212
|
-
totalWidth += 120; // Create button width
|
|
213
|
-
break;
|
|
214
|
-
}
|
|
215
|
-
});
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
// Route actions
|
|
219
|
-
if (props.routes) {
|
|
220
|
-
Object.keys(props.routes).forEach((routeKey) => {
|
|
221
|
-
totalWidth += 120; // Route button width (key.toUpperCase())
|
|
222
|
-
});
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
// Download actions
|
|
226
|
-
if (props.downloadLink) {
|
|
227
|
-
Object.keys(props.downloadLink).forEach((key) => {
|
|
228
|
-
totalWidth += 120; // Download button width
|
|
229
|
-
});
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
// Custom actions
|
|
233
|
-
if (props.customActions) {
|
|
234
|
-
props.customActions.forEach((action) => {
|
|
235
|
-
totalWidth += 140; // Custom action button width
|
|
236
|
-
});
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
// Custom buttons
|
|
240
|
-
if (props.customButtonsFn) {
|
|
241
|
-
// For dynamic buttons, estimate based on typical button count
|
|
242
|
-
totalWidth += 240; // 2 buttons * 120px each
|
|
243
|
-
} else if (props.customButtons) {
|
|
244
|
-
props.customButtons.forEach((button) => {
|
|
245
|
-
totalWidth += 120; // Custom button width
|
|
246
|
-
});
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
// Add spacing between buttons (8px margin per button)
|
|
250
|
-
const buttonCount =
|
|
251
|
-
(props.actions?.length || 0) +
|
|
252
|
-
(props.routes ? Object.keys(props.routes).length : 0) +
|
|
253
|
-
(props.downloadLink ? Object.keys(props.downloadLink).length : 0) +
|
|
254
|
-
(props.customActions?.length || 0) +
|
|
255
|
-
(props.customButtons?.length || (props.customButtonsFn ? 2 : 0));
|
|
256
|
-
|
|
257
|
-
const spacingWidth = Math.max(buttonCount - 1, 0) * 8; // 8px margin between buttons
|
|
258
|
-
totalWidth += spacingWidth;
|
|
259
|
-
|
|
260
|
-
// Add padding for the cell
|
|
261
|
-
totalWidth += 32; // 16px padding on each side
|
|
262
|
-
|
|
263
|
-
// Ensure minimum width
|
|
264
|
-
const actionWidth = Math.max(totalWidth, 200);
|
|
265
|
-
|
|
266
|
-
return [...base, { title: 'عملیات', key: 'actions', sortable: false, width: actionWidth }];
|
|
267
|
-
});
|
|
268
|
-
|
|
269
|
-
const normalHeaders = computed(() => {
|
|
270
|
-
const base = [...(props.selectable ? [selectionHeader] : []), ...autoHeaders.value];
|
|
271
|
-
|
|
272
|
-
if (!hasAnyActions.value) {
|
|
273
|
-
return base;
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
// Calculate dynamic width based on actual button sizes
|
|
277
|
-
let totalWidth = 0;
|
|
278
|
-
|
|
279
|
-
// CRUD actions (edit, delete, view, create)
|
|
280
|
-
if (props.actions) {
|
|
281
|
-
props.actions.forEach((action) => {
|
|
282
|
-
switch (action) {
|
|
283
|
-
case 'edit':
|
|
284
|
-
totalWidth += 140; // "ویرایش ✏️" button width
|
|
285
|
-
break;
|
|
286
|
-
case 'delete':
|
|
287
|
-
totalWidth += 120; // "حذف ❌" button width
|
|
288
|
-
break;
|
|
289
|
-
case 'view':
|
|
290
|
-
totalWidth += 140; // "🔍 نمایش" button width
|
|
291
|
-
break;
|
|
292
|
-
case 'create':
|
|
293
|
-
totalWidth += 120; // Create button width
|
|
294
|
-
break;
|
|
295
|
-
}
|
|
296
|
-
});
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
// Route actions
|
|
300
|
-
if (props.routes) {
|
|
301
|
-
Object.keys(props.routes).forEach((routeKey) => {
|
|
302
|
-
totalWidth += 120; // Route button width (key.toUpperCase())
|
|
303
|
-
});
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
// Download actions
|
|
307
|
-
if (props.downloadLink) {
|
|
308
|
-
Object.keys(props.downloadLink).forEach((key) => {
|
|
309
|
-
totalWidth += 120; // Download button width
|
|
310
|
-
});
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
// Custom actions
|
|
314
|
-
if (props.customActions) {
|
|
315
|
-
props.customActions.forEach((action) => {
|
|
316
|
-
totalWidth += 140; // Custom action button width
|
|
317
|
-
});
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
// Custom buttons
|
|
321
|
-
if (props.customButtonsFn) {
|
|
322
|
-
// For dynamic buttons, estimate based on typical button count
|
|
323
|
-
totalWidth += 240; // 2 buttons * 120px each
|
|
324
|
-
} else if (props.customButtons) {
|
|
325
|
-
props.customButtons.forEach((button) => {
|
|
326
|
-
totalWidth += 120; // Custom button width
|
|
327
|
-
});
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
// Add spacing between buttons (8px margin per button)
|
|
331
|
-
const buttonCount =
|
|
332
|
-
(props.actions?.length || 0) +
|
|
333
|
-
(props.routes ? Object.keys(props.routes).length : 0) +
|
|
334
|
-
(props.downloadLink ? Object.keys(props.downloadLink).length : 0) +
|
|
335
|
-
(props.customActions?.length || 0) +
|
|
336
|
-
(props.customButtons?.length || (props.customButtonsFn ? 2 : 0));
|
|
337
|
-
|
|
338
|
-
const spacingWidth = Math.max(buttonCount - 1, 0) * 8; // 8px margin between buttons
|
|
339
|
-
totalWidth += spacingWidth;
|
|
340
|
-
|
|
341
|
-
// Add padding for the cell
|
|
342
|
-
totalWidth += 32; // 16px padding on each side
|
|
343
|
-
|
|
344
|
-
// Ensure minimum width
|
|
345
|
-
const actionWidth = Math.max(totalWidth, 200);
|
|
346
|
-
|
|
347
|
-
return [...base, { title: 'عملیات', key: 'actions', sortable: false, width: actionWidth }];
|
|
348
|
-
});
|
|
349
|
-
|
|
350
|
-
const hasAutocomplete = (header: Header): header is EnhancedHeader => {
|
|
351
|
-
return Boolean((header as EnhancedHeader).autocompleteItems);
|
|
352
|
-
};
|
|
353
|
-
|
|
354
|
-
const resolveAutocompleteItems = (header: Header, context?: Record<string, any>): any[] => {
|
|
355
|
-
const enhancedHeader = header as EnhancedHeader;
|
|
356
|
-
const source = enhancedHeader.autocompleteItems;
|
|
357
|
-
|
|
358
|
-
if (!source) return [];
|
|
359
|
-
|
|
360
|
-
try {
|
|
361
|
-
if (typeof source === 'function') {
|
|
362
|
-
const baseContext = context ?? formModel.value;
|
|
363
|
-
return source(baseContext) ?? [];
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
if (isRef(source)) {
|
|
367
|
-
return source.value ?? [];
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
const resolved = unref(source);
|
|
371
|
-
return Array.isArray(resolved) ? resolved : [];
|
|
372
|
-
} catch (error) {
|
|
373
|
-
console.error('Error resolving autocomplete items for header:', header.key, error);
|
|
374
|
-
return [];
|
|
375
|
-
}
|
|
376
|
-
};
|
|
377
|
-
|
|
378
|
-
const resolveHeaderDefaultValue = (header: Header, context: Record<string, any>): any => {
|
|
379
|
-
const rawDefault = header.defaultValue;
|
|
380
|
-
|
|
381
|
-
if (rawDefault === undefined) {
|
|
382
|
-
return undefined;
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
try {
|
|
386
|
-
if (typeof rawDefault === 'function') {
|
|
387
|
-
return rawDefault(context);
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
if (isRef(rawDefault)) {
|
|
391
|
-
return unref(rawDefault);
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
return rawDefault;
|
|
395
|
-
} catch (error) {
|
|
396
|
-
console.error('Error resolving default value for header:', header.key, error);
|
|
397
|
-
return undefined;
|
|
398
|
-
}
|
|
399
|
-
};
|
|
400
|
-
|
|
401
|
-
const resolveAutocompleteItemTitle = (header: Header): string => {
|
|
402
|
-
return (header as EnhancedHeader).autocompleteItemTitle ?? 'title';
|
|
403
|
-
};
|
|
404
|
-
|
|
405
|
-
const resolveAutocompleteItemValue = (header: Header): string => {
|
|
406
|
-
return (header as EnhancedHeader).autocompleteItemValue ?? 'value';
|
|
407
|
-
};
|
|
408
|
-
|
|
409
|
-
const resolveAutocompleteReturnObject = (header: Header): boolean => {
|
|
410
|
-
const value = (header as EnhancedHeader).autocompleteReturnObject;
|
|
411
|
-
return value !== false;
|
|
412
|
-
};
|
|
413
|
-
|
|
414
|
-
const resolveAutocompleteMultiple = (header: Header): boolean => {
|
|
415
|
-
return (header as EnhancedHeader).autocompleteMultiple === true;
|
|
416
|
-
};
|
|
417
|
-
|
|
418
|
-
const formHeaders = computed((): Header[] => props.headers as Header[]);
|
|
419
|
-
|
|
420
|
-
const isMoneyHeader = (header: Header): boolean => {
|
|
421
|
-
if (!header || typeof header.type !== 'string') {
|
|
422
|
-
return false;
|
|
423
|
-
}
|
|
424
|
-
return header.type.toLowerCase() === 'money';
|
|
425
|
-
};
|
|
426
|
-
|
|
427
|
-
const getFieldInputType = (header: Header): string | undefined => {
|
|
428
|
-
return typeof header?.type === 'string' ? header.type : undefined;
|
|
429
|
-
};
|
|
430
|
-
|
|
431
|
-
const resolveHeaderKey = (header: Header): string => header.key;
|
|
432
|
-
const resolveHeaderTitle = (header: Header): string => header.title;
|
|
433
|
-
const isHeaderDisabled = (header: Header): boolean => header.editable === false;
|
|
434
|
-
|
|
435
|
-
// Helper function to get unique value from item
|
|
436
|
-
const getUniqueValue = (item: any): string | number => {
|
|
437
|
-
if (typeof props.uniqueKey === 'function') {
|
|
438
|
-
return props.uniqueKey(item);
|
|
439
|
-
}
|
|
440
|
-
|
|
441
|
-
if (typeof props.uniqueKey === 'string') {
|
|
442
|
-
// Handle nested properties like "user.id"
|
|
443
|
-
return props.uniqueKey.split('.').reduce((obj, key) => obj?.[key], item);
|
|
444
|
-
}
|
|
445
|
-
|
|
446
|
-
// Fallback to id
|
|
447
|
-
return item.id;
|
|
448
|
-
};
|
|
449
|
-
|
|
450
|
-
// Helper function to get group value from item
|
|
451
|
-
const getGroupValue = (item: any): string | number => {
|
|
452
|
-
if (!props.groupBy) return '';
|
|
453
|
-
|
|
454
|
-
if (typeof props.groupBy === 'function') {
|
|
455
|
-
return props.groupBy(item);
|
|
456
|
-
}
|
|
457
|
-
|
|
458
|
-
if (typeof props.groupBy === 'string') {
|
|
459
|
-
// Handle nested properties like "user.department"
|
|
460
|
-
return props.groupBy.split('.').reduce((obj, key) => obj?.[key], item);
|
|
461
|
-
}
|
|
462
|
-
|
|
463
|
-
return item[props.groupBy] || '';
|
|
464
|
-
};
|
|
465
|
-
|
|
466
|
-
// Note: Grouping logic is handled by useTableSelection composable
|
|
467
|
-
|
|
468
|
-
// Selection methods
|
|
469
|
-
const toggleSelection = (item: any) => {
|
|
470
|
-
if (!props.selectable) return;
|
|
471
|
-
selection.toggleSelection(item);
|
|
472
|
-
emit('update:selectedItems', selectedItems.value);
|
|
473
|
-
emit('selection-change', selectedItems.value);
|
|
474
|
-
};
|
|
475
|
-
|
|
476
|
-
const toggleSelectAll = () => {
|
|
477
|
-
if (!props.selectable || !props.multiSelect) return;
|
|
478
|
-
selection.toggleSelectAll();
|
|
479
|
-
emit('update:selectedItems', selectedItems.value);
|
|
480
|
-
emit('selection-change', selectedItems.value);
|
|
481
|
-
};
|
|
482
|
-
|
|
483
|
-
// Group toggle function with additional safety
|
|
484
|
-
const toggleGroup = (groupKey: string | number) => {
|
|
485
|
-
// Small delay to ensure any focus/initialization issues are resolved
|
|
486
|
-
setTimeout(() => {
|
|
487
|
-
selection.toggleGroup(groupKey);
|
|
488
|
-
}, 0);
|
|
489
|
-
};
|
|
490
|
-
|
|
491
|
-
// Expand all groups
|
|
492
|
-
const expandAllGroups = () => selection.expandAllGroups();
|
|
493
|
-
|
|
494
|
-
// Collapse all groups
|
|
495
|
-
const collapseAllGroups = () => selection.collapseAllGroups();
|
|
496
|
-
|
|
497
|
-
const isSelected = (item: any) => selection.isSelected(item);
|
|
498
|
-
|
|
499
|
-
// Computed properties for selection
|
|
500
|
-
const selectedCount = computed(() => selectedItems.value.length);
|
|
501
|
-
const hasSelection = computed(() => selectedItems.value.length > 0);
|
|
502
|
-
|
|
503
|
-
// Check if selected items are still present in current filtered data
|
|
504
|
-
const validSelectedItems = computed(() => {
|
|
505
|
-
if (!props.selectable || !props.bulkMode) return selectedItems.value;
|
|
506
|
-
|
|
507
|
-
// Filter selected items to only include those that exist in current items
|
|
508
|
-
return selectedItems.value.filter((selectedItem) => {
|
|
509
|
-
const uniqueValue = getUniqueValue(selectedItem);
|
|
510
|
-
return items.value.some((item) => getUniqueValue(item) === uniqueValue);
|
|
511
|
-
});
|
|
512
|
-
});
|
|
513
|
-
|
|
514
|
-
// Check if we have valid selections for bulk mode
|
|
515
|
-
const hasValidSelection = computed(() => {
|
|
516
|
-
if (!props.selectable || !props.bulkMode) return hasSelection.value;
|
|
517
|
-
return validSelectedItems.value.length > 0;
|
|
518
|
-
});
|
|
519
|
-
|
|
520
|
-
// Clear selection method
|
|
521
|
-
const clearSelection = () => {
|
|
522
|
-
selection.clearSelection();
|
|
523
|
-
emit('update:selectedItems', selectedItems.value);
|
|
524
|
-
emit('selection-change', selectedItems.value);
|
|
525
|
-
};
|
|
526
|
-
|
|
527
|
-
// Method for single item selection in bulk mode
|
|
528
|
-
const selectSingleItem = (item: any) => {
|
|
529
|
-
// Clear all selections first
|
|
530
|
-
selection.clearSelection();
|
|
531
|
-
// Force select only this item (don't use toggle)
|
|
532
|
-
selection.selectedItems.value = [item];
|
|
533
|
-
// Emit the change
|
|
534
|
-
emit('update:selectedItems', selectedItems.value);
|
|
535
|
-
emit('selection-change', selectedItems.value);
|
|
536
|
-
};
|
|
537
|
-
|
|
538
|
-
// Computed for radio group value
|
|
539
|
-
const radioGroupValue = computed(() => {
|
|
540
|
-
return selectedItems.value.length > 0 ? getUniqueValue(selectedItems.value[0]) : null;
|
|
541
|
-
});
|
|
542
|
-
|
|
543
|
-
// Memoize computed properties
|
|
544
|
-
const cleanFilterModel = computed(() => {
|
|
545
|
-
const model = { ...filterModel.value };
|
|
546
|
-
Object.keys(model).forEach((key) => {
|
|
547
|
-
if (model[key] === null || model[key] === undefined || model[key] === '') {
|
|
548
|
-
delete model[key];
|
|
549
|
-
}
|
|
550
|
-
});
|
|
551
|
-
return model;
|
|
552
|
-
});
|
|
553
|
-
|
|
554
|
-
const hasFilterComponent = computed(() => {
|
|
555
|
-
return !!props.filterComponent;
|
|
556
|
-
});
|
|
557
|
-
|
|
558
|
-
/**
|
|
559
|
-
* Fetches data from server using current filters, query params, and pagination.
|
|
560
|
-
* Converts date fields to Shamsi for display and computes grouping/selection state.
|
|
561
|
-
* @param queryParams Optional extra query params to merge with filter and pagination
|
|
562
|
-
*/
|
|
563
|
-
const fetchData = async (queryParams?: Record<string, unknown>) => {
|
|
564
|
-
loading.value = true;
|
|
565
|
-
error.value = null;
|
|
566
|
-
|
|
567
|
-
let params: Record<string, unknown> = {
|
|
568
|
-
...cleanFilterModel.value,
|
|
569
|
-
...props.queryParams
|
|
570
|
-
};
|
|
571
|
-
|
|
572
|
-
if (queryParams) {
|
|
573
|
-
params = { ...params, ...queryParams };
|
|
574
|
-
}
|
|
575
|
-
|
|
576
|
-
try {
|
|
577
|
-
const shouldPaginate = props.showPagination !== false;
|
|
578
|
-
const requestParams = shouldPaginate
|
|
579
|
-
? {
|
|
580
|
-
...params,
|
|
581
|
-
page: currentPage.value - 1,
|
|
582
|
-
size: itemsPerPage.value
|
|
583
|
-
}
|
|
584
|
-
: params;
|
|
585
|
-
|
|
586
|
-
const response = await api.fetch(requestParams) as ApiResponse<TableItem>;
|
|
587
|
-
const serverRawData = shouldPaginate ? response.data?.content ?? [] : response.data ?? [];
|
|
588
|
-
const serverData = Array.isArray(serverRawData) ? serverRawData : [];
|
|
589
|
-
|
|
590
|
-
originalServerData.value = serverData;
|
|
591
|
-
|
|
592
|
-
items.value = serverData.map((item: Record<string, any>) => {
|
|
593
|
-
const newItem = { ...item };
|
|
594
|
-
props.headers.forEach((header) => {
|
|
595
|
-
if (header.isDate && newItem[header.key]) {
|
|
596
|
-
try {
|
|
597
|
-
newItem[header.key] = DateConverter.toShamsi(newItem[header.key]);
|
|
598
|
-
} catch (error) {
|
|
599
|
-
console.error(`Error converting date for field ${header.key}:`, error);
|
|
600
|
-
}
|
|
601
|
-
}
|
|
602
|
-
});
|
|
603
|
-
return newItem;
|
|
604
|
-
});
|
|
605
|
-
|
|
606
|
-
if (shouldPaginate && response.data?.page) {
|
|
607
|
-
totalSize.value = response.data.page.totalElements;
|
|
608
|
-
totalPages.value = response.data.page.totalPages;
|
|
609
|
-
hasMore.value = currentPage.value < response.data.page.totalPages;
|
|
610
|
-
} else {
|
|
611
|
-
totalSize.value = serverData.length;
|
|
612
|
-
totalPages.value = 1;
|
|
613
|
-
hasMore.value = false;
|
|
614
|
-
}
|
|
615
|
-
|
|
616
|
-
if (props.defaultSelected && items.value.length > 0 && props.defaultSelected in items.value[0]) {
|
|
617
|
-
const defaultSelectedItems = items.value.filter((item) => item[props.defaultSelected!] === true);
|
|
618
|
-
selectedItems.value = [...defaultSelectedItems];
|
|
619
|
-
emit('update:selectedItems', selectedItems.value);
|
|
620
|
-
emit('selection-change', selectedItems.value);
|
|
621
|
-
}
|
|
622
|
-
} catch (err: any) {
|
|
623
|
-
if (err.response) {
|
|
624
|
-
error.value = `خطای سرور: ${err.response.status} - ${err.response.data.message || 'خطای ناشناخته'}`;
|
|
625
|
-
} else if (err.request) {
|
|
626
|
-
error.value = 'خطای شبکه. لطفا دوباره تلاش کنید.';
|
|
627
|
-
} else {
|
|
628
|
-
error.value = 'یک خطای غیرمنتظره رخ داد.';
|
|
629
|
-
}
|
|
630
|
-
console.error(err);
|
|
631
|
-
} finally {
|
|
632
|
-
loading.value = false;
|
|
633
|
-
}
|
|
634
|
-
};
|
|
635
|
-
|
|
636
|
-
// Add debounced version after fetchData definition
|
|
637
|
-
const debouncedFetchData = useDebounceFn(fetchData, 300);
|
|
638
|
-
|
|
639
|
-
// Update watcher to use debounced function
|
|
640
|
-
watch(
|
|
641
|
-
[cleanFilterModel],
|
|
642
|
-
() => {
|
|
643
|
-
debouncedFetchData();
|
|
644
|
-
},
|
|
645
|
-
{ deep: true }
|
|
646
|
-
);
|
|
647
|
-
|
|
648
|
-
// Watch for pageSize prop changes
|
|
649
|
-
watch(
|
|
650
|
-
() => props.pageSize,
|
|
651
|
-
(newPageSize) => {
|
|
652
|
-
itemsPerPage.value = newPageSize;
|
|
653
|
-
// Reset to first page when page size changes
|
|
654
|
-
currentPage.value = 1;
|
|
655
|
-
// Refetch data with new page size
|
|
656
|
-
debouncedFetchData();
|
|
657
|
-
}
|
|
658
|
-
);
|
|
659
|
-
|
|
660
|
-
// Watch for items changes and clear invalid selections
|
|
661
|
-
watch(
|
|
662
|
-
() => items.value,
|
|
663
|
-
(newItems) => {
|
|
664
|
-
if (props.selectable && props.bulkMode && selectedItems.value.length > 0) {
|
|
665
|
-
// Check if any selected items are no longer in the current data
|
|
666
|
-
const invalidSelections = selectedItems.value.filter((selectedItem) => {
|
|
667
|
-
const uniqueValue = getUniqueValue(selectedItem);
|
|
668
|
-
return !newItems.some((item) => getUniqueValue(item) === uniqueValue);
|
|
669
|
-
});
|
|
670
|
-
|
|
671
|
-
// If there are invalid selections, remove them
|
|
672
|
-
if (invalidSelections.length > 0) {
|
|
673
|
-
const validSelections = selectedItems.value.filter((selectedItem) => {
|
|
674
|
-
const uniqueValue = getUniqueValue(selectedItem);
|
|
675
|
-
return newItems.some((item) => getUniqueValue(item) === uniqueValue);
|
|
676
|
-
});
|
|
677
|
-
|
|
678
|
-
selectedItems.value = validSelections;
|
|
679
|
-
emit('update:selectedItems', selectedItems.value);
|
|
680
|
-
emit('selection-change', selectedItems.value);
|
|
681
|
-
}
|
|
682
|
-
}
|
|
683
|
-
},
|
|
684
|
-
{ deep: true }
|
|
685
|
-
);
|
|
686
|
-
|
|
687
|
-
// Cleanup on component unmount
|
|
688
|
-
onBeforeUnmount(() => {
|
|
689
|
-
// Remove cancel call as it's not supported
|
|
690
|
-
});
|
|
691
|
-
|
|
692
|
-
const api = apiService(axiosInstance, props.apiResource);
|
|
693
|
-
const customActionDialog = ref(false);
|
|
694
|
-
const customActionComponent = shallowRef<Component | null>(null);
|
|
695
|
-
const customActionItem = ref<any>(null);
|
|
696
|
-
|
|
697
|
-
// Add scroll event handler
|
|
698
|
-
const handleScroll = async (event: Event) => {
|
|
699
|
-
const target = event.target as HTMLElement;
|
|
700
|
-
const { scrollTop, scrollHeight, clientHeight } = target;
|
|
701
|
-
|
|
702
|
-
// Check if we're near the bottom (within 100px)
|
|
703
|
-
if (scrollHeight - scrollTop - clientHeight < 100 && !isLoadingMore.value && hasMore.value) {
|
|
704
|
-
await loadMore();
|
|
705
|
-
}
|
|
706
|
-
};
|
|
707
|
-
|
|
708
|
-
/**
|
|
709
|
-
* Loads next page and appends to current items. Preserves selection defaults.
|
|
710
|
-
*/
|
|
711
|
-
const loadMore = async () => {
|
|
712
|
-
if (isLoadingMore.value || !hasMore.value) return;
|
|
713
|
-
|
|
714
|
-
isLoadingMore.value = true;
|
|
715
|
-
currentPage.value++;
|
|
716
|
-
|
|
717
|
-
try {
|
|
718
|
-
const params = {
|
|
719
|
-
...cleanFilterModel.value,
|
|
720
|
-
...props.queryParams,
|
|
721
|
-
page: currentPage.value - 1,
|
|
722
|
-
size: itemsPerPage.value
|
|
723
|
-
};
|
|
724
|
-
|
|
725
|
-
const response = await api.fetch(params);
|
|
726
|
-
const newItems = response.data.content || [];
|
|
727
|
-
|
|
728
|
-
// Convert dates to Shamsi format
|
|
729
|
-
const formattedItems = newItems.map((item: Record<string, any>) => {
|
|
730
|
-
const newItem = { ...item };
|
|
731
|
-
props.headers.forEach((header) => {
|
|
732
|
-
if (header.isDate && newItem[header.key]) {
|
|
733
|
-
try {
|
|
734
|
-
newItem[header.key] = DateConverter.toShamsi(newItem[header.key]);
|
|
735
|
-
} catch (error) {
|
|
736
|
-
console.error(`Error converting date for field ${header.key}:`, error);
|
|
737
|
-
}
|
|
738
|
-
}
|
|
739
|
-
});
|
|
740
|
-
return newItem;
|
|
741
|
-
});
|
|
742
|
-
|
|
743
|
-
items.value = [...items.value, ...formattedItems];
|
|
744
|
-
hasMore.value = currentPage.value < response.data.totalPages;
|
|
745
|
-
|
|
746
|
-
// Auto-select new items if defaultSelected prop is provided
|
|
747
|
-
if (props.defaultSelected && props.selectable) {
|
|
748
|
-
const newSelectedItems = formattedItems.filter((item: any) => item[props.defaultSelected!] === true);
|
|
749
|
-
if (newSelectedItems.length > 0) {
|
|
750
|
-
selectedItems.value = [...selectedItems.value, ...newSelectedItems];
|
|
751
|
-
emit('update:selectedItems', selectedItems.value);
|
|
752
|
-
emit('selection-change', selectedItems.value);
|
|
753
|
-
}
|
|
754
|
-
}
|
|
755
|
-
} catch (err) {
|
|
756
|
-
console.error('Error loading more items:', err);
|
|
757
|
-
currentPage.value--; // Revert page number on error
|
|
758
|
-
} finally {
|
|
759
|
-
isLoadingMore.value = false;
|
|
760
|
-
}
|
|
761
|
-
};
|
|
762
|
-
|
|
763
|
-
// Expose methods to parent component
|
|
764
|
-
defineExpose({
|
|
765
|
-
fetchData,
|
|
766
|
-
items,
|
|
767
|
-
selectedItems,
|
|
768
|
-
getSelectedItems: () => selectedItems.value,
|
|
769
|
-
clearSelection: () => {
|
|
770
|
-
selectedItems.value = [];
|
|
771
|
-
selectAll.value = false;
|
|
772
|
-
},
|
|
773
|
-
// Grouping methods (handled by useTableSelection composable)
|
|
774
|
-
groupedItems,
|
|
775
|
-
toggleGroup,
|
|
776
|
-
expandAllGroups,
|
|
777
|
-
collapseAllGroups,
|
|
778
|
-
formModel
|
|
779
|
-
});
|
|
780
|
-
|
|
781
|
-
/**
|
|
782
|
-
* Opens the create/edit dialog and normalizes date fields for editing.
|
|
783
|
-
*/
|
|
784
|
-
const openDialog = (item?: any) => {
|
|
785
|
-
editedItem.value = item ? { ...item } : {};
|
|
786
|
-
isEditing.value = !!item;
|
|
787
|
-
dialog.value = true;
|
|
788
|
-
|
|
789
|
-
if (!isEditing.value) {
|
|
790
|
-
const defaultContext: Record<string, any> = { ...editedItem.value };
|
|
791
|
-
|
|
792
|
-
for (const header of props.headers) {
|
|
793
|
-
if (header.defaultValue === undefined) {
|
|
794
|
-
continue;
|
|
795
|
-
}
|
|
796
|
-
|
|
797
|
-
const resolvedDefault = resolveHeaderDefaultValue(header, defaultContext);
|
|
798
|
-
if (resolvedDefault !== undefined) {
|
|
799
|
-
editedItem.value![header.key] = resolvedDefault;
|
|
800
|
-
defaultContext[header.key] = resolvedDefault;
|
|
801
|
-
}
|
|
802
|
-
}
|
|
803
|
-
}
|
|
804
|
-
|
|
805
|
-
// Sync form model with editedItem
|
|
806
|
-
formModel.value = { ...editedItem.value };
|
|
807
|
-
|
|
808
|
-
// Normalize autocomplete fields based on header configuration
|
|
809
|
-
props.headers.forEach((header) => {
|
|
810
|
-
if (!hasAutocomplete(header)) return;
|
|
811
|
-
|
|
812
|
-
const enhancedHeader = header as EnhancedHeader;
|
|
813
|
-
const currentValue = formModel.value[header.key];
|
|
814
|
-
const valueKey = resolveAutocompleteItemValue(header);
|
|
815
|
-
|
|
816
|
-
if (enhancedHeader.autocompleteReturnObject === false) {
|
|
817
|
-
if (Array.isArray(currentValue)) {
|
|
818
|
-
formModel.value[header.key] = currentValue.map((item: any) =>
|
|
819
|
-
item && typeof item === 'object' ? item[valueKey] ?? null : item
|
|
820
|
-
);
|
|
821
|
-
} else if (currentValue && typeof currentValue === 'object') {
|
|
822
|
-
formModel.value[header.key] = currentValue[valueKey] ?? null;
|
|
823
|
-
}
|
|
824
|
-
}
|
|
825
|
-
});
|
|
826
|
-
|
|
827
|
-
// Ensure date fields in edit mode are in Gregorian YYYY-MM-DD for the date picker
|
|
828
|
-
if (isEditing.value) {
|
|
829
|
-
try {
|
|
830
|
-
props.headers.forEach((header) => {
|
|
831
|
-
if (header.isDate) {
|
|
832
|
-
const v = formModel.value[header.key];
|
|
833
|
-
if (typeof v === 'string' && v.includes('/')) {
|
|
834
|
-
// Convert Shamsi jYYYY/jMM/jDD -> YYYY-MM-DD for picker
|
|
835
|
-
formModel.value[header.key] = DateConverter.toGregorian(v);
|
|
836
|
-
} else if (typeof v === 'string' && v.includes('T')) {
|
|
837
|
-
// Normalize ISO to YYYY-MM-DD for picker
|
|
838
|
-
formModel.value[header.key] = v.split('T')[0];
|
|
839
|
-
}
|
|
840
|
-
}
|
|
841
|
-
});
|
|
842
|
-
} catch (e) {
|
|
843
|
-
console.error('Error normalizing date fields for edit form:', e);
|
|
844
|
-
}
|
|
845
|
-
}
|
|
846
|
-
};
|
|
847
|
-
|
|
848
|
-
const openDeleteDialog = (item: any) => {
|
|
849
|
-
itemToDelete.value = item;
|
|
850
|
-
deleteDialog.value = true;
|
|
851
|
-
};
|
|
852
|
-
|
|
853
|
-
const groupDeleteDialog = ref(false);
|
|
854
|
-
|
|
855
|
-
const openGroupDeleteDialog = () => {
|
|
856
|
-
groupDeleteDialog.value = true;
|
|
857
|
-
};
|
|
858
|
-
|
|
859
|
-
const deleteGroupItems = async () => {
|
|
860
|
-
try {
|
|
861
|
-
const selectedIds = validSelectedItems.value.map((item) => getUniqueValue(item));
|
|
862
|
-
|
|
863
|
-
// Use bulk delete endpoint with comma-separated IDs
|
|
864
|
-
const idsParam = selectedIds.join(',');
|
|
865
|
-
await api.delete(`?ids=${idsParam}`);
|
|
866
|
-
|
|
867
|
-
groupDeleteDialog.value = false;
|
|
868
|
-
clearSelection();
|
|
869
|
-
await fetchData();
|
|
870
|
-
|
|
871
|
-
snackbarMessage.value = `✅ ${selectedIds.length} آیتم با موفقیت حذف شد`;
|
|
872
|
-
snackbar.value = true;
|
|
873
|
-
} catch (err) {
|
|
874
|
-
console.error('خطا در حذف گروهی اطلاعات', err);
|
|
875
|
-
snackbarMessage.value = '❌ خطا در حذف گروهی اطلاعات!';
|
|
876
|
-
snackbar.value = true;
|
|
877
|
-
}
|
|
878
|
-
};
|
|
879
|
-
|
|
880
|
-
/**
|
|
881
|
-
* Persists the current form model, converting date fields back to Gregorian
|
|
882
|
-
* (optionally with timezone) before sending to the API. Refreshes the table.
|
|
883
|
-
*/
|
|
884
|
-
const saveItem = async () => {
|
|
885
|
-
if (!formModel.value) return;
|
|
886
|
-
|
|
887
|
-
try {
|
|
888
|
-
// Normalize dates before saving
|
|
889
|
-
const dataToSave = { ...formModel.value };
|
|
890
|
-
props.headers.forEach((header) => {
|
|
891
|
-
if (header.isDate && dataToSave[header.key]) {
|
|
892
|
-
try {
|
|
893
|
-
const raw = dataToSave[header.key];
|
|
894
|
-
if (typeof raw === 'string') {
|
|
895
|
-
const toIsoWithOffset = (ymd: string) => {
|
|
896
|
-
const [y, m, d] = ymd.split('-').map(Number);
|
|
897
|
-
const local = new Date(y, (m || 1) - 1, d || 1, 0, 0, 0, 0);
|
|
898
|
-
const offsetMin = -local.getTimezoneOffset();
|
|
899
|
-
const sign = offsetMin >= 0 ? '+' : '-';
|
|
900
|
-
const abs = Math.abs(offsetMin);
|
|
901
|
-
const hh = String(Math.floor(abs / 60)).padStart(2, '0');
|
|
902
|
-
const mm = String(abs % 60).padStart(2, '0');
|
|
903
|
-
const mm2 = String(m).padStart(2, '0');
|
|
904
|
-
const dd2 = String(d).padStart(2, '0');
|
|
905
|
-
return `${y}-${mm2}-${dd2}T00:00:00${sign}${hh}:${mm}`;
|
|
906
|
-
};
|
|
907
|
-
|
|
908
|
-
if (raw.includes('/')) {
|
|
909
|
-
// Shamsi jYYYY/jMM/jDD -> Gregorian YYYY-MM-DD
|
|
910
|
-
const greg = DateConverter.toGregorian(raw);
|
|
911
|
-
dataToSave[header.key] = props.dateWithTimezone ? toIsoWithOffset(greg) : greg;
|
|
912
|
-
} else if (raw.includes('T')) {
|
|
913
|
-
// ISO string -> keep as is or trim time if not requested
|
|
914
|
-
dataToSave[header.key] = props.dateWithTimezone ? raw : raw.split('T')[0];
|
|
915
|
-
} else {
|
|
916
|
-
// Already YYYY-MM-DD
|
|
917
|
-
dataToSave[header.key] = props.dateWithTimezone ? toIsoWithOffset(raw) : raw;
|
|
918
|
-
}
|
|
919
|
-
}
|
|
920
|
-
} catch (error) {
|
|
921
|
-
console.error(`Error converting date for field ${header.key}:`, error);
|
|
922
|
-
}
|
|
923
|
-
}
|
|
924
|
-
});
|
|
925
|
-
|
|
926
|
-
if (isEditing.value && dataToSave.id) {
|
|
927
|
-
await api.update(dataToSave);
|
|
928
|
-
snackbarMessage.value = '✅ آیتم با موفقیت بروزرسانی شد!';
|
|
929
|
-
} else {
|
|
930
|
-
const response = await api.create(dataToSave);
|
|
931
|
-
snackbarMessage.value = '✅ آیتم با موفقیت ایجاد شد!';
|
|
932
|
-
if (response.data) items.value.push(response.data);
|
|
933
|
-
}
|
|
934
|
-
|
|
935
|
-
snackbar.value = true;
|
|
936
|
-
dialog.value = false;
|
|
937
|
-
await fetchData();
|
|
938
|
-
} catch (err) {
|
|
939
|
-
console.error('خطا در ذخیره اطلاعات', err);
|
|
940
|
-
snackbarMessage.value = '❌ خطا در ذخیره اطلاعات!';
|
|
941
|
-
snackbar.value = true;
|
|
942
|
-
}
|
|
943
|
-
};
|
|
944
|
-
|
|
945
|
-
/**
|
|
946
|
-
* Deletes item by id and refreshes the list.
|
|
947
|
-
*/
|
|
948
|
-
const deleteItem = async (id: string) => {
|
|
949
|
-
try {
|
|
950
|
-
await api.delete(id);
|
|
951
|
-
deleteDialog.value = false;
|
|
952
|
-
items.value = items.value.filter((item) => item.id !== id);
|
|
953
|
-
await fetchData();
|
|
954
|
-
} catch (err) {
|
|
955
|
-
console.error('خطا در حذف اطلاعات', err);
|
|
956
|
-
snackbarMessage.value = '❌ خطا در حذف اطلاعات!';
|
|
957
|
-
snackbar.value = true;
|
|
958
|
-
}
|
|
959
|
-
};
|
|
960
|
-
|
|
961
|
-
/**
|
|
962
|
-
* Navigates to a dynamic route constructed from the provided route template
|
|
963
|
-
* and the selected item fields. Shows a snackbar if params are missing.
|
|
964
|
-
*/
|
|
965
|
-
const goToRoute = (key: string, item?: any) => {
|
|
966
|
-
const routes = getRoutesForItem(item);
|
|
967
|
-
if (!routes || !routes[key] || !item) return;
|
|
968
|
-
|
|
969
|
-
let routePath = routes[key];
|
|
970
|
-
const missingParams: string[] = [];
|
|
971
|
-
|
|
972
|
-
routePath = routePath.replace(/\{(\w+)}/g, (_, field) => {
|
|
973
|
-
if (item[field] !== undefined) {
|
|
974
|
-
return item[field];
|
|
975
|
-
} else {
|
|
976
|
-
missingParams.push(field);
|
|
977
|
-
return `{${field}}`;
|
|
978
|
-
}
|
|
979
|
-
});
|
|
980
|
-
|
|
981
|
-
if (missingParams.length > 0) {
|
|
982
|
-
snackbarMessage.value = ` خطا : ${missingParams.join(' , ')} در خروجی نیست `;
|
|
983
|
-
snackbar.value = true;
|
|
984
|
-
return;
|
|
985
|
-
}
|
|
986
|
-
|
|
987
|
-
router.push(routePath);
|
|
988
|
-
};
|
|
989
|
-
|
|
990
|
-
/**
|
|
991
|
-
* Downloads a file from a URL present on the item. Tries fetch and falls back
|
|
992
|
-
* to axios with blob response, handling common server error content types.
|
|
993
|
-
*/
|
|
994
|
-
const download = async (key: string | number, item: TableItem) => {
|
|
995
|
-
if (!props.downloadLink || !item) return;
|
|
996
|
-
|
|
997
|
-
const fileKey = props.downloadLink[key];
|
|
998
|
-
const url = item[fileKey];
|
|
999
|
-
|
|
1000
|
-
if (!url || typeof url !== 'string') {
|
|
1001
|
-
snackbarMessage.value = `❌ لینک فایل یافت نشد.`;
|
|
1002
|
-
snackbar.value = true;
|
|
1003
|
-
return;
|
|
1004
|
-
}
|
|
1005
|
-
|
|
1006
|
-
// Type assertion after validation
|
|
1007
|
-
const fileUrlString = url as string;
|
|
1008
|
-
|
|
1009
|
-
try {
|
|
1010
|
-
// Method 1: Fetch file as blob with proper headers
|
|
1011
|
-
const response = await fetch(fileUrlString, {
|
|
1012
|
-
method: 'GET',
|
|
1013
|
-
headers: {
|
|
1014
|
-
Accept: 'application/octet-stream,application/pdf,image/*,*/*'
|
|
1015
|
-
},
|
|
1016
|
-
credentials: 'include' // Include cookies for authentication
|
|
1017
|
-
});
|
|
1018
|
-
|
|
1019
|
-
if (!response.ok) {
|
|
1020
|
-
throw new Error(`HTTP error! status: ${response.status}`);
|
|
1021
|
-
}
|
|
1022
|
-
|
|
1023
|
-
// Check if response is actually a file or an error
|
|
1024
|
-
const contentType = response.headers.get('content-type');
|
|
1025
|
-
const contentLength = response.headers.get('content-length');
|
|
1026
|
-
|
|
1027
|
-
console.log('Response headers:', {
|
|
1028
|
-
contentType,
|
|
1029
|
-
contentLength,
|
|
1030
|
-
url: fileUrlString
|
|
1031
|
-
});
|
|
1032
|
-
|
|
1033
|
-
// If content-type is XML, it's likely an error response
|
|
1034
|
-
if (contentType && contentType.includes('xml')) {
|
|
1035
|
-
const errorText = await response.text();
|
|
1036
|
-
console.error('Server returned XML error:', errorText);
|
|
1037
|
-
snackbarMessage.value = `❌ خطای سرور: فایل در دسترس نیست`;
|
|
1038
|
-
snackbar.value = true;
|
|
1039
|
-
return;
|
|
1040
|
-
}
|
|
1041
|
-
|
|
1042
|
-
// If content-length is very small, it might be an error
|
|
1043
|
-
if (contentLength && parseInt(contentLength) < 1000) {
|
|
1044
|
-
const responseText = await response.text();
|
|
1045
|
-
console.log('Small response:', responseText);
|
|
1046
|
-
if (responseText.includes('error') || responseText.includes('Error')) {
|
|
1047
|
-
snackbarMessage.value = `❌ خطای سرور: فایل در دسترس نیست`;
|
|
1048
|
-
snackbar.value = true;
|
|
1049
|
-
return;
|
|
1050
|
-
}
|
|
1051
|
-
}
|
|
1052
|
-
|
|
1053
|
-
const blob = await response.blob();
|
|
1054
|
-
const url = window.URL.createObjectURL(blob);
|
|
1055
|
-
|
|
1056
|
-
// Create download link
|
|
1057
|
-
const link = document.createElement('a');
|
|
1058
|
-
link.href = url;
|
|
1059
|
-
|
|
1060
|
-
// Extract filename from URL or use default
|
|
1061
|
-
const filename = fileUrlString.split('/').pop() || 'download';
|
|
1062
|
-
link.download = filename;
|
|
1063
|
-
|
|
1064
|
-
// Add to DOM, click, and cleanup
|
|
1065
|
-
document.body.appendChild(link);
|
|
1066
|
-
link.click();
|
|
1067
|
-
document.body.removeChild(link);
|
|
1068
|
-
|
|
1069
|
-
// Clean up the blob URL
|
|
1070
|
-
window.URL.revokeObjectURL(url);
|
|
1071
|
-
|
|
1072
|
-
snackbarMessage.value = `✅ دانلود شروع شد`;
|
|
1073
|
-
snackbar.value = true;
|
|
1074
|
-
} catch (error) {
|
|
1075
|
-
console.error('Download error:', error);
|
|
1076
|
-
|
|
1077
|
-
// Method 2: Try with axios instance (includes auth headers)
|
|
1078
|
-
try {
|
|
1079
|
-
const axiosResponse = await axiosInstance.get(fileUrlString, {
|
|
1080
|
-
responseType: 'blob',
|
|
1081
|
-
headers: {
|
|
1082
|
-
Accept: 'application/octet-stream,application/pdf,image/*,*/*'
|
|
1083
|
-
}
|
|
1084
|
-
});
|
|
1085
|
-
|
|
1086
|
-
// Check response type
|
|
1087
|
-
const contentType = axiosResponse.headers['content-type'];
|
|
1088
|
-
const contentLength = axiosResponse.headers['content-length'];
|
|
1089
|
-
|
|
1090
|
-
console.log('Axios response headers:', {
|
|
1091
|
-
contentType,
|
|
1092
|
-
contentLength,
|
|
1093
|
-
url: fileUrlString
|
|
1094
|
-
});
|
|
1095
|
-
|
|
1096
|
-
// If it's XML, convert to text to see the error
|
|
1097
|
-
if (contentType && contentType.includes('xml')) {
|
|
1098
|
-
const textResponse = await axiosInstance.get(fileUrlString, {
|
|
1099
|
-
responseType: 'text'
|
|
1100
|
-
});
|
|
1101
|
-
console.error('Server returned XML error:', textResponse.data);
|
|
1102
|
-
snackbarMessage.value = `❌ خطای سرور: فایل در دسترس نیست`;
|
|
1103
|
-
snackbar.value = true;
|
|
1104
|
-
return;
|
|
1105
|
-
}
|
|
1106
|
-
|
|
1107
|
-
const blob = new Blob([axiosResponse.data]);
|
|
1108
|
-
const url = window.URL.createObjectURL(blob);
|
|
1109
|
-
|
|
1110
|
-
const link = document.createElement('a');
|
|
1111
|
-
link.href = url;
|
|
1112
|
-
const filename = fileUrlString.split('/').pop() || 'download';
|
|
1113
|
-
link.download = filename;
|
|
1114
|
-
|
|
1115
|
-
document.body.appendChild(link);
|
|
1116
|
-
link.click();
|
|
1117
|
-
document.body.removeChild(link);
|
|
1118
|
-
|
|
1119
|
-
window.URL.revokeObjectURL(url);
|
|
1120
|
-
|
|
1121
|
-
snackbarMessage.value = `✅ دانلود شروع شد`;
|
|
1122
|
-
snackbar.value = true;
|
|
1123
|
-
} catch (axiosError: any) {
|
|
1124
|
-
console.error('Axios download error:', axiosError);
|
|
1125
|
-
|
|
1126
|
-
// Try to get the error response as text
|
|
1127
|
-
if (axiosError.response) {
|
|
1128
|
-
try {
|
|
1129
|
-
const errorText = await axiosInstance.get(url, {
|
|
1130
|
-
responseType: 'text'
|
|
1131
|
-
});
|
|
1132
|
-
console.error('Server error response:', errorText.data);
|
|
1133
|
-
} catch (textError) {
|
|
1134
|
-
console.error('Could not get error text:', textError);
|
|
1135
|
-
}
|
|
1136
|
-
}
|
|
1137
|
-
|
|
1138
|
-
snackbarMessage.value = `❌ خطا در دانلود فایل`;
|
|
1139
|
-
snackbar.value = true;
|
|
1140
|
-
}
|
|
1141
|
-
}
|
|
1142
|
-
};
|
|
1143
|
-
|
|
1144
|
-
/**
|
|
1145
|
-
* Handles pagination page change and refetches server data.
|
|
1146
|
-
*/
|
|
1147
|
-
const handlePageChange = (newPage: number) => {
|
|
1148
|
-
currentPage.value = newPage;
|
|
1149
|
-
debouncedFetchData();
|
|
1150
|
-
};
|
|
1151
|
-
|
|
1152
|
-
onMounted(() => {
|
|
1153
|
-
if (props.autoFetch) {
|
|
1154
|
-
fetchData();
|
|
1155
|
-
}
|
|
1156
|
-
});
|
|
1157
|
-
|
|
1158
|
-
const openCustomActionDialog = (action: CustomAction, item: any) => {
|
|
1159
|
-
customActionComponent.value = action.component;
|
|
1160
|
-
|
|
1161
|
-
// Find the original server data for this item
|
|
1162
|
-
const originalItem = originalServerData.value.find((originalItem) => {
|
|
1163
|
-
const itemId = typeof props.uniqueKey === 'function' ? props.uniqueKey(item) : item[props.uniqueKey as string];
|
|
1164
|
-
const originalId = typeof props.uniqueKey === 'function' ? props.uniqueKey(originalItem) : originalItem[props.uniqueKey as string];
|
|
1165
|
-
return itemId === originalId;
|
|
1166
|
-
});
|
|
1167
|
-
|
|
1168
|
-
// Pass original server data to custom action components
|
|
1169
|
-
customActionItem.value = originalItem ? { ...originalItem } : { ...item };
|
|
1170
|
-
customActionDialog.value = true;
|
|
1171
|
-
};
|
|
1172
|
-
|
|
1173
|
-
const getColumnStyle = (column: any, item: any) => {
|
|
1174
|
-
const header = props.headers.find((h) => h.key === column.key);
|
|
1175
|
-
if (!header) return {};
|
|
1176
|
-
|
|
1177
|
-
const baseStyle = header.style || {};
|
|
1178
|
-
if (header.conditionalStyle) {
|
|
1179
|
-
const conditionalStyle = header.conditionalStyle(item[column.key], item);
|
|
1180
|
-
return { ...baseStyle, ...conditionalStyle };
|
|
1181
|
-
}
|
|
1182
|
-
return baseStyle;
|
|
1183
|
-
};
|
|
1184
|
-
|
|
1185
|
-
// Helper function to get nested object values
|
|
1186
|
-
const getNestedValue = (obj: any, path: string) => {
|
|
1187
|
-
return path.split('.').reduce((current, key) => {
|
|
1188
|
-
return current && current[key] !== undefined ? current[key] : null;
|
|
1189
|
-
}, obj);
|
|
1190
|
-
};
|
|
1191
|
-
|
|
1192
|
-
/**
|
|
1193
|
-
* Returns the display value for a cell, applying custom renderer/formatter
|
|
1194
|
-
* and enum translation when configured on the column header.
|
|
1195
|
-
*/
|
|
1196
|
-
const getTranslatedValue = (value: any, column: any, item: any) => {
|
|
1197
|
-
const header = props.headers.find((h) => h.key === column.key);
|
|
1198
|
-
if (!header) return value;
|
|
1199
|
-
|
|
1200
|
-
// Use custom renderer if provided
|
|
1201
|
-
if (header.customRenderer) {
|
|
1202
|
-
return header.customRenderer(item);
|
|
1203
|
-
}
|
|
1204
|
-
|
|
1205
|
-
// Use custom formatter if provided
|
|
1206
|
-
if (header.formatter) {
|
|
1207
|
-
return header.formatter(value, item);
|
|
1208
|
-
}
|
|
1209
|
-
|
|
1210
|
-
// Money type formatting with separators
|
|
1211
|
-
if (String(header.type).toLowerCase() === 'money') {
|
|
1212
|
-
try {
|
|
1213
|
-
return formatNumberWithCommas(value ?? 0, 0);
|
|
1214
|
-
} catch (e) {
|
|
1215
|
-
return value;
|
|
1216
|
-
}
|
|
1217
|
-
}
|
|
1218
|
-
|
|
1219
|
-
if (header.translate) {
|
|
1220
|
-
if (header.options) {
|
|
1221
|
-
// Find matching option for enum value
|
|
1222
|
-
const option = header.options.find((opt) => opt.value === value);
|
|
1223
|
-
return option?.title || value;
|
|
1224
|
-
}
|
|
1225
|
-
// Fallback to basic translation if no options provided
|
|
1226
|
-
return translateValue(value);
|
|
1227
|
-
}
|
|
1228
|
-
return value;
|
|
1229
|
-
};
|
|
1230
|
-
|
|
1231
|
-
const translateValue = (value: string) => {
|
|
1232
|
-
// Example translation mapping
|
|
1233
|
-
const translations: Record<string, string> = {
|
|
1234
|
-
ACTIVE: 'فعال',
|
|
1235
|
-
INACTIVE: 'غیرفعال',
|
|
1236
|
-
PENDING: 'در انتظار',
|
|
1237
|
-
COMPLETED: 'تکمیل شده'
|
|
1238
|
-
// Add more translations as needed
|
|
1239
|
-
};
|
|
1240
|
-
return translations[value] || value;
|
|
1241
|
-
};
|
|
1242
|
-
|
|
1243
|
-
/**
|
|
1244
|
-
* Applies currently edited filters and refetches from the first page.
|
|
1245
|
-
*/
|
|
1246
|
-
const applyFilter = () => {
|
|
1247
|
-
currentPage.value = 1; // Reset to first page when applying new filters
|
|
1248
|
-
debouncedFetchData();
|
|
1249
|
-
filterDialog.value = false;
|
|
1250
|
-
};
|
|
1251
|
-
|
|
1252
|
-
/**
|
|
1253
|
-
* Clears filters and refetches from the first page.
|
|
1254
|
-
*/
|
|
1255
|
-
const resetFilter = () => {
|
|
1256
|
-
filterModel.value = {};
|
|
1257
|
-
currentPage.value = 1;
|
|
1258
|
-
debouncedFetchData();
|
|
1259
|
-
filterDialog.value = false;
|
|
1260
|
-
};
|
|
1261
|
-
|
|
1262
|
-
// Handle filter apply from custom filter component
|
|
1263
|
-
/**
|
|
1264
|
-
* Receives filter data from a custom filter component and refetches.
|
|
1265
|
-
*/
|
|
1266
|
-
const handleFilterApply = (filterData: any) => {
|
|
1267
|
-
filterModel.value = filterData;
|
|
1268
|
-
currentPage.value = 1;
|
|
1269
|
-
debouncedFetchData();
|
|
1270
|
-
filterDialog.value = false;
|
|
1271
|
-
};
|
|
1272
|
-
</script>
|
|
1273
|
-
|
|
1274
|
-
<template>
|
|
1275
|
-
<!-- Page Title -->
|
|
1276
|
-
<div v-if="props.title" class="page-title">
|
|
1277
|
-
<h3 class="title-text">{{ props.title }}</h3>
|
|
1278
|
-
</div>
|
|
1279
|
-
|
|
1280
|
-
<!-- Action Buttons OUTSIDE the table container -->
|
|
1281
|
-
<div class="action-buttons">
|
|
1282
|
-
<v-btn v-if="props.actions?.includes('create')" color="green" class="me-2" @click="openDialog()">ایجاد ✅</v-btn>
|
|
1283
|
-
<v-btn v-if="hasFilterComponent" class="me-2" @click="filterDialog = true">فیلتر 🔍</v-btn>
|
|
1284
|
-
<v-btn v-if="props.showRefreshButton" color="blue" @click="debouncedFetchData" :loading="loading">بروزرسانی 🔄</v-btn>
|
|
1285
|
-
|
|
1286
|
-
<!-- Selection Actions -->
|
|
1287
|
-
<div v-if="props.selectable && hasSelection" class="selection-actions">
|
|
1288
|
-
<v-chip color="primary" class="me-2"> {{ selectedCount }} آیتم انتخاب شده </v-chip>
|
|
1289
|
-
<v-btn color="error" size="small" class="me-2" @click="clearSelection"> پاک کردن انتخاب </v-btn>
|
|
1290
|
-
</div>
|
|
1291
|
-
|
|
1292
|
-
<!-- Action Buttons for Selected Items -->
|
|
1293
|
-
<transition name="slide-left" appear>
|
|
1294
|
-
<div v-if="(props.bulkMode && hasValidSelection) || (props.enableGroupDelete && hasSelection)" class="selected-actions">
|
|
1295
|
-
<!-- Group Actions -->
|
|
1296
|
-
<v-btn v-if="props.enableGroupDelete" color="red" size="small" class="me-2" @click="openGroupDeleteDialog">
|
|
1297
|
-
<span class="me-1">🗑️</span>
|
|
1298
|
-
حذف گروهی ({{ selectedCount }})
|
|
1299
|
-
</v-btn>
|
|
1300
|
-
|
|
1301
|
-
<!-- Individual Actions for Selected Items (only in bulk mode) -->
|
|
1302
|
-
<template v-if="props.bulkMode" v-for="item in validSelectedItems" :key="getUniqueValue(item)">
|
|
1303
|
-
<!-- CRUD Actions -->
|
|
1304
|
-
<v-btn v-if="props.actions?.includes('edit')" color="blue" size="small" class="me-2" @click="openDialog(item)">
|
|
1305
|
-
<span class="me-1">✏️</span>
|
|
1306
|
-
ویرایش
|
|
1307
|
-
</v-btn>
|
|
1308
|
-
|
|
1309
|
-
<v-btn v-if="props.actions?.includes('delete')" color="red" size="small" class="me-2" @click="openDeleteDialog(item)">
|
|
1310
|
-
<span class="me-1">🗑️</span>
|
|
1311
|
-
حذف
|
|
1312
|
-
</v-btn>
|
|
1313
|
-
|
|
1314
|
-
<v-btn v-if="props.actions?.includes('view')" color="purple" size="small" class="me-2" @click="goToRoute('view', item)">
|
|
1315
|
-
<span class="me-1">👁️</span>
|
|
1316
|
-
نمایش
|
|
1317
|
-
</v-btn>
|
|
1318
|
-
|
|
1319
|
-
<!-- Route Actions -->
|
|
1320
|
-
<template v-for="(routePath, routeKey) in getRoutesForItem(item)" :key="routeKey">
|
|
1321
|
-
<v-btn color="indigo" size="small" class="me-2" @click="goToRoute(routeKey, item)">
|
|
1322
|
-
<span class="me-1">➡️</span>
|
|
1323
|
-
{{ String(routeKey) }}
|
|
1324
|
-
</v-btn>
|
|
1325
|
-
</template>
|
|
1326
|
-
|
|
1327
|
-
<!-- Download Actions -->
|
|
1328
|
-
<v-btn v-for="(value, key) in props.downloadLink" size="small" class="me-2" :key="key" @click="download(key, item)">
|
|
1329
|
-
<span class="me-1">⬇️</span>
|
|
1330
|
-
{{ key }}
|
|
1331
|
-
</v-btn>
|
|
1332
|
-
|
|
1333
|
-
<!-- Custom Actions -->
|
|
1334
|
-
<template v-for="(action, index) in props.customActions" :key="action.title || index">
|
|
1335
|
-
<v-btn
|
|
1336
|
-
v-if="!action.condition || action.condition(item)"
|
|
1337
|
-
color="orange"
|
|
1338
|
-
size="small"
|
|
1339
|
-
class="me-2"
|
|
1340
|
-
@click="openCustomActionDialog(action, item)"
|
|
1341
|
-
>
|
|
1342
|
-
{{ action.title }}
|
|
1343
|
-
</v-btn>
|
|
1344
|
-
</template>
|
|
1345
|
-
|
|
1346
|
-
<!-- Custom Buttons -->
|
|
1347
|
-
<template v-if="props.customButtonsFn">
|
|
1348
|
-
<v-btn
|
|
1349
|
-
v-for="button in props.customButtonsFn(item)"
|
|
1350
|
-
:key="button.label"
|
|
1351
|
-
:color="button.color || 'primary'"
|
|
1352
|
-
size="small"
|
|
1353
|
-
class="me-2"
|
|
1354
|
-
:disabled="button.disabled"
|
|
1355
|
-
@click="button.onClick(item)"
|
|
1356
|
-
>
|
|
1357
|
-
<span v-if="(button as any).icon" class="me-1">{{ (button as any).icon }}</span>
|
|
1358
|
-
{{ button.label }}
|
|
1359
|
-
</v-btn>
|
|
1360
|
-
</template>
|
|
1361
|
-
<template v-else>
|
|
1362
|
-
<v-btn
|
|
1363
|
-
v-for="button in props.customButtons"
|
|
1364
|
-
:key="button.label"
|
|
1365
|
-
:color="button.color || 'primary'"
|
|
1366
|
-
size="small"
|
|
1367
|
-
class="me-2"
|
|
1368
|
-
@click="button.onClick(item)"
|
|
1369
|
-
>
|
|
1370
|
-
<span v-if="(button as any).icon" class="me-1">{{ (button as any).icon }}</span>
|
|
1371
|
-
{{ button.label }}
|
|
1372
|
-
</v-btn>
|
|
1373
|
-
</template>
|
|
1374
|
-
</template>
|
|
1375
|
-
</div>
|
|
1376
|
-
</transition>
|
|
1377
|
-
</div>
|
|
1378
|
-
|
|
1379
|
-
<!-- Data Table Container (fills parent height) -->
|
|
1380
|
-
<div
|
|
1381
|
-
class="data-table-container"
|
|
1382
|
-
v-bind="$attrs"
|
|
1383
|
-
role="region"
|
|
1384
|
-
:aria-busy="loading || isLoadingMore"
|
|
1385
|
-
:aria-live="loading || isLoadingMore ? 'polite' : 'off'"
|
|
1386
|
-
>
|
|
1387
|
-
<template v-if="loading && !isLoadingMore">
|
|
1388
|
-
<div class="skeleton-container" :style="{ height: `${props.height}px` }">
|
|
1389
|
-
<v-skeleton-loader type="table" :loading="loading" class="mx-auto" max-width="100%" :boilerplate="false" />
|
|
1390
|
-
</div>
|
|
1391
|
-
</template>
|
|
1392
|
-
<template v-else>
|
|
1393
|
-
<!-- Grouped Table Structure -->
|
|
1394
|
-
<div v-if="props.groupBy && groupedItems.length > 0" class="grouped-table">
|
|
1395
|
-
<!-- Group Controls -->
|
|
1396
|
-
<div class="group-controls mb-3">
|
|
1397
|
-
<v-btn size="small" color="primary" @click="expandAllGroups" class="me-2"> گسترش همه </v-btn>
|
|
1398
|
-
<v-btn size="small" color="secondary" @click="collapseAllGroups" class="me-2"> جمع کردن همه </v-btn>
|
|
1399
|
-
</div>
|
|
1400
|
-
|
|
1401
|
-
<!-- Single Scrollable Container for All Groups -->
|
|
1402
|
-
<div class="groups-scroll-container" :style="{ height: `${props.height - 120}px` }">
|
|
1403
|
-
<div class="groups-container">
|
|
1404
|
-
<div v-for="group in groupedItems" :key="group.groupKey" class="group-section">
|
|
1405
|
-
<!-- Group Header -->
|
|
1406
|
-
<div
|
|
1407
|
-
class="group-header"
|
|
1408
|
-
role="button"
|
|
1409
|
-
tabindex="0"
|
|
1410
|
-
@click.stop="toggleGroup(group.groupKey)"
|
|
1411
|
-
@mousedown.stop
|
|
1412
|
-
@keydown.enter.prevent="toggleGroup(group.groupKey)"
|
|
1413
|
-
@keydown.space.prevent="toggleGroup(group.groupKey)"
|
|
1414
|
-
:aria-expanded="group.isExpanded ? 'true' : 'false'"
|
|
1415
|
-
:aria-controls="`group-panel-${group.groupKey}`"
|
|
1416
|
-
:id="`group-header-${group.groupKey}`"
|
|
1417
|
-
:class="{ expanded: group.isExpanded }"
|
|
1418
|
-
>
|
|
1419
|
-
<IconChevronDown v-if="group.isExpanded" class="me-2 chevron-icon" />
|
|
1420
|
-
<IconChevronRight v-else class="me-2 chevron-icon" />
|
|
1421
|
-
<span class="group-label">{{ group.groupLabel }}</span>
|
|
1422
|
-
<v-chip size="small" color="darkprimary" class="ms-auto">{{ group.count }}</v-chip>
|
|
1423
|
-
</div>
|
|
1424
|
-
|
|
1425
|
-
<!-- Group Items -->
|
|
1426
|
-
<transition name="group-expand" appear>
|
|
1427
|
-
<div
|
|
1428
|
-
v-if="group.isExpanded"
|
|
1429
|
-
class="group-items"
|
|
1430
|
-
:id="`group-panel-${group.groupKey}`"
|
|
1431
|
-
:aria-labelledby="`group-header-${group.groupKey}`"
|
|
1432
|
-
role="region"
|
|
1433
|
-
>
|
|
1434
|
-
<v-data-table
|
|
1435
|
-
:headers="groupedHeaders"
|
|
1436
|
-
:items="group.items"
|
|
1437
|
-
:items-per-page="itemsPerPage"
|
|
1438
|
-
hide-default-footer
|
|
1439
|
-
class="elevation-1 group-table"
|
|
1440
|
-
no-data-text="رکوردی یافت نشد"
|
|
1441
|
-
hover
|
|
1442
|
-
:height="'auto'"
|
|
1443
|
-
density="compact"
|
|
1444
|
-
>
|
|
1445
|
-
<!-- Custom Header for Selection -->
|
|
1446
|
-
<template v-slot:header.selection="{ column }">
|
|
1447
|
-
<v-checkbox
|
|
1448
|
-
v-if="props.selectable && props.multiSelect"
|
|
1449
|
-
:model-value="selectAll"
|
|
1450
|
-
@update:model-value="toggleSelectAll"
|
|
1451
|
-
:indeterminate="selectedCount > 0 && selectedCount < items.length"
|
|
1452
|
-
hide-details
|
|
1453
|
-
density="compact"
|
|
1454
|
-
/>
|
|
1455
|
-
</template>
|
|
1456
|
-
<template v-slot:item="{ item, columns, index }">
|
|
1457
|
-
<tr
|
|
1458
|
-
:style="{
|
|
1459
|
-
background:
|
|
1460
|
-
isSelected(item) && props.bulkMode
|
|
1461
|
-
? 'rgb(var(--v-theme-primary200))'
|
|
1462
|
-
: index % 2 === 0
|
|
1463
|
-
? 'rgb(var(--v-theme-surface))'
|
|
1464
|
-
: 'rgb(var(--v-theme-lightprimary))',
|
|
1465
|
-
cursor: props.bulkMode && props.selectable ? 'pointer' : 'default'
|
|
1466
|
-
}"
|
|
1467
|
-
:tabindex="props.selectable ? 0 : -1"
|
|
1468
|
-
@keydown.enter.prevent="props.selectable && toggleSelection(item)"
|
|
1469
|
-
@click="props.bulkMode && props.selectable && selectSingleItem(item)"
|
|
1470
|
-
>
|
|
1471
|
-
<td
|
|
1472
|
-
v-for="column in columns"
|
|
1473
|
-
:key="column.key || 'unknown'"
|
|
1474
|
-
:style="{
|
|
1475
|
-
...getColumnStyle(column, item),
|
|
1476
|
-
...(column.width
|
|
1477
|
-
? { width: column.width + 'px', minWidth: column.width + 'px', maxWidth: column.width + 'px' }
|
|
1478
|
-
: {})
|
|
1479
|
-
}"
|
|
1480
|
-
>
|
|
1481
|
-
<!-- Selection Checkbox/Radio -->
|
|
1482
|
-
<template v-if="column.key === 'selection'">
|
|
1483
|
-
<v-radio
|
|
1484
|
-
v-if="props.bulkMode"
|
|
1485
|
-
:model-value="radioGroupValue"
|
|
1486
|
-
:value="getUniqueValue(item)"
|
|
1487
|
-
@click.stop="selectSingleItem(item)"
|
|
1488
|
-
:disabled="!props.selectable"
|
|
1489
|
-
hide-details
|
|
1490
|
-
density="compact"
|
|
1491
|
-
/>
|
|
1492
|
-
<v-checkbox
|
|
1493
|
-
v-else
|
|
1494
|
-
:model-value="isSelected(item)"
|
|
1495
|
-
@update:model-value="toggleSelection(item)"
|
|
1496
|
-
:disabled="!props.selectable"
|
|
1497
|
-
hide-details
|
|
1498
|
-
density="compact"
|
|
1499
|
-
/>
|
|
1500
|
-
</template>
|
|
1501
|
-
<template v-if="column.key === 'actions' && hasAnyActions">
|
|
1502
|
-
<v-btn v-if="props.actions?.includes('edit')" color="blue" size="small" class="mr-2" @click="openDialog(item)">
|
|
1503
|
-
ویرایش ✏️
|
|
1504
|
-
</v-btn>
|
|
1505
|
-
<v-btn
|
|
1506
|
-
v-if="props.actions?.includes('delete')"
|
|
1507
|
-
color="red"
|
|
1508
|
-
size="small"
|
|
1509
|
-
class="mr-2"
|
|
1510
|
-
@click="openDeleteDialog(item)"
|
|
1511
|
-
>حذف ❌
|
|
1512
|
-
</v-btn>
|
|
1513
|
-
<v-btn
|
|
1514
|
-
v-if="props.actions?.includes('view')"
|
|
1515
|
-
color="purple"
|
|
1516
|
-
size="small"
|
|
1517
|
-
class="mr-2"
|
|
1518
|
-
@click="goToRoute('view', item)"
|
|
1519
|
-
>🔍 نمایش
|
|
1520
|
-
</v-btn>
|
|
1521
|
-
<template v-for="(routePath, routeKey) in getRoutesForItem(item)" :key="routeKey">
|
|
1522
|
-
<v-btn color="indigo" size="small" class="mr-2" @click="goToRoute(routeKey, item)">
|
|
1523
|
-
{{ routeKey.toUpperCase() }}
|
|
1524
|
-
</v-btn>
|
|
1525
|
-
</template>
|
|
1526
|
-
<v-btn
|
|
1527
|
-
v-for="(value, key) in props.downloadLink"
|
|
1528
|
-
size="small"
|
|
1529
|
-
class="mr-2"
|
|
1530
|
-
:key="key"
|
|
1531
|
-
@click="download(key, item)"
|
|
1532
|
-
>
|
|
1533
|
-
{{ key }} ⬇️
|
|
1534
|
-
</v-btn>
|
|
1535
|
-
<template v-for="(action, index) in props.customActions" :key="action.title || index">
|
|
1536
|
-
<v-btn
|
|
1537
|
-
v-if="!action.condition || action.condition(item)"
|
|
1538
|
-
color="orange"
|
|
1539
|
-
size="small"
|
|
1540
|
-
class="mr-2"
|
|
1541
|
-
@click="openCustomActionDialog(action, item)"
|
|
1542
|
-
>
|
|
1543
|
-
{{ action.title }}
|
|
1544
|
-
</v-btn>
|
|
1545
|
-
</template>
|
|
1546
|
-
<template v-if="props.customButtonsFn">
|
|
1547
|
-
<v-btn
|
|
1548
|
-
v-for="button in props.customButtonsFn(item)"
|
|
1549
|
-
:key="button.label"
|
|
1550
|
-
:color="button.color || 'primary'"
|
|
1551
|
-
size="small"
|
|
1552
|
-
class="mr-2"
|
|
1553
|
-
:disabled="button.disabled"
|
|
1554
|
-
@click="button.onClick(item)"
|
|
1555
|
-
>
|
|
1556
|
-
{{ button.label }}
|
|
1557
|
-
</v-btn>
|
|
1558
|
-
</template>
|
|
1559
|
-
<template v-else>
|
|
1560
|
-
<v-btn
|
|
1561
|
-
v-for="button in props.customButtons"
|
|
1562
|
-
:key="button.label"
|
|
1563
|
-
:color="button.color || 'primary'"
|
|
1564
|
-
size="small"
|
|
1565
|
-
class="mr-2"
|
|
1566
|
-
@click="button.onClick(item)"
|
|
1567
|
-
>
|
|
1568
|
-
{{ button.label }}
|
|
1569
|
-
</v-btn>
|
|
1570
|
-
</template>
|
|
1571
|
-
</template>
|
|
1572
|
-
<template v-else>
|
|
1573
|
-
{{ getTranslatedValue(getNestedValue(item, column.key || ''), column, item) }}
|
|
1574
|
-
</template>
|
|
1575
|
-
</td>
|
|
1576
|
-
</tr>
|
|
1577
|
-
</template>
|
|
1578
|
-
</v-data-table>
|
|
1579
|
-
</div>
|
|
1580
|
-
</transition>
|
|
1581
|
-
</div>
|
|
1582
|
-
</div>
|
|
1583
|
-
</div>
|
|
1584
|
-
</div>
|
|
1585
|
-
|
|
1586
|
-
<!-- Regular Table Structure (when not grouped) -->
|
|
1587
|
-
<v-data-table
|
|
1588
|
-
v-else
|
|
1589
|
-
:headers="normalHeaders"
|
|
1590
|
-
:items="items"
|
|
1591
|
-
:items-per-page="itemsPerPage"
|
|
1592
|
-
hide-default-footer
|
|
1593
|
-
class="elevation-1"
|
|
1594
|
-
no-data-text="رکوردی یافت نشد"
|
|
1595
|
-
hover
|
|
1596
|
-
:height="props.height"
|
|
1597
|
-
density="compact"
|
|
1598
|
-
>
|
|
1599
|
-
<!-- Custom Header for Selection -->
|
|
1600
|
-
<template v-slot:header.selection="{ column }">
|
|
1601
|
-
<v-checkbox
|
|
1602
|
-
v-if="props.selectable && props.multiSelect"
|
|
1603
|
-
:model-value="selectAll"
|
|
1604
|
-
@update:model-value="toggleSelectAll"
|
|
1605
|
-
:indeterminate="selectedCount > 0 && selectedCount < items.length"
|
|
1606
|
-
hide-details
|
|
1607
|
-
density="compact"
|
|
1608
|
-
/>
|
|
1609
|
-
</template>
|
|
1610
|
-
<template v-slot:item="{ item, columns, index }">
|
|
1611
|
-
<tr
|
|
1612
|
-
:style="{
|
|
1613
|
-
color: isSelected(item) && props.bulkMode ? 'rgb(var(--v-theme-white))' : 'rgb(var(--v-theme-darkText))',
|
|
1614
|
-
background:
|
|
1615
|
-
isSelected(item) && props.bulkMode
|
|
1616
|
-
? 'rgb(var(--v-theme-primary))'
|
|
1617
|
-
: index % 2 === 0
|
|
1618
|
-
? 'rgb(var(--v-theme-surface))'
|
|
1619
|
-
: 'rgb(var(--v-theme-lightprimary))',
|
|
1620
|
-
cursor: props.bulkMode && props.selectable ? 'pointer' : 'default'
|
|
1621
|
-
}"
|
|
1622
|
-
:tabindex="props.selectable ? 0 : -1"
|
|
1623
|
-
@keydown.enter.prevent="props.selectable && toggleSelection(item)"
|
|
1624
|
-
@click="props.bulkMode && props.selectable && selectSingleItem(item)"
|
|
1625
|
-
>
|
|
1626
|
-
<td
|
|
1627
|
-
v-for="column in columns"
|
|
1628
|
-
:key="column.key || 'unknown'"
|
|
1629
|
-
:style="{
|
|
1630
|
-
...getColumnStyle(column, item),
|
|
1631
|
-
...(column.width ? { width: column.width + 'px', minWidth: column.width + 'px', maxWidth: column.width + 'px' } : {})
|
|
1632
|
-
}"
|
|
1633
|
-
>
|
|
1634
|
-
<!-- Selection Checkbox/Radio -->
|
|
1635
|
-
<template v-if="column.key === 'selection'">
|
|
1636
|
-
<v-radio
|
|
1637
|
-
v-if="props.bulkMode"
|
|
1638
|
-
:model-value="radioGroupValue"
|
|
1639
|
-
:value="getUniqueValue(item)"
|
|
1640
|
-
@click.stop="selectSingleItem(item)"
|
|
1641
|
-
:disabled="!props.selectable"
|
|
1642
|
-
hide-details
|
|
1643
|
-
density="compact"
|
|
1644
|
-
/>
|
|
1645
|
-
<v-checkbox
|
|
1646
|
-
v-else
|
|
1647
|
-
:model-value="isSelected(item)"
|
|
1648
|
-
@update:model-value="toggleSelection(item)"
|
|
1649
|
-
:disabled="!props.selectable"
|
|
1650
|
-
hide-details
|
|
1651
|
-
density="compact"
|
|
1652
|
-
/>
|
|
1653
|
-
</template>
|
|
1654
|
-
<template v-if="column.key === 'actions' && hasAnyActions">
|
|
1655
|
-
<v-btn v-if="props.actions?.includes('edit')" color="blue" size="small" class="mr-2" @click="openDialog(item)">
|
|
1656
|
-
ویرایش ✏️
|
|
1657
|
-
</v-btn>
|
|
1658
|
-
<v-btn v-if="props.actions?.includes('delete')" color="red" size="small" class="mr-2" @click="openDeleteDialog(item)"
|
|
1659
|
-
>حذف ❌
|
|
1660
|
-
</v-btn>
|
|
1661
|
-
<v-btn v-if="props.actions?.includes('view')" color="purple" size="small" class="mr-2" @click="goToRoute('view', item)"
|
|
1662
|
-
>🔍 نمایش
|
|
1663
|
-
</v-btn>
|
|
1664
|
-
<template v-for="(routePath, routeKey) in getRoutesForItem(item)" :key="routeKey">
|
|
1665
|
-
<v-btn color="indigo" size="small" class="mr-2" @click="goToRoute(routeKey, item)">
|
|
1666
|
-
{{ routeKey.toUpperCase() }}
|
|
1667
|
-
</v-btn>
|
|
1668
|
-
</template>
|
|
1669
|
-
<v-btn v-for="(value, key) in props.downloadLink" size="small" class="mr-2" :key="key" @click="download(key, item)">
|
|
1670
|
-
{{ key }} ⬇️
|
|
1671
|
-
</v-btn>
|
|
1672
|
-
<template v-for="(action, index) in props.customActions" :key="action.title || index">
|
|
1673
|
-
<v-btn
|
|
1674
|
-
v-if="!action.condition || action.condition(item)"
|
|
1675
|
-
color="orange"
|
|
1676
|
-
size="small"
|
|
1677
|
-
class="mr-2"
|
|
1678
|
-
@click="openCustomActionDialog(action, item)"
|
|
1679
|
-
>
|
|
1680
|
-
{{ action.title }}
|
|
1681
|
-
</v-btn>
|
|
1682
|
-
</template>
|
|
1683
|
-
<template v-if="props.customButtonsFn">
|
|
1684
|
-
<v-btn
|
|
1685
|
-
v-for="button in props.customButtonsFn(item)"
|
|
1686
|
-
:key="button.label"
|
|
1687
|
-
:color="button.color || 'primary'"
|
|
1688
|
-
size="small"
|
|
1689
|
-
class="mr-2"
|
|
1690
|
-
:disabled="button.disabled"
|
|
1691
|
-
@click="button.onClick(item)"
|
|
1692
|
-
>
|
|
1693
|
-
{{ button.label }}
|
|
1694
|
-
</v-btn>
|
|
1695
|
-
</template>
|
|
1696
|
-
<template v-else>
|
|
1697
|
-
<v-btn
|
|
1698
|
-
v-for="button in props.customButtons"
|
|
1699
|
-
:key="button.label"
|
|
1700
|
-
:color="button.color || 'primary'"
|
|
1701
|
-
size="small"
|
|
1702
|
-
class="mr-2"
|
|
1703
|
-
@click="button.onClick(item)"
|
|
1704
|
-
>
|
|
1705
|
-
{{ button.label }}
|
|
1706
|
-
</v-btn>
|
|
1707
|
-
</template>
|
|
1708
|
-
</template>
|
|
1709
|
-
<template v-else>
|
|
1710
|
-
{{ getTranslatedValue(getNestedValue(item, column.key || ''), column, item) }}
|
|
1711
|
-
</template>
|
|
1712
|
-
</td>
|
|
1713
|
-
</tr>
|
|
1714
|
-
</template>
|
|
1715
|
-
</v-data-table>
|
|
1716
|
-
|
|
1717
|
-
<!-- Loading indicator for infinite scroll -->
|
|
1718
|
-
<div v-if="isLoadingMore" class="d-flex justify-center align-center pa-4">
|
|
1719
|
-
<v-progress-circular indeterminate color="primary"></v-progress-circular>
|
|
1720
|
-
</div>
|
|
1721
|
-
</template>
|
|
1722
|
-
|
|
1723
|
-
<!-- Custom Pagination always visible at the bottom -->
|
|
1724
|
-
<div v-if="props.showPagination" class="pagination-wrapper">
|
|
1725
|
-
<div class="d-flex justify-space-between align-center pa-4">
|
|
1726
|
-
<div class="text-subtitle-2">
|
|
1727
|
-
نمایش {{ (currentPage - 1) * itemsPerPage + 1 }} تا {{ Math.min(currentPage * itemsPerPage, totalSize) }} از {{ totalSize }} رکورد
|
|
1728
|
-
</div>
|
|
1729
|
-
<v-pagination v-model="currentPage" :length="totalPages" :total-visible="5" size="small" @update:model-value="handlePageChange" />
|
|
1730
|
-
</div>
|
|
1731
|
-
</div>
|
|
1732
|
-
</div>
|
|
1733
|
-
|
|
1734
|
-
<v-dialog v-model="dialog" max-width="1400">
|
|
1735
|
-
<v-card>
|
|
1736
|
-
<v-card-title>{{ isEditing ? 'ویرایش' : 'ایجاد' }}</v-card-title>
|
|
1737
|
-
<v-card-text>
|
|
1738
|
-
<v-container>
|
|
1739
|
-
<component v-if="props.formComponent" :is="props.formComponent" v-model="formModel" />
|
|
1740
|
-
<template v-else>
|
|
1741
|
-
<v-row>
|
|
1742
|
-
<v-col v-for="header in formHeaders" :key="resolveHeaderKey(header)" cols="12" md="4">
|
|
1743
|
-
<template v-if="!header.hidden">
|
|
1744
|
-
<ShamsiDatePicker
|
|
1745
|
-
v-if="header.isDate"
|
|
1746
|
-
v-model="formModel[resolveHeaderKey(header)]"
|
|
1747
|
-
:label="resolveHeaderTitle(header)"
|
|
1748
|
-
:disabled="isHeaderDisabled(header)"
|
|
1749
|
-
/>
|
|
1750
|
-
<v-autocomplete
|
|
1751
|
-
v-else-if="hasAutocomplete(header)"
|
|
1752
|
-
v-model="formModel[resolveHeaderKey(header)]"
|
|
1753
|
-
:label="resolveHeaderTitle(header)"
|
|
1754
|
-
:items="resolveAutocompleteItems(header, formModel.value)"
|
|
1755
|
-
:item-title="resolveAutocompleteItemTitle(header)"
|
|
1756
|
-
:item-value="resolveAutocompleteItemValue(header)"
|
|
1757
|
-
:return-object="resolveAutocompleteReturnObject(header)"
|
|
1758
|
-
:multiple="resolveAutocompleteMultiple(header)"
|
|
1759
|
-
:chips="resolveAutocompleteMultiple(header)"
|
|
1760
|
-
:closable-chips="resolveAutocompleteMultiple(header)"
|
|
1761
|
-
:disabled="isHeaderDisabled(header)"
|
|
1762
|
-
clearable
|
|
1763
|
-
variant="outlined"
|
|
1764
|
-
/>
|
|
1765
|
-
<MoneyInput
|
|
1766
|
-
v-else-if="isMoneyHeader(header)"
|
|
1767
|
-
v-model="formModel[resolveHeaderKey(header)] as number"
|
|
1768
|
-
:label="resolveHeaderTitle(header)"
|
|
1769
|
-
:disabled="isHeaderDisabled(header)"
|
|
1770
|
-
/>
|
|
1771
|
-
<v-text-field
|
|
1772
|
-
v-else
|
|
1773
|
-
v-model="formModel[resolveHeaderKey(header)]"
|
|
1774
|
-
:label="resolveHeaderTitle(header)"
|
|
1775
|
-
variant="outlined"
|
|
1776
|
-
:disabled="isHeaderDisabled(header)"
|
|
1777
|
-
:type="getFieldInputType(header)"
|
|
1778
|
-
/>
|
|
1779
|
-
</template>
|
|
1780
|
-
</v-col>
|
|
1781
|
-
</v-row>
|
|
1782
|
-
</template>
|
|
1783
|
-
</v-container>
|
|
1784
|
-
</v-card-text>
|
|
1785
|
-
<v-card-actions>
|
|
1786
|
-
<v-btn variant="tonal" color="error" @click="dialog = false">انصراف</v-btn>
|
|
1787
|
-
<v-btn color="primary" var @click="saveItem">{{ isEditing ? 'ذخیره' : 'ایجاد' }}</v-btn>
|
|
1788
|
-
</v-card-actions>
|
|
1789
|
-
</v-card>
|
|
1790
|
-
</v-dialog>
|
|
1791
|
-
|
|
1792
|
-
<v-dialog v-model="deleteDialog" max-width="400">
|
|
1793
|
-
<v-card>
|
|
1794
|
-
<v-card-title>حذف آیتم</v-card-title>
|
|
1795
|
-
<v-card-text> آیا مایل به حذف این رکورد هستید ?</v-card-text>
|
|
1796
|
-
<v-card-actions>
|
|
1797
|
-
<v-btn color="grey" @click="deleteDialog = false">انصراف</v-btn>
|
|
1798
|
-
<v-btn color="red" @click="deleteItem(String(itemToDelete?.id || ''))">حذف</v-btn>
|
|
1799
|
-
</v-card-actions>
|
|
1800
|
-
</v-card>
|
|
1801
|
-
</v-dialog>
|
|
1802
|
-
|
|
1803
|
-
<!-- Group Delete Confirmation Dialog -->
|
|
1804
|
-
<v-dialog v-model="groupDeleteDialog" max-width="500">
|
|
1805
|
-
<v-card>
|
|
1806
|
-
<v-card-title class="text-h6">
|
|
1807
|
-
<v-icon color="red" class="me-2">🗑️</v-icon>
|
|
1808
|
-
حذف گروهی
|
|
1809
|
-
</v-card-title>
|
|
1810
|
-
<v-card-text>
|
|
1811
|
-
<p>
|
|
1812
|
-
آیا مایل به حذف <strong>{{ selectedCount }}</strong> آیتم انتخاب شده هستید؟
|
|
1813
|
-
</p>
|
|
1814
|
-
<v-alert type="warning" variant="tonal" class="mt-3"> این عمل قابل بازگشت نیست! </v-alert>
|
|
1815
|
-
</v-card-text>
|
|
1816
|
-
<v-card-actions>
|
|
1817
|
-
<v-spacer></v-spacer>
|
|
1818
|
-
<v-btn color="grey" @click="groupDeleteDialog = false">انصراف</v-btn>
|
|
1819
|
-
<v-btn color="red" @click="deleteGroupItems" :loading="loading"> حذف {{ selectedCount }} آیتم </v-btn>
|
|
1820
|
-
</v-card-actions>
|
|
1821
|
-
</v-card>
|
|
1822
|
-
</v-dialog>
|
|
1823
|
-
|
|
1824
|
-
<v-dialog v-model="customActionDialog" max-width="1300">
|
|
1825
|
-
<v-card>
|
|
1826
|
-
<v-card-title>
|
|
1827
|
-
{{ props.customActions?.find((a) => a.component === customActionComponent)?.title || '' }}
|
|
1828
|
-
</v-card-title>
|
|
1829
|
-
<v-card-text>
|
|
1830
|
-
<component v-if="customActionComponent" :is="customActionComponent" :item="customActionItem" @close="customActionDialog = false" />
|
|
1831
|
-
</v-card-text>
|
|
1832
|
-
</v-card>
|
|
1833
|
-
</v-dialog>
|
|
1834
|
-
|
|
1835
|
-
<!-- Filter Dialog -->
|
|
1836
|
-
<v-dialog v-model="filterDialog" max-width="800">
|
|
1837
|
-
<v-card>
|
|
1838
|
-
<v-card-title>فیلتر</v-card-title>
|
|
1839
|
-
<v-card-text>
|
|
1840
|
-
<component
|
|
1841
|
-
v-if="props.filterComponent"
|
|
1842
|
-
:is="props.filterComponent"
|
|
1843
|
-
v-model="filterModel"
|
|
1844
|
-
@update:modelValue="filterModel = $event"
|
|
1845
|
-
@apply="handleFilterApply"
|
|
1846
|
-
/>
|
|
1847
|
-
</v-card-text>
|
|
1848
|
-
</v-card>
|
|
1849
|
-
</v-dialog>
|
|
1850
|
-
|
|
1851
|
-
<!-- Snackbar for messages -->
|
|
1852
|
-
<v-snackbar v-if="snackbar" v-model="snackbar" :timeout="3000" location="top">
|
|
1853
|
-
{{ snackbarMessage }}
|
|
1854
|
-
<template v-slot:actions>
|
|
1855
|
-
<v-btn color="white" variant="text" @click="snackbar = false"> بستن </v-btn>
|
|
1856
|
-
</template>
|
|
1857
|
-
</v-snackbar>
|
|
1858
|
-
</template>
|
|
1859
|
-
|