@innertia-solutions/innertia-nuxt 0.1.1
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/.github/workflows/auto-publish.yml +64 -0
- package/.github/workflows/release.yml +59 -0
- package/README.md +60 -0
- package/app.config.ts +70 -0
- package/components/Admin/Base.vue +144 -0
- package/components/Admin/Header.vue +32 -0
- package/components/Admin/Page.vue +65 -0
- package/components/Admin/PageHeader.vue +31 -0
- package/components/App/Button.vue +59 -0
- package/components/App/DevEnvironmentBar.vue +43 -0
- package/components/App/Dropdown.vue +286 -0
- package/components/App/EmptyState.vue +433 -0
- package/components/App/LoadingState.vue +40 -0
- package/components/App/PageLoadingSpinner.vue +118 -0
- package/components/App/PreviewDock.vue +64 -0
- package/components/App/SwitchColorTheme.vue +51 -0
- package/components/App/Tag.vue +193 -0
- package/components/DataTable.vue +713 -0
- package/components/Forms/DatePicker.vue +255 -0
- package/components/Forms/Input.vue +75 -0
- package/components/Forms/Select.vue +100 -0
- package/components/Forms/SelectServer.vue +726 -0
- package/components/Layout/Admin.vue +32 -0
- package/components/Layout/Auth.vue +29 -0
- package/components/Layout/SidebarWithAppColumn.vue +388 -0
- package/components/Layout/TopBar.vue +113 -0
- package/components/MobileBlocker.vue +85 -0
- package/components/MobileLoginPicker.vue +83 -0
- package/components/Modal/Base.vue +29 -0
- package/components/Modal/DeleteConfirm.vue +48 -0
- package/components/Modal.vue +103 -0
- package/components/Nav/Tabs.vue +55 -0
- package/components/PermissionsTree.vue +272 -0
- package/components/Table/Database.vue +183 -0
- package/components/Table/DownloadDropdown.vue +111 -0
- package/components/Table/Enterprise.vue +540 -0
- package/components/Table/FilterDropdown.vue +226 -0
- package/components/Table/Grid.vue +62 -0
- package/components/Table/Kanban.vue +188 -0
- package/components/Table/List.vue +128 -0
- package/components/Table/PreviewTimeline.vue +118 -0
- package/components/Table/Standard.vue +1217 -0
- package/components/Table/index.vue +974 -0
- package/components/TableExportable.vue +172 -0
- package/components/TableFilter.vue +93 -0
- package/components/Toast/Alert.vue +113 -0
- package/components/Toast/Container.vue +34 -0
- package/components/Toast/Notification.vue +45 -0
- package/components/Toast/Process.vue +88 -0
- package/composables/useApi.js +95 -0
- package/composables/useApp.ts +46 -0
- package/composables/useAuth.js +82 -0
- package/composables/useContext.js +44 -0
- package/composables/useDate.js +241 -0
- package/composables/useDevice.js +21 -0
- package/composables/useDockedPreviews.js +56 -0
- package/composables/useDownload.js +87 -0
- package/composables/useEntity.js +82 -0
- package/composables/useForm.js +119 -0
- package/composables/useInnertiaMode.ts +25 -0
- package/composables/useMobileGuard.ts +81 -0
- package/composables/useNotifications.js +22 -0
- package/composables/usePermissions.js +23 -0
- package/composables/useRealtime.js +123 -0
- package/composables/useRequestInterceptors.js +27 -0
- package/composables/useRoles.js +53 -0
- package/composables/useRutFormatter.js +39 -0
- package/composables/useTable.ts +94 -0
- package/composables/useTablePreferences.ts +33 -0
- package/composables/useTenant.js +27 -0
- package/composables/useTimeAgo.js +37 -0
- package/composables/useToast.js +69 -0
- package/composables/useUserRealtime.js +17 -0
- package/composables/useUsers.js +111 -0
- package/css/themes/autumn.css +401 -0
- package/css/themes/bubblegum.css +408 -0
- package/css/themes/cashmere.css +412 -0
- package/css/themes/harvest.css +416 -0
- package/css/themes/moon.css +140 -0
- package/css/themes/ocean.css +273 -0
- package/css/themes/olive.css +413 -0
- package/css/themes/retro.css +431 -0
- package/css/themes/theme.css +725 -0
- package/error.vue +78 -0
- package/middleware/01.detect-subdomain.global.ts +43 -0
- package/middleware/02.validate-tenant.global.ts +67 -0
- package/middleware/03.apps.global.ts +88 -0
- package/middleware/auth.ts +9 -0
- package/middleware/guest.ts +9 -0
- package/nuxt.config.ts +42 -0
- package/package.json +60 -0
- package/pages/tenant-error.vue +50 -0
- package/plugins/api-auth.ts +12 -0
- package/plugins/api-tenant.client.ts +21 -0
- package/plugins/appearance.ts +8 -0
- package/plugins/auth-init.ts +34 -0
- package/plugins/dark-state.client.ts +29 -0
- package/plugins/dockedPreviewsSync.client.js +17 -0
- package/plugins/preline.client.ts +68 -0
- package/plugins/theme.client.ts +7 -0
- package/plugins/vue-query.ts +29 -0
- package/public/init-theme.js +15 -0
- package/spark.css +721 -0
- package/stores/auth.js +130 -0
- package/stores/dockedPreviews.js +34 -0
- package/stores/notifications.js +24 -0
- package/stores/tenant.js +54 -0
- package/stores/toast.js +129 -0
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
const props = defineProps({
|
|
3
|
+
modelValue: {
|
|
4
|
+
type: [String, Number],
|
|
5
|
+
default: null,
|
|
6
|
+
},
|
|
7
|
+
options: {
|
|
8
|
+
type: Array,
|
|
9
|
+
default: () => [],
|
|
10
|
+
},
|
|
11
|
+
placeholder: {
|
|
12
|
+
type: String,
|
|
13
|
+
default: "Seleccionar...",
|
|
14
|
+
},
|
|
15
|
+
optionLabelKey: {
|
|
16
|
+
type: String,
|
|
17
|
+
default: "label",
|
|
18
|
+
},
|
|
19
|
+
optionValueKey: {
|
|
20
|
+
type: String,
|
|
21
|
+
default: "value",
|
|
22
|
+
},
|
|
23
|
+
clearable: {
|
|
24
|
+
type: Boolean,
|
|
25
|
+
default: true,
|
|
26
|
+
},
|
|
27
|
+
disabled: {
|
|
28
|
+
type: Boolean,
|
|
29
|
+
default: false,
|
|
30
|
+
},
|
|
31
|
+
menuClass: {
|
|
32
|
+
type: String,
|
|
33
|
+
default: "",
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const emit = defineEmits(["update:modelValue", "change"]);
|
|
38
|
+
|
|
39
|
+
const isOpen = ref(false);
|
|
40
|
+
const dropdownRef = ref(null);
|
|
41
|
+
|
|
42
|
+
const getOptionLabel = (option) => option?.[props.optionLabelKey] ?? option?.label ?? "";
|
|
43
|
+
const getOptionValue = (option) => option?.[props.optionValueKey] ?? option?.value ?? null;
|
|
44
|
+
|
|
45
|
+
const hasSelection = computed(() => props.modelValue !== null && props.modelValue !== undefined && props.modelValue !== "");
|
|
46
|
+
|
|
47
|
+
const selectedOption = computed(() =>
|
|
48
|
+
props.options.find((option) => getOptionValue(option) === props.modelValue),
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
const displayText = computed(() => {
|
|
52
|
+
if (!selectedOption.value) return props.placeholder;
|
|
53
|
+
return getOptionLabel(selectedOption.value) || props.placeholder;
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const selectClasses = computed(() => {
|
|
57
|
+
const base =
|
|
58
|
+
"relative w-full rounded-lg border bg-card transition-colors cursor-pointer text-foreground py-2 px-3 text-sm focus:outline-none focus:ring-0 focus:border-gray-400";
|
|
59
|
+
const validation = "border-card-line";
|
|
60
|
+
const disabled = props.disabled ? "opacity-50 cursor-not-allowed" : "";
|
|
61
|
+
return `${base} ${validation} ${disabled}`;
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const closeDropdown = () => {
|
|
65
|
+
isOpen.value = false;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const toggleDropdown = () => {
|
|
69
|
+
if (props.disabled) return;
|
|
70
|
+
isOpen.value = !isOpen.value;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const selectOption = (option) => {
|
|
74
|
+
const selectedValue = getOptionValue(option);
|
|
75
|
+
const nextValue = props.modelValue === selectedValue ? null : selectedValue;
|
|
76
|
+
emit("update:modelValue", nextValue);
|
|
77
|
+
emit("change", nextValue);
|
|
78
|
+
closeDropdown();
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const clearSelection = () => {
|
|
82
|
+
emit("update:modelValue", null);
|
|
83
|
+
emit("change", null);
|
|
84
|
+
closeDropdown();
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const isOptionSelected = (option) => getOptionValue(option) === props.modelValue;
|
|
88
|
+
|
|
89
|
+
const handleClickOutside = (event) => {
|
|
90
|
+
if (dropdownRef.value && !dropdownRef.value.contains(event.target)) {
|
|
91
|
+
closeDropdown();
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
onMounted(() => document.addEventListener("mousedown", handleClickOutside));
|
|
96
|
+
onUnmounted(() => document.removeEventListener("mousedown", handleClickOutside));
|
|
97
|
+
</script>
|
|
98
|
+
|
|
99
|
+
<template>
|
|
100
|
+
<div ref="dropdownRef" class="relative w-full">
|
|
101
|
+
<button
|
|
102
|
+
type="button"
|
|
103
|
+
:class="selectClasses"
|
|
104
|
+
:disabled="disabled"
|
|
105
|
+
:aria-expanded="isOpen"
|
|
106
|
+
:aria-haspopup="true"
|
|
107
|
+
@click="toggleDropdown"
|
|
108
|
+
>
|
|
109
|
+
<div class="flex items-center justify-between w-full">
|
|
110
|
+
<span
|
|
111
|
+
class="truncate flex-1 text-left pr-10"
|
|
112
|
+
:class="{
|
|
113
|
+
'text-muted-foreground': !hasSelection,
|
|
114
|
+
'text-slate-900 dark:text-white': hasSelection,
|
|
115
|
+
}"
|
|
116
|
+
>
|
|
117
|
+
<slot name="display" :selected-option="selectedOption" :display-text="displayText">
|
|
118
|
+
{{ displayText }}
|
|
119
|
+
</slot>
|
|
120
|
+
</span>
|
|
121
|
+
|
|
122
|
+
<span
|
|
123
|
+
v-if="clearable && hasSelection && !disabled"
|
|
124
|
+
class="absolute end-8 top-1/2 -translate-y-1/2 hover:bg-muted-hover rounded-full p-1 transition-colors focus:outline-none focus:ring-1 focus:ring-slate-400"
|
|
125
|
+
role="button"
|
|
126
|
+
tabindex="0"
|
|
127
|
+
@click.stop="clearSelection"
|
|
128
|
+
@keydown.enter.prevent="clearSelection"
|
|
129
|
+
@keydown.space.prevent="clearSelection"
|
|
130
|
+
>
|
|
131
|
+
<svg class="size-3.5 text-slate-400" fill="currentColor" viewBox="0 0 20 20">
|
|
132
|
+
<path
|
|
133
|
+
fill-rule="evenodd"
|
|
134
|
+
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
|
|
135
|
+
clip-rule="evenodd"
|
|
136
|
+
/>
|
|
137
|
+
</svg>
|
|
138
|
+
</span>
|
|
139
|
+
|
|
140
|
+
<div class="absolute top-1/2 end-3 -translate-y-1/2">
|
|
141
|
+
<svg
|
|
142
|
+
class="shrink-0 size-3.5 text-muted-foreground transition-transform"
|
|
143
|
+
:class="{ 'rotate-180': isOpen }"
|
|
144
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
145
|
+
width="24"
|
|
146
|
+
height="24"
|
|
147
|
+
viewBox="0 0 24 24"
|
|
148
|
+
fill="none"
|
|
149
|
+
stroke="currentColor"
|
|
150
|
+
stroke-width="2"
|
|
151
|
+
stroke-linecap="round"
|
|
152
|
+
stroke-linejoin="round"
|
|
153
|
+
>
|
|
154
|
+
<path d="m7 15 5 5 5-5" />
|
|
155
|
+
<path d="m7 9 5-5 5 5" />
|
|
156
|
+
</svg>
|
|
157
|
+
</div>
|
|
158
|
+
</div>
|
|
159
|
+
</button>
|
|
160
|
+
|
|
161
|
+
<Transition
|
|
162
|
+
enter-active-class="transition ease-out duration-100"
|
|
163
|
+
enter-from-class="transform opacity-0 scale-95"
|
|
164
|
+
enter-to-class="transform opacity-100 scale-100"
|
|
165
|
+
leave-active-class="transition ease-in duration-75"
|
|
166
|
+
leave-from-class="transform opacity-100 scale-100"
|
|
167
|
+
leave-to-class="transform opacity-0 scale-95"
|
|
168
|
+
>
|
|
169
|
+
<div
|
|
170
|
+
v-show="isOpen"
|
|
171
|
+
:class="[
|
|
172
|
+
'absolute z-50 w-full mt-1 bg-dropdown border border-dropdown-line rounded-xl shadow-xl max-h-60 overflow-auto',
|
|
173
|
+
menuClass,
|
|
174
|
+
]"
|
|
175
|
+
>
|
|
176
|
+
<div v-if="options.length">
|
|
177
|
+
<button
|
|
178
|
+
v-for="option in options"
|
|
179
|
+
:key="getOptionValue(option)"
|
|
180
|
+
type="button"
|
|
181
|
+
:class="[
|
|
182
|
+
'w-full px-3 py-2 text-left hover:bg-muted-hover flex items-center text-sm',
|
|
183
|
+
isOptionSelected(option) ? 'bg-muted' : '',
|
|
184
|
+
]"
|
|
185
|
+
@click="selectOption(option)"
|
|
186
|
+
>
|
|
187
|
+
<slot name="option" :option="option" :selected="isOptionSelected(option)">
|
|
188
|
+
<div class="flex items-center flex-1 min-w-0">
|
|
189
|
+
<span
|
|
190
|
+
v-if="option.dot"
|
|
191
|
+
class="relative flex-shrink-0 size-2.5 flex items-center justify-center mr-2.5"
|
|
192
|
+
>
|
|
193
|
+
<span
|
|
194
|
+
v-if="option.pulse"
|
|
195
|
+
:class="['animate-ping absolute inline-flex h-full w-full rounded-full opacity-75', option.dot]"
|
|
196
|
+
/>
|
|
197
|
+
<span :class="['relative inline-flex rounded-full size-2.5', option.dot]" />
|
|
198
|
+
</span>
|
|
199
|
+
<span class="font-bold text-foreground truncate">
|
|
200
|
+
{{ getOptionLabel(option) }}
|
|
201
|
+
</span>
|
|
202
|
+
</div>
|
|
203
|
+
</slot>
|
|
204
|
+
|
|
205
|
+
<svg
|
|
206
|
+
v-if="isOptionSelected(option)"
|
|
207
|
+
class="w-4 h-4 text-slate-500 ml-2 shrink-0"
|
|
208
|
+
fill="currentColor"
|
|
209
|
+
viewBox="0 0 20 20"
|
|
210
|
+
>
|
|
211
|
+
<path
|
|
212
|
+
fill-rule="evenodd"
|
|
213
|
+
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
|
214
|
+
clip-rule="evenodd"
|
|
215
|
+
/>
|
|
216
|
+
</svg>
|
|
217
|
+
</button>
|
|
218
|
+
</div>
|
|
219
|
+
|
|
220
|
+
<div v-else class="px-3 py-4 text-center text-muted-foreground text-sm">
|
|
221
|
+
Sin opciones
|
|
222
|
+
</div>
|
|
223
|
+
</div>
|
|
224
|
+
</Transition>
|
|
225
|
+
</div>
|
|
226
|
+
</template>
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { IconSearch } from '@tabler/icons-vue'
|
|
3
|
+
|
|
4
|
+
// Grid / card layout view — wraps DataTable with viewMode="grid"
|
|
5
|
+
const props = defineProps({
|
|
6
|
+
endpoint: { type: String, required: true },
|
|
7
|
+
columns: { type: Array, required: true },
|
|
8
|
+
name: { type: String, required: true },
|
|
9
|
+
params: { type: Object, default: () => ({}) },
|
|
10
|
+
cached: { type: Boolean, default: true },
|
|
11
|
+
searchPlaceholder: { type: String, default: 'Buscar...' },
|
|
12
|
+
showSearch: { type: Boolean, default: true },
|
|
13
|
+
gridClass: { type: String, default: 'grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4' },
|
|
14
|
+
clickRowToOpen: { type: Boolean, default: false },
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
const emit = defineEmits(['row-click', 'loaded'])
|
|
18
|
+
|
|
19
|
+
const search = ref('')
|
|
20
|
+
const tableRef = ref(null)
|
|
21
|
+
|
|
22
|
+
const reload = () => tableRef.value?.reload()
|
|
23
|
+
const clearCache = () => tableRef.value?.clearCache()
|
|
24
|
+
|
|
25
|
+
defineExpose({ reload, clearCache })
|
|
26
|
+
</script>
|
|
27
|
+
|
|
28
|
+
<template>
|
|
29
|
+
<div class="flex flex-col gap-4">
|
|
30
|
+
<div v-if="showSearch" class="relative">
|
|
31
|
+
<div class="absolute inset-y-0 start-0 flex items-center ps-3 pointer-events-none">
|
|
32
|
+
<IconSearch class="size-4 text-slate-400" stroke="1.5" />
|
|
33
|
+
</div>
|
|
34
|
+
<input
|
|
35
|
+
v-model="search"
|
|
36
|
+
type="search"
|
|
37
|
+
:placeholder="searchPlaceholder"
|
|
38
|
+
class="block w-full rounded-lg border border-card-line bg-card text-foreground py-2 ps-10 pe-4 text-sm focus:outline-none focus:ring-1 focus:ring-indigo-500"
|
|
39
|
+
/>
|
|
40
|
+
</div>
|
|
41
|
+
|
|
42
|
+
<DataTable
|
|
43
|
+
ref="tableRef"
|
|
44
|
+
:endpoint="endpoint"
|
|
45
|
+
:columns="columns"
|
|
46
|
+
:name="name"
|
|
47
|
+
:params="params"
|
|
48
|
+
:search="search"
|
|
49
|
+
:cached="cached"
|
|
50
|
+
:click-row-to-open="clickRowToOpen"
|
|
51
|
+
view-mode="grid"
|
|
52
|
+
:grid-class="gridClass"
|
|
53
|
+
@row-click="emit('row-click', $event)"
|
|
54
|
+
@loaded="emit('loaded', $event)"
|
|
55
|
+
>
|
|
56
|
+
<!-- Card slot: pass through for custom card rendering -->
|
|
57
|
+
<template v-if="$slots.card" #card="slotProps">
|
|
58
|
+
<slot name="card" v-bind="slotProps" />
|
|
59
|
+
</template>
|
|
60
|
+
</DataTable>
|
|
61
|
+
</div>
|
|
62
|
+
</template>
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { IconLoader2, IconPlus } from '@tabler/icons-vue'
|
|
3
|
+
import { useQueryClient } from '@tanstack/vue-query'
|
|
4
|
+
|
|
5
|
+
// Kanban board: states as columns, HTML5 DnD, optimistic updates
|
|
6
|
+
// Usage: <TableKanban endpoint="..." :states="[{key:'todo',label:'Pendiente',color:'slate'}]" state-key="status" ... />
|
|
7
|
+
|
|
8
|
+
const props = defineProps({
|
|
9
|
+
endpoint: { type: String, required: true }, // POST endpoint returning paginated list
|
|
10
|
+
name: { type: String, required: true }, // used as queryKey base
|
|
11
|
+
params: { type: Object, default: () => ({}) },
|
|
12
|
+
stateKey: { type: String, default: 'status' }, // field in row that holds the state key
|
|
13
|
+
states: { // column definitions
|
|
14
|
+
type: Array,
|
|
15
|
+
required: true,
|
|
16
|
+
// [{ key: 'todo', label: 'Pendiente', color: 'slate' }]
|
|
17
|
+
// color = tailwind color name: slate|red|yellow|green|blue|indigo|purple|pink
|
|
18
|
+
},
|
|
19
|
+
moveMutation: { type: Function, default: null }, // (id, newState) => Promise — called on drop
|
|
20
|
+
perPage: { type: Number, default: 50 },
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
const emit = defineEmits(['move', 'card-click'])
|
|
24
|
+
|
|
25
|
+
const api = useApi()
|
|
26
|
+
const queryClient = useQueryClient()
|
|
27
|
+
|
|
28
|
+
// ─── Fetch all rows ───────────────────────────────────────────────────────────
|
|
29
|
+
const loading = ref(false)
|
|
30
|
+
const rows = ref([])
|
|
31
|
+
|
|
32
|
+
const fetchAll = async () => {
|
|
33
|
+
loading.value = true
|
|
34
|
+
try {
|
|
35
|
+
const res = await api.post(props.endpoint, { perPage: props.perPage, ...props.params })
|
|
36
|
+
rows.value = Array.isArray(res?.data) ? res.data : (Array.isArray(res) ? res : [])
|
|
37
|
+
} catch (e) {
|
|
38
|
+
console.error('[Kanban] fetch error', e)
|
|
39
|
+
} finally {
|
|
40
|
+
loading.value = false
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
onMounted(fetchAll)
|
|
45
|
+
|
|
46
|
+
watch(() => props.params, fetchAll, { deep: true })
|
|
47
|
+
|
|
48
|
+
// ─── Rows grouped by state ────────────────────────────────────────────────────
|
|
49
|
+
const columnRows = computed(() => {
|
|
50
|
+
const map = {}
|
|
51
|
+
for (const s of props.states) map[s.key] = []
|
|
52
|
+
for (const row of rows.value) {
|
|
53
|
+
const state = row[props.stateKey]
|
|
54
|
+
if (map[state]) map[state].push(row)
|
|
55
|
+
}
|
|
56
|
+
return map
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
// ─── DnD ─────────────────────────────────────────────────────────────────────
|
|
60
|
+
const draggedId = ref(null)
|
|
61
|
+
const draggedFromState = ref(null)
|
|
62
|
+
const dragOverState = ref(null)
|
|
63
|
+
|
|
64
|
+
const onDragStart = (row, state) => {
|
|
65
|
+
draggedId.value = row.id
|
|
66
|
+
draggedFromState.value = state
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const onDragOver = (e, state) => {
|
|
70
|
+
e.preventDefault()
|
|
71
|
+
dragOverState.value = state
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const onDragLeave = () => { dragOverState.value = null }
|
|
75
|
+
|
|
76
|
+
const onDrop = async (targetState) => {
|
|
77
|
+
dragOverState.value = null
|
|
78
|
+
if (!draggedId.value || draggedFromState.value === targetState) {
|
|
79
|
+
draggedId.value = null
|
|
80
|
+
draggedFromState.value = null
|
|
81
|
+
return
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const id = draggedId.value
|
|
85
|
+
const fromState = draggedFromState.value
|
|
86
|
+
draggedId.value = null
|
|
87
|
+
draggedFromState.value = null
|
|
88
|
+
|
|
89
|
+
// Optimistic update
|
|
90
|
+
const idx = rows.value.findIndex(r => r.id === id)
|
|
91
|
+
if (idx >= 0) rows.value[idx] = { ...rows.value[idx], [props.stateKey]: targetState }
|
|
92
|
+
|
|
93
|
+
emit('move', { id, from: fromState, to: targetState })
|
|
94
|
+
|
|
95
|
+
if (props.moveMutation) {
|
|
96
|
+
try {
|
|
97
|
+
await props.moveMutation(id, targetState)
|
|
98
|
+
} catch (e) {
|
|
99
|
+
// Rollback
|
|
100
|
+
const ridx = rows.value.findIndex(r => r.id === id)
|
|
101
|
+
if (ridx >= 0) rows.value[ridx] = { ...rows.value[ridx], [props.stateKey]: fromState }
|
|
102
|
+
console.error('[Kanban] move failed, rolled back', e)
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ─── Color map ────────────────────────────────────────────────────────────────
|
|
108
|
+
const colorMap = {
|
|
109
|
+
slate: { header: 'bg-surface text-foreground', over: 'ring-2 ring-slate-400' },
|
|
110
|
+
red: { header: 'bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-300', over: 'ring-2 ring-red-400' },
|
|
111
|
+
yellow: { header: 'bg-yellow-50 dark:bg-yellow-900/20 text-yellow-700 dark:text-yellow-300', over: 'ring-2 ring-yellow-400' },
|
|
112
|
+
green: { header: 'bg-green-50 dark:bg-green-900/20 text-green-700 dark:text-green-300', over: 'ring-2 ring-green-400' },
|
|
113
|
+
blue: { header: 'bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300', over: 'ring-2 ring-blue-400' },
|
|
114
|
+
indigo: { header: 'bg-indigo-50 dark:bg-indigo-900/20 text-indigo-700 dark:text-indigo-300', over: 'ring-2 ring-indigo-400' },
|
|
115
|
+
purple: { header: 'bg-purple-50 dark:bg-purple-900/20 text-purple-700 dark:text-purple-300', over: 'ring-2 ring-purple-400' },
|
|
116
|
+
pink: { header: 'bg-pink-50 dark:bg-pink-900/20 text-pink-700 dark:text-pink-300', over: 'ring-2 ring-pink-400' },
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const getColors = (state) => colorMap[state.color ?? 'slate'] ?? colorMap.slate
|
|
120
|
+
|
|
121
|
+
const reload = () => fetchAll()
|
|
122
|
+
defineExpose({ reload, rows })
|
|
123
|
+
</script>
|
|
124
|
+
|
|
125
|
+
<template>
|
|
126
|
+
<div>
|
|
127
|
+
<!-- Loading -->
|
|
128
|
+
<div v-if="loading" class="flex items-center justify-center py-16 gap-2 text-slate-400">
|
|
129
|
+
<IconLoader2 class="size-5 animate-spin" stroke="1.5" />
|
|
130
|
+
<span class="text-sm">Cargando...</span>
|
|
131
|
+
</div>
|
|
132
|
+
|
|
133
|
+
<!-- Board -->
|
|
134
|
+
<div v-else class="flex gap-4 overflow-x-auto pb-4">
|
|
135
|
+
<div
|
|
136
|
+
v-for="state in states"
|
|
137
|
+
:key="state.key"
|
|
138
|
+
class="flex-shrink-0 w-72 flex flex-col rounded-xl border border-card-line overflow-hidden transition-shadow"
|
|
139
|
+
:class="dragOverState === state.key ? getColors(state).over : ''"
|
|
140
|
+
@dragover="onDragOver($event, state.key)"
|
|
141
|
+
@dragleave="onDragLeave"
|
|
142
|
+
@drop="onDrop(state.key)"
|
|
143
|
+
>
|
|
144
|
+
<!-- Column header -->
|
|
145
|
+
<div :class="['px-4 py-3 flex items-center justify-between', getColors(state).header]">
|
|
146
|
+
<span class="font-semibold text-sm">{{ state.label }}</span>
|
|
147
|
+
<span class="text-xs font-bold bg-white/60 dark:bg-black/20 rounded-full px-2 py-0.5">
|
|
148
|
+
{{ columnRows[state.key]?.length ?? 0 }}
|
|
149
|
+
</span>
|
|
150
|
+
</div>
|
|
151
|
+
|
|
152
|
+
<!-- Cards -->
|
|
153
|
+
<div class="flex-1 flex flex-col gap-2 p-3 bg-muted min-h-24">
|
|
154
|
+
<div
|
|
155
|
+
v-for="row in columnRows[state.key]"
|
|
156
|
+
:key="row.id"
|
|
157
|
+
draggable="true"
|
|
158
|
+
@dragstart="onDragStart(row, state.key)"
|
|
159
|
+
class="bg-card border border-card-line rounded-lg p-3 cursor-grab active:cursor-grabbing hover:shadow-md transition-shadow select-none"
|
|
160
|
+
:class="draggedId === row.id ? 'opacity-40' : ''"
|
|
161
|
+
@click="emit('card-click', row)"
|
|
162
|
+
>
|
|
163
|
+
<slot name="card" :row="row" :state="state">
|
|
164
|
+
<!-- Default card -->
|
|
165
|
+
<div class="space-y-1">
|
|
166
|
+
<div
|
|
167
|
+
v-for="(val, key) in Object.fromEntries(Object.entries(row).filter(([k]) => k !== props.stateKey).slice(0, 3))"
|
|
168
|
+
:key="key"
|
|
169
|
+
class="text-sm"
|
|
170
|
+
>
|
|
171
|
+
<span class="text-muted-foreground text-xs capitalize">{{ key }}: </span>
|
|
172
|
+
<span class="text-foreground font-medium">{{ val }}</span>
|
|
173
|
+
</div>
|
|
174
|
+
</div>
|
|
175
|
+
</slot>
|
|
176
|
+
</div>
|
|
177
|
+
|
|
178
|
+
<div
|
|
179
|
+
v-if="!columnRows[state.key]?.length"
|
|
180
|
+
class="flex-1 flex items-center justify-center py-6 text-sm text-muted-foreground-2"
|
|
181
|
+
>
|
|
182
|
+
Sin elementos
|
|
183
|
+
</div>
|
|
184
|
+
</div>
|
|
185
|
+
</div>
|
|
186
|
+
</div>
|
|
187
|
+
</div>
|
|
188
|
+
</template>
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { IconSearch, IconLoader2 } from '@tabler/icons-vue'
|
|
3
|
+
import { useInfiniteQuery } from '@tanstack/vue-query'
|
|
4
|
+
|
|
5
|
+
// Infinite scroll list — fetches next page when sentinel enters viewport
|
|
6
|
+
const props = defineProps({
|
|
7
|
+
endpoint: { type: String, required: true },
|
|
8
|
+
name: { type: String, required: true },
|
|
9
|
+
params: { type: Object, default: () => ({}) },
|
|
10
|
+
searchPlaceholder: { type: String, default: 'Buscar...' },
|
|
11
|
+
showSearch: { type: Boolean, default: true },
|
|
12
|
+
perPage: { type: Number, default: 20 },
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
const emit = defineEmits(['row-click'])
|
|
16
|
+
|
|
17
|
+
const api = useApi()
|
|
18
|
+
const search = ref('')
|
|
19
|
+
const sentinel = ref(null)
|
|
20
|
+
|
|
21
|
+
let searchTimeout = null
|
|
22
|
+
const debouncedSearch = ref('')
|
|
23
|
+
watch(search, (v) => {
|
|
24
|
+
if (searchTimeout) clearTimeout(searchTimeout)
|
|
25
|
+
searchTimeout = setTimeout(() => { debouncedSearch.value = v }, 400)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
const queryKey = computed(() => [props.name, 'infinite', debouncedSearch.value, props.params])
|
|
29
|
+
|
|
30
|
+
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading, refetch } = useInfiniteQuery({
|
|
31
|
+
queryKey,
|
|
32
|
+
queryFn: ({ pageParam = 1 }) =>
|
|
33
|
+
api.post(props.endpoint, {
|
|
34
|
+
search: debouncedSearch.value,
|
|
35
|
+
page: pageParam,
|
|
36
|
+
perPage: props.perPage,
|
|
37
|
+
...props.params,
|
|
38
|
+
}),
|
|
39
|
+
getNextPageParam: (lastPage) => {
|
|
40
|
+
const meta = lastPage?.meta ?? lastPage
|
|
41
|
+
if (!meta?.current_page || !meta?.last_page) return undefined
|
|
42
|
+
return meta.current_page < meta.last_page ? meta.current_page + 1 : undefined
|
|
43
|
+
},
|
|
44
|
+
initialPageParam: 1,
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
const rows = computed(() => data.value?.pages.flatMap(p => p?.data ?? (Array.isArray(p) ? p : [])) ?? [])
|
|
48
|
+
|
|
49
|
+
// Intersection observer for auto-load
|
|
50
|
+
let observer = null
|
|
51
|
+
onMounted(() => {
|
|
52
|
+
observer = new IntersectionObserver((entries) => {
|
|
53
|
+
if (entries[0].isIntersecting && hasNextPage.value && !isFetchingNextPage.value) {
|
|
54
|
+
fetchNextPage()
|
|
55
|
+
}
|
|
56
|
+
}, { rootMargin: '200px' })
|
|
57
|
+
if (sentinel.value) observer.observe(sentinel.value)
|
|
58
|
+
})
|
|
59
|
+
onBeforeUnmount(() => observer?.disconnect())
|
|
60
|
+
|
|
61
|
+
watch(sentinel, (el) => {
|
|
62
|
+
if (el && observer) observer.observe(el)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
const reload = () => refetch()
|
|
66
|
+
defineExpose({ reload, rows })
|
|
67
|
+
</script>
|
|
68
|
+
|
|
69
|
+
<template>
|
|
70
|
+
<div class="flex flex-col gap-4">
|
|
71
|
+
<div v-if="showSearch" class="relative">
|
|
72
|
+
<div class="absolute inset-y-0 start-0 flex items-center ps-3 pointer-events-none">
|
|
73
|
+
<IconSearch class="size-4 text-slate-400" stroke="1.5" />
|
|
74
|
+
</div>
|
|
75
|
+
<input
|
|
76
|
+
v-model="search"
|
|
77
|
+
type="search"
|
|
78
|
+
:placeholder="searchPlaceholder"
|
|
79
|
+
class="block w-full rounded-lg border border-card-line bg-card text-foreground py-2 ps-10 pe-4 text-sm focus:outline-none focus:ring-1 focus:ring-indigo-500"
|
|
80
|
+
/>
|
|
81
|
+
</div>
|
|
82
|
+
|
|
83
|
+
<!-- Loading skeleton -->
|
|
84
|
+
<div v-if="isLoading" class="space-y-2">
|
|
85
|
+
<div
|
|
86
|
+
v-for="i in 8"
|
|
87
|
+
:key="i"
|
|
88
|
+
class="h-16 bg-surface rounded-xl animate-pulse"
|
|
89
|
+
/>
|
|
90
|
+
</div>
|
|
91
|
+
|
|
92
|
+
<!-- Rows -->
|
|
93
|
+
<div v-else class="space-y-2">
|
|
94
|
+
<div
|
|
95
|
+
v-for="row in rows"
|
|
96
|
+
:key="row.id ?? JSON.stringify(row)"
|
|
97
|
+
class="bg-card border border-card-line rounded-xl px-4 py-3 hover:border-indigo-300 dark:hover:border-indigo-600 transition-colors cursor-pointer"
|
|
98
|
+
@click="emit('row-click', row)"
|
|
99
|
+
>
|
|
100
|
+
<slot name="item" :row="row">
|
|
101
|
+
<!-- Default: show first few keys -->
|
|
102
|
+
<div class="flex flex-wrap gap-x-6 gap-y-1">
|
|
103
|
+
<div
|
|
104
|
+
v-for="(val, key) in Object.fromEntries(Object.entries(row).slice(0, 4))"
|
|
105
|
+
:key="key"
|
|
106
|
+
class="text-sm"
|
|
107
|
+
>
|
|
108
|
+
<span class="text-muted-foreground text-xs">{{ key }}: </span>
|
|
109
|
+
<span class="text-foreground">{{ val }}</span>
|
|
110
|
+
</div>
|
|
111
|
+
</div>
|
|
112
|
+
</slot>
|
|
113
|
+
</div>
|
|
114
|
+
|
|
115
|
+
<div v-if="rows.length === 0" class="text-center py-12 text-muted-foreground text-sm">
|
|
116
|
+
Sin resultados
|
|
117
|
+
</div>
|
|
118
|
+
</div>
|
|
119
|
+
|
|
120
|
+
<!-- Sentinel + loader -->
|
|
121
|
+
<div ref="sentinel" class="flex justify-center py-4">
|
|
122
|
+
<div v-if="isFetchingNextPage" class="flex items-center gap-2 text-sm text-slate-400">
|
|
123
|
+
<IconLoader2 class="size-4 animate-spin" stroke="1.5" />
|
|
124
|
+
Cargando más...
|
|
125
|
+
</div>
|
|
126
|
+
</div>
|
|
127
|
+
</div>
|
|
128
|
+
</template>
|