@htlkg/components 0.0.3 → 0.0.4

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 (47) hide show
  1. package/dist/AdminWrapper.vue_vue_type_script_setup_true_lang-B32IylcT.js +367 -0
  2. package/dist/AdminWrapper.vue_vue_type_script_setup_true_lang-B32IylcT.js.map +1 -0
  3. package/dist/Alert.vue_vue_type_script_setup_true_lang-DxPCS-Hx.js +263 -0
  4. package/dist/Alert.vue_vue_type_script_setup_true_lang-DxPCS-Hx.js.map +1 -0
  5. package/dist/DateRange.vue_vue_type_script_setup_true_lang-BLVg1Hah.js +580 -0
  6. package/dist/DateRange.vue_vue_type_script_setup_true_lang-BLVg1Hah.js.map +1 -0
  7. package/dist/ProductBadge.vue_vue_type_script_setup_true_lang-Cmr2f4Cy.js +187 -0
  8. package/dist/ProductBadge.vue_vue_type_script_setup_true_lang-Cmr2f4Cy.js.map +1 -0
  9. package/dist/_plugin-vue_export-helper-1tPrXgE0.js +11 -0
  10. package/dist/_plugin-vue_export-helper-1tPrXgE0.js.map +1 -0
  11. package/dist/components.css +15 -0
  12. package/dist/composables/index.js +32 -765
  13. package/dist/composables/index.js.map +1 -1
  14. package/dist/data/index.js +18 -0
  15. package/dist/data/index.js.map +1 -0
  16. package/dist/domain/index.js +8 -0
  17. package/dist/domain/index.js.map +1 -0
  18. package/dist/filterHelpers-DgRyoYSa.js +1386 -0
  19. package/dist/filterHelpers-DgRyoYSa.js.map +1 -0
  20. package/dist/forms/index.js +6 -0
  21. package/dist/forms/index.js.map +1 -0
  22. package/dist/index-DGO_pNgG.js +79 -0
  23. package/dist/index-DGO_pNgG.js.map +1 -0
  24. package/dist/index-QK97OdqQ.js +25 -0
  25. package/dist/index-QK97OdqQ.js.map +1 -0
  26. package/dist/index.js +67 -0
  27. package/dist/index.js.map +1 -0
  28. package/dist/navigation/index.js +8 -0
  29. package/dist/navigation/index.js.map +1 -0
  30. package/dist/overlays/index.js +8 -0
  31. package/dist/overlays/index.js.map +1 -0
  32. package/dist/stores/index.js +14 -0
  33. package/dist/stores/index.js.map +1 -0
  34. package/dist/useAdminPage-GhgXp0x8.js +1070 -0
  35. package/dist/useAdminPage-GhgXp0x8.js.map +1 -0
  36. package/dist/useTable-DutR1gkg.js +293 -0
  37. package/dist/useTable-DutR1gkg.js.map +1 -0
  38. package/package.json +37 -11
  39. package/src/composables/index.ts +52 -0
  40. package/src/composables/useAdminPage.ts +462 -0
  41. package/src/composables/useConfirmation.ts +358 -0
  42. package/src/composables/useStats.ts +361 -0
  43. package/src/composables/useWizard.ts +448 -0
  44. package/src/data/columnHelpers.ts +169 -0
  45. package/src/data/filterHelpers.ts +358 -0
  46. package/src/data/index.ts +11 -0
  47. package/src/forms/JsonSchemaForm.vue +4 -1
