@stacksjs/defaults 0.75.279 → 0.75.281

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 (60) hide show
  1. package/ai/skills/stacks-database/SKILL.md +1 -3
  2. package/ai/skills/stacks-native/CATALOG.md +4 -4
  3. package/ide/vscode/package.json +1 -1
  4. package/package.json +3 -3
  5. package/project/bun-version +1 -1
  6. package/resources/components/Dashboard/Analytics/CampaignAnalytics.stx +28 -30
  7. package/resources/components/Dashboard/Commerce/CommerceCategoriesDashboard.stx +8 -28
  8. package/resources/components/Dashboard/Commerce/CommerceCategoriesTable.stx +72 -29
  9. package/resources/components/Dashboard/Commerce/CommerceCouponsDashboard.stx +8 -29
  10. package/resources/components/Dashboard/Commerce/CommerceCouponsTable.stx +69 -49
  11. package/resources/components/Dashboard/Commerce/CommerceCustomersDashboard.stx +8 -27
  12. package/resources/components/Dashboard/Commerce/CommerceCustomersTable.stx +70 -48
  13. package/resources/components/Dashboard/Commerce/CommerceGiftCardsDashboard.stx +8 -29
  14. package/resources/components/Dashboard/Commerce/CommerceGiftCardsTable.stx +72 -82
  15. package/resources/components/Dashboard/Commerce/CommerceManufacturersDashboard.stx +1 -1
  16. package/resources/components/Dashboard/Commerce/CommerceManufacturersTable.stx +3 -0
  17. package/resources/components/Dashboard/Commerce/CommerceOrdersDashboard.stx +8 -28
  18. package/resources/components/Dashboard/Commerce/CommerceOrdersTable.stx +76 -49
  19. package/resources/components/Dashboard/Commerce/CommercePaymentsDashboard.stx +1 -1
  20. package/resources/components/Dashboard/Commerce/CommercePaymentsTable.stx +3 -0
  21. package/resources/components/Dashboard/Commerce/CommerceProductsDashboard.stx +8 -28
  22. package/resources/components/Dashboard/Commerce/CommerceProductsTable.stx +81 -34
  23. package/resources/components/Dashboard/Commerce/CommerceReviewsDashboard.stx +11 -37
  24. package/resources/components/Dashboard/Commerce/CommerceReviewsTable.stx +106 -77
  25. package/resources/components/Dashboard/Commerce/CommerceTaxesDashboard.stx +1 -1
  26. package/resources/components/Dashboard/Commerce/CommerceTaxesTable.stx +3 -0
  27. package/resources/components/Dashboard/Commerce/CommerceUnitsDashboard.stx +1 -1
  28. package/resources/components/Dashboard/Commerce/CommerceUnitsTable.stx +3 -0
  29. package/resources/components/Dashboard/Commerce/CommerceVariantsDashboard.stx +1 -1
  30. package/resources/components/Dashboard/Commerce/CommerceVariantsTable.stx +3 -0
  31. package/resources/components/Dashboard/Commerce/Delivery/CouriersDashboard.stx +1 -11
  32. package/resources/components/Dashboard/Commerce/Delivery/CouriersTable.stx +55 -82
  33. package/resources/components/Dashboard/Commerce/Delivery/DeliveryRoutesDashboard.stx +1 -12
  34. package/resources/components/Dashboard/Commerce/Delivery/DeliveryRoutesTable.stx +57 -86
  35. package/resources/components/Dashboard/Commerce/Delivery/DigitalDeliveriesDashboard.stx +1 -24
  36. package/resources/components/Dashboard/Commerce/Delivery/DigitalDeliveryTable.stx +62 -80
  37. package/resources/components/Dashboard/Commerce/Delivery/LicenseKeysDashboard.stx +1 -12
  38. package/resources/components/Dashboard/Commerce/Delivery/LicenseKeysTable.stx +58 -99
  39. package/resources/components/Dashboard/Commerce/Delivery/ShippingMethodsDashboard.stx +1 -23
  40. package/resources/components/Dashboard/Commerce/Delivery/ShippingMethodsTable.stx +51 -86
  41. package/resources/components/Dashboard/Commerce/Delivery/ShippingRatesDashboard.stx +1 -12
  42. package/resources/components/Dashboard/Commerce/Delivery/ShippingRatesTable.stx +58 -71
  43. package/resources/components/Dashboard/Commerce/Delivery/ShippingZonesDashboard.stx +1 -12
  44. package/resources/components/Dashboard/Commerce/Delivery/ShippingZonesTable.stx +85 -110
  45. package/resources/components/Dashboard/Commerce/PrintDevicesDashboard.stx +8 -28
  46. package/resources/components/Dashboard/Commerce/PrintDevicesTable.stx +62 -57
  47. package/resources/components/Dashboard/Marketing/AbandonedCartsDashboard.stx +7 -24
  48. package/resources/components/Dashboard/Marketing/AbandonedCartsTable.stx +63 -59
  49. package/resources/components/Dashboard/Marketing/CampaignsDashboard.stx +7 -25
  50. package/resources/components/Dashboard/Marketing/CampaignsTable.stx +80 -57
  51. package/resources/components/Dashboard/Marketing/MarketingListsDashboard.stx +7 -27
  52. package/resources/components/Dashboard/Marketing/MarketingListsTable.stx +66 -71
  53. package/resources/components/Dashboard/Marketing/SocialPostsDashboard.stx +7 -25
  54. package/resources/components/Dashboard/Marketing/SocialPostsTable.stx +75 -62
  55. package/resources/components/Dashboard/Monitoring/ErrorDashboard.stx +9 -50
  56. package/resources/components/Dashboard/Monitoring/ErrorTable.stx +68 -97
  57. package/resources/components/Dashboard/Notifications/NotificationDeliveries.stx +22 -44
  58. package/resources/components/Dashboard/Notifications/NotificationDeliveryHistory.stx +13 -4
  59. package/resources/components/Dashboard/Notifications/NotificationDeliveryTable.stx +74 -86
  60. package/resources/components/Dashboard/UI/Table.stx +41 -2
