@ossy/timesheets 3.7.1 → 3.9.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/package.json CHANGED
@@ -1,20 +1,23 @@
1
1
  {
2
2
  "name": "@ossy/timesheets",
3
3
  "description": "Timesheets — generate, review, save, and export monthly work hours",
4
- "version": "3.7.1",
4
+ "version": "3.9.0",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
7
7
  "module": "./src/index.js",
8
8
  "exports": {
9
9
  ".": "./src/index.js"
10
10
  },
11
+ "scripts": {
12
+ "test": "NODE_OPTIONS=--experimental-vm-modules jest --verbose"
13
+ },
11
14
  "author": "Ossy <yourfriends@ossy.se> (https://ossy.se)",
12
15
  "license": "MIT",
13
16
  "ossy": {
14
17
  "src": "./src"
15
18
  },
16
19
  "dependencies": {
17
- "@ossy/resources": "^3.7.1",
20
+ "@ossy/resources": "^3.9.0",
18
21
  "html-to-image": "^1.11.13",
19
22
  "nanoid": "^5.1.11",
20
23
  "pdf-lib": "^1.17.1"
@@ -38,5 +41,5 @@
38
41
  "/src",
39
42
  "README.md"
40
43
  ],
41
- "gitHead": "1ee220424da6b065034a7f52f52aa4a6ce7c88d5"
44
+ "gitHead": "f404be69becb27a1fd853a6ff1903554e76e7d17"
42
45
  }
@@ -0,0 +1,32 @@
1
+ import React, { useMemo } from 'react'
2
+ import { Select, Text, useLocale, View } from '@ossy/design-system'
3
+ import { HOLIDAY_COUNTRY_CODES } from '@ossy/calendar/holiday-locale'
4
+
5
+ export function HolidayLocaleSelect ({ value, onChange, disabled }) {
6
+ const { language, t } = useLocale()
7
+ const displayNames = useMemo(() => {
8
+ try {
9
+ return new Intl.DisplayNames([language || 'en'], { type: 'region' })
10
+ } catch {
11
+ return null
12
+ }
13
+ }, [language])
14
+
15
+ return (
16
+ <View gap="xs">
17
+ <Text variant="small" weight="medium" text="timesheets.holidayLocale" />
18
+ <Select
19
+ value={value}
20
+ onChange={onChange}
21
+ disabled={disabled}
22
+ aria-label={t('timesheets.holidayLocale')}
23
+ >
24
+ {HOLIDAY_COUNTRY_CODES.map((code) => (
25
+ <option key={code} value={code}>
26
+ {displayNames?.of(code) || code}
27
+ </option>
28
+ ))}
29
+ </Select>
30
+ </View>
31
+ )
32
+ }
package/src/Timesheet.jsx CHANGED
@@ -1,6 +1,7 @@
1
1
  import React, { useMemo } from 'react'
2
2
  import { View, Text, Input, DateDisplay, Separator, useLocale } from '@ossy/design-system'
3
3
  import { buildMonthGrid, dateKeyFromMs } from '@ossy/calendar'
4
+ import { TimesheetPartyRows } from './TimesheetPartyRows.jsx'
4
5
 
