@afurgeri/crud-vue 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +46 -0
- package/src/__test__/CrudCards.test.ts +118 -0
- package/src/__test__/CrudEmptyState.test.ts +82 -0
- package/src/__test__/CrudForm.test.ts +101 -0
- package/src/__test__/CrudShow.test.ts +135 -0
- package/src/__test__/CrudTable.test.ts +111 -0
- package/src/__test__/CrudToolbar.test.ts +102 -0
- package/src/__test__/setup.ts +43 -0
- package/src/__test__/useCrud.test.ts +349 -0
- package/src/components/CrudCards.vue +105 -0
- package/src/components/CrudDeleteDialog.vue +80 -0
- package/src/components/CrudEmptyState.vue +58 -0
- package/src/components/CrudFilters.vue +194 -0
- package/src/components/CrudForm.vue +232 -0
- package/src/components/CrudPage.vue +206 -0
- package/src/components/CrudPagination.vue +130 -0
- package/src/components/CrudSearch.vue +42 -0
- package/src/components/CrudShow.vue +216 -0
- package/src/components/CrudTable.vue +146 -0
- package/src/components/CrudToolbar.vue +86 -0
- package/src/components/InputError.vue +13 -0
- package/src/components/ui/button/Button.vue +27 -0
- package/src/components/ui/button/index.ts +36 -0
- package/src/components/ui/card/Card.vue +22 -0
- package/src/components/ui/card/CardAction.vue +17 -0
- package/src/components/ui/card/CardContent.vue +17 -0
- package/src/components/ui/card/CardDescription.vue +17 -0
- package/src/components/ui/card/CardFooter.vue +17 -0
- package/src/components/ui/card/CardHeader.vue +17 -0
- package/src/components/ui/card/CardTitle.vue +17 -0
- package/src/components/ui/card/index.ts +7 -0
- package/src/components/ui/checkbox/Checkbox.vue +37 -0
- package/src/components/ui/checkbox/index.ts +1 -0
- package/src/components/ui/combobox/ComboboxInput.vue +83 -0
- package/src/components/ui/combobox/index.ts +1 -0
- package/src/components/ui/dialog/Dialog.vue +17 -0
- package/src/components/ui/dialog/DialogClose.vue +14 -0
- package/src/components/ui/dialog/DialogContent.vue +49 -0
- package/src/components/ui/dialog/DialogDescription.vue +25 -0
- package/src/components/ui/dialog/DialogFooter.vue +15 -0
- package/src/components/ui/dialog/DialogHeader.vue +17 -0
- package/src/components/ui/dialog/DialogOverlay.vue +23 -0
- package/src/components/ui/dialog/DialogScrollContent.vue +59 -0
- package/src/components/ui/dialog/DialogTitle.vue +25 -0
- package/src/components/ui/dialog/DialogTrigger.vue +14 -0
- package/src/components/ui/dialog/index.ts +10 -0
- package/src/components/ui/dropdown-menu/DropdownMenu.vue +17 -0
- package/src/components/ui/dropdown-menu/DropdownMenuCheckboxItem.vue +41 -0
- package/src/components/ui/dropdown-menu/DropdownMenuContent.vue +39 -0
- package/src/components/ui/dropdown-menu/DropdownMenuGroup.vue +14 -0
- package/src/components/ui/dropdown-menu/DropdownMenuItem.vue +30 -0
- package/src/components/ui/dropdown-menu/DropdownMenuLabel.vue +22 -0
- package/src/components/ui/dropdown-menu/DropdownMenuRadioGroup.vue +22 -0
- package/src/components/ui/dropdown-menu/DropdownMenuRadioItem.vue +42 -0
- package/src/components/ui/dropdown-menu/DropdownMenuSeparator.vue +26 -0
- package/src/components/ui/dropdown-menu/DropdownMenuShortcut.vue +17 -0
- package/src/components/ui/dropdown-menu/DropdownMenuSub.vue +19 -0
- package/src/components/ui/dropdown-menu/DropdownMenuSubContent.vue +31 -0
- package/src/components/ui/dropdown-menu/DropdownMenuSubTrigger.vue +30 -0
- package/src/components/ui/dropdown-menu/DropdownMenuTrigger.vue +16 -0
- package/src/components/ui/dropdown-menu/index.ts +16 -0
- package/src/components/ui/input/Input.vue +33 -0
- package/src/components/ui/input/index.ts +1 -0
- package/src/components/ui/label/Label.vue +28 -0
- package/src/components/ui/label/index.ts +1 -0
- package/src/components/ui/select/Select.vue +15 -0
- package/src/components/ui/select/SelectContent.vue +49 -0
- package/src/components/ui/select/SelectGroup.vue +17 -0
- package/src/components/ui/select/SelectItem.vue +41 -0
- package/src/components/ui/select/SelectItemText.vue +12 -0
- package/src/components/ui/select/SelectLabel.vue +14 -0
- package/src/components/ui/select/SelectScrollDownButton.vue +22 -0
- package/src/components/ui/select/SelectScrollUpButton.vue +22 -0
- package/src/components/ui/select/SelectSeparator.vue +15 -0
- package/src/components/ui/select/SelectTrigger.vue +29 -0
- package/src/components/ui/select/SelectValue.vue +12 -0
- package/src/components/ui/select/index.ts +11 -0
- package/src/components/ui/separator/Separator.vue +28 -0
- package/src/components/ui/separator/index.ts +1 -0
- package/src/components/ui/spinner/Spinner.vue +17 -0
- package/src/components/ui/spinner/index.ts +1 -0
- package/src/components/ui/table/Table.vue +16 -0
- package/src/components/ui/table/TableBody.vue +14 -0
- package/src/components/ui/table/TableCaption.vue +14 -0
- package/src/components/ui/table/TableCell.vue +21 -0
- package/src/components/ui/table/TableEmpty.vue +34 -0
- package/src/components/ui/table/TableFooter.vue +14 -0
- package/src/components/ui/table/TableHead.vue +14 -0
- package/src/components/ui/table/TableHeader.vue +14 -0
- package/src/components/ui/table/TableRow.vue +14 -0
- package/src/components/ui/table/index.ts +9 -0
- package/src/composables/useCrud.ts +328 -0
- package/src/index.ts +33 -0
- package/src/lib/utils.ts +18 -0
- package/src/types/crud.ts +133 -0
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { Card, CardContent } from './ui/card';
|
|
3
|
+
import { useCrud } from '../composables/useCrud';
|
|
4
|
+
import type { CrudColumn, CrudFeatureCreate, CrudFeatureSearch, CrudFilter, CrudPageProps } from '../types/crud';
|
|
5
|
+
import { computed, ref, onMounted, onUnmounted } from 'vue';
|
|
6
|
+
import CrudCards from './CrudCards.vue';
|
|
7
|
+
import CrudDeleteDialog from './CrudDeleteDialog.vue';
|
|
8
|
+
import CrudEmptyState from './CrudEmptyState.vue';
|
|
9
|
+
import CrudFilters from './CrudFilters.vue';
|
|
10
|
+
import CrudPagination from './CrudPagination.vue';
|
|
11
|
+
import CrudTable from './CrudTable.vue';
|
|
12
|
+
import CrudToolbar from './CrudToolbar.vue';
|
|
13
|
+
|
|
14
|
+
const props = withDefaults(defineProps<CrudPageProps>(), {
|
|
15
|
+
description: undefined,
|
|
16
|
+
features: () => ({}),
|
|
17
|
+
card: undefined,
|
|
18
|
+
itemsPropName: undefined,
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
// Responsive detection
|
|
22
|
+
const isMobile = ref(false);
|
|
23
|
+
|
|
24
|
+
function checkMobile() {
|
|
25
|
+
isMobile.value = window.innerWidth < 1024;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
onMounted(() => {
|
|
29
|
+
checkMobile();
|
|
30
|
+
window.addEventListener('resize', checkMobile);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
onUnmounted(() => {
|
|
34
|
+
window.removeEventListener('resize', checkMobile);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
// Parse features
|
|
38
|
+
const features = props.features;
|
|
39
|
+
|
|
40
|
+
const filterConfig: CrudFilter[] =
|
|
41
|
+
features.filters && typeof features.filters !== 'boolean' ? features.filters : [];
|
|
42
|
+
|
|
43
|
+
const searchConfig: CrudFeatureSearch | null =
|
|
44
|
+
features.search && typeof features.search !== 'boolean' ? features.search : null;
|
|
45
|
+
|
|
46
|
+
const createConfig: CrudFeatureCreate | null =
|
|
47
|
+
features.create && typeof features.create !== 'boolean' ? features.create : null;
|
|
48
|
+
|
|
49
|
+
const hasShow = features.show === true;
|
|
50
|
+
const hasEdit = features.edit === true;
|
|
51
|
+
const hasDelete = features.delete === true;
|
|
52
|
+
const showActions = hasShow || hasEdit || hasDelete;
|
|
53
|
+
|
|
54
|
+
// Composable
|
|
55
|
+
const {
|
|
56
|
+
search,
|
|
57
|
+
activeFilters,
|
|
58
|
+
sortField,
|
|
59
|
+
sortDirection,
|
|
60
|
+
hasActiveFilters,
|
|
61
|
+
filtersVisible,
|
|
62
|
+
onSearchInput,
|
|
63
|
+
onFilterChange,
|
|
64
|
+
onSortToggle,
|
|
65
|
+
applyFilters,
|
|
66
|
+
clearFilters,
|
|
67
|
+
toggleFilters,
|
|
68
|
+
} = useCrud(
|
|
69
|
+
filterConfig,
|
|
70
|
+
searchConfig?.fields ?? [],
|
|
71
|
+
props.itemsPropName,
|
|
72
|
+
features.defaultSort,
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
// Delete dialog ref
|
|
76
|
+
const deleteDialog = ref<InstanceType<typeof CrudDeleteDialog> | null>(null);
|
|
77
|
+
|
|
78
|
+
// Actions
|
|
79
|
+
function handleDelete(item: Record<string, unknown>) {
|
|
80
|
+
deleteDialog.value?.confirm({
|
|
81
|
+
id: item.id as string | number,
|
|
82
|
+
name: item.name as string | undefined,
|
|
83
|
+
delete_url: item.delete_url as string | undefined,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Check if we have any data and if filters are active
|
|
88
|
+
const isEmpty = computed(() => !props.items.data || props.items.data.length === 0);
|
|
89
|
+
const hasFiltersApplied = computed(() => hasActiveFilters.value);
|
|
90
|
+
</script>
|
|
91
|
+
|
|
92
|
+
<template>
|
|
93
|
+
<div class="mx-auto w-full max-w-7xl px-4 py-6 sm:px-6 lg:px-8">
|
|
94
|
+
<Card class="overflow-hidden border shadow-sm">
|
|
95
|
+
<!-- Toolbar -->
|
|
96
|
+
<CrudToolbar
|
|
97
|
+
:title="title"
|
|
98
|
+
:description="description"
|
|
99
|
+
:search="search"
|
|
100
|
+
:search-config="searchConfig"
|
|
101
|
+
:create-config="createConfig"
|
|
102
|
+
:has-active-filters="hasActiveFilters"
|
|
103
|
+
:filters-visible="filtersVisible"
|
|
104
|
+
@update:search="onSearchInput"
|
|
105
|
+
@search:clear="onSearchInput('')"
|
|
106
|
+
@toggle-filters="toggleFilters"
|
|
107
|
+
@clear-filters="clearFilters"
|
|
108
|
+
>
|
|
109
|
+
<template #title-prepend>
|
|
110
|
+
<slot name="title-prepend" />
|
|
111
|
+
</template>
|
|
112
|
+
<template #title-append>
|
|
113
|
+
<slot name="title-append" />
|
|
114
|
+
</template>
|
|
115
|
+
<template #toolbar-actions>
|
|
116
|
+
<slot name="toolbar-actions" />
|
|
117
|
+
</template>
|
|
118
|
+
<template #toolbar-append>
|
|
119
|
+
<slot name="toolbar-append" />
|
|
120
|
+
</template>
|
|
121
|
+
</CrudToolbar>
|
|
122
|
+
|
|
123
|
+
<!-- Filters Panel -->
|
|
124
|
+
<CrudFilters
|
|
125
|
+
v-if="filterConfig.length > 0"
|
|
126
|
+
v-model="activeFilters"
|
|
127
|
+
:filters="filterConfig"
|
|
128
|
+
:visible="filtersVisible"
|
|
129
|
+
@apply="applyFilters"
|
|
130
|
+
@reset="clearFilters"
|
|
131
|
+
/>
|
|
132
|
+
|
|
133
|
+
<!-- Content -->
|
|
134
|
+
<CardContent class="p-0">
|
|
135
|
+
<!-- Empty State -->
|
|
136
|
+
<CrudEmptyState
|
|
137
|
+
v-if="isEmpty"
|
|
138
|
+
:has-filters="hasFiltersApplied"
|
|
139
|
+
:create="features.create"
|
|
140
|
+
@clear-filters="clearFilters"
|
|
141
|
+
/>
|
|
142
|
+
|
|
143
|
+
<!-- Desktop: Table -->
|
|
144
|
+
<template v-else-if="!isMobile">
|
|
145
|
+
<CrudTable
|
|
146
|
+
:items="items"
|
|
147
|
+
:columns="columns"
|
|
148
|
+
:sort-field="sortField"
|
|
149
|
+
:sort-direction="sortDirection"
|
|
150
|
+
:show-actions="showActions"
|
|
151
|
+
:has-show="hasShow"
|
|
152
|
+
:has-edit="hasEdit"
|
|
153
|
+
:has-delete="hasDelete"
|
|
154
|
+
@sort="onSortToggle"
|
|
155
|
+
@delete="handleDelete"
|
|
156
|
+
>
|
|
157
|
+
<!-- Forward all cell slots -->
|
|
158
|
+
<template
|
|
159
|
+
v-for="col in columns"
|
|
160
|
+
:key="col.key"
|
|
161
|
+
#[`cell-${col.key}`]="slotProps"
|
|
162
|
+
>
|
|
163
|
+
<slot
|
|
164
|
+
:name="`cell-${col.key}`"
|
|
165
|
+
v-bind="slotProps"
|
|
166
|
+
/>
|
|
167
|
+
</template>
|
|
168
|
+
<!-- Forward table actions slot -->
|
|
169
|
+
<template #actions="slotProps">
|
|
170
|
+
<slot name="actions" v-bind="slotProps" />
|
|
171
|
+
</template>
|
|
172
|
+
</CrudTable>
|
|
173
|
+
</template>
|
|
174
|
+
|
|
175
|
+
<!-- Mobile: Cards -->
|
|
176
|
+
<template v-else>
|
|
177
|
+
<CrudCards
|
|
178
|
+
:items="items"
|
|
179
|
+
:card="card ?? { title: () => '' }"
|
|
180
|
+
:show-actions="showActions"
|
|
181
|
+
:has-show="hasShow"
|
|
182
|
+
:has-edit="hasEdit"
|
|
183
|
+
:has-delete="hasDelete"
|
|
184
|
+
@delete="handleDelete"
|
|
185
|
+
>
|
|
186
|
+
<template #actions="slotProps">
|
|
187
|
+
<slot name="actions" v-bind="slotProps" />
|
|
188
|
+
</template>
|
|
189
|
+
<template #card-content="slotProps">
|
|
190
|
+
<slot name="card-content" v-bind="slotProps" />
|
|
191
|
+
</template>
|
|
192
|
+
</CrudCards>
|
|
193
|
+
</template>
|
|
194
|
+
</CardContent>
|
|
195
|
+
|
|
196
|
+
<!-- Pagination -->
|
|
197
|
+
<CrudPagination
|
|
198
|
+
v-if="(features.pagination !== false) && !isEmpty"
|
|
199
|
+
:items="items"
|
|
200
|
+
/>
|
|
201
|
+
</Card>
|
|
202
|
+
|
|
203
|
+
<!-- Delete Dialog -->
|
|
204
|
+
<CrudDeleteDialog ref="deleteDialog" />
|
|
205
|
+
</div>
|
|
206
|
+
</template>
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { Button } from './ui/button';
|
|
3
|
+
import type { Paginator, PaginatorLink } from '../types/crud';
|
|
4
|
+
import { router } from '@inertiajs/vue3';
|
|
5
|
+
import { ChevronLeft, ChevronRight } from 'lucide-vue-next';
|
|
6
|
+
|
|
7
|
+
const props = defineProps<{
|
|
8
|
+
items: Paginator;
|
|
9
|
+
}>();
|
|
10
|
+
|
|
11
|
+
function goToUrl(url: string | null) {
|
|
12
|
+
if (!url) return;
|
|
13
|
+
router.get(url, {}, {
|
|
14
|
+
preserveState: true,
|
|
15
|
+
preserveScroll: false,
|
|
16
|
+
replace: true,
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function isPageNumber(link: PaginatorLink): boolean {
|
|
21
|
+
return !isNaN(Number(link.label));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const visibleLinks = () => {
|
|
25
|
+
const links = props.items.links;
|
|
26
|
+
if (links.length <= 5) return links;
|
|
27
|
+
|
|
28
|
+
// Show: first, ..., current-1, current, current+1, ..., last
|
|
29
|
+
const currentIdx = links.findIndex((l) => l.active);
|
|
30
|
+
const result: PaginatorLink[] = [];
|
|
31
|
+
let prevDots = false;
|
|
32
|
+
let nextDots = false;
|
|
33
|
+
|
|
34
|
+
for (let i = 0; i < links.length; i++) {
|
|
35
|
+
const link = links[i];
|
|
36
|
+
|
|
37
|
+
if (i === 0 || i === links.length - 1) {
|
|
38
|
+
result.push(link);
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (isPageNumber(link)) {
|
|
43
|
+
const pageNum = Number(link.label);
|
|
44
|
+
const currentPageNum = props.items.current_page;
|
|
45
|
+
|
|
46
|
+
if (Math.abs(pageNum - currentPageNum) <= 1) {
|
|
47
|
+
result.push(link);
|
|
48
|
+
prevDots = false;
|
|
49
|
+
nextDots = false;
|
|
50
|
+
} else if (pageNum < currentPageNum && !prevDots) {
|
|
51
|
+
result.push({ url: null, label: '...', active: false });
|
|
52
|
+
prevDots = true;
|
|
53
|
+
} else if (pageNum > currentPageNum && !nextDots) {
|
|
54
|
+
result.push({ url: null, label: '...', active: false });
|
|
55
|
+
nextDots = true;
|
|
56
|
+
}
|
|
57
|
+
} else {
|
|
58
|
+
result.push(link);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return result;
|
|
63
|
+
};
|
|
64
|
+
</script>
|
|
65
|
+
|
|
66
|
+
<template>
|
|
67
|
+
<div
|
|
68
|
+
v-if="items.last_page > 1"
|
|
69
|
+
class="flex flex-col items-center gap-3 border-t px-6 py-4 sm:flex-row sm:justify-between"
|
|
70
|
+
>
|
|
71
|
+
<p class="text-sm text-muted-foreground">
|
|
72
|
+
Showing
|
|
73
|
+
<span class="font-medium text-foreground">{{ items.from ?? 0 }}</span>
|
|
74
|
+
to
|
|
75
|
+
<span class="font-medium text-foreground">{{ items.to ?? 0 }}</span>
|
|
76
|
+
of
|
|
77
|
+
<span class="font-medium text-foreground">{{ items.total }}</span>
|
|
78
|
+
results
|
|
79
|
+
</p>
|
|
80
|
+
|
|
81
|
+
<div class="flex items-center gap-1">
|
|
82
|
+
<Button
|
|
83
|
+
variant="outline"
|
|
84
|
+
size="icon"
|
|
85
|
+
class="h-8 w-8"
|
|
86
|
+
:disabled="!items.links[0]?.url"
|
|
87
|
+
@click="goToUrl(items.links[0]?.url ?? null)"
|
|
88
|
+
>
|
|
89
|
+
<ChevronLeft class="h-4 w-4" />
|
|
90
|
+
</Button>
|
|
91
|
+
|
|
92
|
+
<template v-for="link in visibleLinks()" :key="link.label">
|
|
93
|
+
<Button
|
|
94
|
+
v-if="link.label === '...'"
|
|
95
|
+
variant="ghost"
|
|
96
|
+
size="icon"
|
|
97
|
+
class="h-8 w-8 cursor-default"
|
|
98
|
+
disabled
|
|
99
|
+
>
|
|
100
|
+
<span class="text-xs">...</span>
|
|
101
|
+
</Button>
|
|
102
|
+
<Button
|
|
103
|
+
v-else
|
|
104
|
+
variant="outline"
|
|
105
|
+
size="icon"
|
|
106
|
+
class="h-8 w-8 text-sm font-medium"
|
|
107
|
+
:class="{
|
|
108
|
+
'border-primary bg-primary text-primary-foreground hover:bg-primary/90 hover:text-primary-foreground':
|
|
109
|
+
link.active,
|
|
110
|
+
'hover:bg-accent': !link.active,
|
|
111
|
+
}"
|
|
112
|
+
:disabled="!link.url"
|
|
113
|
+
@click="goToUrl(link.url)"
|
|
114
|
+
>
|
|
115
|
+
<span v-html="link.label" />
|
|
116
|
+
</Button>
|
|
117
|
+
</template>
|
|
118
|
+
|
|
119
|
+
<Button
|
|
120
|
+
variant="outline"
|
|
121
|
+
size="icon"
|
|
122
|
+
class="h-8 w-8"
|
|
123
|
+
:disabled="!items.links[items.links.length - 1]?.url"
|
|
124
|
+
@click="goToUrl(items.links[items.links.length - 1]?.url ?? null)"
|
|
125
|
+
>
|
|
126
|
+
<ChevronRight class="h-4 w-4" />
|
|
127
|
+
</Button>
|
|
128
|
+
</div>
|
|
129
|
+
</div>
|
|
130
|
+
</template>
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { Input } from './ui/input';
|
|
3
|
+
import { Search, X } from 'lucide-vue-next';
|
|
4
|
+
import { computed } from 'vue';
|
|
5
|
+
|
|
6
|
+
const props = defineProps<{
|
|
7
|
+
modelValue: string;
|
|
8
|
+
placeholder?: string;
|
|
9
|
+
}>();
|
|
10
|
+
|
|
11
|
+
const emit = defineEmits<{
|
|
12
|
+
'update:modelValue': [value: string];
|
|
13
|
+
clear: [];
|
|
14
|
+
}>();
|
|
15
|
+
|
|
16
|
+
const hasValue = computed(() => props.modelValue.length > 0);
|
|
17
|
+
|
|
18
|
+
function onClear() {
|
|
19
|
+
emit('update:modelValue', '');
|
|
20
|
+
emit('clear');
|
|
21
|
+
}
|
|
22
|
+
</script>
|
|
23
|
+
|
|
24
|
+
<template>
|
|
25
|
+
<div class="relative w-full max-w-sm">
|
|
26
|
+
<Search class="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
|
27
|
+
<Input
|
|
28
|
+
:model-value="modelValue"
|
|
29
|
+
type="search"
|
|
30
|
+
:placeholder="placeholder ?? 'Search...'"
|
|
31
|
+
class="h-9 pl-9 pr-9 text-sm"
|
|
32
|
+
@update:model-value="emit('update:modelValue', $event as string)"
|
|
33
|
+
/>
|
|
34
|
+
<button
|
|
35
|
+
v-if="hasValue"
|
|
36
|
+
class="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
|
37
|
+
@click="onClear"
|
|
38
|
+
>
|
|
39
|
+
<X class="h-4 w-4" />
|
|
40
|
+
</button>
|
|
41
|
+
</div>
|
|
42
|
+
</template>
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { Button } from './ui/button';
|
|
3
|
+
import { Card, CardContent, CardHeader, CardTitle } from './ui/card';
|
|
4
|
+
import type { CrudShowField, CrudShowSection, CrudShowSidebarItem } from '../types/crud';
|
|
5
|
+
import { Link, router } from '@inertiajs/vue3';
|
|
6
|
+
import {
|
|
7
|
+
ArrowLeft,
|
|
8
|
+
Edit,
|
|
9
|
+
type LucideIcon,
|
|
10
|
+
User,
|
|
11
|
+
Phone,
|
|
12
|
+
Mail,
|
|
13
|
+
MapPin,
|
|
14
|
+
FileText,
|
|
15
|
+
CreditCard,
|
|
16
|
+
DollarSign,
|
|
17
|
+
Hash,
|
|
18
|
+
Tag,
|
|
19
|
+
} from 'lucide-vue-next';
|
|
20
|
+
import { computed } from 'vue';
|
|
21
|
+
|
|
22
|
+
const props = defineProps<{
|
|
23
|
+
title: string;
|
|
24
|
+
description?: string;
|
|
25
|
+
entity: Record<string, unknown>;
|
|
26
|
+
sections: CrudShowSection[];
|
|
27
|
+
backRoute: string;
|
|
28
|
+
editRoute?: string;
|
|
29
|
+
sidebar?: CrudShowSidebarItem[];
|
|
30
|
+
entityId?: number | string;
|
|
31
|
+
}>();
|
|
32
|
+
|
|
33
|
+
const iconMap: Record<string, LucideIcon> = {
|
|
34
|
+
user: User,
|
|
35
|
+
phone: Phone,
|
|
36
|
+
mail: Mail,
|
|
37
|
+
'map-pin': MapPin,
|
|
38
|
+
'file-text': FileText,
|
|
39
|
+
'credit-card': CreditCard,
|
|
40
|
+
'dollar-sign': DollarSign,
|
|
41
|
+
hash: Hash,
|
|
42
|
+
tag: Tag,
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
function getIcon(name: string | undefined): LucideIcon | undefined {
|
|
46
|
+
if (!name) return undefined;
|
|
47
|
+
return iconMap[name] ?? undefined;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function getFieldValue(field: CrudShowField): unknown {
|
|
51
|
+
if (field.value !== undefined) return field.value;
|
|
52
|
+
if (field.key !== undefined) return props.entity[field.key];
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function formatMoney(value: unknown): string {
|
|
57
|
+
const num = Number(value);
|
|
58
|
+
if (isNaN(num)) return '';
|
|
59
|
+
return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(num);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function getSidebarValue(item: CrudShowSidebarItem): unknown {
|
|
63
|
+
if (item.value !== undefined) return item.value;
|
|
64
|
+
if (item.key !== undefined) return props.entity[item.key];
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
</script>
|
|
68
|
+
|
|
69
|
+
<template>
|
|
70
|
+
<div class="mx-auto w-full max-w-6xl px-4 py-6 sm:px-6 lg:px-8">
|
|
71
|
+
<!-- Header -->
|
|
72
|
+
<div class="mb-8">
|
|
73
|
+
<div class="flex items-center justify-between">
|
|
74
|
+
<div>
|
|
75
|
+
<h1 class="text-3xl font-bold tracking-tight text-foreground">
|
|
76
|
+
{{ title }}
|
|
77
|
+
</h1>
|
|
78
|
+
<p v-if="description" class="mt-2 text-sm text-muted-foreground">
|
|
79
|
+
{{ description }}
|
|
80
|
+
</p>
|
|
81
|
+
</div>
|
|
82
|
+
<div class="flex items-center gap-3">
|
|
83
|
+
<Button variant="outline" @click="router.get(backRoute)">
|
|
84
|
+
<ArrowLeft class="mr-1.5 h-4 w-4" />
|
|
85
|
+
Back
|
|
86
|
+
</Button>
|
|
87
|
+
<Link
|
|
88
|
+
v-if="editRoute"
|
|
89
|
+
:href="editRoute"
|
|
90
|
+
class="inline-flex items-center gap-2 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
|
|
91
|
+
>
|
|
92
|
+
<Edit class="h-4 w-4" />
|
|
93
|
+
Edit
|
|
94
|
+
</Link>
|
|
95
|
+
</div>
|
|
96
|
+
</div>
|
|
97
|
+
</div>
|
|
98
|
+
|
|
99
|
+
<!-- Grid -->
|
|
100
|
+
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
|
101
|
+
<!-- Main content -->
|
|
102
|
+
<div class="space-y-6 lg:col-span-2">
|
|
103
|
+
<Card
|
|
104
|
+
v-for="(section, si) in sections"
|
|
105
|
+
:key="si"
|
|
106
|
+
class="overflow-hidden border shadow-sm"
|
|
107
|
+
>
|
|
108
|
+
<CardHeader class="border-b px-6 py-4">
|
|
109
|
+
<CardTitle class="flex items-center gap-2 text-base font-semibold">
|
|
110
|
+
<component
|
|
111
|
+
:is="getIcon(section.icon)"
|
|
112
|
+
v-if="section.icon && getIcon(section.icon)"
|
|
113
|
+
class="h-4 w-4 text-muted-foreground"
|
|
114
|
+
/>
|
|
115
|
+
{{ section.title }}
|
|
116
|
+
</CardTitle>
|
|
117
|
+
</CardHeader>
|
|
118
|
+
<CardContent class="px-6 py-4">
|
|
119
|
+
<div
|
|
120
|
+
class="grid gap-4"
|
|
121
|
+
:class="(section.cols ?? 1) === 2 ? 'sm:grid-cols-2' : 'grid-cols-1'"
|
|
122
|
+
>
|
|
123
|
+
<div v-for="(field, fi) in section.fields" :key="fi">
|
|
124
|
+
<slot :name="`field-${field.key}`" :field="field" :value="getFieldValue(field)" :entity="entity">
|
|
125
|
+
<dt class="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
|
126
|
+
{{ field.label }}
|
|
127
|
+
</dt>
|
|
128
|
+
<dd class="mt-1 text-sm text-foreground">
|
|
129
|
+
<!-- Badge -->
|
|
130
|
+
<span
|
|
131
|
+
v-if="field.type === 'badge'"
|
|
132
|
+
class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium"
|
|
133
|
+
:class="field.badgeClass ?? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-400'"
|
|
134
|
+
>
|
|
135
|
+
{{ getFieldValue(field) ?? field.fallback ?? '—' }}
|
|
136
|
+
</span>
|
|
137
|
+
|
|
138
|
+
<!-- Email -->
|
|
139
|
+
<a
|
|
140
|
+
v-else-if="field.type === 'email' && getFieldValue(field)"
|
|
141
|
+
:href="`mailto:${getFieldValue(field)}`"
|
|
142
|
+
class="text-primary hover:underline"
|
|
143
|
+
>
|
|
144
|
+
{{ getFieldValue(field) }}
|
|
145
|
+
</a>
|
|
146
|
+
|
|
147
|
+
<!-- Phone -->
|
|
148
|
+
<a
|
|
149
|
+
v-else-if="field.type === 'phone' && getFieldValue(field)"
|
|
150
|
+
:href="`tel:${getFieldValue(field)}`"
|
|
151
|
+
class="text-primary hover:underline"
|
|
152
|
+
>
|
|
153
|
+
{{ getFieldValue(field) }}
|
|
154
|
+
</a>
|
|
155
|
+
|
|
156
|
+
<!-- Money -->
|
|
157
|
+
<span
|
|
158
|
+
v-else-if="field.type === 'money'"
|
|
159
|
+
class="font-semibold text-emerald-600 dark:text-emerald-400"
|
|
160
|
+
>
|
|
161
|
+
{{ formatMoney(getFieldValue(field)) }}
|
|
162
|
+
</span>
|
|
163
|
+
|
|
164
|
+
<!-- Default text -->
|
|
165
|
+
<span v-else>
|
|
166
|
+
{{ getFieldValue(field) ?? field.fallback ?? '—' }}
|
|
167
|
+
</span>
|
|
168
|
+
</dd>
|
|
169
|
+
</slot>
|
|
170
|
+
</div>
|
|
171
|
+
</div>
|
|
172
|
+
</CardContent>
|
|
173
|
+
</Card>
|
|
174
|
+
</div>
|
|
175
|
+
|
|
176
|
+
<!-- Sidebar -->
|
|
177
|
+
<div v-if="sidebar && sidebar.length" class="space-y-6">
|
|
178
|
+
<Card
|
|
179
|
+
v-for="(card, ci) in [
|
|
180
|
+
{
|
|
181
|
+
title: `${title.split(' ')[0]} Overview`,
|
|
182
|
+
items: sidebar,
|
|
183
|
+
},
|
|
184
|
+
]"
|
|
185
|
+
:key="ci"
|
|
186
|
+
class="overflow-hidden border shadow-sm"
|
|
187
|
+
>
|
|
188
|
+
<CardHeader class="border-b px-6 py-4">
|
|
189
|
+
<CardTitle class="text-base font-semibold">{{ card.title }}</CardTitle>
|
|
190
|
+
</CardHeader>
|
|
191
|
+
<CardContent class="px-6 py-4">
|
|
192
|
+
<div class="space-y-3">
|
|
193
|
+
<div
|
|
194
|
+
v-for="(item, ii) in card.items"
|
|
195
|
+
:key="ii"
|
|
196
|
+
class="flex items-center justify-between"
|
|
197
|
+
>
|
|
198
|
+
<span class="text-sm text-muted-foreground">{{ item.label }}</span>
|
|
199
|
+
<span
|
|
200
|
+
v-if="item.type === 'badge'"
|
|
201
|
+
class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium"
|
|
202
|
+
:class="item.badgeClass ?? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-400'"
|
|
203
|
+
>
|
|
204
|
+
{{ getSidebarValue(item) ?? '—' }}
|
|
205
|
+
</span>
|
|
206
|
+
<span v-else class="text-sm font-medium text-foreground">
|
|
207
|
+
{{ getSidebarValue(item) ?? '—' }}
|
|
208
|
+
</span>
|
|
209
|
+
</div>
|
|
210
|
+
</div>
|
|
211
|
+
</CardContent>
|
|
212
|
+
</Card>
|
|
213
|
+
</div>
|
|
214
|
+
</div>
|
|
215
|
+
</div>
|
|
216
|
+
</template>
|