@@ -1,94 +1,123 @@
1
+ @import('../UI/Table')
2
+
1
3
  <script client>
2
4
  import type { ReviewRecord } from '../../../../app/Actions/Dashboard/Commerce/review-records'
5
+ import { parseDashboardDate } from '../../../../functions/dashboard-dates'
3
6
 
4
7
  const reviews = useReactiveProp('reviews', [] as ReviewRecord[])
5
8
  const loading = useReactiveProp('loading', false)
6
9
  const busyId = useReactiveProp('busyId', '')
10
+ /** The parent's sort select: a change hands the order back from a header sort. */
11
+ const sortResetKey = useReactiveProp<unknown>('sortResetKey', '')
12
+ /** The parent's search and filters: a change returns to the first page. */
13
+ const pageResetKey = useReactiveProp<unknown>('pageResetKey', '')
7
14
  const emit = defineEmits()
8
15
 
16
+ /** Epoch seconds or milliseconds as well as stored timestamps, as the API sends either. */
9
17
  function formatDate(value: string): string {
10
18
  if (!value)
11
19
  return 'Not recorded'
12
- const numeric = /^\d{10,13}$/.test(value)
13
- ? Number(value) * (value.length === 10 ? 1000 : 1)
14
- : Number.NaN
15
- const normalized = /^\d{4}-\d{2}-\d{2} \d/.test(value)
16
- ? `${value.replace(' ', 'T')}Z`
17
- : value
18
- const date = new Date(Number.isFinite(numeric) ? numeric : normalized)
20
+ const date = /^\d{10,13}$/.test(value)
21
+ ? new Date(Number(value) * (value.length === 10 ? 1000 : 1))
22
+ : parseDashboardDate(value)
19
23
  return Number.isNaN(date.getTime())
20
24
  ? value
21
25
  : new Intl.DateTimeFormat(undefined, { dateStyle: 'medium' }).format(date)
22
26
  }
