@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.
Files changed (95) hide show
  1. package/package.json +46 -0
  2. package/src/__test__/CrudCards.test.ts +118 -0
  3. package/src/__test__/CrudEmptyState.test.ts +82 -0
  4. package/src/__test__/CrudForm.test.ts +101 -0
  5. package/src/__test__/CrudShow.test.ts +135 -0
  6. package/src/__test__/CrudTable.test.ts +111 -0
  7. package/src/__test__/CrudToolbar.test.ts +102 -0
  8. package/src/__test__/setup.ts +43 -0
  9. package/src/__test__/useCrud.test.ts +349 -0
  10. package/src/components/CrudCards.vue +105 -0
  11. package/src/components/CrudDeleteDialog.vue +80 -0
  12. package/src/components/CrudEmptyState.vue +58 -0
  13. package/src/components/CrudFilters.vue +194 -0
  14. package/src/components/CrudForm.vue +232 -0
  15. package/src/components/CrudPage.vue +206 -0
  16. package/src/components/CrudPagination.vue +130 -0
  17. package/src/components/CrudSearch.vue +42 -0
  18. package/src/components/CrudShow.vue +216 -0
  19. package/src/components/CrudTable.vue +146 -0
  20. package/src/components/CrudToolbar.vue +86 -0
  21. package/src/components/InputError.vue +13 -0
  22. package/src/components/ui/button/Button.vue +27 -0
  23. package/src/components/ui/button/index.ts +36 -0
  24. package/src/components/ui/card/Card.vue +22 -0
  25. package/src/components/ui/card/CardAction.vue +17 -0
  26. package/src/components/ui/card/CardContent.vue +17 -0
  27. package/src/components/ui/card/CardDescription.vue +17 -0
  28. package/src/components/ui/card/CardFooter.vue +17 -0
  29. package/src/components/ui/card/CardHeader.vue +17 -0
  30. package/src/components/ui/card/CardTitle.vue +17 -0
  31. package/src/components/ui/card/index.ts +7 -0
  32. package/src/components/ui/checkbox/Checkbox.vue +37 -0
  33. package/src/components/ui/checkbox/index.ts +1 -0
  34. package/src/components/ui/combobox/ComboboxInput.vue +83 -0
  35. package/src/components/ui/combobox/index.ts +1 -0
  36. package/src/components/ui/dialog/Dialog.vue +17 -0
  37. package/src/components/ui/dialog/DialogClose.vue +14 -0
  38. package/src/components/ui/dialog/DialogContent.vue +49 -0
  39. package/src/components/ui/dialog/DialogDescription.vue +25 -0
  40. package/src/components/ui/dialog/DialogFooter.vue +15 -0
  41. package/src/components/ui/dialog/DialogHeader.vue +17 -0
  42. package/src/components/ui/dialog/DialogOverlay.vue +23 -0
  43. package/src/components/ui/dialog/DialogScrollContent.vue +59 -0
  44. package/src/components/ui/dialog/DialogTitle.vue +25 -0
  45. package/src/components/ui/dialog/DialogTrigger.vue +14 -0
  46. package/src/components/ui/dialog/index.ts +10 -0
  47. package/src/components/ui/dropdown-menu/DropdownMenu.vue +17 -0
  48. package/src/components/ui/dropdown-menu/DropdownMenuCheckboxItem.vue +41 -0
  49. package/src/components/ui/dropdown-menu/DropdownMenuContent.vue +39 -0
  50. package/src/components/ui/dropdown-menu/DropdownMenuGroup.vue +14 -0
  51. package/src/components/ui/dropdown-menu/DropdownMenuItem.vue +30 -0
  52. package/src/components/ui/dropdown-menu/DropdownMenuLabel.vue +22 -0
  53. package/src/components/ui/dropdown-menu/DropdownMenuRadioGroup.vue +22 -0
  54. package/src/components/ui/dropdown-menu/DropdownMenuRadioItem.vue +42 -0
  55. package/src/components/ui/dropdown-menu/DropdownMenuSeparator.vue +26 -0
  56. package/src/components/ui/dropdown-menu/DropdownMenuShortcut.vue +17 -0
  57. package/src/components/ui/dropdown-menu/DropdownMenuSub.vue +19 -0
  58. package/src/components/ui/dropdown-menu/DropdownMenuSubContent.vue +31 -0
  59. package/src/components/ui/dropdown-menu/DropdownMenuSubTrigger.vue +30 -0
  60. package/src/components/ui/dropdown-menu/DropdownMenuTrigger.vue +16 -0
  61. package/src/components/ui/dropdown-menu/index.ts +16 -0
  62. package/src/components/ui/input/Input.vue +33 -0
  63. package/src/components/ui/input/index.ts +1 -0
  64. package/src/components/ui/label/Label.vue +28 -0
  65. package/src/components/ui/label/index.ts +1 -0
  66. package/src/components/ui/select/Select.vue +15 -0
  67. package/src/components/ui/select/SelectContent.vue +49 -0
  68. package/src/components/ui/select/SelectGroup.vue +17 -0
  69. package/src/components/ui/select/SelectItem.vue +41 -0
  70. package/src/components/ui/select/SelectItemText.vue +12 -0
  71. package/src/components/ui/select/SelectLabel.vue +14 -0
  72. package/src/components/ui/select/SelectScrollDownButton.vue +22 -0
  73. package/src/components/ui/select/SelectScrollUpButton.vue +22 -0
  74. package/src/components/ui/select/SelectSeparator.vue +15 -0
  75. package/src/components/ui/select/SelectTrigger.vue +29 -0
  76. package/src/components/ui/select/SelectValue.vue +12 -0
  77. package/src/components/ui/select/index.ts +11 -0
  78. package/src/components/ui/separator/Separator.vue +28 -0
  79. package/src/components/ui/separator/index.ts +1 -0
  80. package/src/components/ui/spinner/Spinner.vue +17 -0
  81. package/src/components/ui/spinner/index.ts +1 -0
  82. package/src/components/ui/table/Table.vue +16 -0
  83. package/src/components/ui/table/TableBody.vue +14 -0
  84. package/src/components/ui/table/TableCaption.vue +14 -0
  85. package/src/components/ui/table/TableCell.vue +21 -0
  86. package/src/components/ui/table/TableEmpty.vue +34 -0
  87. package/src/components/ui/table/TableFooter.vue +14 -0
  88. package/src/components/ui/table/TableHead.vue +14 -0
  89. package/src/components/ui/table/TableHeader.vue +14 -0
  90. package/src/components/ui/table/TableRow.vue +14 -0
  91. package/src/components/ui/table/index.ts +9 -0
  92. package/src/composables/useCrud.ts +328 -0
  93. package/src/index.ts +33 -0
  94. package/src/lib/utils.ts +18 -0
  95. 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>