@@ -0,0 +1,358 @@
1
+ /**
2
+ * Confirmation Composable
3
+ *
4
+ * Manages confirmation dialog state for destructive actions.
5
+ * Works with uiModal from @hotelinking/ui.
6
+ */
7
+
8
+ import { ref, computed, type Ref, type ComputedRef, type Component } from 'vue';
9
+
10
+ /**
11
+ * Confirmation dialog variant
12
+ */
13
+ export type ConfirmationVariant = 'danger' | 'warning' | 'info' | 'default';
14
+
15
+ /**
16
+ * Configuration for a confirmation dialog
17
+ */
18
+ export interface ConfirmationConfig {
19
+ /** Dialog title */
20
+ title: string;
21
+ /** Dialog message/description */
22
+ message: string;
23
+ /** Text for confirm button */
24
+ confirmText?: string;
25
+ /** Text for cancel button */
26
+ cancelText?: string;
27
+ /** Visual variant */
28
+ variant?: ConfirmationVariant;
29
+ /** Icon component to display */
30
+ icon?: Component;
31
+ /** Whether action is in progress */
32
+ loading?: boolean;
33
+ }
34
+
35
+ /**
36
+ * Internal state for pending confirmation
37
+ */
38
+ interface PendingConfirmation {
39
+ resolve: (value: boolean) => void;
40
+ reject: (reason?: unknown) => void;
41
+ }
42
+
43
+ /**
44
+ * Options for useConfirmation
45
+ */
46
+ export interface UseConfirmationOptions {
47
+ /** Default confirm button text */
48
+ defaultConfirmText?: string;
49
+ /** Default cancel button text */
50
+ defaultCancelText?: string;
51
+ /** Default variant */
52
+ defaultVariant?: ConfirmationVariant;
53
+ }
54
+
55
+ /**
56
+ * Return type for useConfirmation
57
+ */
58
+ export interface UseConfirmationReturn {
59
+ /** Whether dialog is open */
60
+ isOpen: Ref<boolean>;
61
+ /** Current dialog configuration */
62
+ config: Ref<ConfirmationConfig>;
63
+ /** Whether action is in progress */
64
+ isLoading: Ref<boolean>;
65
+
66
+ /** Open confirmation dialog and wait for response */
67
+ confirm: (options: ConfirmationConfig) => Promise<boolean>;
68
+ /** Handle confirm button click */
69
+ handleConfirm: () => void;
70
+ /** Handle cancel button click */
71
+ handleCancel: () => void;
72
+ /** Close dialog without triggering callbacks */
73
+ close: () => void;
74
+
75
+ /** Convenience: Confirm delete action */
76
+ confirmDelete: (itemName: string, options?: Partial<ConfirmationConfig>) => Promise<boolean>;
77
+ /** Convenience: Confirm bulk delete action */
78
+ confirmBulkDelete: (count: number, itemType?: string, options?: Partial<ConfirmationConfig>) => Promise<boolean>;
79
+ /** Convenience: Confirm destructive action */
80
+ confirmDestructive: (actionName: string, options?: Partial<ConfirmationConfig>) => Promise<boolean>;
81
+ /** Convenience: Confirm with custom async action */
82
+ confirmWithAction: <T>(
83
+ options: ConfirmationConfig,
84
+ action: () => Promise<T>
85
+ ) => Promise<{ confirmed: boolean; result?: T; error?: Error }>;
86
+
87
+ /** Props for uiModal component */
88
+ modalProps: ComputedRef<{
89
+ title: string;
90
+ open: boolean;
91
+ modalName: string;
92
+ actions: Array<{ name: string; value: string }>;
93
+ }>;
94
+ }
95
+
96
+ /**
97
+ * Creates a confirmation dialog composable
98
+ *
99
+ * @example
100
+ * ```typescript
101
+ * const confirmation = useConfirmation();
102
+ *
103
+ * async function handleDelete(item: Item) {
104
+ * const confirmed = await confirmation.confirmDelete(item.name);
105
+ * if (confirmed) {
106
+ * await deleteItem(item.id);
107
+ * }
108
+ * }
109
+ *
110
+ * // Or with async action
111
+ * async function handleDeleteWithAction(item: Item) {
112
+ * const { confirmed, error } = await confirmation.confirmWithAction(
113
+ * { title: 'Delete Item', message: `Delete "${item.name}"?` },
114
+ * () => deleteItem(item.id)
115
+ * );
116
+ * if (confirmed && !error) {
117
+ * showSuccess('Item deleted');
118
+ * }
119
+ * }
120
+ *
121
+ * // In template
122
+ * <Modal
123
+ * v-bind="confirmation.modalProps"
124
+ * @modal-action="(e) => e.action === 'confirm' ? confirmation.handleConfirm() : confirmation.handleCancel()"
125
+ * >
126
+ * <p>{{ confirmation.config.message }}</p>
127
+ * </Modal>
128
+ * ```
129
+ */
130
+ export function useConfirmation(options: UseConfirmationOptions = {}): UseConfirmationReturn {
131
+ const {
132
+ defaultConfirmText = 'Confirm',
133
+ defaultCancelText = 'Cancel',
134
+ defaultVariant = 'default',
135
+ } = options;
136
+
137
+ // State
138
+ const isOpen = ref(false);
139
+ const isLoading = ref(false);
140
+ const config = ref<ConfirmationConfig>({
141
+ title: '',
142
+ message: '',
143
+ confirmText: defaultConfirmText,
144
+ cancelText: defaultCancelText,
145
+ variant: defaultVariant,
146
+ });
147
+
148
+ // Pending promise resolution
149
+ let pending: PendingConfirmation | null = null;
150
+
151
+ /**
152
+ * Open confirmation dialog and wait for user response
153
+ */
154
+ function confirm(confirmConfig: ConfirmationConfig): Promise<boolean> {
155
+ // Set configuration with defaults
156
+ config.value = {
157
+ ...confirmConfig,
158
+ confirmText: confirmConfig.confirmText ?? defaultConfirmText,
159
+ cancelText: confirmConfig.cancelText ?? defaultCancelText,
160
+ variant: confirmConfig.variant ?? defaultVariant,
161
+ };
162
+
163
+ isOpen.value = true;
164
+ isLoading.value = false;
165
+
166
+ return new Promise((resolve, reject) => {
167
+ pending = { resolve, reject };
168
+ });
169
+ }
170
+
171
+ /**
172
+ * Handle confirm button click
173
+ */
174
+ function handleConfirm(): void {
175
+ if (pending) {
176
+ pending.resolve(true);
177
+ pending = null;
178
+ }
179
+ isOpen.value = false;
180
+ isLoading.value = false;
181
+ }
182
+
183
+ /**
184
+ * Handle cancel button click
185
+ */
186
+ function handleCancel(): void {
187
+ if (pending) {
188
+ pending.resolve(false);
189
+ pending = null;
190
+ }
191
+ isOpen.value = false;
192
+ isLoading.value = false;
193
+ }
194
+
195
+ /**
196
+ * Close dialog without resolving
197
+ */
198
+ function close(): void {
199
+ if (pending) {
200
+ pending.resolve(false);
201
+ pending = null;
202
+ }
203
+ isOpen.value = false;
204
+ isLoading.value = false;
205
+ }
206
+
207
+ /**
208
+ * Convenience: Confirm delete action
209
+ */
210
+ function confirmDelete(
211
+ itemName: string,
212
+ confirmConfig?: Partial<ConfirmationConfig>
213
+ ): Promise<boolean> {
214
+ return confirm({
215
+ title: 'Delete Item',
216
+ message: `Are you sure you want to delete "${itemName}"? This action cannot be undone.`,
217
+ confirmText: 'Delete',
218
+ cancelText: 'Cancel',
219
+ variant: 'danger',
220
+ ...confirmConfig,
221
+ });
222
+ }
223
+
224
+ /**
225
+ * Convenience: Confirm bulk delete action
226
+ */
227
+ function confirmBulkDelete(
228
+ count: number,
229
+ itemType: string = 'items',
230
+ confirmConfig?: Partial<ConfirmationConfig>
231
+ ): Promise<boolean> {
232
+ return confirm({
233
+ title: `Delete ${count} ${itemType}`,
234
+ message: `Are you sure you want to delete ${count} ${itemType}? This action cannot be undone.`,
235
+ confirmText: 'Delete All',
236
+ cancelText: 'Cancel',
237
+ variant: 'danger',
238
+ ...confirmConfig,
239
+ });
240
+ }
241
+
242
+ /**
243
+ * Convenience: Confirm destructive action
244
+ */
245
+ function confirmDestructive(
246
+ actionName: string,
247
+ confirmConfig?: Partial<ConfirmationConfig>
248
+ ): Promise<boolean> {
249
+ return confirm({
250
+ title: `Confirm ${actionName}`,
251
+ message: `Are you sure you want to ${actionName.toLowerCase()}? This action cannot be undone.`,
252
+ confirmText: actionName,
253
+ cancelText: 'Cancel',
254
+ variant: 'danger',
255
+ ...confirmConfig,
256
+ });
257
+ }
258
+
259
+ /**
260
+ * Convenience: Confirm with async action execution
261
+ */
262
+ async function confirmWithAction<T>(
263
+ confirmConfig: ConfirmationConfig,
264
+ action: () => Promise<T>
265
+ ): Promise<{ confirmed: boolean; result?: T; error?: Error }> {
266
+ const confirmed = await confirm(confirmConfig);
267
+
268
+ if (!confirmed) {
269
+ return { confirmed: false };
270
+ }
271
+
272
+ // Show loading state
273
+ isLoading.value = true;
274
+
275
+ try {
276
+ const result = await action();
277
+ isOpen.value = false;
278
+ isLoading.value = false;
279
+ return { confirmed: true, result };
280
+ } catch (error) {
281
+ isLoading.value = false;
282
+ return {
283
+ confirmed: true,
284
+ error: error instanceof Error ? error : new Error(String(error)),
285
+ };
286
+ }
287
+ }
288
+
289
+ /**
290
+ * Props for uiModal component
291
+ */
292
+ const modalProps = computed(() => ({
293
+ title: config.value.title,
294
+ open: isOpen.value,
295
+ modalName: 'confirmation-dialog',
296
+ actions: [
297
+ {
298
+ name: config.value.cancelText ?? defaultCancelText,
299
+ value: 'cancel',
300
+ },
301
+ {
302
+ name: config.value.confirmText ?? defaultConfirmText,
303
+ value: 'confirm',
304
+ },
305
+ ],
306
+ }));
307
+
308
+ return {
309
+ // State
310
+ isOpen,
311
+ config,
312
+ isLoading,
313
+
314
+ // Core methods
315
+ confirm,
316
+ handleConfirm,
317
+ handleCancel,
318
+ close,
319
+
320
+ // Convenience methods
321
+ confirmDelete,
322
+ confirmBulkDelete,
323
+ confirmDestructive,
324
+ confirmWithAction,
325
+
326
+ // Modal props
327
+ modalProps,
328
+ };
329
+ }
330
+
331
+ /**
332
+ * Global confirmation instance for use across components
333
+ */
334
+ let globalConfirmation: UseConfirmationReturn | null = null;
335
+
336
+ /**
337
+ * Get or create global confirmation instance
338
+ *
339
+ * @example
340
+ * ```typescript
341
+ * // In any component
342
+ * const confirmation = useGlobalConfirmation();
343
+ * await confirmation.confirmDelete('Item Name');
344
+ * ```
345
+ */
346
+ export function useGlobalConfirmation(): UseConfirmationReturn {
347
+ if (!globalConfirmation) {
348
+ globalConfirmation = useConfirmation();
349
+ }
350
+ return globalConfirmation;
351
+ }
352
+
353
+ /**
354
+ * Reset global confirmation instance (useful for testing)
355
+ */
356
+ export function resetGlobalConfirmation(): void {
357
+ globalConfirmation = null;
358
+ }
@@ -0,0 +1,361 @@
1
+ /**
2
+ * Stats Composable
3
+ *
4
+ * Derives statistics from reactive data with staggered loading animation.
5
+ * Integrates with uiStats component from @hotelinking/ui.
6
+ */
7
+
8
+ import { ref, computed, watch, type Ref, type ComputedRef, type Component } from 'vue';
9
+
10
+ /**
11
+ * Color options matching @hotelinking/ui
12
+ */
13
+ export type StatColor = 'green' | 'red' | 'yellow' | 'blue' | 'gray' | 'purple' | 'orange' | 'pink';
14
+
15
+ /**
16
+ * Change type for stat trends
17
+ */
18
+ export type ChangeType = 'increase' | 'decrease' | 'neutral';
19
+
20
+ /**
21
+ * Definition for a single stat
22
+ */
23
+ export interface StatDefinition<T> {
24
+ /** Unique identifier */
25
+ id: string;
26
+ /** Display name */
27
+ name: string;
28
+ /** Icon component (Heroicons) */
29
+ icon: Component;
30
+ /** Color theme */
31
+ color?: StatColor;
32
+ /** Function to compute stat value from data */
33
+ compute: (items: T[]) => string | number;
34
+ /** Optional: Compute change percentage/value */
35
+ computeChange?: (items: T[], previousItems?: T[]) => string;
36
+ /** Optional: Compute change type (increase/decrease/neutral) */
37
+ computeChangeType?: (items: T[], previousItems?: T[]) => ChangeType;
38
+ /** Show footer with action text */
39
+ showFooter?: boolean;
40
+ /** Action text for footer */
41
+ actionText?: string;
42
+ }
43
+
44
+ /**
45
+ * Stat item formatted for uiStats component
46
+ */
47
+ export interface StatItem {
48
+ id: string;
49
+ name: string;
50
+ stat: string | number;
51
+ icon: Component;
52
+ color: StatColor;
53
+ change?: string;
54
+ changeType?: ChangeType;
55
+ showFooter?: boolean;
56
+ actionText?: string;
57
+ }
58
+
59
+ /**
60
+ * Options for useStats
61
+ */
62
+ export interface UseStatsOptions<T> {
63
+ /** Reactive data source */
64
+ data: Ref<T[]> | ComputedRef<T[]>;
65
+ /** Loading state of the data source */
66
+ loading: Ref<boolean> | ComputedRef<boolean>;
67
+ /** Stat definitions */
68
+ definitions: StatDefinition<T>[];
69
+ /** Delay between each stat reveal (ms) */
70
+ staggerDelay?: number;
71
+ /** Initial delay before first stat reveals (ms) */
72
+ initialDelay?: number;
73
+ /** Previous data for computing changes (optional) */
74
+ previousData?: Ref<T[]> | ComputedRef<T[]>;
75
+ }
76
+
77
+ /**
78
+ * Return type for useStats
79
+ */
80
+ export interface UseStatsReturn<T> {
81
+ /** Computed stats array for uiStats */
82
+ stats: ComputedRef<StatItem[]>;
83
+ /** Individual loading states for each stat */
84
+ loadingStates: Ref<boolean[]>;
85
+ /** Combined stats with loading property */
86
+ statsWithLoading: ComputedRef<Array<StatItem & { loading: boolean }>>;
87
+ /** Whether all stats are loaded */
88
+ allLoaded: ComputedRef<boolean>;
89
+ /** Force refresh loading animation */
90
+ refreshAnimation: () => void;
91
+ /** Get a single stat by ID */
92
+ getStat: (id: string) => StatItem | undefined;
93
+ }
94
+
95
+ /**
96
+ * Creates a stats composable for deriving statistics from reactive data
97
+ *
98
+ * @example
99
+ * ```typescript
100
+ * import { CheckCircleIcon, ClockIcon, XCircleIcon } from '@heroicons/vue/24/outline';
101
+ *
102
+ * const { statsWithLoading } = useStats({
103
+ * data: campaigns,
104
+ * loading: campaignsLoading,
105
+ * definitions: [
106
+ * {
107
+ * id: 'active',
108
+ * name: 'Active Campaigns',
109
+ * icon: CheckCircleIcon,
110
+ * color: 'green',
111
+ * compute: (items) => items.filter(c => c.status === 'active').length,
112
+ * },
113
+ * {
114
+ * id: 'pending',
115
+ * name: 'Pending',
116
+ * icon: ClockIcon,
117
+ * color: 'yellow',
118
+ * compute: (items) => items.filter(c => c.status === 'pending').length,
119
+ * },
120
+ * {
121
+ * id: 'total-sent',
122
+ * name: 'Total Sent',
123
+ * icon: PaperAirplaneIcon,
124
+ * color: 'blue',
125
+ * compute: (items) => items.reduce((sum, c) => sum + c.sentCount, 0).toLocaleString(),
126
+ * computeChange: (items) => '+12%',
127
+ * computeChangeType: () => 'increase',
128
+ * },
129
+ * ],
130
+ * staggerDelay: 100,
131
+ * });
132
+ *
133
+ * // In template
134
+ * <div class="grid grid-cols-4 gap-4">
135
+ * <uiStats
136
+ * v-for="stat in statsWithLoading"
137
+ * :key="stat.id"
138
+ * :item="stat"
139
+ * :loading="stat.loading"
140
+ * @statClick="handleStatClick"
141
+ * />
142
+ * </div>
143
+ * ```
144
+ */
145
+ export function useStats<T>(options: UseStatsOptions<T>): UseStatsReturn<T> {
146
+ const {
147
+ data,
148
+ loading,
149
+ definitions,
150
+ staggerDelay = 100,
151
+ initialDelay = 0,
152
+ previousData,
153
+ } = options;
154
+
155
+ // Individual loading states for staggered animation
156
+ const loadingStates = ref<boolean[]>(definitions.map(() => true));
157
+
158
+ // Timers for cleanup
159
+ let timers: ReturnType<typeof setTimeout>[] = [];
160
+
161
+ // Clear all pending timers
162
+ function clearTimers(): void {
163
+ timers.forEach(timer => clearTimeout(timer));
164
+ timers = [];
165
+ }
166
+
167
+ // Trigger staggered loading animation
168
+ function triggerStaggeredReveal(): void {
169
+ clearTimers();
170
+
171
+ // Reset all to loading
172
+ loadingStates.value = definitions.map(() => true);
173
+
174
+ // Staggered reveal
175
+ definitions.forEach((_, index) => {
176
+ const timer = setTimeout(() => {
177
+ loadingStates.value[index] = false;
178
+ }, initialDelay + staggerDelay * (index + 1));
179
+ timers.push(timer);
180
+ });
181
+ }
182
+
183
+ // Watch for loading state changes
184
+ watch(
185
+ () => loading.value,
186
+ (isLoading, wasLoading) => {
187
+ if (wasLoading && !isLoading) {
188
+ // Data just finished loading, trigger reveal
189
+ triggerStaggeredReveal();
190
+ } else if (isLoading && !wasLoading) {
191
+ // Started loading, reset states
192
+ clearTimers();
193
+ loadingStates.value = definitions.map(() => true);
194
+ }
195
+ },
196
+ { immediate: true }
197
+ );
198
+
199
+ // If data is already loaded on mount, trigger reveal
200
+ if (!loading.value) {
201
+ triggerStaggeredReveal();
202
+ }
203
+
204
+ // Computed: Stats derived from data
205
+ const stats = computed<StatItem[]>(() => {
206
+ const items = data.value;
207
+ const prevItems = previousData?.value;
208
+
209
+ return definitions.map(def => {
210
+ const stat: StatItem = {
211
+ id: def.id,
212
+ name: def.name,
213
+ stat: def.compute(items),
214
+ icon: def.icon,
215
+ color: def.color ?? 'gray',
216
+ };
217
+
218
+ // Add change information if provided
219
+ if (def.computeChange) {
220
+ stat.change = def.computeChange(items, prevItems);
221
+ }
222
+
223
+ if (def.computeChangeType) {
224
+ stat.changeType = def.computeChangeType(items, prevItems);
225
+ }
226
+
227
+ // Footer options
228
+ if (def.showFooter !== undefined) {
229
+ stat.showFooter = def.showFooter;
230
+ }
231
+
232
+ if (def.actionText) {
233
+ stat.actionText = def.actionText;
234
+ }
235
+
236
+ return stat;
237
+ });
238
+ });
239
+
240
+ // Computed: Stats with loading property included
241
+ const statsWithLoading = computed(() =>
242
+ stats.value.map((stat, index) => ({
243
+ ...stat,
244
+ loading: loading.value || loadingStates.value[index],
245
+ }))
246
+ );
247
+
248
+ // Computed: All stats loaded
249
+ const allLoaded = computed(() =>
250
+ !loading.value && loadingStates.value.every(s => !s)
251
+ );
252
+
253
+ // Get a single stat by ID
254
+ function getStat(id: string): StatItem | undefined {
255
+ return stats.value.find(s => s.id === id);
256
+ }
257
+
258
+ // Manually refresh animation
259
+ function refreshAnimation(): void {
260
+ triggerStaggeredReveal();
261
+ }
262
+
263
+ return {
264
+ stats,
265
+ loadingStates,
266
+ statsWithLoading,
267
+ allLoaded,
268
+ refreshAnimation,
269
+ getStat,
270
+ };
271
+ }
272
+
273
+ /**
274
+ * Helper: Create a simple count stat definition
275
+ */
276
+ export function countStat<T>(
277
+ id: string,
278
+ name: string,
279
+ icon: Component,
280
+ filter: (item: T) => boolean,
281
+ options?: { color?: StatColor }
282
+ ): StatDefinition<T> {
283
+ return {
284
+ id,
285
+ name,
286
+ icon,
287
+ color: options?.color ?? 'gray',
288
+ compute: (items) => items.filter(filter).length,
289
+ };
290
+ }
291
+
292
+ /**
293
+ * Helper: Create a sum stat definition
294
+ */
295
+ export function sumStat<T>(
296
+ id: string,
297
+ name: string,
298
+ icon: Component,
299
+ getValue: (item: T) => number,
300
+ options?: { color?: StatColor; format?: (value: number) => string }
301
+ ): StatDefinition<T> {
302
+ return {
303
+ id,
304
+ name,
305
+ icon,
306
+ color: options?.color ?? 'gray',
307
+ compute: (items) => {
308
+ const sum = items.reduce((total, item) => total + getValue(item), 0);
309
+ return options?.format ? options.format(sum) : sum.toLocaleString();
310
+ },
311
+ };
312
+ }
313
+
314
+ /**
315
+ * Helper: Create an average stat definition
316
+ */
317
+ export function averageStat<T>(
318
+ id: string,
319
+ name: string,
320
+ icon: Component,
321
+ getValue: (item: T) => number,
322
+ options?: { color?: StatColor; decimals?: number; suffix?: string }
323
+ ): StatDefinition<T> {
324
+ return {
325
+ id,
326
+ name,
327
+ icon,
328
+ color: options?.color ?? 'gray',
329
+ compute: (items) => {
330
+ if (items.length === 0) return '0';
331
+ const sum = items.reduce((total, item) => total + getValue(item), 0);
332
+ const avg = sum / items.length;
333
+ const formatted = avg.toFixed(options?.decimals ?? 1);
334
+ return options?.suffix ? `${formatted}${options.suffix}` : formatted;
335
+ },
336
+ };
337
+ }
338
+
339
+ /**
340
+ * Helper: Create a percentage stat definition
341
+ */
342
+ export function percentageStat<T>(
343
+ id: string,
344
+ name: string,
345
+ icon: Component,
346
+ filter: (item: T) => boolean,
347
+ options?: { color?: StatColor; decimals?: number }
348
+ ): StatDefinition<T> {
349
+ return {
350
+ id,
351
+ name,
352
+ icon,
353
+ color: options?.color ?? 'gray',
354
+ compute: (items) => {
355
+ if (items.length === 0) return '0%';
356
+ const count = items.filter(filter).length;
357
+ const percentage = (count / items.length) * 100;
358
+ return `${percentage.toFixed(options?.decimals ?? 1)}%`;
359
+ },
360
+ };
361
+ }