27
+
28
+ /**
29
+ * Customer and review are slotted: the verified badge, the stars and the
30
+ * vote counts are layout a column option cannot express. Approve and feature
31
+ * each have two declared actions, one per state, so a row offers the one
32
+ * that applies.
33
+ */
34
+ const COLUMNS = [
35
+ { key: 'customerLabel', label: 'Customer', sortable: true, slotted: true },
36
+ { key: 'productLabel', label: 'Product', sortable: true, class: 'text-gray-700 dark:text-neutral-200' },
37
+ { key: 'rating', label: 'Review', sortable: true, slotted: true },
38
+ {
39
+ key: 'moderation',
40
+ label: 'State',
41
+ type: 'badge' as const,
42
+ sortable: true,
43
+ subtitleKey: 'featuredLabel',
44
+ badgeClasses: {
45
+ approved: 'text-green-700 dark:text-green-300 bg-green-50 dark:bg-green-950/30',
46
+ pending: 'text-amber-700 dark:text-amber-300 bg-amber-50 dark:bg-amber-950/30',
47
+ },
48
+ badgeLabels: { approved: 'Approved', pending: 'Pending' },
49
+ },
50
+ ]
51
+
52
+ const ROW_ACTIONS = [
53
+ { action: 'approve', label: 'Approve review', icon: 'i-hugeicons-checkmark-circle-02', class: 'text-green-500', when: { key: 'approved', equals: false } },
54
+ { action: 'approve', label: 'Return review to pending', icon: 'i-hugeicons-cancel-circle', class: 'text-green-500', when: { key: 'approved', equals: true } },
55
+ { action: 'feature', label: 'Feature review', icon: 'i-hugeicons-star', class: 'text-amber-500', when: { key: 'featured', equals: false } },
56
+ { action: 'feature', label: 'Remove review from featured', icon: 'i-hugeicons-star-off', class: 'text-amber-500', when: { key: 'featured', equals: true } },
57
+ { action: 'edit', label: 'Edit review', icon: 'i-hugeicons-edit-01' },
58
+ { action: 'delete', label: 'Delete review', icon: 'i-hugeicons-waste', class: 'text-red-500' },
59
+ ]
60
+
61
+ const rows = derived(() => reviews().map(review => ({
62
+ ...review,
63
+ customerLabel: review.customerName || 'Guest customer',
64
+ productLabel: review.productName || 'Unlinked product',
65
+ titleLabel: review.title || 'Untitled review',
66
+ contentLabel: review.content || 'No review content recorded.',
67
+ stars: `${'★'.repeat(review.rating)}${'☆'.repeat(5 - review.rating)}`,
68
+ starsLabel: `${review.rating} out of 5 stars`,
69
+ helpfulLabel: `${review.helpfulVotes} helpful`,
70
+ unhelpfulLabel: `${review.unhelpfulVotes} not helpful`,
71
+ dateLabel: formatDate(review.createdAt),
72
+ moderation: review.approved ? 'approved' : 'pending',
73
+ featuredLabel: review.featured ? 'Featured' : '',
74
+ })))
75
+
76
+ function handleRowAction(payload: { action: string, row: ReviewRecord }): void {
77
+ emit(payload.action, payload.row)
78
+ }
23
79
  </script>
24
80
 
