@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.
- package/AGENTS.md +105 -0
- package/CLAUDE.md +1 -0
- package/LICENSE +7 -0
- package/README.md +242 -2
- package/dist/__smrt-register__.d.ts +2 -0
- package/dist/__smrt-register__.d.ts.map +1 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +495 -0
- package/dist/index.js.map +1 -0
- package/dist/manifest.json +1412 -0
- package/dist/models/index.d.ts +3 -0
- package/dist/models/index.d.ts.map +1 -0
- package/dist/models/json.d.ts +6 -0
- package/dist/models/json.d.ts.map +1 -0
- package/dist/models/service-snapshots.d.ts +66 -0
- package/dist/models/service-snapshots.d.ts.map +1 -0
- package/dist/models/service-time-entry.d.ts +66 -0
- package/dist/models/service-time-entry.d.ts.map +1 -0
- package/dist/services/index.d.ts +2 -0
- package/dist/services/index.d.ts.map +1 -0
- package/dist/services/service-evidence-service.d.ts +67 -0
- package/dist/services/service-evidence-service.d.ts.map +1 -0
- package/dist/smrt-knowledge.json +1016 -0
- package/dist/svelte/components/DurationDisplay.svelte +81 -0
- package/dist/svelte/components/DurationDisplay.svelte.d.ts +20 -0
- package/dist/svelte/components/DurationDisplay.svelte.d.ts.map +1 -0
- package/dist/svelte/components/TimeEntryApprovalQueue.svelte +253 -0
- package/dist/svelte/components/TimeEntryApprovalQueue.svelte.d.ts +15 -0
- package/dist/svelte/components/TimeEntryApprovalQueue.svelte.d.ts.map +1 -0
- package/dist/svelte/components/TimeEntryCard.svelte +304 -0
- package/dist/svelte/components/TimeEntryCard.svelte.d.ts +25 -0
- package/dist/svelte/components/TimeEntryCard.svelte.d.ts.map +1 -0
- package/dist/svelte/components/TimeEntryList.svelte +360 -0
- package/dist/svelte/components/TimeEntryList.svelte.d.ts +24 -0
- package/dist/svelte/components/TimeEntryList.svelte.d.ts.map +1 -0
- package/dist/svelte/components/TimeSummary.svelte +176 -0
- package/dist/svelte/components/TimeSummary.svelte.d.ts +30 -0
- package/dist/svelte/components/TimeSummary.svelte.d.ts.map +1 -0
- package/dist/svelte/components/__tests__/DurationDisplay.test.js +57 -0
- package/dist/svelte/components/__tests__/TimeEntryApprovalQueue.test.js +114 -0
- package/dist/svelte/components/__tests__/TimeEntryCard.test.js +45 -0
- package/dist/svelte/components/__tests__/TimeEntryList.test.js +50 -0
- package/dist/svelte/components/__tests__/TimeSummary.test.js +39 -0
- package/dist/svelte/components/utils.d.ts +51 -0
- package/dist/svelte/components/utils.d.ts.map +1 -0
- package/dist/svelte/components/utils.js +58 -0
- package/dist/svelte/i18n.d.ts +31 -0
- package/dist/svelte/i18n.d.ts.map +1 -0
- package/dist/svelte/i18n.js +31 -0
- package/dist/svelte/index.d.ts +25 -0
- package/dist/svelte/index.d.ts.map +1 -0
- package/dist/svelte/index.js +27 -0
- package/dist/svelte/types.d.ts +41 -0
- package/dist/svelte/types.d.ts.map +1 -0
- package/dist/svelte/types.js +28 -0
- package/dist/types.d.ts +29 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +16 -0
- package/dist/types.js.map +1 -0
- package/dist/ui.d.ts +17 -0
- package/dist/ui.d.ts.map +1 -0
- package/dist/ui.js +68 -0
- package/dist/ui.js.map +1 -0
- 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>
|