@happyvertical/smrt-timesheets 0.0.0-stage → 0.53.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 (65) hide show
  1. package/AGENTS.md +105 -0
  2. package/CLAUDE.md +1 -0
  3. package/LICENSE +7 -0
  4. package/README.md +242 -2
  5. package/dist/__smrt-register__.d.ts +2 -0
  6. package/dist/__smrt-register__.d.ts.map +1 -0
  7. package/dist/index.d.ts +20 -0
  8. package/dist/index.d.ts.map +1 -0
  9. package/dist/index.js +495 -0
  10. package/dist/index.js.map +1 -0
  11. package/dist/manifest.json +1412 -0
  12. package/dist/models/index.d.ts +3 -0
  13. package/dist/models/index.d.ts.map +1 -0
  14. package/dist/models/json.d.ts +6 -0
  15. package/dist/models/json.d.ts.map +1 -0
  16. package/dist/models/service-snapshots.d.ts +66 -0
  17. package/dist/models/service-snapshots.d.ts.map +1 -0
  18. package/dist/models/service-time-entry.d.ts +66 -0
  19. package/dist/models/service-time-entry.d.ts.map +1 -0
  20. package/dist/services/index.d.ts +2 -0
  21. package/dist/services/index.d.ts.map +1 -0
  22. package/dist/services/service-evidence-service.d.ts +67 -0
  23. package/dist/services/service-evidence-service.d.ts.map +1 -0
  24. package/dist/smrt-knowledge.json +1016 -0
  25. package/dist/svelte/components/DurationDisplay.svelte +81 -0
  26. package/dist/svelte/components/DurationDisplay.svelte.d.ts +20 -0
  27. package/dist/svelte/components/DurationDisplay.svelte.d.ts.map +1 -0
  28. package/dist/svelte/components/TimeEntryApprovalQueue.svelte +253 -0
  29. package/dist/svelte/components/TimeEntryApprovalQueue.svelte.d.ts +15 -0
  30. package/dist/svelte/components/TimeEntryApprovalQueue.svelte.d.ts.map +1 -0
  31. package/dist/svelte/components/TimeEntryCard.svelte +304 -0
  32. package/dist/svelte/components/TimeEntryCard.svelte.d.ts +25 -0
  33. package/dist/svelte/components/TimeEntryCard.svelte.d.ts.map +1 -0
  34. package/dist/svelte/components/TimeEntryList.svelte +360 -0
  35. package/dist/svelte/components/TimeEntryList.svelte.d.ts +24 -0
  36. package/dist/svelte/components/TimeEntryList.svelte.d.ts.map +1 -0
  37. package/dist/svelte/components/TimeSummary.svelte +176 -0
  38. package/dist/svelte/components/TimeSummary.svelte.d.ts +30 -0
  39. package/dist/svelte/components/TimeSummary.svelte.d.ts.map +1 -0
  40. package/dist/svelte/components/__tests__/DurationDisplay.test.js +57 -0
  41. package/dist/svelte/components/__tests__/TimeEntryApprovalQueue.test.js +114 -0
  42. package/dist/svelte/components/__tests__/TimeEntryCard.test.js +45 -0
  43. package/dist/svelte/components/__tests__/TimeEntryList.test.js +50 -0
  44. package/dist/svelte/components/__tests__/TimeSummary.test.js +39 -0
  45. package/dist/svelte/components/utils.d.ts +51 -0
  46. package/dist/svelte/components/utils.d.ts.map +1 -0
  47. package/dist/svelte/components/utils.js +58 -0
  48. package/dist/svelte/i18n.d.ts +31 -0
  49. package/dist/svelte/i18n.d.ts.map +1 -0
  50. package/dist/svelte/i18n.js +31 -0
  51. package/dist/svelte/index.d.ts +25 -0
  52. package/dist/svelte/index.d.ts.map +1 -0
  53. package/dist/svelte/index.js +27 -0
  54. package/dist/svelte/types.d.ts +41 -0
  55. package/dist/svelte/types.d.ts.map +1 -0
  56. package/dist/svelte/types.js +28 -0
  57. package/dist/types.d.ts +29 -0
  58. package/dist/types.d.ts.map +1 -0
  59. package/dist/types.js +16 -0
  60. package/dist/types.js.map +1 -0
  61. package/dist/ui.d.ts +17 -0
  62. package/dist/ui.d.ts.map +1 -0
  63. package/dist/ui.js +68 -0
  64. package/dist/ui.js.map +1 -0
  65. package/package.json +85 -3