25
- <div class="overflow-hidden bg-white dark:bg-neutral-800 border border-gray-200 rounded-lg dark:border-neutral-700">
26
- <div :if="loading() && reviews().length === 0" class="flex items-center justify-center p-10 text-gray-500 text-sm dark:text-neutral-400">
27
- <span class="mr-2 h-4 w-4 animate-spin i-hugeicons-loading-03"></span>
28
- Loading review records
29
- </div>
30
- <div :else-if="reviews().length === 0" class="p-10 text-center">
31
- <p class="font-medium text-gray-900 text-sm dark:text-white">No reviews found</p>
32
- <p class="mt-1 text-gray-500 text-sm dark:text-neutral-400">Adjust the current filters or wait for a customer review.</p>
33
- </div>
34
- <div :else class="overflow-x-auto">
35
- <table class="min-w-full divide-gray-200 divide-y dark:divide-neutral-700">
36
- <thead class="bg-gray-50 dark:bg-neutral-900/50">
37
- <tr>
38
- <th scope="col" class="px-4 py-3 font-semibold text-gray-600 text-left text-xs uppercase dark:text-neutral-400">Customer</th>
39
- <th scope="col" class="px-4 py-3 font-semibold text-gray-600 text-left text-xs uppercase dark:text-neutral-400">Product</th>
40
- <th scope="col" class="px-4 py-3 font-semibold text-gray-600 text-left text-xs uppercase dark:text-neutral-400">Review</th>
41
- <th scope="col" class="px-4 py-3 font-semibold text-gray-600 text-left text-xs uppercase dark:text-neutral-400">State</th>
42
- <th scope="col" class="relative px-4 py-3"><span class="sr-only">Actions</span></th>
43
- </tr>
44
- </thead>
45
- <tbody class="divide-gray-100 divide-y dark:divide-neutral-700">
46
- <template :for="review in reviews()" :key="review.id">
47
- <tr class="hover:bg-gray-50/60 dark:hover:bg-neutral-700/40 align-top">
48
- <td class="px-4 py-4 min-w-44">
49
- <p class="font-medium text-gray-900 text-sm dark:text-white">{{ review.customerName || 'Guest customer' }}</p>
50
- <p :if="review.customerEmail" class="mt-1 text-gray-500 text-xs dark:text-neutral-400">{{ review.customerEmail }}</p>
51
- <span :if="review.verifiedPurchase" class="inline-flex items-center mt-2 font-medium text-green-700 text-xs dark:text-green-300">
52
- <span class="mr-1 h-3.5 w-3.5 i-hugeicons-checkmark-badge-01"></span>
53
- Verified purchase
54
- </span>
55
- </td>
56
- <td class="px-4 py-4 min-w-40 text-gray-700 text-sm dark:text-neutral-200">{{ review.productName || 'Unlinked product' }}</td>
57
- <td class="px-4 py-4 max-w-lg min-w-72">
58
- <p class="font-medium text-gray-900 text-sm dark:text-white">{{ review.title || 'Untitled review' }}</p>
59
- <p class="mt-1 line-clamp-2 text-gray-500 text-sm dark:text-neutral-400">{{ review.content || 'No review content recorded.' }}</p>
60
- <div class="flex flex-wrap gap-x-3 gap-y-1 items-center mt-2 text-gray-400 text-xs dark:text-neutral-500">
61
- <span :aria-label="review.rating + ' out of 5 stars'" class="text-amber-500 text-sm">{{ '★'.repeat(review.rating) }}{{ '☆'.repeat(5 - review.rating) }}</span>
62
- <span>{{ review.helpfulVotes }} helpful</span>
63
- <span>{{ review.unhelpfulVotes }} not helpful</span>
64
- <span>{{ formatDate(review.createdAt) }}</span>
65
- </div>
66
- </td>
67
- <td class="px-4 py-4 whitespace-nowrap">
68
- <span :class="'inline-flex px-2 py-1 font-medium text-xs rounded-full ' + (review.approved ? 'text-green-700 dark:text-green-300 bg-green-50 dark:bg-green-950/30' : 'text-amber-700 dark:text-amber-300 bg-amber-50 dark:bg-amber-950/30')">
69
- {{ review.approved ? 'Approved' : 'Pending' }}
70
- </span>
71
- <span :if="review.featured" class="block mt-2 font-medium text-blue-700 text-xs dark:text-blue-300">Featured</span>
72
- </td>
73
- <td class="px-4 py-3 text-right whitespace-nowrap">
74
- <div class="flex gap-1 justify-end">
75
- <Button variant="ghost" size="sm" iconOnly :disabled="busyId() === review.id" :ariaLabel="review.approved ? 'Return review to pending' : 'Approve review'" @click="emit('approve', review)">
76
- <span :class="'block h-4 w-4 text-green-500 ' + (review.approved ? 'i-hugeicons-cancel-circle' : 'i-hugeicons-checkmark-circle-02')"></span>
77
- </Button>
78
- <Button variant="ghost" size="sm" iconOnly :disabled="busyId() === review.id" :ariaLabel="review.featured ? 'Remove review from featured' : 'Feature review'" @click="emit('feature', review)">
79
- <span :class="'block h-4 w-4 text-amber-500 ' + (review.featured ? 'i-hugeicons-star-off' : 'i-hugeicons-star')"></span>
80
- </Button>
81
- <Button variant="ghost" size="sm" iconOnly :ariaLabel="'Edit review ' + review.id" @click="emit('edit', review)">
82
- <span class="block h-4 w-4 text-blue-500 i-hugeicons-edit-01"></span>
83
- </Button>
84
- <Button variant="ghost" size="sm" iconOnly :ariaLabel="'Delete review ' + review.id" @click="emit('delete', review)">
85
- <span class="block h-4 w-4 text-red-500 i-hugeicons-waste"></span>
86
- </Button>
87
- </div>
88
- </td>
89
- </tr>
90
- </template>
91
- </tbody>
92
- </table>
93
- </div>
94
- </div>
81
+ {{--
82
+ No `searchable` or `filterable`: the parent dashboard owns the search box
83
+ and the filter selects. Table pages the whole filtered list the parent passes.
84
+ --}}
85
+ <Table
86
+ :sort-reset-key="sortResetKey()"
87
+ :page-reset-key="pageResetKey()"
88
+ :columns="COLUMNS"
89
+ :data="rows()"
90
+ :loading="loading()"
91
+ :row-actions="ROW_ACTIONS"
92
+ action-label-key="id"
93
+ busy-key="id"
94
+ :busy-value="busyId()"
95
+ paginated
96
+ :page-size="12"
97
+ empty-title="No reviews found"
98
+ empty-description="Adjust the current filters or wait for a customer review."
99
+ @row-action="handleRowAction"
100
+ >
101
+ <template #cell="{ row, column }">
102
+ <template :if="column.key === 'customerLabel'">
103
+ <p class="font-medium text-gray-900 text-sm dark:text-white" x-text="row.customerLabel"></p>
104
+ <p :if="row.customerEmail" class="mt-1 text-gray-500 text-xs dark:text-neutral-400" x-text="row.customerEmail"></p>
105
+ <span :if="row.verifiedPurchase" class="inline-flex items-center mt-2 font-medium text-green-700 text-xs dark:text-green-300">
106
+ <span class="mr-1 h-3.5 w-3.5 i-hugeicons-checkmark-badge-01"></span>
107
+ Verified purchase
108
+ </span>
109
+ </template>
110
+ <template :if="column.key === 'rating'">
111
+ <div class="max-w-lg min-w-72">
112
+ <p class="font-medium text-gray-900 text-sm dark:text-white" x-text="row.titleLabel"></p>
113
+ <p class="mt-1 line-clamp-2 text-gray-500 text-sm dark:text-neutral-400" x-text="row.contentLabel"></p>
114
+ <div class="flex flex-wrap gap-x-3 gap-y-1 items-center mt-2 text-gray-400 text-xs dark:text-neutral-500">
115
+ <span :aria-label="row.starsLabel" class="text-amber-500 text-sm" x-text="row.stars"></span>
116
+ <span x-text="row.helpfulLabel"></span>
117
+ <span x-text="row.unhelpfulLabel"></span>
118
+ <span x-text="row.dateLabel"></span>
119
+ </div>
120
+ </div>
121
+ </template>
122
+ </template>
123
+ </Table>
@@ -268,7 +268,7 @@ onMount(() => {
268
268
  </label>
269
269
  </div>
270
270
 
271
- <CommerceTaxesTable :records="visibleRecords()" :loading="loading()" @edit="openEdit" @delete="openDelete" />
271
+ <CommerceTaxesTable :sort-reset-key="sort()" :records="visibleRecords()" :loading="loading()" @edit="openEdit" @delete="openDelete" />
272
272
  </section>
273
273
  </div>
274
274
 
@@ -5,6 +5,8 @@ import type { TaxRateRecord } from '../../../../app/Actions/Dashboard/Commerce/t
5
5
 
6
6
  const records = useReactiveProp('records', [] as TaxRateRecord[])
7
7
  const loading = useReactiveProp('loading', false)
8
+ /** The parent's sort select; a change hands the order back from a header sort. */
9
+ const sortResetKey = useReactiveProp<unknown>('sortResetKey', '')
8
10
  const emit = defineEmits()
9
11
 
10
12
  /**
@@ -50,6 +52,7 @@ function handleRowAction(payload: { action: string, row: TaxRateRecord }): void
50
52
  and the filter selects. Paging is what was missing, and Table brings it.
51
53
  --}}
52
54
  <Table
55
+ :sort-reset-key="sortResetKey()"
53
56
  :columns="COLUMNS"
54
57
  :data="rows()"
55
58
  :loading="loading()"
@@ -265,7 +265,7 @@ onMount(() => {
265
265
  </label>
266
266
  </div>
267
267
 
268
- <CommerceUnitsTable :records="visibleRecords()" :loading="loading()" @edit="openEdit" @delete="openDelete" />
268
+ <CommerceUnitsTable :sort-reset-key="sort()" :records="visibleRecords()" :loading="loading()" @edit="openEdit" @delete="openDelete" />
269
269
  </section>
270
270
  </div>
271
271
 
@@ -5,6 +5,8 @@ import type { ProductUnitRecord } from '../../../../app/Actions/Dashboard/Commer
5
5
 
6
6
  const records = useReactiveProp('records', [] as ProductUnitRecord[])
7
7
  const loading = useReactiveProp('loading', false)
8
+ /** The parent's sort select; a change hands the order back from a header sort. */
9
+ const sortResetKey = useReactiveProp<unknown>('sortResetKey', '')
8
10
  const emit = defineEmits()
9
11
 
10
12
  /**
@@ -56,6 +58,7 @@ function handleRowAction(payload: { action: string, row: ProductUnitRecord }): v
56
58
  duplicate them. Paging is what was missing, and Table brings it.
57
59
  --}}
58
60
  <Table
61
+ :sort-reset-key="sortResetKey()"
59
62
  :columns="COLUMNS"
60
63
  :data="rows()"
61
64
  :loading="loading()"
@@ -226,7 +226,7 @@ onMount(() => {
226
226
  <label class="flex gap-2 items-center text-gray-500 text-sm dark:text-neutral-400">Sort<select x-model="sort" class="px-3 py-1.5 text-gray-900 text-sm dark:text-white bg-white dark:bg-neutral-800 border border-gray-300 rounded-md dark:border-neutral-700"><option value="variant">Name</option><option value="options">Most options</option><option value="product">Product</option><option value="newest">Newest</option></select></label>
227
227
  </div>
228
228
 
229
- <CommerceVariantsTable :records="visibleRecords()" :loading="loading()" @edit="openEdit" @delete="openDelete" />
229
+ <CommerceVariantsTable :sort-reset-key="sort()" :records="visibleRecords()" :loading="loading()" @edit="openEdit" @delete="openDelete" />
230
230
  </section>
231
231
  </div>
232
232
 
@@ -5,6 +5,8 @@ import type { ProductVariantRecord } from '../../../../app/Actions/Dashboard/Com
5
5
 
6
6
  const records = useReactiveProp('records', [] as ProductVariantRecord[])
7
7
  const loading = useReactiveProp('loading', false)
8
+ /** The parent's sort select; a change hands the order back from a header sort. */
9
+ const sortResetKey = useReactiveProp<unknown>('sortResetKey', '')
8
10
  const emit = defineEmits()
9
11
 
10
12
  /**
@@ -48,6 +50,7 @@ function handleRowAction(payload: { action: string, row: ProductVariantRecord })
48
50
  </script>
49
51
 
50
52
  <Table
53
+ :sort-reset-key="sortResetKey()"
51
54
  :columns="COLUMNS"
52
55
  :data="rows()"
53
56
  :loading="loading()"
@@ -52,8 +52,6 @@ const {
52
52
  loading,
53
53
  loadError,
54
54
  search,
55
- page,
56
- perPage,
57
55
  editorOpen,
58
56
  editorMode,
59
57
  editing,
@@ -63,8 +61,6 @@ const {
63
61
  deleteBusy,
64
62
  deleteError,
65
63
  filteredRecords,
66
- paginatedRecords,
67
- goToPage,
68
64
  handleSearch,
69
65
  openCreate,
70
66
  openEdit,
@@ -73,7 +69,6 @@ const {
73
69
  closeDelete,
74
70
  upsert,
75
71
  remove,
76
- clampPage,
77
72
  } = useRecordsDashboard<CourierRecord>({ filter: matchesQuery })
78
73
 
79
74
  const users = state<UserOption[]>([])
@@ -114,7 +109,6 @@ async function loadCouriers(): Promise<void> {
114
109
  throw new TypeError('Courier resources were not returned in the expected shape.')
115
110
  users.set(userData.users)
116
111
  records.set(courierData.map(withUser))
117
- clampPage()
118
112
  }
119
113
  catch (error) {
120
114
  loadError.set(error instanceof Error ? error.message : String(error))
@@ -203,11 +197,7 @@ onMount(() => {
203
197
  <div><h2 class="font-semibold text-gray-900 dark:text-white">Courier roster</h2><p class="mt-0.5 text-gray-500 text-xs dark:text-neutral-400">{{ filteredRecords().length }} matching records</p></div>
204
198
  <SearchFilter placeholder="Search couriers" class="w-full sm:w-80" @search="handleSearch" />
205
199
  </div>
206
- <div :if="loading()" class="flex items-center justify-center py-24 text-gray-500 text-sm dark:text-neutral-400"><span class="mr-2 h-4 w-4 animate-spin i-hugeicons-loading-03"></span>Loading couriers</div>
207
- <CouriersTable :if="!loading()" :couriers="paginatedRecords()" @edit="openEdit" @delete="requestDelete" />
208
- <div :if="!loading()" class="px-4 py-3 bg-gray-50 dark:bg-neutral-900/50 border-gray-200 border-t dark:border-neutral-700">
209
- <DeliveryPagination :current-page="page()" :total-items="filteredRecords().length" :items-per-page="perPage()" @select="goToPage" />
210
- </div>
200
+ <CouriersTable :couriers="filteredRecords()" :loading="loading()" :page-reset-key="search()" @edit="openEdit" @delete="requestDelete" />
211
201
  </section>
212
202
  </div>
213
203
 
@@ -1,3 +1,5 @@
1
+ @import('../../UI/Table')
2
+
1
3
  <script client>
2
4
  type CourierStatus = 'active' | 'on_delivery' | 'on_break'
3
5
 
@@ -13,91 +15,62 @@ interface CourierRow {
13
15
  }
14
16
 
15
17
  const couriers = useReactiveProp('couriers', [] as CourierRow[])
18
+ const loading = useReactiveProp('loading', false)
19
+ /** The parent's search: a change returns to the first page. */
20
+ const pageResetKey = useReactiveProp<unknown>('pageResetKey', '')
16
21
  const emit = defineEmits()
17
22
 
18
- function statusLabel(status: CourierStatus): string {
19
- return {
20
- active: 'Available',
21
- on_delivery: 'On delivery',
22
- on_break: 'On break',
23
- }[status] || status
24
- }
23
+ const COLUMNS = [
24
+ { key: 'name', label: 'Courier', sortable: true, class: 'font-medium text-stone-800 whitespace-nowrap dark:text-white' },
25
+ { key: 'phone', label: 'Phone', sortable: true, class: 'whitespace-nowrap' },
26
+ { key: 'vehicle_number', label: 'Vehicle', sortable: true, class: 'font-mono whitespace-nowrap' },
27
+ { key: 'license', label: 'License', sortable: true, class: 'font-mono whitespace-nowrap' },
28
+ { key: 'userLabel', label: 'User', sortable: true, class: 'whitespace-nowrap' },
29
+ {
30
+ key: 'status',
31
+ label: 'Status',
32
+ type: 'badge' as const,
33
+ sortable: true,
34
+ badgeClasses: {
35
+ active: 'bg-green-50 text-green-700 dark:bg-green-900/30 dark:text-green-300',
36
+ on_delivery: 'bg-blue-50 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300',
37
+ on_break: 'bg-amber-50 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300',
38
+ },
39
+ badgeLabels: { active: 'Available', on_delivery: 'On delivery', on_break: 'On break' },
40
+ },
41
+ ]
42
+
43
+ const ROW_ACTIONS = [
44
+ { action: 'edit', label: 'Edit', icon: 'i-hugeicons-edit-01' },
45
+ { action: 'delete', label: 'Delete', icon: 'i-hugeicons-waste', class: 'text-red-500' },
46
+ ]
25
47
 
26
- function statusClass(status: CourierStatus): string {
27
- if (status === 'active')
28
- return 'bg-green-50 text-green-700 ring-green-600/20 dark:bg-green-900/30 dark:text-green-300'
29
- if (status === 'on_delivery')
30
- return 'bg-blue-50 text-blue-700 ring-blue-600/20 dark:bg-blue-900/30 dark:text-blue-300'
31
- return 'bg-amber-50 text-amber-700 ring-amber-600/20 dark:bg-amber-900/30 dark:text-amber-300'
48
+ const rows = derived(() => couriers().map(courier => ({
49
+ ...courier,
50
+ userLabel: courier.user?.name || (courier.user_id ? `User #${courier.user_id}` : 'Not linked'),
51
+ actionName: `courier ${courier.name}, record ${courier.id}`,
52
+ })))
53
+
54
+ function handleRowAction(payload: { action: string, row: CourierRow }): void {
55
+ emit(payload.action, payload.row)
32
56
  }
33
57
  </script>
34
58
 
35
- <div class="overflow-x-auto border border-stone-200/80 rounded-xl dark:border-neutral-700 shadow-sm">
36
- <table class="min-w-full divide-stone-100 divide-y dark:divide-neutral-700">
37
- <thead class="bg-stone-50/80 dark:bg-neutral-800">
38
- <tr>
39
- <th scope="col" class="pl-6 pr-3 py-3.5 font-semibold text-left text-stone-500 text-xs tracking-wider uppercase dark:text-neutral-400">Courier</th>
40
- <th scope="col" class="px-4 py-3.5 font-semibold text-left text-stone-500 text-xs tracking-wider uppercase dark:text-neutral-400">Phone</th>
41
- <th scope="col" class="px-4 py-3.5 font-semibold text-left text-stone-500 text-xs tracking-wider uppercase dark:text-neutral-400">Vehicle</th>
42
- <th scope="col" class="px-4 py-3.5 font-semibold text-left text-stone-500 text-xs tracking-wider uppercase dark:text-neutral-400">License</th>
43
- <th scope="col" class="px-4 py-3.5 font-semibold text-left text-stone-500 text-xs tracking-wider uppercase dark:text-neutral-400">User</th>
44
- <th scope="col" class="px-4 py-3.5 font-semibold text-left text-stone-500 text-xs tracking-wider uppercase dark:text-neutral-400">Status</th>
45
- <th scope="col" class="relative pl-3 pr-6 py-3.5">
46
- <span class="sr-only">Actions</span>
47
- </th>
48
- </tr>
49
- </thead>
50
- <tbody class="bg-white dark:bg-neutral-800 divide-stone-100 divide-y dark:divide-neutral-700">
51
- <template :for="courier in couriers" :key="courier.id">
52
- <tr class="hover:bg-stone-50/50 dark:hover:bg-neutral-700/50 transition-colors">
53
- <td class="pl-6 pr-3 py-3.5 font-medium text-sm text-stone-800 whitespace-nowrap dark:text-white">
54
- {{ courier.name }}
55
- </td>
56
- <td class="px-4 py-3.5 text-sm text-stone-500 whitespace-nowrap dark:text-neutral-300">
57
- {{ courier.phone }}
58
- </td>
59
- <td class="px-4 py-3.5 font-mono text-sm text-stone-500 whitespace-nowrap dark:text-neutral-300">
60
- {{ courier.vehicle_number }}
61
- </td>
62
- <td class="px-4 py-3.5 font-mono text-sm text-stone-500 whitespace-nowrap dark:text-neutral-300">
63
- {{ courier.license }}
64
- </td>
65
- <td class="px-4 py-3.5 text-sm text-stone-500 whitespace-nowrap dark:text-neutral-300">
66
- {{ courier.user?.name || (courier.user_id ? 'User #' + courier.user_id : 'Not linked') }}
67
- </td>
68
- <td class="px-4 py-3.5 text-sm whitespace-nowrap">
69
- <span :class="'inline-flex items-center px-2 py-1 font-medium text-xs ring-1 ring-inset rounded-md ' + statusClass(courier.status)">
70
- {{ statusLabel(courier.status) }}
71
- </span>
72
- </td>
73
- <td class="relative pl-3 pr-6 py-3.5 font-medium text-right text-sm whitespace-nowrap">
74
- <Button
75
- variant="ghost"
76
- size="sm"
77
- iconOnly
78
- @click="emit('edit', courier)"
79
- :ariaLabel="'Edit courier ' + courier.name + ', record ' + courier.id"
80
- >
81
- <span class="block h-4 w-4 text-blue-500 i-hugeicons-edit-01"></span>
82
- </Button>
83
- <Button
84
- variant="ghost"
85
- size="sm"
86
- iconOnly
87
- @click="emit('delete', courier)"
88
- :ariaLabel="'Delete courier ' + courier.name + ', record ' + courier.id"
89
- >
90
- <span class="block h-4 w-4 text-red-500 i-hugeicons-waste"></span>
91
- </Button>
92
- </td>
93
- </tr>
94
- </template>
95
-
96
- <tr :if="couriers().length === 0">
97
- <td colspan="7" class="px-6 py-12 text-center text-sm text-stone-500 dark:text-neutral-400">
98
- No couriers found.
99
- </td>
100
- </tr>
101
- </tbody>
102
- </table>
103
- </div>
59
+ {{--
60
+ No `searchable` or `filterable`: the parent dashboard owns the search box.
61
+ Table pages the whole filtered list the parent passes, and stays mounted
62
+ while it loads.
63
+ --}}
64
+ <Table
65
+ :page-reset-key="pageResetKey()"
66
+ :columns="COLUMNS"
67
+ :data="rows()"
68
+ :loading="loading()"
69
+ :row-actions="ROW_ACTIONS"
70
+ action-label-key="actionName"
71
+ paginated
72
+ :page-size="10"
73
+ empty-title="No couriers found"
74
+ empty-description="Add a courier, or adjust the search."
75
+ @row-action="handleRowAction"
76
+ />
@@ -49,8 +49,6 @@ const {
49
49
  loading,
50
50
  loadError,
51
51
  search,
52
- page,
53
- perPage,
54
52
  editorOpen,
55
53
  editorMode,
56
54
  editing,
@@ -60,8 +58,6 @@ const {
60
58
  deleteBusy,
61
59
  deleteError,
62
60
  filteredRecords,
63
- paginatedRecords,
64
- goToPage,
65
61
  handleSearch,
66
62
  openCreate,
67
63
  openEdit,
@@ -70,7 +66,6 @@ const {
70
66
  closeDelete,
71
67
  upsert,
72
68
  remove,
73
- clampPage,
74
69
  } = useRecordsDashboard<DeliveryRouteRecord>({ filter: matchesQuery })
75
70
 
76
71
  const couriers = state<CourierRecord[]>([])
@@ -117,7 +112,6 @@ async function loadRoutes(): Promise<void> {
117
112
  throw new TypeError('Delivery route resources were not returned in the expected shape.')
118
113
  couriers.set(courierData)
119
114
  records.set(routeData.map(withCourier))
120
- clampPage()
121
115
  }
122
116
  catch (error) {
123
117
  loadError.set(error instanceof Error ? error.message : String(error))
@@ -172,7 +166,6 @@ async function confirmDelete(): Promise<void> {
172
166
  })
173
167
  remove(current)
174
168
  deleting.set(null)
175
- clampPage()
176
169
  pushToast('success', 'Delivery route deleted', { detail: `Route #${current.id}` })
177
170
  }
178
171
  catch (error) {
@@ -208,11 +201,7 @@ onMount(() => {
208
201
  <div><h2 class="font-semibold text-gray-900 dark:text-white">Route plan</h2><p class="mt-0.5 text-gray-500 text-xs dark:text-neutral-400">{{ filteredRecords().length }} matching records</p></div>
209
202
  <SearchFilter placeholder="Search delivery routes" class="w-full sm:w-80" @search="handleSearch" />
210
203
  </div>
211
- <div :if="loading()" class="flex items-center justify-center py-24 text-gray-500 text-sm dark:text-neutral-400"><span class="mr-2 h-4 w-4 animate-spin i-hugeicons-loading-03"></span>Loading delivery routes</div>
212
- <DeliveryRoutesTable :if="!loading()" :routes="paginatedRecords()" @edit="openEdit" @delete="requestDelete" />
213
- <div :if="!loading()" class="px-4 py-3 bg-gray-50 dark:bg-neutral-900/50 border-gray-200 border-t dark:border-neutral-700">
214
- <DeliveryPagination :current-page="page()" :total-items="filteredRecords().length" :items-per-page="perPage()" @select="goToPage" />
215
- </div>
204
+ <DeliveryRoutesTable :routes="filteredRecords()" :loading="loading()" :page-reset-key="search()" @edit="openEdit" @delete="requestDelete" />
216
205
  </section>
217
206
  </div>
218
207