@happyvertical/smrt-support 0.52.0 → 0.53.2
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/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 +14 -13
- 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__/CaseDetail.test.js +0 -86
- package/dist/svelte/components/__tests__/CaseQueue.test.js +0 -67
- package/dist/svelte/components/__tests__/RoutingRationale.test.js +0 -69
- package/dist/svelte/components/__tests__/TargetList.test.js +0 -84
- 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,86 +0,0 @@
|
|
|
1
|
-
// @vitest-environment jsdom
|
|
2
|
-
/**
|
|
3
|
-
* Component coverage for CaseDetail via the shared S11 harness (#1416).
|
|
4
|
-
*/
|
|
5
|
-
import { expectNoA11yViolations, render, screen, } from '@happyvertical/smrt-vitest/svelte';
|
|
6
|
-
import { describe, expect, it } from 'vitest';
|
|
7
|
-
import CaseDetail from '../CaseDetail.svelte';
|
|
8
|
-
const caseView = {
|
|
9
|
-
id: 'case-1',
|
|
10
|
-
caseNumber: 'SUP-9',
|
|
11
|
-
subject: 'Checkout errors under load',
|
|
12
|
-
status: 'escalated',
|
|
13
|
-
priority: 'urgent',
|
|
14
|
-
severity: 'sev1',
|
|
15
|
-
channelKind: 'email',
|
|
16
|
-
clientProfileId: 'client-1',
|
|
17
|
-
projectId: 'shop-rebuild',
|
|
18
|
-
assignedSpecialistId: 'spec-2',
|
|
19
|
-
assignedSpecialistName: 'Grace',
|
|
20
|
-
reopenCount: 1,
|
|
21
|
-
createdAt: '2026-07-01T09:00:00.000Z',
|
|
22
|
-
updatedAt: '2026-07-03T16:00:00.000Z',
|
|
23
|
-
resolutionSummary: '',
|
|
24
|
-
};
|
|
25
|
-
const timeline = [
|
|
26
|
-
{
|
|
27
|
-
kind: 'interaction',
|
|
28
|
-
occurredAt: '2026-07-01T09:00:00.000Z',
|
|
29
|
-
actorKind: 'client',
|
|
30
|
-
summary: 'inbound email',
|
|
31
|
-
body: 'Checkout is failing for many users.',
|
|
32
|
-
direction: 'inbound',
|
|
33
|
-
channelKind: 'email',
|
|
34
|
-
eventType: null,
|
|
35
|
-
},
|
|
36
|
-
{
|
|
37
|
-
kind: 'event',
|
|
38
|
-
occurredAt: '2026-07-01T09:05:00.000Z',
|
|
39
|
-
actorKind: 'system',
|
|
40
|
-
summary: 'Status new → triaged',
|
|
41
|
-
body: '',
|
|
42
|
-
direction: null,
|
|
43
|
-
channelKind: null,
|
|
44
|
-
eventType: 'transition',
|
|
45
|
-
},
|
|
46
|
-
];
|
|
47
|
-
const workLinks = [
|
|
48
|
-
{
|
|
49
|
-
id: 'link-1',
|
|
50
|
-
linkKind: 'development_work_item',
|
|
51
|
-
targetLabel: 'shop#101: fix checkout race',
|
|
52
|
-
externalUrl: 'https://github.com/acme/shop/issues/101',
|
|
53
|
-
status: 'in_progress',
|
|
54
|
-
},
|
|
55
|
-
];
|
|
56
|
-
describe('CaseDetail', () => {
|
|
57
|
-
it('renders header, meta, linked work, and the merged timeline', async () => {
|
|
58
|
-
const { container } = render(CaseDetail, {
|
|
59
|
-
props: { caseView, timeline, workLinks },
|
|
60
|
-
});
|
|
61
|
-
expect(screen.getByText('SUP-9')).toBeInTheDocument();
|
|
62
|
-
expect(screen.getByText('Checkout errors under load')).toBeInTheDocument();
|
|
63
|
-
expect(screen.getByText('escalated')).toBeInTheDocument();
|
|
64
|
-
expect(screen.getByText('reopened ×1')).toBeInTheDocument();
|
|
65
|
-
expect(screen.getByText('Grace')).toBeInTheDocument();
|
|
66
|
-
expect(screen.getByText('shop-rebuild')).toBeInTheDocument();
|
|
67
|
-
expect(screen.getByRole('link', { name: 'shop#101: fix checkout race' })).toHaveAttribute('href', 'https://github.com/acme/shop/issues/101');
|
|
68
|
-
expect(screen.getByText('Checkout is failing for many users.')).toBeInTheDocument();
|
|
69
|
-
expect(screen.getByText('Status new → triaged')).toBeInTheDocument();
|
|
70
|
-
await expectNoA11yViolations(container);
|
|
71
|
-
});
|
|
72
|
-
it('shows the empty history state and resolution when present', () => {
|
|
73
|
-
render(CaseDetail, {
|
|
74
|
-
props: {
|
|
75
|
-
caseView: {
|
|
76
|
-
...caseView,
|
|
77
|
-
status: 'resolved',
|
|
78
|
-
resolutionSummary: 'Scaled the checkout workers.',
|
|
79
|
-
},
|
|
80
|
-
timeline: [],
|
|
81
|
-
},
|
|
82
|
-
});
|
|
83
|
-
expect(screen.getByText('No activity yet')).toBeInTheDocument();
|
|
84
|
-
expect(screen.getByText('Scaled the checkout workers.')).toBeInTheDocument();
|
|
85
|
-
});
|
|
86
|
-
});
|
|
@@ -1,67 +0,0 @@
|
|
|
1
|
-
// @vitest-environment jsdom
|
|
2
|
-
/**
|
|
3
|
-
* Component coverage for CaseQueue via the shared S11 harness (#1416).
|
|
4
|
-
*/
|
|
5
|
-
import { expectNoA11yViolations, render, screen, userEvent, } from '@happyvertical/smrt-vitest/svelte';
|
|
6
|
-
import { describe, expect, it, vi } from 'vitest';
|
|
7
|
-
import CaseQueue from '../CaseQueue.svelte';
|
|
8
|
-
function caseView(overrides = {}) {
|
|
9
|
-
return {
|
|
10
|
-
id: 'case-1',
|
|
11
|
-
caseNumber: 'SUP-1',
|
|
12
|
-
subject: 'Login is broken',
|
|
13
|
-
status: 'in_progress',
|
|
14
|
-
priority: 'high',
|
|
15
|
-
severity: 'sev2',
|
|
16
|
-
channelKind: 'chat',
|
|
17
|
-
clientProfileId: 'client-1',
|
|
18
|
-
projectId: 'project-1',
|
|
19
|
-
assignedSpecialistId: 'spec-1',
|
|
20
|
-
assignedSpecialistName: 'Ada',
|
|
21
|
-
reopenCount: 0,
|
|
22
|
-
createdAt: '2026-07-01T10:00:00.000Z',
|
|
23
|
-
updatedAt: '2026-07-02T11:30:00.000Z',
|
|
24
|
-
resolutionSummary: '',
|
|
25
|
-
...overrides,
|
|
26
|
-
};
|
|
27
|
-
}
|
|
28
|
-
describe('CaseQueue', () => {
|
|
29
|
-
it('renders case number, subject, badges, and assignee', async () => {
|
|
30
|
-
const { container } = render(CaseQueue, {
|
|
31
|
-
props: { cases: [caseView()] },
|
|
32
|
-
});
|
|
33
|
-
expect(screen.getByText('SUP-1')).toBeInTheDocument();
|
|
34
|
-
expect(screen.getByText('Login is broken')).toBeInTheDocument();
|
|
35
|
-
expect(screen.getByText('high')).toBeInTheDocument();
|
|
36
|
-
expect(screen.getByText('in progress')).toBeInTheDocument();
|
|
37
|
-
expect(screen.getByText('Ada')).toBeInTheDocument();
|
|
38
|
-
await expectNoA11yViolations(container);
|
|
39
|
-
});
|
|
40
|
-
it('invokes onselect with the case id', async () => {
|
|
41
|
-
const onselect = vi.fn();
|
|
42
|
-
render(CaseQueue, { props: { cases: [caseView()], onselect } });
|
|
43
|
-
await userEvent.click(screen.getByRole('button', {
|
|
44
|
-
name: 'Open case SUP-1: Login is broken',
|
|
45
|
-
}));
|
|
46
|
-
expect(onselect).toHaveBeenCalledWith('case-1');
|
|
47
|
-
});
|
|
48
|
-
it('shows the empty message when there are no cases', () => {
|
|
49
|
-
render(CaseQueue, {
|
|
50
|
-
props: { cases: [], emptyMessage: 'Queue is clear' },
|
|
51
|
-
});
|
|
52
|
-
expect(screen.getByText('Queue is clear')).toBeInTheDocument();
|
|
53
|
-
});
|
|
54
|
-
it('falls back to unassigned when no specialist is set', () => {
|
|
55
|
-
render(CaseQueue, {
|
|
56
|
-
props: {
|
|
57
|
-
cases: [
|
|
58
|
-
caseView({
|
|
59
|
-
assignedSpecialistId: null,
|
|
60
|
-
assignedSpecialistName: null,
|
|
61
|
-
}),
|
|
62
|
-
],
|
|
63
|
-
},
|
|
64
|
-
});
|
|
65
|
-
expect(screen.getByText('unassigned')).toBeInTheDocument();
|
|
66
|
-
});
|
|
67
|
-
});
|
|
@@ -1,69 +0,0 @@
|
|
|
1
|
-
// @vitest-environment jsdom
|
|
2
|
-
/**
|
|
3
|
-
* Component coverage for RoutingRationale via the shared S11 harness (#1416).
|
|
4
|
-
*/
|
|
5
|
-
import { expectNoA11yViolations, render, screen, userEvent, } from '@happyvertical/smrt-vitest/svelte';
|
|
6
|
-
import { describe, expect, it, vi } from 'vitest';
|
|
7
|
-
import RoutingRationale from '../RoutingRationale.svelte';
|
|
8
|
-
function rankedView(overrides = {}) {
|
|
9
|
-
return {
|
|
10
|
-
specialistId: 'spec-1',
|
|
11
|
-
displayName: 'Ada Lovelace',
|
|
12
|
-
score: 45,
|
|
13
|
-
eligible: true,
|
|
14
|
-
factors: {
|
|
15
|
-
status: 'active',
|
|
16
|
-
projectQualification: 'expert',
|
|
17
|
-
weeklyAvailable: true,
|
|
18
|
-
openCases: 2,
|
|
19
|
-
},
|
|
20
|
-
...overrides,
|
|
21
|
-
};
|
|
22
|
-
}
|
|
23
|
-
describe('RoutingRationale', () => {
|
|
24
|
-
it('renders name, score, eligibility, and factor chips', async () => {
|
|
25
|
-
const { container } = render(RoutingRationale, {
|
|
26
|
-
props: {
|
|
27
|
-
ranking: [
|
|
28
|
-
rankedView(),
|
|
29
|
-
rankedView({
|
|
30
|
-
specialistId: 'spec-2',
|
|
31
|
-
displayName: 'Grace Hopper',
|
|
32
|
-
score: 0,
|
|
33
|
-
eligible: false,
|
|
34
|
-
factors: { status: 'active', projectQualification: 'none' },
|
|
35
|
-
}),
|
|
36
|
-
],
|
|
37
|
-
},
|
|
38
|
-
});
|
|
39
|
-
expect(screen.getByText('Ada Lovelace')).toBeInTheDocument();
|
|
40
|
-
expect(screen.getByText('score 45')).toBeInTheDocument();
|
|
41
|
-
expect(screen.getByText('eligible')).toBeInTheDocument();
|
|
42
|
-
expect(screen.getByText('ineligible')).toBeInTheDocument();
|
|
43
|
-
// Boolean-true factors render as bare chips; others as key: value.
|
|
44
|
-
expect(screen.getByText('weeklyAvailable')).toBeInTheDocument();
|
|
45
|
-
expect(screen.getByText('projectQualification: expert')).toBeInTheDocument();
|
|
46
|
-
expect(screen.getByText('projectQualification: none')).toBeInTheDocument();
|
|
47
|
-
expect(screen.getByText('openCases: 2')).toBeInTheDocument();
|
|
48
|
-
await expectNoA11yViolations(container);
|
|
49
|
-
});
|
|
50
|
-
it('fires onreassign with the specialist id', async () => {
|
|
51
|
-
const onreassign = vi.fn();
|
|
52
|
-
const { container } = render(RoutingRationale, {
|
|
53
|
-
props: { ranking: [rankedView()], onreassign },
|
|
54
|
-
});
|
|
55
|
-
await userEvent.click(screen.getByRole('button', { name: 'Reassign to Ada Lovelace' }));
|
|
56
|
-
expect(onreassign).toHaveBeenCalledWith('spec-1');
|
|
57
|
-
await expectNoA11yViolations(container);
|
|
58
|
-
});
|
|
59
|
-
it('renders no reassign action without an onreassign handler', () => {
|
|
60
|
-
render(RoutingRationale, { props: { ranking: [rankedView()] } });
|
|
61
|
-
expect(screen.queryByRole('button')).not.toBeInTheDocument();
|
|
62
|
-
});
|
|
63
|
-
it('shows the empty message for an empty ranking', () => {
|
|
64
|
-
render(RoutingRationale, {
|
|
65
|
-
props: { ranking: [], emptyMessage: 'Nobody to rank' },
|
|
66
|
-
});
|
|
67
|
-
expect(screen.getByText('Nobody to rank')).toBeInTheDocument();
|
|
68
|
-
});
|
|
69
|
-
});
|
|
@@ -1,84 +0,0 @@
|
|
|
1
|
-
// @vitest-environment jsdom
|
|
2
|
-
/**
|
|
3
|
-
* Component coverage for TargetList via the shared S11 harness (#1416).
|
|
4
|
-
*/
|
|
5
|
-
import { expectNoA11yViolations, render, screen, } from '@happyvertical/smrt-vitest/svelte';
|
|
6
|
-
import { describe, expect, it } from 'vitest';
|
|
7
|
-
import TargetList from '../TargetList.svelte';
|
|
8
|
-
function targetView(overrides = {}) {
|
|
9
|
-
return {
|
|
10
|
-
id: 'target-1',
|
|
11
|
-
targetType: 'acknowledgement',
|
|
12
|
-
cycle: 0,
|
|
13
|
-
status: 'pending',
|
|
14
|
-
severity: 'sev2',
|
|
15
|
-
baseMinutes: 30,
|
|
16
|
-
startedAt: '2026-01-05T10:00:00.000Z',
|
|
17
|
-
dueAt: '2026-01-05T10:30:00.000Z',
|
|
18
|
-
satisfiedAt: null,
|
|
19
|
-
breachedAt: null,
|
|
20
|
-
paused: false,
|
|
21
|
-
...overrides,
|
|
22
|
-
};
|
|
23
|
-
}
|
|
24
|
-
describe('TargetList', () => {
|
|
25
|
-
it('renders target type, due time, and status badge', async () => {
|
|
26
|
-
const { container } = render(TargetList, {
|
|
27
|
-
props: {
|
|
28
|
-
targets: [
|
|
29
|
-
targetView(),
|
|
30
|
-
targetView({
|
|
31
|
-
id: 'target-2',
|
|
32
|
-
targetType: 'resolution',
|
|
33
|
-
status: 'satisfied',
|
|
34
|
-
satisfiedAt: '2026-01-05T11:00:00.000Z',
|
|
35
|
-
}),
|
|
36
|
-
],
|
|
37
|
-
},
|
|
38
|
-
});
|
|
39
|
-
expect(screen.getByText('acknowledgement')).toBeInTheDocument();
|
|
40
|
-
expect(screen.getByText('resolution')).toBeInTheDocument();
|
|
41
|
-
expect(screen.getAllByText(/due /)).toHaveLength(2);
|
|
42
|
-
expect(screen.getByText('pending')).toBeInTheDocument();
|
|
43
|
-
expect(screen.getByText('satisfied')).toBeInTheDocument();
|
|
44
|
-
await expectNoA11yViolations(container);
|
|
45
|
-
});
|
|
46
|
-
it('shows the paused indicator and the recurrence cycle', async () => {
|
|
47
|
-
const { container } = render(TargetList, {
|
|
48
|
-
props: {
|
|
49
|
-
targets: [
|
|
50
|
-
targetView({
|
|
51
|
-
id: 'target-3',
|
|
52
|
-
targetType: 'update',
|
|
53
|
-
status: 'paused',
|
|
54
|
-
paused: true,
|
|
55
|
-
cycle: 2,
|
|
56
|
-
}),
|
|
57
|
-
],
|
|
58
|
-
},
|
|
59
|
-
});
|
|
60
|
-
expect(screen.getByText('paused', { selector: '.target-list-paused' })).toBeInTheDocument();
|
|
61
|
-
expect(screen.getByText('cycle 2')).toBeInTheDocument();
|
|
62
|
-
await expectNoA11yViolations(container);
|
|
63
|
-
});
|
|
64
|
-
it('renders a breached clock with the error badge label', () => {
|
|
65
|
-
render(TargetList, {
|
|
66
|
-
props: {
|
|
67
|
-
targets: [
|
|
68
|
-
targetView({
|
|
69
|
-
id: 'target-4',
|
|
70
|
-
status: 'breached',
|
|
71
|
-
breachedAt: '2026-01-05T11:00:00.000Z',
|
|
72
|
-
}),
|
|
73
|
-
],
|
|
74
|
-
},
|
|
75
|
-
});
|
|
76
|
-
expect(screen.getByText('breached')).toBeInTheDocument();
|
|
77
|
-
});
|
|
78
|
-
it('shows the empty message when there are no targets', () => {
|
|
79
|
-
render(TargetList, {
|
|
80
|
-
props: { targets: [], emptyMessage: 'No clocks running' },
|
|
81
|
-
});
|
|
82
|
-
expect(screen.getByText('No clocks running')).toBeInTheDocument();
|
|
83
|
-
});
|
|
84
|
-
});
|
|
@@ -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
|
-
});
|