@happyvertical/smrt-support 0.52.0 → 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 +16 -9
- package/README.md +16 -3
- package/dist/index.js +10 -2
- package/dist/index.js.map +1 -1
- package/dist/manifest.json +2 -1
- package/dist/models/service-time-entry.d.ts +16 -6
- package/dist/models/service-time-entry.d.ts.map +1 -1
- package/dist/smrt-knowledge.json +7 -5
- package/dist/svelte/components/__tests__/TimesheetsReexports.test.js +16 -0
- package/dist/svelte/index.d.ts +2 -3
- package/dist/svelte/index.d.ts.map +1 -1
- package/dist/svelte/index.js +3 -2
- package/dist/svelte/types.d.ts +6 -35
- package/dist/svelte/types.d.ts.map +1 -1
- package/dist/svelte/types.js +2 -17
- package/dist/types.d.ts +1 -10
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +1 -11
- package/dist/types.js.map +1 -1
- package/package.json +13 -12
- package/dist/svelte/components/TimeEntryApprovalQueue.svelte +0 -245
- package/dist/svelte/components/TimeEntryApprovalQueue.svelte.d.ts +0 -15
- package/dist/svelte/components/TimeEntryApprovalQueue.svelte.d.ts.map +0 -1
- package/dist/svelte/components/__tests__/TimeEntryApprovalQueue.test.js +0 -115
|
@@ -1,245 +0,0 @@
|
|
|
1
|
-
<script lang="ts">
|
|
2
|
-
/**
|
|
3
|
-
* TimeEntryApprovalQueue — reusable Service Time Entry review list (issue
|
|
4
|
-
* #1930): date, hours, description, worker, status, and the client charge
|
|
5
|
-
* amount at a glance, with approve/reject actions on `submitted` rows.
|
|
6
|
-
* Presentational: the host loads entries through `TimeEntryApprovalService`
|
|
7
|
-
* / `ServiceTimeEntryCollection`, adapts them with `toSupportTimeEntryView`,
|
|
8
|
-
* and wires `onapprove` / `onreject` to the approval service.
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
import { Button, StatusBadge } from '@happyvertical/smrt-ui';
|
|
12
|
-
import { Input } from '@happyvertical/smrt-ui/forms';
|
|
13
|
-
import {
|
|
14
|
-
humanizeStatus,
|
|
15
|
-
type SupportTimeEntryView,
|
|
16
|
-
timeEntryStatusBadgeKey,
|
|
17
|
-
} from '../types.js';
|
|
18
|
-
|
|
19
|
-
export interface TimeEntryApprovalQueueProps {
|
|
20
|
-
/** Array of time entries awaiting approval review. */
|
|
21
|
-
entries: SupportTimeEntryView[];
|
|
22
|
-
/** Callback invoked with entry ID when approving a time entry. */
|
|
23
|
-
onapprove?: (id: string) => void;
|
|
24
|
-
/** Callback invoked with entry ID and rejection reason when rejecting a time entry. */
|
|
25
|
-
onreject?: (id: string, reason: string) => void;
|
|
26
|
-
/** Message displayed when the approval queue is empty. */
|
|
27
|
-
emptyMessage?: string;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
const {
|
|
31
|
-
entries,
|
|
32
|
-
onapprove,
|
|
33
|
-
onreject,
|
|
34
|
-
emptyMessage = 'No time entries',
|
|
35
|
-
}: TimeEntryApprovalQueueProps = $props();
|
|
36
|
-
|
|
37
|
-
/** The entry currently collecting a rejection reason (one at a time). */
|
|
38
|
-
let rejectingId = $state<string | null>(null);
|
|
39
|
-
let rejectReason = $state('');
|
|
40
|
-
|
|
41
|
-
function beginReject(id: string): void {
|
|
42
|
-
rejectingId = id;
|
|
43
|
-
rejectReason = '';
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
function cancelReject(): void {
|
|
47
|
-
rejectingId = null;
|
|
48
|
-
rejectReason = '';
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
function confirmReject(id: string): void {
|
|
52
|
-
const reason = rejectReason.trim();
|
|
53
|
-
if (!reason) return;
|
|
54
|
-
onreject?.(id, reason);
|
|
55
|
-
cancelReject();
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
function formatHours(hours: number): string {
|
|
59
|
-
return `${hours.toFixed(1)}h`;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
/**
|
|
63
|
-
* Render a minor-units charge for display.
|
|
64
|
-
*
|
|
65
|
-
* `amount` is integer minor units (#2401), so `toFixed(2)` would print 180
|
|
66
|
-
* cents as "180.00" — a hundredfold overstatement. The scale is the currency's
|
|
67
|
-
* own minor-unit exponent rather than a hard-coded 100, so a zero-decimal
|
|
68
|
-
* currency (JPY, KRW) is not divided at all and a three-decimal one (BHD) is
|
|
69
|
-
* divided by 1000. No currency symbol is added: the queue shows a bare figure
|
|
70
|
-
* and the host app supplies the label.
|
|
71
|
-
*/
|
|
72
|
-
function formatAmount(amount: number, currency = 'USD'): string {
|
|
73
|
-
const digits =
|
|
74
|
-
new Intl.NumberFormat(undefined, {
|
|
75
|
-
style: 'currency',
|
|
76
|
-
currency,
|
|
77
|
-
}).resolvedOptions().maximumFractionDigits ?? 2;
|
|
78
|
-
return (amount / 10 ** digits).toFixed(digits);
|
|
79
|
-
}
|
|
80
|
-
</script>
|
|
81
|
-
|
|
82
|
-
{#if entries.length === 0}
|
|
83
|
-
<p class="time-approval-empty">{emptyMessage}</p>
|
|
84
|
-
{:else}
|
|
85
|
-
<ul class="time-approval-queue">
|
|
86
|
-
{#each entries as entry (entry.id)}
|
|
87
|
-
<li class="time-approval-row">
|
|
88
|
-
<div class="time-approval-line">
|
|
89
|
-
<span class="time-approval-date">{entry.date}</span>
|
|
90
|
-
<span class="time-approval-main">
|
|
91
|
-
<span class="time-approval-description">{entry.description}</span>
|
|
92
|
-
{#if entry.workerName}
|
|
93
|
-
<span class="time-approval-worker">{entry.workerName}</span>
|
|
94
|
-
{/if}
|
|
95
|
-
</span>
|
|
96
|
-
<span class="time-approval-meta">
|
|
97
|
-
<span class="time-approval-hours">{formatHours(entry.hours)}</span>
|
|
98
|
-
<StatusBadge
|
|
99
|
-
status={timeEntryStatusBadgeKey(entry.status)}
|
|
100
|
-
label={humanizeStatus(entry.status)}
|
|
101
|
-
size="sm"
|
|
102
|
-
/>
|
|
103
|
-
{#if entry.amount !== undefined}
|
|
104
|
-
<span class="time-approval-amount">
|
|
105
|
-
{formatAmount(entry.amount, entry.currency)}
|
|
106
|
-
</span>
|
|
107
|
-
{/if}
|
|
108
|
-
</span>
|
|
109
|
-
</div>
|
|
110
|
-
{#if entry.status === 'submitted'}
|
|
111
|
-
<div class="time-approval-actions">
|
|
112
|
-
{#if rejectingId === entry.id}
|
|
113
|
-
<Input
|
|
114
|
-
bind:value={rejectReason}
|
|
115
|
-
placeholder="Rejection reason"
|
|
116
|
-
aria-label={`Rejection reason for: ${entry.description}`}
|
|
117
|
-
/>
|
|
118
|
-
<Button
|
|
119
|
-
variant="danger"
|
|
120
|
-
size="sm"
|
|
121
|
-
disabled={rejectReason.trim().length === 0}
|
|
122
|
-
onclick={() => confirmReject(entry.id)}
|
|
123
|
-
aria-label={`Confirm rejection of: ${entry.description}`}
|
|
124
|
-
>
|
|
125
|
-
Confirm reject
|
|
126
|
-
</Button>
|
|
127
|
-
<Button
|
|
128
|
-
variant="ghost"
|
|
129
|
-
size="sm"
|
|
130
|
-
onclick={cancelReject}
|
|
131
|
-
aria-label={`Cancel rejecting: ${entry.description}`}
|
|
132
|
-
>
|
|
133
|
-
Cancel
|
|
134
|
-
</Button>
|
|
135
|
-
{:else}
|
|
136
|
-
<Button
|
|
137
|
-
variant="primary"
|
|
138
|
-
size="sm"
|
|
139
|
-
onclick={() => onapprove?.(entry.id)}
|
|
140
|
-
aria-label={`Approve time entry: ${entry.description}`}
|
|
141
|
-
>
|
|
142
|
-
Approve
|
|
143
|
-
</Button>
|
|
144
|
-
<Button
|
|
145
|
-
variant="ghost"
|
|
146
|
-
size="sm"
|
|
147
|
-
onclick={() => beginReject(entry.id)}
|
|
148
|
-
aria-label={`Reject time entry: ${entry.description}`}
|
|
149
|
-
>
|
|
150
|
-
Reject
|
|
151
|
-
</Button>
|
|
152
|
-
{/if}
|
|
153
|
-
</div>
|
|
154
|
-
{/if}
|
|
155
|
-
</li>
|
|
156
|
-
{/each}
|
|
157
|
-
</ul>
|
|
158
|
-
{/if}
|
|
159
|
-
|
|
160
|
-
<style>
|
|
161
|
-
.time-approval-queue {
|
|
162
|
-
list-style: none;
|
|
163
|
-
margin: 0;
|
|
164
|
-
padding: 0;
|
|
165
|
-
display: flex;
|
|
166
|
-
flex-direction: column;
|
|
167
|
-
gap: 0.25rem;
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
.time-approval-row {
|
|
171
|
-
display: flex;
|
|
172
|
-
flex-direction: column;
|
|
173
|
-
gap: 0.375rem;
|
|
174
|
-
padding: 0.5rem 0.25rem;
|
|
175
|
-
border-bottom: 1px solid var(--smrt-color-outline-variant, transparent);
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
.time-approval-row:last-child {
|
|
179
|
-
border-bottom: none;
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
.time-approval-line {
|
|
183
|
-
display: flex;
|
|
184
|
-
width: 100%;
|
|
185
|
-
align-items: center;
|
|
186
|
-
justify-content: space-between;
|
|
187
|
-
gap: 0.75rem;
|
|
188
|
-
text-align: left;
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
.time-approval-date {
|
|
192
|
-
flex-shrink: 0;
|
|
193
|
-
font-size: 0.75rem;
|
|
194
|
-
color: var(--smrt-color-on-surface-variant, inherit);
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
.time-approval-main {
|
|
198
|
-
display: flex;
|
|
199
|
-
flex-direction: column;
|
|
200
|
-
gap: 0.125rem;
|
|
201
|
-
min-width: 0;
|
|
202
|
-
flex: 1;
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
.time-approval-description {
|
|
206
|
-
font-weight: 500;
|
|
207
|
-
overflow: hidden;
|
|
208
|
-
text-overflow: ellipsis;
|
|
209
|
-
white-space: nowrap;
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
.time-approval-worker {
|
|
213
|
-
font-size: 0.75rem;
|
|
214
|
-
color: var(--smrt-color-on-surface-variant, inherit);
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
.time-approval-meta {
|
|
218
|
-
display: flex;
|
|
219
|
-
align-items: center;
|
|
220
|
-
gap: 0.5rem;
|
|
221
|
-
flex-shrink: 0;
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
.time-approval-hours {
|
|
225
|
-
font-weight: 500;
|
|
226
|
-
color: var(--smrt-color-primary, inherit);
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
.time-approval-amount {
|
|
230
|
-
font-variant-numeric: tabular-nums;
|
|
231
|
-
font-weight: 500;
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
.time-approval-actions {
|
|
235
|
-
display: flex;
|
|
236
|
-
align-items: center;
|
|
237
|
-
gap: 0.5rem;
|
|
238
|
-
justify-content: flex-end;
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
.time-approval-empty {
|
|
242
|
-
color: var(--smrt-color-on-surface-variant, inherit);
|
|
243
|
-
padding: 1rem 0;
|
|
244
|
-
}
|
|
245
|
-
</style>
|
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
import { type SupportTimeEntryView } from '../types.js';
|
|
2
|
-
export interface TimeEntryApprovalQueueProps {
|
|
3
|
-
/** Array of time entries awaiting approval review. */
|
|
4
|
-
entries: SupportTimeEntryView[];
|
|
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
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"TimeEntryApprovalQueue.svelte.d.ts","sourceRoot":"","sources":["../../../src/svelte/components/TimeEntryApprovalQueue.svelte.ts"],"names":[],"mappings":"AAaA,OAAO,EAEL,KAAK,oBAAoB,EAE1B,MAAM,aAAa,CAAC;AAGrB,MAAM,WAAW,2BAA2B;IAC1C,sDAAsD;IACtD,OAAO,EAAE,oBAAoB,EAAE,CAAC;IAChC,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;AAkHD,QAAA,MAAM,sBAAsB,iEAAwC,CAAC;AACrE,KAAK,sBAAsB,GAAG,UAAU,CAAC,OAAO,sBAAsB,CAAC,CAAC;AACxE,eAAe,sBAAsB,CAAC"}
|
|
@@ -1,115 +0,0 @@
|
|
|
1
|
-
// @vitest-environment jsdom
|
|
2
|
-
/**
|
|
3
|
-
* Component coverage for TimeEntryApprovalQueue via the shared S11 harness
|
|
4
|
-
* (#1416): rendering, the approve/reject callbacks (reject collects a
|
|
5
|
-
* reason first), and accessibility.
|
|
6
|
-
*/
|
|
7
|
-
import { expectNoA11yViolations, render, screen, userEvent, } from '@happyvertical/smrt-vitest/svelte';
|
|
8
|
-
import { describe, expect, it, vi } from 'vitest';
|
|
9
|
-
import TimeEntryApprovalQueue from '../TimeEntryApprovalQueue.svelte';
|
|
10
|
-
function entryView(overrides = {}) {
|
|
11
|
-
return {
|
|
12
|
-
id: 'te-1',
|
|
13
|
-
date: '2026-07-01',
|
|
14
|
-
hours: 1.5,
|
|
15
|
-
description: 'Debugged the login loop',
|
|
16
|
-
status: 'submitted',
|
|
17
|
-
amount: 18000, // $180.00 in cents (#2401)
|
|
18
|
-
currency: 'USD',
|
|
19
|
-
workerName: 'Ada',
|
|
20
|
-
hourlyRate: 12000, // $120.00/h in cents
|
|
21
|
-
source: 'timer',
|
|
22
|
-
participantKind: 'human',
|
|
23
|
-
caseId: 'case-1',
|
|
24
|
-
...overrides,
|
|
25
|
-
};
|
|
26
|
-
}
|
|
27
|
-
describe('TimeEntryApprovalQueue', () => {
|
|
28
|
-
it('renders date, hours, description, worker, status, and amount', async () => {
|
|
29
|
-
const { container } = render(TimeEntryApprovalQueue, {
|
|
30
|
-
props: { entries: [entryView()] },
|
|
31
|
-
});
|
|
32
|
-
expect(screen.getByText('2026-07-01')).toBeInTheDocument();
|
|
33
|
-
expect(screen.getByText('1.5h')).toBeInTheDocument();
|
|
34
|
-
expect(screen.getByText('Debugged the login loop')).toBeInTheDocument();
|
|
35
|
-
expect(screen.getByText('Ada')).toBeInTheDocument();
|
|
36
|
-
expect(screen.getByText('submitted')).toBeInTheDocument();
|
|
37
|
-
expect(screen.getByText('180.00')).toBeInTheDocument();
|
|
38
|
-
await expectNoA11yViolations(container);
|
|
39
|
-
});
|
|
40
|
-
it('omits the amount when no charge has derived yet', () => {
|
|
41
|
-
render(TimeEntryApprovalQueue, {
|
|
42
|
-
props: { entries: [entryView({ amount: undefined })] },
|
|
43
|
-
});
|
|
44
|
-
expect(screen.queryByText('180.00')).not.toBeInTheDocument();
|
|
45
|
-
});
|
|
46
|
-
it('scales by the currency exponent, not a hard-coded 100', () => {
|
|
47
|
-
// ¥18000 is 18000 minor units — JPY has no minor unit — so dividing by 100
|
|
48
|
-
// here would understate the charge a hundredfold (#2401).
|
|
49
|
-
render(TimeEntryApprovalQueue, {
|
|
50
|
-
props: { entries: [entryView({ currency: 'JPY' })] },
|
|
51
|
-
});
|
|
52
|
-
expect(screen.getByText('18000')).toBeInTheDocument();
|
|
53
|
-
expect(screen.queryByText('180.00')).not.toBeInTheDocument();
|
|
54
|
-
});
|
|
55
|
-
it('invokes onapprove with the entry id', async () => {
|
|
56
|
-
const onapprove = vi.fn();
|
|
57
|
-
render(TimeEntryApprovalQueue, {
|
|
58
|
-
props: { entries: [entryView()], onapprove },
|
|
59
|
-
});
|
|
60
|
-
await userEvent.click(screen.getByRole('button', {
|
|
61
|
-
name: 'Approve time entry: Debugged the login loop',
|
|
62
|
-
}));
|
|
63
|
-
expect(onapprove).toHaveBeenCalledWith('te-1');
|
|
64
|
-
});
|
|
65
|
-
it('collects a reason before invoking onreject', async () => {
|
|
66
|
-
const onreject = vi.fn();
|
|
67
|
-
const { container } = render(TimeEntryApprovalQueue, {
|
|
68
|
-
props: { entries: [entryView()], onreject },
|
|
69
|
-
});
|
|
70
|
-
await userEvent.click(screen.getByRole('button', {
|
|
71
|
-
name: 'Reject time entry: Debugged the login loop',
|
|
72
|
-
}));
|
|
73
|
-
const confirmButton = screen.getByRole('button', {
|
|
74
|
-
name: 'Confirm rejection of: Debugged the login loop',
|
|
75
|
-
});
|
|
76
|
-
expect(confirmButton).toBeDisabled();
|
|
77
|
-
await userEvent.type(screen.getByRole('textbox', {
|
|
78
|
-
name: 'Rejection reason for: Debugged the login loop',
|
|
79
|
-
}), 'duplicate entry');
|
|
80
|
-
await expectNoA11yViolations(container);
|
|
81
|
-
await userEvent.click(confirmButton);
|
|
82
|
-
expect(onreject).toHaveBeenCalledWith('te-1', 'duplicate entry');
|
|
83
|
-
});
|
|
84
|
-
it('cancelling a rejection restores the actions without firing onreject', async () => {
|
|
85
|
-
const onreject = vi.fn();
|
|
86
|
-
render(TimeEntryApprovalQueue, {
|
|
87
|
-
props: { entries: [entryView()], onreject },
|
|
88
|
-
});
|
|
89
|
-
await userEvent.click(screen.getByRole('button', {
|
|
90
|
-
name: 'Reject time entry: Debugged the login loop',
|
|
91
|
-
}));
|
|
92
|
-
await userEvent.click(screen.getByRole('button', {
|
|
93
|
-
name: 'Cancel rejecting: Debugged the login loop',
|
|
94
|
-
}));
|
|
95
|
-
expect(onreject).not.toHaveBeenCalled();
|
|
96
|
-
expect(screen.getByRole('button', {
|
|
97
|
-
name: 'Approve time entry: Debugged the login loop',
|
|
98
|
-
})).toBeInTheDocument();
|
|
99
|
-
});
|
|
100
|
-
it('shows no actions on rows that are not submitted', () => {
|
|
101
|
-
render(TimeEntryApprovalQueue, {
|
|
102
|
-
props: {
|
|
103
|
-
entries: [entryView({ id: 'te-2', status: 'approved' })],
|
|
104
|
-
onapprove: vi.fn(),
|
|
105
|
-
},
|
|
106
|
-
});
|
|
107
|
-
expect(screen.queryByRole('button')).not.toBeInTheDocument();
|
|
108
|
-
});
|
|
109
|
-
it('shows the empty message when there are no entries', () => {
|
|
110
|
-
render(TimeEntryApprovalQueue, {
|
|
111
|
-
props: { entries: [], emptyMessage: 'Nothing awaiting review' },
|
|
112
|
-
});
|
|
113
|
-
expect(screen.getByText('Nothing awaiting review')).toBeInTheDocument();
|
|
114
|
-
});
|
|
115
|
-
});
|