@@ -0,0 +1,81 @@
1
+ <script lang="ts">
2
+ /**
3
+ * DurationDisplay - Formats hours for display
4
+ * Supports decimal (8.5) or HH:MM (8:30) formats
5
+ */
6
+
7
+ import { formatHoursHHMM } from './utils.js';
8
+
9
+ /** Props for DurationDisplay component */
10
+ export interface Props {
11
+ /** Duration in hours to display. */
12
+ hours: number;
13
+ /** Format duration as decimal (8.5) or hours:minutes (8:30). */
14
+ format?: 'decimal' | 'hhmm';
15
+ /** Sets the font size of the duration display. */
16
+ size?: 'sm' | 'md' | 'lg';
17
+ /**
18
+ * Renders the `h` unit after a `decimal` value; set `false` for a bare
19
+ * number, such as in a column whose header already names the unit. An
20
+ * `hhmm` value spells its own unit in the `8:30` shape and never appends a
21
+ * separate one, so this prop does not affect that format.
22
+ */
23
+ showLabel?: boolean;
24
+ }
25
+
26
+ let {
27
+ hours,
28
+ format = 'decimal',
29
+ size = 'md',
30
+ showLabel = true,
31
+ }: Props = $props();
32
+
33
+ // $derived creates a reactive value, not a function - no wrapper or parentheses needed
34
+ const formattedValue = $derived(
35
+ format === 'hhmm' ? formatHoursHHMM(hours) : hours.toFixed(1),
36
+ );
37
+
38
+ // Only the decimal form needs a unit appended: `8:30` already reads as hours
39
+ // and minutes. That makes `h` the one unit `showLabel` has to gate.
40
+ const unit = $derived(format === 'hhmm' ? '' : 'h');
41
+ </script>
42
+
43
+ <span class="duration" class:sm={size === 'sm'} class:lg={size === 'lg'}>
44
+ <span class="value">{formattedValue}</span>
45
+ {#if showLabel && unit}
46
+ <span class="unit">{unit}</span>
47
+ {/if}
48
+ </span>
49
+
50
+ <style>
51
+ .duration {
52
+ display: inline-flex;
53
+ align-items: baseline;
54
+ gap: 0.125rem;
55
+ font-variant-numeric: tabular-nums;
56
+ font-size: var(--smrt-typography-body-large-size, 1rem);
57
+ }
58
+
59
+ .value {
60
+ font-weight: var(--smrt-typography-title-medium-weight, 500);
61
+ color: var(--smrt-color-on-surface);
62
+ }
63
+
64
+ .unit {
65
+ font-weight: var(--smrt-typography-body-medium-weight, 400);
66
+ color: var(--smrt-color-on-surface-variant);
67
+ }
68
+
69
+ /* Size variants */
70
+ .duration.sm {
71
+ font-size: var(--smrt-typography-body-medium-size, 0.875rem);
72
+ }
73
+
74
+ .duration.sm .value {
75
+ font-weight: var(--smrt-typography-body-medium-weight, 400);
76
+ }
77
+
78
+ .duration.lg {
79
+ font-size: var(--smrt-typography-headline-small-size, 1.5rem);
80
+ }
81
+ </style>
@@ -0,0 +1,20 @@
1
+ /** Props for DurationDisplay component */
2
+ export interface Props {
3
+ /** Duration in hours to display. */
4
+ hours: number;
5
+ /** Format duration as decimal (8.5) or hours:minutes (8:30). */
6
+ format?: 'decimal' | 'hhmm';
7
+ /** Sets the font size of the duration display. */
8
+ size?: 'sm' | 'md' | 'lg';
9
+ /**
10
+ * Renders the `h` unit after a `decimal` value; set `false` for a bare
11
+ * number, such as in a column whose header already names the unit. An
12
+ * `hhmm` value spells its own unit in the `8:30` shape and never appends a
13
+ * separate one, so this prop does not affect that format.
14
+ */
15
+ showLabel?: boolean;
16
+ }
17
+ declare const DurationDisplay: import("svelte").Component<Props, {}, "">;
18
+ type DurationDisplay = ReturnType<typeof DurationDisplay>;
19
+ export default DurationDisplay;
20
+ //# sourceMappingURL=DurationDisplay.svelte.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"DurationDisplay.svelte.d.ts","sourceRoot":"","sources":["../../../src/svelte/components/DurationDisplay.svelte.ts"],"names":[],"mappings":"AAUA,0CAA0C;AAC1C,MAAM,WAAW,KAAK;IACpB,oCAAoC;IACpC,KAAK,EAAE,MAAM,CAAC;IACd,gEAAgE;IAChE,MAAM,CAAC,EAAE,SAAS,GAAG,MAAM,CAAC;IAC5B,kDAAkD;IAClD,IAAI,CAAC,EAAE,IAAI,GAAG,IAAI,GAAG,IAAI,CAAC;IAC1B;;;;;OAKG;IACH,SAAS,CAAC,EAAE,OAAO,CAAC;CACrB;AAmCD,QAAA,MAAM,eAAe,2CAAwC,CAAC;AAC9D,KAAK,eAAe,GAAG,UAAU,CAAC,OAAO,eAAe,CAAC,CAAC;AAC1D,eAAe,eAAe,CAAC"}
@@ -0,0 +1,253 @@
1
+ <script lang="ts">
2
+ /**
3
+ * TimeEntryApprovalQueue — reusable time-entry review list (issue #1930,
4
+ * moved to smrt-timesheets in #3288): date, hours, description, worker,
5
+ * status, and the client charge amount at a glance, with approve/reject
6
+ * actions on `submitted` rows. Presentational: the host loads entries (e.g.
7
+ * through `ServiceTimeEntryCollection`), adapts them to
8
+ * `TimeEntryApprovalView` (smrt-support's `toSupportTimeEntryView` does this
9
+ * for support entries), and wires `onapprove` / `onreject` to its approval
10
+ * service.
11
+ */
12
+
13
+ import { Button, StatusBadge } from '@happyvertical/smrt-ui';
14
+ import { Input } from '@happyvertical/smrt-ui/forms';
15
+ import { useI18n } from '@happyvertical/smrt-ui/i18n';
16
+ import { M } from '../i18n.js';
17
+ import {
18
+ humanizeTimeEntryStatus,
19
+ type TimeEntryApprovalView,
20
+ timeEntryStatusBadgeKey,
21
+ } from '../types.js';
22
+
23
+ const { t } = useI18n();
24
+
25
+ export interface TimeEntryApprovalQueueProps {
26
+ /** Array of time entries awaiting approval review. */
27
+ entries: TimeEntryApprovalView[];
28
+ /** Callback invoked with entry ID when approving a time entry. */
29
+ onapprove?: (id: string) => void;
30
+ /** Callback invoked with entry ID and rejection reason when rejecting a time entry. */
31
+ onreject?: (id: string, reason: string) => void;
32
+ /** Message displayed when the approval queue is empty. */
33
+ emptyMessage?: string;
34
+ }
35
+
36
+ const {
37
+ entries,
38
+ onapprove,
39
+ onreject,
40
+ emptyMessage,
41
+ }: TimeEntryApprovalQueueProps = $props();
42
+
43
+ /** The entry currently collecting a rejection reason (one at a time). */
44
+ let rejectingId = $state<string | null>(null);
45
+ let rejectReason = $state('');
46
+
47
+ function beginReject(id: string): void {
48
+ rejectingId = id;
49
+ rejectReason = '';
50
+ }
51
+
52
+ function cancelReject(): void {
53
+ rejectingId = null;
54
+ rejectReason = '';
55
+ }
56
+
57
+ function confirmReject(id: string): void {
58
+ const reason = rejectReason.trim();
59
+ if (!reason) return;
60
+ onreject?.(id, reason);
61
+ cancelReject();
62
+ }
63
+
64
+ function formatHours(hours: number): string {
65
+ return `${hours.toFixed(1)}h`;
66
+ }
67
+
68
+ /**
69
+ * Render a minor-units charge for display.
70
+ *
71
+ * `amount` is integer minor units (#2401), so `toFixed(2)` would print 180
72
+ * cents as "180.00" — a hundredfold overstatement. The scale is the currency's
73
+ * own minor-unit exponent rather than a hard-coded 100, so a zero-decimal
74
+ * currency (JPY, KRW) is not divided at all and a three-decimal one (BHD) is
75
+ * divided by 1000. No currency symbol is added: the queue shows a bare figure
76
+ * and the host app supplies the label.
77
+ */
78
+ function formatAmount(amount: number, currency = 'USD'): string {
79
+ const digits =
80
+ new Intl.NumberFormat(undefined, {
81
+ style: 'currency',
82
+ currency,
83
+ }).resolvedOptions().maximumFractionDigits ?? 2;
84
+ return (amount / 10 ** digits).toFixed(digits);
85
+ }
86
+ </script>
87
+
88
+ {#if entries.length === 0}
89
+ <p class="time-approval-empty">
90
+ {emptyMessage ?? t(M['timesheets.approval_queue.empty'])}
91
+ </p>
92
+ {:else}
93
+ <ul class="time-approval-queue">
94
+ {#each entries as entry (entry.id)}
95
+ <li class="time-approval-row">
96
+ <div class="time-approval-line">
97
+ <span class="time-approval-date">{entry.date}</span>
98
+ <span class="time-approval-main">
99
+ <span class="time-approval-description">{entry.description}</span>
100
+ {#if entry.workerName}
101
+ <span class="time-approval-worker">{entry.workerName}</span>
102
+ {/if}
103
+ </span>
104
+ <span class="time-approval-meta">
105
+ <span class="time-approval-hours">{formatHours(entry.hours)}</span>
106
+ <StatusBadge
107
+ status={timeEntryStatusBadgeKey(entry.status)}
108
+ label={humanizeTimeEntryStatus(entry.status)}
109
+ size="sm"
110
+ />
111
+ {#if entry.amount !== undefined}
112
+ <span class="time-approval-amount">
113
+ {formatAmount(entry.amount, entry.currency)}
114
+ </span>
115
+ {/if}
116
+ </span>
117
+ </div>
118
+ {#if entry.status === 'submitted'}
119
+ <div class="time-approval-actions">
120
+ {#if rejectingId === entry.id}
121
+ <Input
122
+ bind:value={rejectReason}
123
+ placeholder={t(M['timesheets.approval_queue.reason_placeholder'])}
124
+ aria-label={t(M['timesheets.approval_queue.reason_aria'], { description: entry.description })}
125
+ />
126
+ <Button
127
+ variant="danger"
128
+ size="sm"
129
+ disabled={rejectReason.trim().length === 0}
130
+ onclick={() => confirmReject(entry.id)}
131
+ aria-label={t(M['timesheets.approval_queue.confirm_reject_aria'], { description: entry.description })}
132
+ >
133
+ {t(M['timesheets.approval_queue.confirm_reject'])}
134
+ </Button>
135
+ <Button
136
+ variant="ghost"
137
+ size="sm"
138
+ onclick={cancelReject}
139
+ aria-label={t(M['timesheets.approval_queue.cancel_aria'], { description: entry.description })}
140
+ >
141
+ {t(M['timesheets.approval_queue.cancel'])}
142
+ </Button>
143
+ {:else}
144
+ <Button
145
+ variant="primary"
146
+ size="sm"
147
+ onclick={() => onapprove?.(entry.id)}
148
+ aria-label={t(M['timesheets.approval_queue.approve_aria'], { description: entry.description })}
149
+ >
150
+ {t(M['timesheets.approval_queue.approve'])}
151
+ </Button>
152
+ <Button
153
+ variant="ghost"
154
+ size="sm"
155
+ onclick={() => beginReject(entry.id)}
156
+ aria-label={t(M['timesheets.approval_queue.reject_aria'], { description: entry.description })}
157
+ >
158
+ {t(M['timesheets.approval_queue.reject'])}
159
+ </Button>
160
+ {/if}
161
+ </div>
162
+ {/if}
163
+ </li>
164
+ {/each}
165
+ </ul>
166
+ {/if}
167
+
168
+ <style>
169
+ .time-approval-queue {
170
+ list-style: none;
171
+ margin: 0;
172
+ padding: 0;
173
+ display: flex;
174
+ flex-direction: column;
175
+ gap: 0.25rem;
176
+ }
177
+
178
+ .time-approval-row {
179
+ display: flex;
180
+ flex-direction: column;
181
+ gap: 0.375rem;
182
+ padding: 0.5rem 0.25rem;
183
+ border-bottom: 1px solid var(--smrt-color-outline-variant, transparent);
184
+ }
185
+
186
+ .time-approval-row:last-child {
187
+ border-bottom: none;
188
+ }
189
+
190
+ .time-approval-line {
191
+ display: flex;
192
+ width: 100%;
193
+ align-items: center;
194
+ justify-content: space-between;
195
+ gap: 0.75rem;
196
+ text-align: left;
197
+ }
198
+
199
+ .time-approval-date {
200
+ flex-shrink: 0;
201
+ font-size: 0.75rem;
202
+ color: var(--smrt-color-on-surface-variant, inherit);
203
+ }
204
+
205
+ .time-approval-main {
206
+ display: flex;
207
+ flex-direction: column;
208
+ gap: 0.125rem;
209
+ min-width: 0;
210
+ flex: 1;
211
+ }
212
+
213
+ .time-approval-description {
214
+ font-weight: 500;
215
+ overflow: hidden;
216
+ text-overflow: ellipsis;
217
+ white-space: nowrap;
218
+ }
219
+
220
+ .time-approval-worker {
221
+ font-size: 0.75rem;
222
+ color: var(--smrt-color-on-surface-variant, inherit);
223
+ }
224
+
225
+ .time-approval-meta {
226
+ display: flex;
227
+ align-items: center;
228
+ gap: 0.5rem;
229
+ flex-shrink: 0;
230
+ }
231
+
232
+ .time-approval-hours {
233
+ font-weight: 500;
234
+ color: var(--smrt-color-primary, inherit);
235
+ }
236
+
237
+ .time-approval-amount {
238
+ font-variant-numeric: tabular-nums;
239
+ font-weight: 500;
240
+ }
241
+
242
+ .time-approval-actions {
243
+ display: flex;
244
+ align-items: center;
245
+ gap: 0.5rem;
246
+ justify-content: flex-end;
247
+ }
248
+
249
+ .time-approval-empty {
250
+ color: var(--smrt-color-on-surface-variant, inherit);
251
+ padding: 1rem 0;
252
+ }
253
+ </style>
@@ -0,0 +1,15 @@
1
+ import { type TimeEntryApprovalView } from '../types.js';
2
+ export interface TimeEntryApprovalQueueProps {
3
+ /** Array of time entries awaiting approval review. */
4
+ entries: TimeEntryApprovalView[];
5
+ /** Callback invoked with entry ID when approving a time entry. */
6
+ onapprove?: (id: string) => void;
7
+ /** Callback invoked with entry ID and rejection reason when rejecting a time entry. */
8
+ onreject?: (id: string, reason: string) => void;
9
+ /** Message displayed when the approval queue is empty. */
10
+ emptyMessage?: string;
11
+ }
12
+ declare const TimeEntryApprovalQueue: import("svelte").Component<TimeEntryApprovalQueueProps, {}, "">;
13
+ type TimeEntryApprovalQueue = ReturnType<typeof TimeEntryApprovalQueue>;
14
+ export default TimeEntryApprovalQueue;
15
+ //# sourceMappingURL=TimeEntryApprovalQueue.svelte.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"TimeEntryApprovalQueue.svelte.d.ts","sourceRoot":"","sources":["../../../src/svelte/components/TimeEntryApprovalQueue.svelte.ts"],"names":[],"mappings":"AAiBA,OAAO,EAEL,KAAK,qBAAqB,EAE3B,MAAM,aAAa,CAAC;AAGrB,MAAM,WAAW,2BAA2B;IAC1C,sDAAsD;IACtD,OAAO,EAAE,qBAAqB,EAAE,CAAC;IACjC,kEAAkE;IAClE,SAAS,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,IAAI,CAAC;IACjC,uFAAuF;IACvF,QAAQ,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,KAAK,IAAI,CAAC;IAChD,0DAA0D;IAC1D,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AAwHD,QAAA,MAAM,sBAAsB,iEAAwC,CAAC;AACrE,KAAK,sBAAsB,GAAG,UAAU,CAAC,OAAO,sBAAsB,CAAC,CAAC;AACxE,eAAe,sBAAsB,CAAC"}
@@ -0,0 +1,304 @@
1
+ <script lang="ts">
2
+ /**
3
+ * TimeEntryCard - Card component for displaying time entries
4
+ * Supports selection mode for bulk operations
5
+ */
6
+
7
+ import { useI18n } from '@happyvertical/smrt-ui/i18n';
8
+ import type { Snippet } from 'svelte';
9
+ import { M } from '../i18n.js';
10
+ import {
11
+ type Currency,
12
+ formatCurrency,
13
+ formatDate,
14
+ formatHours,
15
+ statusColors,
16
+ type TimeEntry,
17
+ } from './utils.js';
18
+
19
+ /** Props for TimeEntryCard component */
20
+ export interface Props {
21
+ /** Time entry data to display. */
22
+ entry: TimeEntry;
23
+ /** URL to navigate to when the card is clicked. */
24
+ href?: string;
25
+ /** Invoked when the card content is clicked. */
26
+ onclick?: () => void;
27
+ /** Shows a checkbox for selecting the entry. */
28
+ selectable?: boolean;
29
+ /** Indicates whether the entry is selected. */
30
+ selected?: boolean;
31
+ /** Invoked when the selection checkbox is toggled. */
32
+ onselect?: (id: string, selected: boolean) => void;
33
+ /** Currency code for formatting hourly rates and amounts. */
34
+ currency?: Currency;
35
+ /** Renders action buttons on the right side of the card. */
36
+ actions?: Snippet;
37
+ }
38
+
39
+ let {
40
+ entry,
41
+ href,
42
+ onclick,
43
+ selectable = false,
44
+ selected = false,
45
+ onselect,
46
+ currency = 'CAD',
47
+ actions,
48
+ }: Props = $props();
49
+
50
+ const { t } = useI18n();
51
+
52
+ function handleCheckboxChange(event: Event) {
53
+ const target = event.target as HTMLInputElement;
54
+ onselect?.(entry.id, target.checked);
55
+ }
56
+
57
+ function handleCardClick(event: MouseEvent) {
58
+ // If there's an onclick handler, use it
59
+ if (onclick) {
60
+ event.preventDefault();
61
+ onclick();
62
+ }
63
+ // If href is provided, let the anchor handle navigation naturally
64
+ }
65
+
66
+ function handleKeydown(event: KeyboardEvent) {
67
+ if (event.key === 'Enter' || event.key === ' ') {
68
+ if (onclick) {
69
+ event.preventDefault();
70
+ onclick();
71
+ }
72
+ // For href, let the anchor handle it naturally
73
+ }
74
+ }
75
+ </script>
76
+
77
+ <div
78
+ class="time-entry-card"
79
+ class:selectable
80
+ class:selected
81
+ class:clickable={!!href || !!onclick}
82
+ >
83
+ {#if selectable}
84
+ <div class="checkbox-wrapper">
85
+ <!-- raw-primitive-allow: native checkbox; no Provider-free checkbox primitive, kept native to match the indeterminate select-all in TimeEntryList -->
86
+ <input
87
+ type="checkbox"
88
+ checked={selected}
89
+ onchange={handleCheckboxChange}
90
+ aria-label={t(M['projects.time_entry_card.select_entry'], { description: entry.description })}
91
+ />
92
+ </div>
93
+ {/if}
94
+
95
+ {#if href}
96
+ <a
97
+ {href}
98
+ class="card-content"
99
+ onclick={handleCardClick}
100
+ onkeydown={handleKeydown}
101
+ >
102
+ {@render cardBody()}
103
+ </a>
104
+ {:else if onclick}
105
+ <!-- raw-primitive-allow: pressable selection card wrapping rich content; structural, not a standard action button -->
106
+ <button
107
+ type="button"
108
+ class="card-content"
109
+ onclick={() => onclick?.()}
110
+ >
111
+ {@render cardBody()}
112
+ </button>
113
+ {:else}
114
+ <div class="card-content">
115
+ {@render cardBody()}
116
+ </div>
117
+ {/if}
118
+
119
+ {#if actions}
120
+ <div class="card-actions">
121
+ {@render actions()}
122
+ </div>
123
+ {/if}
124
+ </div>
125
+
126
+ {#snippet cardBody()}
127
+ <div class="entry-header">
128
+ <span class="date">{formatDate(entry.date)}</span>
129
+ <span class="status-badge" style="--status-color: {statusColors[entry.status]}">
130
+ {entry.status.toUpperCase()}
131
+ </span>
132
+ </div>
133
+
134
+ <div class="entry-body">
135
+ <p class="description">{entry.description}</p>
136
+ {#if entry.workerName}
137
+ <p class="worker">{entry.workerName}</p>
138
+ {/if}
139
+ </div>
140
+
141
+ <div class="entry-footer">
142
+ <div class="hours">
143
+ <span class="hours-value">{formatHours(entry.hours)}</span>
144
+ {#if entry.hourlyRate}
145
+ <span class="rate">@ {formatCurrency(entry.hourlyRate, currency)}/hr</span>
146
+ {/if}
147
+ </div>
148
+ {#if entry.amount !== undefined}
149
+ <span class="amount">{formatCurrency(entry.amount, currency)}</span>
150
+ {/if}
151
+ </div>
152
+
153
+ {#if entry.mileage && entry.mileage > 0}
154
+ <div class="mileage">
155
+ {t(M['projects.time_entry_card.mileage'], { mileage: entry.mileage })}
156
+ </div>
157
+ {/if}
158
+ {/snippet}
159
+
160
+ <style>
161
+ .time-entry-card {
162
+ display: flex;
163
+ align-items: stretch;
164
+ background: var(--smrt-color-surface);
165
+ border: 1px solid var(--smrt-color-outline-variant);
166
+ border-radius: var(--smrt-radius-medium, 12px);
167
+ overflow: hidden;
168
+ transition: all 0.2s var(--smrt-easing-standard);
169
+ }
170
+
171
+ .time-entry-card:hover {
172
+ border-color: var(--smrt-color-outline);
173
+ }
174
+
175
+ .time-entry-card.selected {
176
+ border-color: var(--smrt-color-primary);
177
+ background: var(--smrt-color-primary-container);
178
+ }
179
+
180
+ .time-entry-card.clickable .card-content {
181
+ cursor: pointer;
182
+ }
183
+
184
+ .time-entry-card.clickable:hover {
185
+ box-shadow: var(--smrt-elevation-1);
186
+ }
187
+
188
+ .checkbox-wrapper {
189
+ display: flex;
190
+ align-items: center;
191
+ padding: 1rem;
192
+ background: var(--smrt-color-surface-container-low);
193
+ border-right: 1px solid var(--smrt-color-outline-variant);
194
+ }
195
+
196
+ .checkbox-wrapper input[type='checkbox'] {
197
+ width: 1.25rem;
198
+ height: 1.25rem;
199
+ cursor: pointer;
200
+ accent-color: var(--smrt-color-primary);
201
+ }
202
+
203
+ .card-content {
204
+ flex: 1;
205
+ padding: 1rem;
206
+ min-width: 0;
207
+ text-decoration: none;
208
+ color: inherit;
209
+ background: transparent;
210
+ border: none;
211
+ text-align: left;
212
+ font: inherit;
213
+ display: block;
214
+ width: 100%;
215
+ }
216
+
217
+ a.card-content:focus,
218
+ button.card-content:focus {
219
+ outline: 2px solid var(--smrt-color-primary);
220
+ outline-offset: -2px;
221
+ border-radius: var(--smrt-radius-small, 8px);
222
+ }
223
+
224
+ .entry-header {
225
+ display: flex;
226
+ justify-content: space-between;
227
+ align-items: center;
228
+ margin-bottom: 0.5rem;
229
+ }
230
+
231
+ .date {
232
+ font-size: var(--smrt-typography-body-medium-size, 0.875rem);
233
+ color: var(--smrt-color-on-surface-variant);
234
+ }
235
+
236
+ .status-badge {
237
+ font-size: var(--smrt-typography-label-small-size, 0.625rem);
238
+ font-weight: var(--smrt-typography-label-small-weight, 500);
239
+ padding: 0.25rem 0.5rem;
240
+ border-radius: var(--smrt-radius-small, 8px);
241
+ background: var(--status-color);
242
+ color: var(--smrt-color-on-primary);
243
+ letter-spacing: var(--smrt-typography-label-small-tracking, 0.5px);
244
+ }
245
+
246
+ .entry-body {
247
+ margin-bottom: 0.75rem;
248
+ }
249
+
250
+ .description {
251
+ margin: 0;
252
+ font-size: var(--smrt-typography-body-large-size, 0.9375rem);
253
+ color: var(--smrt-color-on-surface);
254
+ line-height: var(--smrt-typography-body-large-line-height, 1.5);
255
+ }
256
+
257
+ .worker {
258
+ margin: 0.25rem 0 0;
259
+ font-size: var(--smrt-typography-body-small-size, 0.8125rem);
260
+ color: var(--smrt-color-on-surface-variant);
261
+ }
262
+
263
+ .entry-footer {
264
+ display: flex;
265
+ justify-content: space-between;
266
+ align-items: baseline;
267
+ }
268
+
269
+ .hours {
270
+ display: flex;
271
+ align-items: baseline;
272
+ gap: 0.5rem;
273
+ }
274
+
275
+ .hours-value {
276
+ font-size: var(--smrt-typography-body-large-size, 1rem);
277
+ font-weight: var(--smrt-typography-title-medium-weight, 500);
278
+ color: var(--smrt-color-primary);
279
+ }
280
+
281
+ .rate {
282
+ font-size: var(--smrt-typography-body-small-size, 0.75rem);
283
+ color: var(--smrt-color-on-surface-variant);
284
+ }
285
+
286
+ .amount {
287
+ font-size: var(--smrt-typography-body-large-size, 1rem);
288
+ font-weight: var(--smrt-typography-title-medium-weight, 500);
289
+ color: var(--smrt-color-on-surface);
290
+ }
291
+
292
+ .mileage {
293
+ margin-top: 0.5rem;
294
+ font-size: var(--smrt-typography-body-small-size, 0.75rem);
295
+ color: var(--smrt-color-on-surface-variant);
296
+ }
297
+
298
+ .card-actions {
299
+ display: flex;
300
+ align-items: center;
301
+ padding: 0.5rem;
302
+ border-left: 1px solid var(--smrt-color-outline-variant);
303
+ }
304
+ </style>