@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,25 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
import { type Currency, type TimeEntry } from './utils.js';
|
|
3
|
+
/** Props for TimeEntryCard component */
|
|
4
|
+
export interface Props {
|
|
5
|
+
/** Time entry data to display. */
|
|
6
|
+
entry: TimeEntry;
|
|
7
|
+
/** URL to navigate to when the card is clicked. */
|
|
8
|
+
href?: string;
|
|
9
|
+
/** Invoked when the card content is clicked. */
|
|
10
|
+
onclick?: () => void;
|
|
11
|
+
/** Shows a checkbox for selecting the entry. */
|
|
12
|
+
selectable?: boolean;
|
|
13
|
+
/** Indicates whether the entry is selected. */
|
|
14
|
+
selected?: boolean;
|
|
15
|
+
/** Invoked when the selection checkbox is toggled. */
|
|
16
|
+
onselect?: (id: string, selected: boolean) => void;
|
|
17
|
+
/** Currency code for formatting hourly rates and amounts. */
|
|
18
|
+
currency?: Currency;
|
|
19
|
+
/** Renders action buttons on the right side of the card. */
|
|
20
|
+
actions?: Snippet;
|
|
21
|
+
}
|
|
22
|
+
declare const TimeEntryCard: import("svelte").Component<Props, {}, "">;
|
|
23
|
+
type TimeEntryCard = ReturnType<typeof TimeEntryCard>;
|
|
24
|
+
export default TimeEntryCard;
|
|
25
|
+
//# sourceMappingURL=TimeEntryCard.svelte.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"TimeEntryCard.svelte.d.ts","sourceRoot":"","sources":["../../../src/svelte/components/TimeEntryCard.svelte.ts"],"names":[],"mappings":"AAQA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,QAAQ,CAAC;AAEtC,OAAO,EACL,KAAK,QAAQ,EAKb,KAAK,SAAS,EACf,MAAM,YAAY,CAAC;AAGpB,wCAAwC;AACxC,MAAM,WAAW,KAAK;IACpB,kCAAkC;IAClC,KAAK,EAAE,SAAS,CAAC;IACjB,mDAAmD;IACnD,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,gDAAgD;IAChD,OAAO,CAAC,EAAE,MAAM,IAAI,CAAC;IACrB,gDAAgD;IAChD,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,+CAA+C;IAC/C,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,sDAAsD;IACtD,QAAQ,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,EAAE,QAAQ,EAAE,OAAO,KAAK,IAAI,CAAC;IACnD,6DAA6D;IAC7D,QAAQ,CAAC,EAAE,QAAQ,CAAC;IACpB,4DAA4D;IAC5D,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB;AAoHD,QAAA,MAAM,aAAa,2CAAwC,CAAC;AAC5D,KAAK,aAAa,GAAG,UAAU,CAAC,OAAO,aAAa,CAAC,CAAC;AACtD,eAAe,aAAa,CAAC"}
|
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* TimeEntryList - List of time entries with optional selection
|
|
4
|
+
* Supports bulk selection and grouping
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { useI18n } from '@happyvertical/smrt-ui/i18n';
|
|
8
|
+
import { M } from '../i18n.js';
|
|
9
|
+
import {
|
|
10
|
+
type Currency,
|
|
11
|
+
formatCurrency,
|
|
12
|
+
formatDate,
|
|
13
|
+
formatHours,
|
|
14
|
+
statusColors,
|
|
15
|
+
type TimeEntry,
|
|
16
|
+
} from './utils.js';
|
|
17
|
+
|
|
18
|
+
const { t } = useI18n();
|
|
19
|
+
|
|
20
|
+
/** Props for TimeEntryList component */
|
|
21
|
+
export interface Props {
|
|
22
|
+
/** Time entries to display in the list. */
|
|
23
|
+
entries: TimeEntry[];
|
|
24
|
+
/** Shows checkboxes for selecting entries. */
|
|
25
|
+
selectable?: boolean;
|
|
26
|
+
/** Identifiers of currently selected entries. */
|
|
27
|
+
selectedIds?: string[];
|
|
28
|
+
/** Invoked when the selection changes. */
|
|
29
|
+
onselectionchange?: (ids: string[]) => void;
|
|
30
|
+
/** Message shown when the entries list is empty. */
|
|
31
|
+
emptyMessage?: string;
|
|
32
|
+
/** Base URL to construct entry detail links. */
|
|
33
|
+
baseHref?: string;
|
|
34
|
+
/** Currency code for formatting amounts. */
|
|
35
|
+
currency?: Currency;
|
|
36
|
+
/** Filter function to determine which entries can be selected */
|
|
37
|
+
canSelect?: (entry: TimeEntry) => boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
let {
|
|
41
|
+
entries,
|
|
42
|
+
selectable = false,
|
|
43
|
+
selectedIds = [],
|
|
44
|
+
onselectionchange,
|
|
45
|
+
emptyMessage = 'No time entries',
|
|
46
|
+
baseHref,
|
|
47
|
+
currency = 'CAD',
|
|
48
|
+
canSelect = () => true,
|
|
49
|
+
}: Props = $props();
|
|
50
|
+
|
|
51
|
+
// Filter entries that can actually be selected
|
|
52
|
+
const selectableEntries = $derived(selectable ? entries.filter(canSelect) : []);
|
|
53
|
+
|
|
54
|
+
const allSelected = $derived(
|
|
55
|
+
selectableEntries.length > 0 &&
|
|
56
|
+
selectableEntries.every((e) => selectedIds.includes(e.id)),
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
const someSelected = $derived(selectedIds.length > 0 && !allSelected);
|
|
60
|
+
|
|
61
|
+
function handleSelectAll(event: Event) {
|
|
62
|
+
const target = event.target as HTMLInputElement;
|
|
63
|
+
if (target.checked) {
|
|
64
|
+
onselectionchange?.(selectableEntries.map((e) => e.id));
|
|
65
|
+
} else {
|
|
66
|
+
onselectionchange?.([]);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function handleSelect(id: string, selected: boolean, event: Event) {
|
|
71
|
+
event.preventDefault();
|
|
72
|
+
event.stopPropagation();
|
|
73
|
+
if (selected) {
|
|
74
|
+
onselectionchange?.([...selectedIds, id]);
|
|
75
|
+
} else {
|
|
76
|
+
onselectionchange?.(selectedIds.filter((i) => i !== id));
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function getEntryHref(entry: TimeEntry): string | undefined {
|
|
81
|
+
if (!baseHref) return undefined;
|
|
82
|
+
return `${baseHref}/${entry.id}`;
|
|
83
|
+
}
|
|
84
|
+
</script>
|
|
85
|
+
|
|
86
|
+
{#if entries.length === 0}
|
|
87
|
+
<div class="empty-state">
|
|
88
|
+
<p>{emptyMessage}</p>
|
|
89
|
+
</div>
|
|
90
|
+
{:else}
|
|
91
|
+
<div class="time-entry-list">
|
|
92
|
+
{#if selectable && selectableEntries.length > 0}
|
|
93
|
+
<div class="list-header">
|
|
94
|
+
<label class="select-all">
|
|
95
|
+
<!-- raw-primitive-allow: select-all checkbox needs the indeterminate state; must stay native -->
|
|
96
|
+
<input
|
|
97
|
+
type="checkbox"
|
|
98
|
+
checked={allSelected}
|
|
99
|
+
indeterminate={someSelected}
|
|
100
|
+
onchange={handleSelectAll}
|
|
101
|
+
aria-label={t(M['projects.time_entry_list.select_all_aria'])}
|
|
102
|
+
/>
|
|
103
|
+
<span>{t(M['projects.time_entry_list.select_all'])}</span>
|
|
104
|
+
</label>
|
|
105
|
+
<span class="selection-count">
|
|
106
|
+
{t(M['projects.time_entry_list.selection_count'], { selected: selectedIds.length, total: selectableEntries.length })}
|
|
107
|
+
</span>
|
|
108
|
+
</div>
|
|
109
|
+
{/if}
|
|
110
|
+
|
|
111
|
+
<div class="entries">
|
|
112
|
+
{#each entries as entry (entry.id)}
|
|
113
|
+
{@const isSelected = selectedIds.includes(entry.id)}
|
|
114
|
+
{@const isSelectable = selectable && canSelect(entry)}
|
|
115
|
+
{@const entryHref = getEntryHref(entry)}
|
|
116
|
+
<div
|
|
117
|
+
class="entry-row"
|
|
118
|
+
class:selected={isSelected}
|
|
119
|
+
class:selectable={isSelectable}
|
|
120
|
+
class:has-checkbox={selectable && selectableEntries.length > 0}
|
|
121
|
+
>
|
|
122
|
+
{#if selectable && selectableEntries.length > 0}
|
|
123
|
+
<div class="checkbox-cell">
|
|
124
|
+
{#if isSelectable}
|
|
125
|
+
<!-- raw-primitive-allow: native row checkbox; no Provider-free checkbox primitive, kept native to match the indeterminate select-all -->
|
|
126
|
+
<input
|
|
127
|
+
type="checkbox"
|
|
128
|
+
checked={isSelected}
|
|
129
|
+
onchange={(e) => handleSelect(entry.id, (e.target as HTMLInputElement).checked, e)}
|
|
130
|
+
aria-label={t(M['projects.time_entry_list.select_entry_aria'], { description: entry.description })}
|
|
131
|
+
/>
|
|
132
|
+
{/if}
|
|
133
|
+
</div>
|
|
134
|
+
{/if}
|
|
135
|
+
|
|
136
|
+
{#if entryHref}
|
|
137
|
+
<a href={entryHref} class="entry-content">
|
|
138
|
+
{@render entryDetails(entry)}
|
|
139
|
+
</a>
|
|
140
|
+
{:else}
|
|
141
|
+
<div class="entry-content">
|
|
142
|
+
{@render entryDetails(entry)}
|
|
143
|
+
</div>
|
|
144
|
+
{/if}
|
|
145
|
+
</div>
|
|
146
|
+
{/each}
|
|
147
|
+
</div>
|
|
148
|
+
</div>
|
|
149
|
+
{/if}
|
|
150
|
+
|
|
151
|
+
{#snippet entryDetails(entry: TimeEntry)}
|
|
152
|
+
<div class="date-cell">
|
|
153
|
+
{formatDate(entry.date)}
|
|
154
|
+
</div>
|
|
155
|
+
|
|
156
|
+
<div class="description-cell">
|
|
157
|
+
<span class="description">{entry.description}</span>
|
|
158
|
+
{#if entry.workerName}
|
|
159
|
+
<span class="worker">{entry.workerName}</span>
|
|
160
|
+
{/if}
|
|
161
|
+
</div>
|
|
162
|
+
|
|
163
|
+
<div class="hours-cell">
|
|
164
|
+
{formatHours(entry.hours)}
|
|
165
|
+
</div>
|
|
166
|
+
|
|
167
|
+
<div class="status-cell">
|
|
168
|
+
<span class="status-badge" style="--status-color: {statusColors[entry.status]}">
|
|
169
|
+
{entry.status}
|
|
170
|
+
</span>
|
|
171
|
+
</div>
|
|
172
|
+
|
|
173
|
+
{#if entry.amount !== undefined}
|
|
174
|
+
<div class="amount-cell">
|
|
175
|
+
{formatCurrency(entry.amount, currency)}
|
|
176
|
+
</div>
|
|
177
|
+
{/if}
|
|
178
|
+
{/snippet}
|
|
179
|
+
|
|
180
|
+
<style>
|
|
181
|
+
.empty-state {
|
|
182
|
+
padding: 3rem 1rem;
|
|
183
|
+
text-align: center;
|
|
184
|
+
color: var(--smrt-color-on-surface-variant);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
.time-entry-list {
|
|
188
|
+
background: var(--smrt-color-surface);
|
|
189
|
+
border-radius: var(--smrt-radius-large, 16px);
|
|
190
|
+
overflow: hidden;
|
|
191
|
+
border: 1px solid var(--smrt-color-outline-variant);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
.list-header {
|
|
195
|
+
display: flex;
|
|
196
|
+
justify-content: space-between;
|
|
197
|
+
align-items: center;
|
|
198
|
+
padding: 0.75rem 1rem;
|
|
199
|
+
background: var(--smrt-color-surface-container-low);
|
|
200
|
+
border-bottom: 1px solid var(--smrt-color-outline-variant);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
.select-all {
|
|
204
|
+
display: flex;
|
|
205
|
+
align-items: center;
|
|
206
|
+
gap: 0.5rem;
|
|
207
|
+
cursor: pointer;
|
|
208
|
+
font-size: var(--smrt-typography-body-medium-size, 0.875rem);
|
|
209
|
+
font-weight: var(--smrt-typography-label-large-weight, 500);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
.select-all input[type='checkbox'] {
|
|
213
|
+
width: 1rem;
|
|
214
|
+
height: 1rem;
|
|
215
|
+
cursor: pointer;
|
|
216
|
+
accent-color: var(--smrt-color-primary);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
.selection-count {
|
|
220
|
+
font-size: var(--smrt-typography-body-medium-size, 0.875rem);
|
|
221
|
+
color: var(--smrt-color-on-surface-variant);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
.entries {
|
|
225
|
+
display: flex;
|
|
226
|
+
flex-direction: column;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
.entry-row {
|
|
230
|
+
display: flex;
|
|
231
|
+
align-items: center;
|
|
232
|
+
border-bottom: 1px solid var(--smrt-color-outline-variant);
|
|
233
|
+
transition: background 0.15s var(--smrt-easing-standard);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
.entry-row:last-child {
|
|
237
|
+
border-bottom: none;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
.entry-row:hover {
|
|
241
|
+
background: var(--smrt-color-surface-container-lowest);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
.entry-row.selected {
|
|
245
|
+
background: var(--smrt-color-primary-container);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
.checkbox-cell {
|
|
249
|
+
width: 2rem;
|
|
250
|
+
flex-shrink: 0;
|
|
251
|
+
padding-left: 1rem;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
.checkbox-cell input[type='checkbox'] {
|
|
255
|
+
width: 1rem;
|
|
256
|
+
height: 1rem;
|
|
257
|
+
cursor: pointer;
|
|
258
|
+
accent-color: var(--smrt-color-primary);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
.entry-content {
|
|
262
|
+
display: flex;
|
|
263
|
+
align-items: center;
|
|
264
|
+
flex: 1;
|
|
265
|
+
padding: 0.875rem 1rem;
|
|
266
|
+
text-decoration: none;
|
|
267
|
+
color: inherit;
|
|
268
|
+
min-width: 0;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
a.entry-content:focus {
|
|
272
|
+
outline: 2px solid var(--smrt-color-primary);
|
|
273
|
+
outline-offset: -2px;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
.date-cell {
|
|
277
|
+
width: 4.5rem;
|
|
278
|
+
flex-shrink: 0;
|
|
279
|
+
font-size: var(--smrt-typography-body-medium-size, 0.875rem);
|
|
280
|
+
color: var(--smrt-color-on-surface-variant);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
.description-cell {
|
|
284
|
+
flex: 1;
|
|
285
|
+
min-width: 0;
|
|
286
|
+
padding-right: 1rem;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
.description {
|
|
290
|
+
display: block;
|
|
291
|
+
font-size: var(--smrt-typography-body-large-size, 0.9375rem);
|
|
292
|
+
color: var(--smrt-color-on-surface);
|
|
293
|
+
white-space: nowrap;
|
|
294
|
+
overflow: hidden;
|
|
295
|
+
text-overflow: ellipsis;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
.worker {
|
|
299
|
+
display: block;
|
|
300
|
+
font-size: var(--smrt-typography-body-small-size, 0.75rem);
|
|
301
|
+
color: var(--smrt-color-on-surface-variant);
|
|
302
|
+
margin-top: 0.125rem;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
.hours-cell {
|
|
306
|
+
width: 4rem;
|
|
307
|
+
flex-shrink: 0;
|
|
308
|
+
font-size: var(--smrt-typography-body-large-size, 0.9375rem);
|
|
309
|
+
font-weight: var(--smrt-typography-title-medium-weight, 500);
|
|
310
|
+
color: var(--smrt-color-primary);
|
|
311
|
+
text-align: right;
|
|
312
|
+
padding-right: 1rem;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
.status-cell {
|
|
316
|
+
width: 6rem;
|
|
317
|
+
flex-shrink: 0;
|
|
318
|
+
text-align: center;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
.status-badge {
|
|
322
|
+
display: inline-block;
|
|
323
|
+
font-size: var(--smrt-typography-label-small-size, 0.625rem);
|
|
324
|
+
font-weight: var(--smrt-typography-label-small-weight, 500);
|
|
325
|
+
padding: 0.25rem 0.5rem;
|
|
326
|
+
border-radius: var(--smrt-radius-small, 8px);
|
|
327
|
+
background: var(--status-color);
|
|
328
|
+
color: var(--smrt-color-on-primary);
|
|
329
|
+
text-transform: uppercase;
|
|
330
|
+
letter-spacing: var(--smrt-typography-label-small-tracking, 0.5px);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
.amount-cell {
|
|
334
|
+
width: 6rem;
|
|
335
|
+
flex-shrink: 0;
|
|
336
|
+
font-size: var(--smrt-typography-body-large-size, 0.9375rem);
|
|
337
|
+
font-weight: var(--smrt-typography-title-medium-weight, 500);
|
|
338
|
+
text-align: right;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
@media (max-width: 640px) {
|
|
342
|
+
.entry-content {
|
|
343
|
+
flex-wrap: wrap;
|
|
344
|
+
gap: 0.5rem;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
.description-cell {
|
|
348
|
+
order: 1;
|
|
349
|
+
width: 100%;
|
|
350
|
+
padding-right: 0;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
.date-cell,
|
|
354
|
+
.hours-cell,
|
|
355
|
+
.status-cell,
|
|
356
|
+
.amount-cell {
|
|
357
|
+
flex: none;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
</style>
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { type Currency, type TimeEntry } from './utils.js';
|
|
2
|
+
/** Props for TimeEntryList component */
|
|
3
|
+
export interface Props {
|
|
4
|
+
/** Time entries to display in the list. */
|
|
5
|
+
entries: TimeEntry[];
|
|
6
|
+
/** Shows checkboxes for selecting entries. */
|
|
7
|
+
selectable?: boolean;
|
|
8
|
+
/** Identifiers of currently selected entries. */
|
|
9
|
+
selectedIds?: string[];
|
|
10
|
+
/** Invoked when the selection changes. */
|
|
11
|
+
onselectionchange?: (ids: string[]) => void;
|
|
12
|
+
/** Message shown when the entries list is empty. */
|
|
13
|
+
emptyMessage?: string;
|
|
14
|
+
/** Base URL to construct entry detail links. */
|
|
15
|
+
baseHref?: string;
|
|
16
|
+
/** Currency code for formatting amounts. */
|
|
17
|
+
currency?: Currency;
|
|
18
|
+
/** Filter function to determine which entries can be selected */
|
|
19
|
+
canSelect?: (entry: TimeEntry) => boolean;
|
|
20
|
+
}
|
|
21
|
+
declare const TimeEntryList: import("svelte").Component<Props, {}, "">;
|
|
22
|
+
type TimeEntryList = ReturnType<typeof TimeEntryList>;
|
|
23
|
+
export default TimeEntryList;
|
|
24
|
+
//# sourceMappingURL=TimeEntryList.svelte.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"TimeEntryList.svelte.d.ts","sourceRoot":"","sources":["../../../src/svelte/components/TimeEntryList.svelte.ts"],"names":[],"mappings":"AASA,OAAO,EACL,KAAK,QAAQ,EAKb,KAAK,SAAS,EACf,MAAM,YAAY,CAAC;AAGpB,wCAAwC;AACxC,MAAM,WAAW,KAAK;IACpB,2CAA2C;IAC3C,OAAO,EAAE,SAAS,EAAE,CAAC;IACrB,8CAA8C;IAC9C,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,iDAAiD;IACjD,WAAW,CAAC,EAAE,MAAM,EAAE,CAAC;IACvB,0CAA0C;IAC1C,iBAAiB,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,IAAI,CAAC;IAC5C,oDAAoD;IACpD,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,gDAAgD;IAChD,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,4CAA4C;IAC5C,QAAQ,CAAC,EAAE,QAAQ,CAAC;IACpB,iEAAiE;IACjE,SAAS,CAAC,EAAE,CAAC,KAAK,EAAE,SAAS,KAAK,OAAO,CAAC;CAC3C;AA2ID,QAAA,MAAM,aAAa,2CAAwC,CAAC;AAC5D,KAAK,aAAa,GAAG,UAAU,CAAC,OAAO,aAAa,CAAC,CAAC;AACtD,eAAe,aAAa,CAAC"}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* TimeSummary - Summary statistics for time entries
|
|
4
|
+
* Shows total hours, amounts, and pending items
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { useI18n } from '@happyvertical/smrt-ui/i18n';
|
|
8
|
+
import { M } from '../i18n.js';
|
|
9
|
+
import { type Currency, formatCurrency, formatHours } from './utils.js';
|
|
10
|
+
|
|
11
|
+
/** Props for TimeSummary component */
|
|
12
|
+
export interface Props {
|
|
13
|
+
/** Total hours across all time entries. */
|
|
14
|
+
totalHours: number;
|
|
15
|
+
/** Total charged or invoiced amount. */
|
|
16
|
+
totalAmount: number;
|
|
17
|
+
/** Hours awaiting approval. */
|
|
18
|
+
pendingHours?: number;
|
|
19
|
+
/** Amount awaiting approval. */
|
|
20
|
+
pendingAmount?: number;
|
|
21
|
+
/** Hours that have been approved. */
|
|
22
|
+
approvedHours?: number;
|
|
23
|
+
/** Amount that has been approved. */
|
|
24
|
+
approvedAmount?: number;
|
|
25
|
+
/** Number of time entries included in the summary. */
|
|
26
|
+
entryCount?: number;
|
|
27
|
+
/** Currency code for formatting amounts. */
|
|
28
|
+
currency?: Currency;
|
|
29
|
+
/** Shows the pending hours and amount card when there are pending items. */
|
|
30
|
+
showPending?: boolean;
|
|
31
|
+
/** Shows the approved hours and amount card when there are approved items. */
|
|
32
|
+
showApproved?: boolean;
|
|
33
|
+
/** Arranges summary cards in a grid or flexbox layout. */
|
|
34
|
+
layout?: 'horizontal' | 'grid';
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
let {
|
|
38
|
+
totalHours,
|
|
39
|
+
totalAmount,
|
|
40
|
+
pendingHours = 0,
|
|
41
|
+
pendingAmount = 0,
|
|
42
|
+
approvedHours = 0,
|
|
43
|
+
approvedAmount = 0,
|
|
44
|
+
entryCount,
|
|
45
|
+
currency = 'CAD',
|
|
46
|
+
showPending = true,
|
|
47
|
+
showApproved = false,
|
|
48
|
+
layout = 'grid',
|
|
49
|
+
}: Props = $props();
|
|
50
|
+
|
|
51
|
+
const { t } = useI18n();
|
|
52
|
+
</script>
|
|
53
|
+
|
|
54
|
+
<div class="time-summary" class:horizontal={layout === 'horizontal'}>
|
|
55
|
+
<div class="summary-card">
|
|
56
|
+
<span class="label">{t(M['projects.time_summary.total_hours'])}</span>
|
|
57
|
+
<span class="value">{formatHours(totalHours)}</span>
|
|
58
|
+
{#if entryCount !== undefined}
|
|
59
|
+
<span class="count">{entryCount} {entryCount === 1 ? 'entry' : 'entries'}</span>
|
|
60
|
+
{/if}
|
|
61
|
+
</div>
|
|
62
|
+
|
|
63
|
+
<div class="summary-card">
|
|
64
|
+
<span class="label">{t(M['projects.time_summary.total_value'])}</span>
|
|
65
|
+
<span class="value">{formatCurrency(totalAmount, currency)}</span>
|
|
66
|
+
</div>
|
|
67
|
+
|
|
68
|
+
{#if showPending && (pendingHours > 0 || pendingAmount > 0)}
|
|
69
|
+
<div class="summary-card highlight">
|
|
70
|
+
<span class="label">{t(M['projects.time_summary.pending_approval'])}</span>
|
|
71
|
+
<span class="value">{formatHours(pendingHours)}</span>
|
|
72
|
+
<span class="sub-value">{formatCurrency(pendingAmount, currency)}</span>
|
|
73
|
+
</div>
|
|
74
|
+
{/if}
|
|
75
|
+
|
|
76
|
+
{#if showApproved && (approvedHours > 0 || approvedAmount > 0)}
|
|
77
|
+
<div class="summary-card success">
|
|
78
|
+
<span class="label">Approved</span>
|
|
79
|
+
<span class="value">{formatHours(approvedHours)}</span>
|
|
80
|
+
<span class="sub-value">{formatCurrency(approvedAmount, currency)}</span>
|
|
81
|
+
</div>
|
|
82
|
+
{/if}
|
|
83
|
+
</div>
|
|
84
|
+
|
|
85
|
+
<style>
|
|
86
|
+
.time-summary {
|
|
87
|
+
display: grid;
|
|
88
|
+
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
|
89
|
+
gap: 1rem;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
.time-summary.horizontal {
|
|
93
|
+
display: flex;
|
|
94
|
+
flex-wrap: wrap;
|
|
95
|
+
gap: 1rem;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
.time-summary.horizontal .summary-card {
|
|
99
|
+
flex: 1 1 140px;
|
|
100
|
+
min-width: 140px;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
.summary-card {
|
|
104
|
+
background: var(--smrt-color-surface);
|
|
105
|
+
border: 1px solid var(--smrt-color-outline-variant);
|
|
106
|
+
border-radius: var(--smrt-radius-medium, 12px);
|
|
107
|
+
padding: 1rem;
|
|
108
|
+
display: flex;
|
|
109
|
+
flex-direction: column;
|
|
110
|
+
gap: 0.25rem;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
.summary-card.highlight {
|
|
114
|
+
background: var(--smrt-color-tertiary-container);
|
|
115
|
+
border-color: var(--smrt-color-tertiary);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
.summary-card.success {
|
|
119
|
+
background: var(--smrt-color-primary-container);
|
|
120
|
+
border-color: var(--smrt-color-primary);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
.label {
|
|
124
|
+
font-size: var(--smrt-typography-label-small-size, 0.75rem);
|
|
125
|
+
font-weight: var(--smrt-typography-label-small-weight, 500);
|
|
126
|
+
color: var(--smrt-color-on-surface-variant);
|
|
127
|
+
text-transform: uppercase;
|
|
128
|
+
letter-spacing: var(--smrt-typography-label-small-tracking, 0.5px);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
.value {
|
|
132
|
+
font-size: var(--smrt-typography-headline-small-size, 1.5rem);
|
|
133
|
+
font-weight: var(--smrt-typography-headline-small-weight, 400);
|
|
134
|
+
color: var(--smrt-color-on-surface);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
.sub-value {
|
|
138
|
+
font-size: var(--smrt-typography-body-medium-size, 0.875rem);
|
|
139
|
+
color: var(--smrt-color-on-surface-variant);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
.count {
|
|
143
|
+
font-size: var(--smrt-typography-body-small-size, 0.75rem);
|
|
144
|
+
color: var(--smrt-color-on-surface-variant);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
.highlight .label {
|
|
148
|
+
color: var(--smrt-color-on-tertiary-container);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
.highlight .value {
|
|
152
|
+
color: var(--smrt-color-tertiary);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
.highlight .sub-value {
|
|
156
|
+
color: var(--smrt-color-on-tertiary-container);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
.success .label {
|
|
160
|
+
color: var(--smrt-color-on-primary-container);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
.success .value {
|
|
164
|
+
color: var(--smrt-color-primary);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
.success .sub-value {
|
|
168
|
+
color: var(--smrt-color-on-primary-container);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
@media (max-width: 480px) {
|
|
172
|
+
.time-summary {
|
|
173
|
+
grid-template-columns: 1fr 1fr;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
</style>
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { type Currency } from './utils.js';
|
|
2
|
+
/** Props for TimeSummary component */
|
|
3
|
+
export interface Props {
|
|
4
|
+
/** Total hours across all time entries. */
|
|
5
|
+
totalHours: number;
|
|
6
|
+
/** Total charged or invoiced amount. */
|
|
7
|
+
totalAmount: number;
|
|
8
|
+
/** Hours awaiting approval. */
|
|
9
|
+
pendingHours?: number;
|
|
10
|
+
/** Amount awaiting approval. */
|
|
11
|
+
pendingAmount?: number;
|
|
12
|
+
/** Hours that have been approved. */
|
|
13
|
+
approvedHours?: number;
|
|
14
|
+
/** Amount that has been approved. */
|
|
15
|
+
approvedAmount?: number;
|
|
16
|
+
/** Number of time entries included in the summary. */
|
|
17
|
+
entryCount?: number;
|
|
18
|
+
/** Currency code for formatting amounts. */
|
|
19
|
+
currency?: Currency;
|
|
20
|
+
/** Shows the pending hours and amount card when there are pending items. */
|
|
21
|
+
showPending?: boolean;
|
|
22
|
+
/** Shows the approved hours and amount card when there are approved items. */
|
|
23
|
+
showApproved?: boolean;
|
|
24
|
+
/** Arranges summary cards in a grid or flexbox layout. */
|
|
25
|
+
layout?: 'horizontal' | 'grid';
|
|
26
|
+
}
|
|
27
|
+
declare const TimeSummary: import("svelte").Component<Props, {}, "">;
|
|
28
|
+
type TimeSummary = ReturnType<typeof TimeSummary>;
|
|
29
|
+
export default TimeSummary;
|
|
30
|
+
//# sourceMappingURL=TimeSummary.svelte.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"TimeSummary.svelte.d.ts","sourceRoot":"","sources":["../../../src/svelte/components/TimeSummary.svelte.ts"],"names":[],"mappings":"AASA,OAAO,EAAE,KAAK,QAAQ,EAA+B,MAAM,YAAY,CAAC;AAGxE,sCAAsC;AACtC,MAAM,WAAW,KAAK;IACpB,2CAA2C;IAC3C,UAAU,EAAE,MAAM,CAAC;IACnB,wCAAwC;IACxC,WAAW,EAAE,MAAM,CAAC;IACpB,+BAA+B;IAC/B,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,gCAAgC;IAChC,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,qCAAqC;IACrC,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,qCAAqC;IACrC,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,sDAAsD;IACtD,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,4CAA4C;IAC5C,QAAQ,CAAC,EAAE,QAAQ,CAAC;IACpB,4EAA4E;IAC5E,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,8EAA8E;IAC9E,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,0DAA0D;IAC1D,MAAM,CAAC,EAAE,YAAY,GAAG,MAAM,CAAC;CAChC;AA6DD,QAAA,MAAM,WAAW,2CAAwC,CAAC;AAC1D,KAAK,WAAW,GAAG,UAAU,CAAC,OAAO,WAAW,CAAC,CAAC;AAClD,eAAe,WAAW,CAAC"}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
/**
|
|
3
|
+
* First component test in smrt-projects 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 DurationDisplay from '../DurationDisplay.svelte';
|
|
8
|
+
describe('DurationDisplay', () => {
|
|
9
|
+
it('renders decimal hours to one decimal place', () => {
|
|
10
|
+
render(DurationDisplay, { props: { hours: 2.5 } });
|
|
11
|
+
expect(screen.getByText('2.5')).toBeInTheDocument();
|
|
12
|
+
});
|
|
13
|
+
it('renders an HH:MM value in hhmm format', () => {
|
|
14
|
+
render(DurationDisplay, { props: { hours: 1.5, format: 'hhmm' } });
|
|
15
|
+
expect(screen.getByText('1:30')).toBeInTheDocument();
|
|
16
|
+
});
|
|
17
|
+
// #2605: `showLabel` used to be inert. `decimal` rendered `h` whatever the
|
|
18
|
+
// prop said, and `hhmm` rendered an empty `<span class="unit">` instead of a
|
|
19
|
+
// label. These pin the unit to the prop, and pin the unchanged defaults so a
|
|
20
|
+
// later change to them has to be deliberate.
|
|
21
|
+
describe('showLabel', () => {
|
|
22
|
+
it('renders the decimal unit by default', () => {
|
|
23
|
+
const { container } = render(DurationDisplay, { props: { hours: 2.5 } });
|
|
24
|
+
expect(container.querySelector('.unit')?.textContent).toBe('h');
|
|
25
|
+
});
|
|
26
|
+
it('renders the decimal unit when showLabel is set', () => {
|
|
27
|
+
const { container } = render(DurationDisplay, {
|
|
28
|
+
props: { hours: 2.5, showLabel: true },
|
|
29
|
+
});
|
|
30
|
+
expect(container.querySelector('.unit')?.textContent).toBe('h');
|
|
31
|
+
});
|
|
32
|
+
it('drops the decimal unit when showLabel is cleared', () => {
|
|
33
|
+
const { container } = render(DurationDisplay, {
|
|
34
|
+
props: { hours: 2.5, showLabel: false },
|
|
35
|
+
});
|
|
36
|
+
expect(container.querySelector('.unit')).toBeNull();
|
|
37
|
+
expect(container.textContent?.trim()).toBe('2.5');
|
|
38
|
+
});
|
|
39
|
+
it.each([
|
|
40
|
+
undefined,
|
|
41
|
+
true,
|
|
42
|
+
false,
|
|
43
|
+
])('renders no unit element in hhmm format with showLabel=%s', (showLabel) => {
|
|
44
|
+
const { container } = render(DurationDisplay, {
|
|
45
|
+
props: { hours: 1.5, format: 'hhmm', showLabel },
|
|
46
|
+
});
|
|
47
|
+
expect(container.querySelector('.unit')).toBeNull();
|
|
48
|
+
expect(container.textContent?.trim()).toBe('1:30');
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
it('is axe-clean', async () => {
|
|
52
|
+
const { container } = render(DurationDisplay, {
|
|
53
|
+
props: { hours: 8, showLabel: true },
|
|
54
|
+
});
|
|
55
|
+
await expectNoA11yViolations(container);
|
|
56
|
+
});
|
|
57
|
+
});
|