5
6
  const calendarGrid = {
6
7
  display: 'grid',
@@ -13,25 +14,39 @@ const calendarGrid = {
13
14
  }
14
15
 
15
16
  const dayCell = {
17
+ position: 'relative',
16
18
  boxSizing: 'border-box',
17
19
  display: 'flex',
18
- flexDirection: 'column',
19
20
  alignItems: 'center',
20
- justifyContent: 'flex-start',
21
- gap: 4,
21
+ justifyContent: 'center',
22
22
  width: '100%',
23
23
  minHeight: 72,
24
+ margin: 0,
24
25
  borderRadius: 'var(--space-xxs, 2px)',
25
- padding: '6px 4px',
26
+ padding: '18px 4px 8px',
27
+ cursor: 'text',
28
+ }
29
+
30
+ const dateNumber = {
31
+ position: 'absolute',
32
+ top: 4,
33
+ right: 5,
34
+ fontSize: 11,
35
+ fontWeight: 500,
36
+ lineHeight: 1,
37
+ letterSpacing: 0,
38
+ fontFamily: 'var(--text-default-font-family, sans-serif)',
39
+ pointerEvents: 'none',
26
40
  }
27
41
 
28
42
  const hoursInput = {
29
43
  width: '100%',
30
- maxWidth: 48,
31
- minHeight: 32,
32
- padding: '6px 4px',
44
+ minHeight: 28,
45
+ padding: 0,
33
46
  textAlign: 'center',
34
- fontSize: 13,
47
+ fontSize: 14,
48
+ fontWeight: 700,
49
+ lineHeight: 1,
35
50
  borderRadius: 'var(--space-xxs, 2px)',
36
51
  background: 'transparent',
37
52
  borderColor: 'transparent',
@@ -39,6 +54,8 @@ const hoursInput = {
39
54
  borderStyle: 'solid',
40
55
  boxShadow: 'none',
41
56
  outline: 'none',
57
+ appearance: 'textfield',
58
+ MozAppearance: 'textfield',
42
59
  }
43
60
 
44
61
  const WEEKDAY_KEYS = [
@@ -56,7 +73,17 @@ const WEEKDAY_KEYS = [
56
73
  * Non-workdays use CalendarDaySurfaceStyles (red).
57
74
  * Pass `captureRef` only if capturing this live card; prefer {@link TimesheetExportCard} for PNG/PDF.
58
75
  */
59
- export function Timesheet ({ lines = [], onChange, disabled = false, periodStart, captureRef }) {
76
+ export function Timesheet ({
77
+ lines = [],
78
+ onChange,
79
+ disabled = false,
80
+ periodStart,
81
+ captureRef,
82
+ employeeId,
83
+ contractId,
84
+ onEmployeeChange,
85
+ onContractChange,
86
+ }) {
60
87
  const { t } = useLocale()
61
88
 
62
89
  const lineByKey = useMemo(() => {
@@ -90,6 +117,11 @@ export function Timesheet ({ lines = [], onChange, disabled = false, periodStart
90
117
  <>
91
118
  <style href="@ossy/timesheets/day-surface" precedence="high">
92
119
  {`
120
+ [data-timesheet-root] [data-timesheet-day-number] {
121
+ color: var(--foreground);
122
+ font-family: var(--text-default-font-family, sans-serif);
123
+ }
124
+
93
125
  [data-timesheet-root] [data-ossy-calendar-day] {
94
126
  background: var(--surface-primary);
95
127
  }
@@ -102,6 +134,40 @@ export function Timesheet ({ lines = [], onChange, disabled = false, periodStart
102
134
  [data-timesheet-root] [data-ossy-calendar-day][data-day-off="true"]:hover {
103
135
  background: color-mix(in srgb, hsl(350 32% 55%) 13%, var(--surface-primary));
104
136
  }
137
+
138
+ [data-timesheet-root] [data-ossy-calendar-day][data-day-off="true"] [data-timesheet-day-number] {
139
+ color: color-mix(in srgb, hsl(350 28% 38%) 72%, var(--foreground));
140
+ }
141
+
142
+ [data-timesheet-root] [data-ossy-calendar-day]:focus-within {
143
+ background: color-mix(in srgb, var(--color-accent, #3dcf9a) 16%, var(--surface-primary));
144
+ box-shadow: inset 0 0 0 2px var(--color-accent, #3dcf9a);
145
+ }
146
+
147
+ [data-timesheet-root] [data-ossy-calendar-day][data-day-off="true"]:focus-within {
148
+ background: color-mix(in srgb, var(--color-accent, #3dcf9a) 14%, color-mix(in srgb, hsl(350 32% 58%) 9%, var(--surface-primary)));
149
+ }
150
+
151
+ [data-timesheet-root] [data-ossy-calendar-day] [data-component="input"] {
152
+ font-weight: 700;
153
+ font-size: 14px;
154
+ text-align: center;
155
+ appearance: textfield;
156
+ -moz-appearance: textfield;
157
+ }
158
+
159
+ [data-timesheet-root] [data-ossy-calendar-day] [data-component="input"]::-webkit-outer-spin-button,
160
+ [data-timesheet-root] [data-ossy-calendar-day] [data-component="input"]::-webkit-inner-spin-button {
161
+ -webkit-appearance: none;
162
+ margin: 0;
163
+ display: none;
164
+ }
165
+
166
+ [data-timesheet-root] [data-ossy-calendar-day] [data-component="input"]:focus {
167
+ outline: none;
168
+ border-color: transparent;
169
+ box-shadow: none;
170
+ }
105
171
  `}
106
172
  </style>
107
173
  <div
@@ -134,6 +200,19 @@ export function Timesheet ({ lines = [], onChange, disabled = false, periodStart
134
200
  />
135
201
  </View>
136
202
 
203
+ {typeof onEmployeeChange === 'function' && typeof onContractChange === 'function' ? (
204
+ <div style={{ width: 0, minWidth: '100%' }}>
205
+ <TimesheetPartyRows
206
+ employeeId={employeeId}
207
+ contractId={contractId}
208
+ onEmployeeChange={onEmployeeChange}
209
+ onContractChange={onContractChange}
210
+ disabled={disabled}
211
+ totalHours={totalHours}
212
+ />
213
+ </div>
214
+ ) : null}
215
+
137
216
  <div style={calendarGrid}>
138
217
  {WEEKDAY_KEYS.map((key) => (
139
218
  <Text key={key} variant="small" color="secondary" style={{ textAlign: 'center' }}>
@@ -152,23 +231,20 @@ export function Timesheet ({ lines = [], onChange, disabled = false, periodStart
152
231
  const off = !line.isWorkday
153
232
 
154
233
  return (
155
- <div
234
+ <label
156
235
  key={cell.dateKey}
157
236
  data-ossy-calendar-day
158
237
  data-day-off={off}
159
238
  style={dayCell}
160
239
  >
161
- <Text
240
+ <DateDisplay
241
+ value={line.date}
242
+ formatOptions={{ day: 'numeric' }}
162
243
  variant="small"
163
- style={{
164
- fontWeight: 600,
165
- fontSize: 13,
166
- lineHeight: 1.2,
167
- color: 'var(--foreground)',
168
- }}
169
- >
170
- {cell.day}
171
- </Text>
244
+ as="span"
245
+ data-timesheet-day-number
246
+ style={dateNumber}
247
+ />
172
248
  <Input
173
249
  type="number"
174
250
  min={0}
@@ -180,7 +256,7 @@ export function Timesheet ({ lines = [], onChange, disabled = false, periodStart
180
256
  style={hoursInput}
181
257
  aria-label="timesheets.hours"
182
258
  />
183
- </div>
259
+ </label>
184
260
  )
185
261
  })}
186
262
  </div>
@@ -1,36 +1,75 @@
1
- import React, { useMemo } from 'react'
2
- import { View, Text, DateDisplay, List, Icon, useLocale } from '@ossy/design-system'
1
+ import React, { useCallback, useMemo, useState } from 'react'
2
+ import { Button, ContextMenu, DateDisplay, Dropdown, Icon, List, Text, View, useLocale } from '@ossy/design-system'
3
3
 
4
4
  /**
5
- * List row for a timesheet period.
5
+ * Storage-style list row for a timesheet period.
6
6
  */
7
- export function TimesheetCard ({ timesheet, onClick }) {
7
+ export function TimesheetCard ({ timesheet, onClick, selected = false, actions }) {
8
8
  const { t } = useLocale()
9
+ const [menuOpen, setMenuOpen] = useState(false)
10
+ const [menuPosition, setMenuPosition] = useState(null)
9
11
  const totalHours = useMemo(
10
12
  () => (timesheet?.lines ?? []).reduce((sum, line) => sum + (Number(line.hours) || 0), 0),
11
13
  [timesheet?.lines],
12
14
  )
13
15
  const status = timesheet?.status ?? 'draft'
14
16
 
17
+ const handleMenuOpenChange = useCallback((open) => {
18
+ setMenuOpen(open)
19
+ if (!open) setMenuPosition(null)
20
+ }, [])
21
+
22
+ const handleContextMenu = useCallback((event) => {
23
+ if (!actions) return
24
+ event.preventDefault()
25
+ event.stopPropagation()
26
+ setMenuPosition({ top: event.clientY, left: event.clientX })
27
+ setMenuOpen(true)
28
+ }, [actions])
29
+
15
30
  return (
16
31
  <List.Item
17
32
  selectable
18
- density="comfortable"
33
+ selected={selected}
19
34
  onClick={onClick}
20
- trailing={<Icon name="chevron-right" size="s" />}
21
- >
22
- <View gap="xxs" style={{ minWidth: 0 }}>
23
- <DateDisplay
24
- value={timesheet.periodStart}
25
- formatOptions={{ month: 'long', year: 'numeric' }}
26
- weight="medium"
27
- />
28
- <Text variant="small" color="secondary">
29
- {t('timesheets.statusLabel', { status: t(`timesheets.status.${status}`) })}
30
- {' · '}
31
- {t('timesheets.totalHours', { hours: totalHours })}
35
+ onContextMenu={handleContextMenu}
36
+ leading={(
37
+ <Icon size="s" name="calendar" style={{ fill: 'hsl(0, 0%, 80%)' }} />
38
+ )}
39
+ meta={(
40
+ <Text variant="small">
41
+ {t('timesheets.meta.hoursValue', { hours: totalHours })} {t(`timesheets.status.${status}`)}
32
42
  </Text>
33
- </View>
43
+ )}
44
+ trailing={actions ? (
45
+ <Dropdown
46
+ trigger={(
47
+ <Button
48
+ prefix="more-vertical-alt"
49
+ variant="command"
50
+ aria-label={t('timesheets.rowActions')}
51
+ onClick={(event) => {
52
+ event.stopPropagation()
53
+ setMenuPosition(null)
54
+ }}
55
+ />
56
+ )}
57
+ open={menuOpen}
58
+ onOpenChange={handleMenuOpenChange}
59
+ position={menuPosition}
60
+ >
61
+ <View inset="xs" surface="primary" roundness="s">
62
+ <ContextMenu roundness="s" surface="primary">
63
+ {actions}
64
+ </ContextMenu>
65
+ </View>
66
+ </Dropdown>
67
+ ) : undefined}
68
+ >
69
+ <DateDisplay
70
+ value={timesheet.periodStart}
71
+ formatOptions={{ month: 'long', year: 'numeric' }}
72
+ />
34
73
  </List.Item>
35
74
  )
36
75
  }
@@ -6,6 +6,8 @@ import { TIMESHEET_EXPORT_FONT_FAMILY } from './export-capture.js'
6
6
  /** Matches on-screen Timesheet.jsx — solid colors for html-to-image capture. */
7
7
  const FG = 'hsl(199, 90%, 10%)'
8
8
  const FG_MUTED = 'hsl(182, 15%, 42%)'
9
+ const DATE_FG = 'hsl(182, 23%, 30%)'
10
+ const DATE_OFF_FG = 'hsl(350, 28%, 38%)'
9
11
  const ACCENT = 'hsl(167, 89%, 43%)'
10
12
  const CELL_BG = '#ffffff'
11
13
  const OFF_BG = 'color-mix(in srgb, hsl(350 32% 58%) 9%, #ffffff)'
@@ -22,16 +24,15 @@ const calendarGrid = {
22
24
  }
23
25
 
24
26
  const dayCell = {
27
+ position: 'relative',
25
28
  boxSizing: 'border-box',
26
29
  display: 'flex',
27
- flexDirection: 'column',
28
30
  alignItems: 'center',
29
- justifyContent: 'flex-start',
30
- gap: 4,
31
+ justifyContent: 'center',
31
32
  width: '100%',
32
33
  minHeight: 72,
33
34
  borderRadius: 2,
34
- padding: '6px 4px',
35
+ padding: '18px 4px 8px',
35
36
  background: CELL_BG,
36
37
  }
37
38
 
@@ -45,12 +46,42 @@ const WEEKDAY_KEYS = [
45
46
  'timesheets.weekday.sun',
46
47
  ]
47
48
 
49
+ function ExportMetaRow ({ label, value }) {
50
+ if (value == null || value === '') return null
51
+ return (
52
+ <div style={{ display: 'flex', justifyContent: 'space-between', gap: 16 }}>
53
+ <span style={{ fontFamily: TIMESHEET_EXPORT_FONT_FAMILY, fontSize: 13, fontWeight: 400, color: FG_MUTED }}>
54
+ {label}
55
+ </span>
56
+ <span style={{ fontFamily: TIMESHEET_EXPORT_FONT_FAMILY, fontSize: 13, fontWeight: 400, color: FG }}>
57
+ {value}
58
+ </span>
59
+ </div>
60
+ )
61
+ }
62
+
63
+ function formatSek (value, language) {
64
+ if (value == null || value === '' || !Number.isFinite(Number(value))) return ''
65
+ return new Intl.NumberFormat(language === 'sv' ? 'sv-SE' : 'en-SE', {
66
+ style: 'currency',
67
+ currency: 'SEK',
68
+ }).format(Number(value))
69
+ }
70
+
48
71
  /**
49
72
  * Print-ready timesheet (hours as text — no inputs).
50
73
  * Matches on-screen {@link Timesheet} layout; white background, no card chrome.
51
74
  * Mount off-screen and pass `captureRef` for PNG/PDF via html-to-image.
52
75
  */
53
- export function TimesheetExportCard ({ lines = [], periodStart, captureRef }) {
76
+ export function TimesheetExportCard ({
77
+ lines = [],
78
+ periodStart,
79
+ captureRef,
80
+ employeeLabel,
81
+ contractLabel,
82
+ clientName,
83
+ hourlyRate,
84
+ }) {
54
85
  const { t, language } = useLocale()
55
86
 
56
87
  const lineByKey = useMemo(() => {
@@ -73,6 +104,12 @@ export function TimesheetExportCard ({ lines = [], periodStart, captureRef }) {
73
104
  .format(new Date(anchorMs))
74
105
  }, [anchorMs, language])
75
106
 
107
+ const rate = hourlyRate == null || hourlyRate === '' ? null : Number(hourlyRate)
108
+ const totalCost = rate != null && Number.isFinite(rate) ? totalHours * rate : null
109
+ const hasPartyMeta = Boolean(
110
+ employeeLabel || contractLabel || clientName || (rate != null && Number.isFinite(rate)),
111
+ )
112
+
76
113
  if (!lines.length) return null
77
114
 
78
115
  return (
@@ -134,6 +171,25 @@ export function TimesheetExportCard ({ lines = [], periodStart, captureRef }) {
134
171
  width: '100%',
135
172
  }}
136
173
  />
174
+ {hasPartyMeta ? (
175
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
176
+ <ExportMetaRow label={t('timesheets.employee')} value={employeeLabel} />
177
+ <ExportMetaRow label={t('timesheets.meta.client')} value={clientName} />
178
+ <ExportMetaRow label={t('timesheets.contract')} value={contractLabel} />
179
+ <ExportMetaRow
180
+ label={t('timesheets.meta.hourlyRate')}
181
+ value={
182
+ rate != null && Number.isFinite(rate)
183
+ ? `${formatSek(rate, language)}${t('timesheets.meta.perHour')}`
184
+ : ''
185
+ }
186
+ />
187
+ <ExportMetaRow
188
+ label={t('timesheets.meta.totalCost')}
189
+ value={totalCost != null && Number.isFinite(totalCost) ? formatSek(totalCost, language) : ''}
190
+ />
191
+ </div>
192
+ ) : null}
137
193
  </div>
138
194
 
139
195
  <div style={calendarGrid}>
@@ -175,12 +231,14 @@ export function TimesheetExportCard ({ lines = [], periodStart, captureRef }) {
175
231
  >
176
232
  <span
177
233
  style={{
178
- display: 'block',
234
+ position: 'absolute',
235
+ top: 4,
236
+ right: 5,
179
237
  fontFamily: TIMESHEET_EXPORT_FONT_FAMILY,
180
- fontSize: 13,
181
- fontWeight: 600,
182
- lineHeight: 1.2,
183
- color: 'inherit',
238
+ fontSize: 11,
239
+ fontWeight: 500,
240
+ lineHeight: 1,
241
+ color: off ? DATE_OFF_FG : DATE_FG,
184
242
  }}
185
243
  >
186
244
  {cell.day}
@@ -190,14 +248,11 @@ export function TimesheetExportCard ({ lines = [], periodStart, captureRef }) {
190
248
  display: 'block',
191
249
  boxSizing: 'border-box',
192
250
  width: '100%',
193
- maxWidth: 48,
194
- minHeight: 32,
195
- padding: '6px 4px',
196
251
  textAlign: 'center',
197
252
  fontFamily: TIMESHEET_EXPORT_FONT_FAMILY,
198
- fontSize: 13,
199
- fontWeight: 600,
200
- lineHeight: 1.2,
253
+ fontSize: 14,
254
+ fontWeight: 700,
255
+ lineHeight: 1,
201
256
  color: 'inherit',
202
257
  }}
203
258
  >