@ossy/timesheets 3.0.6 → 3.0.7

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.
@@ -0,0 +1,212 @@
1
+ import React, { useMemo } from 'react'
2
+ import { useLocale } from '@ossy/design-system'
3
+ import { buildMonthGrid, dateKeyFromMs } from '@ossy/calendar'
4
+ import { TIMESHEET_EXPORT_FONT_FAMILY } from './export-capture.js'
5
+
6
+ /** Matches on-screen Timesheet.jsx — solid colors for html-to-image capture. */
7
+ const FG = 'hsl(199, 90%, 10%)'
8
+ const FG_MUTED = 'hsl(182, 15%, 42%)'
9
+ const ACCENT = 'hsl(167, 89%, 43%)'
10
+ const CELL_BG = '#ffffff'
11
+ const OFF_BG = 'color-mix(in srgb, hsl(350 32% 58%) 9%, #ffffff)'
12
+ const OFF_FG = 'color-mix(in srgb, hsl(350 28% 46%) 45%, hsl(199, 90%, 10%))'
13
+
14
+ const calendarGrid = {
15
+ display: 'grid',
16
+ gridTemplateColumns: 'repeat(7, minmax(44px, 56px))',
17
+ gap: '1px',
18
+ justifyItems: 'stretch',
19
+ alignItems: 'stretch',
20
+ width: 'fit-content',
21
+ maxWidth: '100%',
22
+ }
23
+
24
+ const dayCell = {
25
+ boxSizing: 'border-box',
26
+ display: 'flex',
27
+ flexDirection: 'column',
28
+ alignItems: 'center',
29
+ justifyContent: 'flex-start',
30
+ gap: 4,
31
+ width: '100%',
32
+ minHeight: 72,
33
+ borderRadius: 2,
34
+ padding: '6px 4px',
35
+ background: CELL_BG,
36
+ }
37
+
38
+ const WEEKDAY_KEYS = [
39
+ 'timesheets.weekday.mon',
40
+ 'timesheets.weekday.tue',
41
+ 'timesheets.weekday.wed',
42
+ 'timesheets.weekday.thu',
43
+ 'timesheets.weekday.fri',
44
+ 'timesheets.weekday.sat',
45
+ 'timesheets.weekday.sun',
46
+ ]
47
+
48
+ /**
49
+ * Print-ready timesheet (hours as text — no inputs).
50
+ * Matches on-screen {@link Timesheet} layout; white background, no card chrome.
51
+ * Mount off-screen and pass `captureRef` for PNG/PDF via html-to-image.
52
+ */
53
+ export function TimesheetExportCard ({ lines = [], periodStart, captureRef }) {
54
+ const { t, language } = useLocale()
55
+
56
+ const lineByKey = useMemo(() => {
57
+ const map = new Map()
58
+ for (const line of lines) {
59
+ map.set(dateKeyFromMs(line.date), line)
60
+ }
61
+ return map
62
+ }, [lines])
63
+
64
+ const anchorMs = periodStart ?? lines[0]?.date ?? Date.now()
65
+ const cells = useMemo(() => buildMonthGrid(anchorMs), [anchorMs])
66
+ const totalHours = useMemo(
67
+ () => lines.reduce((sum, line) => sum + (Number(line.hours) || 0), 0),
68
+ [lines],
69
+ )
70
+ const monthLabel = useMemo(() => {
71
+ const locale = language === 'sv' ? 'sv-SE' : 'en-GB'
72
+ return new Intl.DateTimeFormat(locale, { month: 'long', year: 'numeric' })
73
+ .format(new Date(anchorMs))
74
+ }, [anchorMs, language])
75
+
76
+ if (!lines.length) return null
77
+
78
+ return (
79
+ <div
80
+ ref={captureRef}
81
+ data-timesheet-export-root
82
+ style={{
83
+ width: 'fit-content',
84
+ boxSizing: 'border-box',
85
+ padding: 16,
86
+ background: '#ffffff',
87
+ color: FG,
88
+ fontFamily: TIMESHEET_EXPORT_FONT_FAMILY,
89
+ display: 'flex',
90
+ flexDirection: 'column',
91
+ gap: 16,
92
+ }}
93
+ >
94
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
95
+ <div
96
+ style={{
97
+ display: 'flex',
98
+ flexDirection: 'row',
99
+ flexWrap: 'wrap',
100
+ alignItems: 'baseline',
101
+ justifyContent: 'space-between',
102
+ gap: 16,
103
+ }}
104
+ >
105
+ <span
106
+ style={{
107
+ margin: 0,
108
+ fontFamily: TIMESHEET_EXPORT_FONT_FAMILY,
109
+ fontSize: 20,
110
+ fontWeight: 600,
111
+ lineHeight: 1.3,
112
+ color: FG,
113
+ }}
114
+ >
115
+ {monthLabel}
116
+ </span>
117
+ <span
118
+ style={{
119
+ fontFamily: TIMESHEET_EXPORT_FONT_FAMILY,
120
+ fontSize: 13,
121
+ fontWeight: 500,
122
+ color: FG_MUTED,
123
+ }}
124
+ >
125
+ {t('timesheets.totalHours', { hours: totalHours })}
126
+ </span>
127
+ </div>
128
+ <div
129
+ role="separator"
130
+ style={{
131
+ height: 0,
132
+ border: 'none',
133
+ borderTop: `1px solid ${ACCENT}`,
134
+ width: '100%',
135
+ }}
136
+ />
137
+ </div>
138
+
139
+ <div style={calendarGrid}>
140
+ {WEEKDAY_KEYS.map((key) => (
141
+ <span
142
+ key={key}
143
+ style={{
144
+ display: 'block',
145
+ textAlign: 'center',
146
+ fontFamily: TIMESHEET_EXPORT_FONT_FAMILY,
147
+ fontSize: 13,
148
+ fontWeight: 500,
149
+ color: FG_MUTED,
150
+ }}
151
+ >
152
+ {t(key)}
153
+ </span>
154
+ ))}
155
+ </div>
156
+
157
+ <div style={calendarGrid}>
158
+ {cells.map((cell, idx) => {
159
+ if (!cell) return <div key={`pad-${idx}`} />
160
+ const line = lineByKey.get(cell.dateKey)
161
+ if (!line) return <div key={cell.dateKey} />
162
+ const off = !line.isWorkday
163
+
164
+ return (
165
+ <div
166
+ key={cell.dateKey}
167
+ data-ossy-calendar-day
168
+ data-day-off={off}
169
+ style={{
170
+ ...dayCell,
171
+ fontFamily: TIMESHEET_EXPORT_FONT_FAMILY,
172
+ color: off ? OFF_FG : FG,
173
+ background: off ? OFF_BG : CELL_BG,
174
+ }}
175
+ >
176
+ <span
177
+ style={{
178
+ display: 'block',
179
+ fontFamily: TIMESHEET_EXPORT_FONT_FAMILY,
180
+ fontSize: 13,
181
+ fontWeight: 600,
182
+ lineHeight: 1.2,
183
+ color: 'inherit',
184
+ }}
185
+ >
186
+ {cell.day}
187
+ </span>
188
+ <span
189
+ style={{
190
+ display: 'block',
191
+ boxSizing: 'border-box',
192
+ width: '100%',
193
+ maxWidth: 48,
194
+ minHeight: 32,
195
+ padding: '6px 4px',
196
+ textAlign: 'center',
197
+ fontFamily: TIMESHEET_EXPORT_FONT_FAMILY,
198
+ fontSize: 13,
199
+ fontWeight: 600,
200
+ lineHeight: 1.2,
201
+ color: 'inherit',
202
+ }}
203
+ >
204
+ {Number(line.hours) || 0}
205
+ </span>
206
+ </div>
207
+ )
208
+ })}
209
+ </div>
210
+ </div>
211
+ )
212
+ }
@@ -0,0 +1,319 @@
1
+ import React, { useEffect, useMemo, useRef, useState } from 'react'
2
+ import {
3
+ Alert,
4
+ Button,
5
+ ContextMenu,
6
+ Dropdown,
7
+ Text,
8
+ useLocale,
9
+ View,
10
+ } from '@ossy/design-system'
11
+ import { useRouter } from '@ossy/router-react'
12
+ import { Timesheet } from './Timesheet.jsx'
13
+ import { TimesheetExportCard } from './TimesheetExportCard.jsx'
14
+ import { downloadTimesheetCsv } from './download-timesheet-csv.js'
15
+ import { downloadTimesheetImage } from './download-timesheet-image.js'
16
+ import { downloadTimesheetPdf } from './download-timesheet-pdf.js'
17
+ import { invokeErrorMessage } from './invoke-error-message.js'
18
+ import { sampleShowcaseTimesheet } from './sample-showcase-timesheet.js'
19
+ import { timesheetsAuthHref } from './timesheets-auth-return.js'
20
+
21
+ /**
22
+ * Client-only timesheet for the sales page — prefilled, editable, exportable.
23
+ * Never persists; Save routes to sign-up or enable-service.
24
+ * When `title` / `text` are passed, they replace the section heading (cover copy).
25
+ */
26
+ export function TimesheetsFreeTool ({
27
+ title,
28
+ text,
29
+ isAuthenticated = false,
30
+ onEnable,
31
+ enableAction,
32
+ enableError = null,
33
+ }) {
34
+ const { t, language } = useLocale()
35
+ const router = useRouter()
36
+ const captureRef = useRef(null)
37
+ const heading = title ?? t('timesheets.freeTool.sectionTitle')
38
+ const description = text ?? t('timesheets.freeTool.sectionDescription')
39
+ const isCoverCopy = Boolean(title)
40
+
41
+ const sample = useMemo(() => sampleShowcaseTimesheet(language), [language])
42
+ const [lines, setLines] = useState(sample.lines)
43
+ const [periodStart, setPeriodStart] = useState(sample.periodStart)
44
+ const [exporting, setExporting] = useState(false)
45
+ const [error, setError] = useState(null)
46
+
47
+ useEffect(() => {
48
+ setLines(sample.lines)
49
+ setPeriodStart(sample.periodStart)
50
+ }, [sample])
51
+
52
+ const exportPayload = {
53
+ periodStart,
54
+ lines,
55
+ status: 'draft',
56
+ }
57
+
58
+ const runExport = async (kind) => {
59
+ if (!lines.length) return
60
+ setExporting(true)
61
+ setError(null)
62
+ try {
63
+ if (kind === 'csv') {
64
+ downloadTimesheetCsv(exportPayload)
65
+ } else if (kind === 'png') {
66
+ await downloadTimesheetImage(captureRef.current, exportPayload)
67
+ } else if (kind === 'pdf') {
68
+ await downloadTimesheetPdf(captureRef.current, exportPayload)
69
+ }
70
+ } catch (err) {
71
+ setError(await invokeErrorMessage(err, t('timesheets.errorExport')))
72
+ } finally {
73
+ setExporting(false)
74
+ }
75
+ }
76
+
77
+ const timesheetsHome = router.getHref('timesheets/home') || '/timesheets'
78
+ const signUpHref = timesheetsAuthHref(
79
+ router.getHref('sign-up') || router.getHref('@sign-up') || '/sign-up',
80
+ timesheetsHome,
81
+ )
82
+ const signInHref = timesheetsAuthHref(
83
+ router.getHref('sign-in') || router.getHref('@sign-in') || '/sign-in',
84
+ timesheetsHome,
85
+ )
86
+
87
+ const handleSave = () => {
88
+ if (isAuthenticated) {
89
+ if (typeof onEnable === 'function') onEnable()
90
+ else if (enableAction?.onClick) enableAction.onClick()
91
+ return
92
+ }
93
+ window.location.href = signUpHref
94
+ }
95
+
96
+ const exportDisabled = !lines.length || exporting
97
+
98
+ return (
99
+ <div data-timesheets-free-tool data-cover-copy={isCoverCopy ? 'true' : undefined}>
100
+ <style href="@ossy/timesheets/free-tool" precedence="high">
101
+ {`
102
+ [data-timesheets-free-tool] {
103
+ width: 100%;
104
+ }
105
+
106
+ /* Even split; stack on narrow viewports (design-system split-even auto-flow is unreliable). */
107
+ [data-timesheets-free-tool] > [data-layout="split-even"] {
108
+ display: grid;
109
+ grid-template-columns: 1fr;
110
+ grid-auto-flow: row;
111
+ align-items: center;
112
+ width: 100%;
113
+ }
114
+
115
+ /* Column: text above, calendar below (matches DOM order). */
116
+ [data-timesheets-free-tool] [data-timesheets-free-tool-copy] {
117
+ order: 1;
118
+ text-align: center;
119
+ align-items: center !important;
120
+ justify-self: center;
121
+ width: 100%;
122
+ max-width: 420px;
123
+ }
124
+
125
+ [data-timesheets-free-tool][data-cover-copy="true"] [data-timesheets-free-tool-copy] {
126
+ max-width: 520px;
127
+ }
128
+
129
+ [data-timesheets-free-tool] [data-timesheets-free-tool-copy] > [data-view] {
130
+ text-align: center;
131
+ align-items: center !important;
132
+ }
133
+
134
+ [data-timesheets-free-tool] [data-timesheets-free-tool-actions] {
135
+ justify-content: center !important;
136
+ }
137
+
138
+ [data-timesheets-free-tool] [data-timesheets-free-tool-sheet] {
139
+ order: 2;
140
+ width: fit-content;
141
+ max-width: 100%;
142
+ justify-self: center;
143
+ border-radius: var(--space-m);
144
+ /* Black-based shadow — foreground is near-white on Cloud Dark and reads as a glow. */
145
+ box-shadow:
146
+ 0 18px 40px color-mix(in srgb, #000 22%, transparent),
147
+ 0 2px 8px color-mix(in srgb, #000 12%, transparent);
148
+ }
149
+
150
+ [data-timesheets-free-tool] [data-timesheets-free-tool-sheet] [data-timesheet-root] {
151
+ width: fit-content;
152
+ max-width: 100%;
153
+ }
154
+
155
+ /* Row: calendar left, text beside it (not a stretched 50/50 split). */
156
+ @media (min-width: 900px) {
157
+ [data-timesheets-free-tool] > [data-layout="split-even"] {
158
+ grid-template-columns: auto minmax(0, 36rem);
159
+ justify-content: start;
160
+ column-gap: var(--space-xl);
161
+ }
162
+
163
+ [data-timesheets-free-tool] [data-timesheets-free-tool-sheet] {
164
+ order: 1;
165
+ justify-self: start;
166
+ }
167
+
168
+ [data-timesheets-free-tool] [data-timesheets-free-tool-copy] {
169
+ order: 2;
170
+ justify-self: start;
171
+ max-width: 36rem;
172
+ text-align: start;
173
+ align-items: flex-start !important;
174
+ }
175
+
176
+ [data-timesheets-free-tool][data-cover-copy="true"] [data-timesheets-free-tool-copy] {
177
+ max-width: 36rem;
178
+ justify-self: start;
179
+ }
180
+
181
+ [data-timesheets-free-tool] [data-timesheets-free-tool-copy] > [data-view] {
182
+ text-align: start;
183
+ align-items: flex-start !important;
184
+ }
185
+
186
+ [data-timesheets-free-tool] [data-timesheets-free-tool-actions] {
187
+ justify-content: flex-start !important;
188
+ }
189
+ }
190
+ `}
191
+ </style>
192
+
193
+ <View
194
+ layout="split-even"
195
+ gap="l"
196
+ alignItems="center"
197
+ style={{ width: '100%' }}
198
+ >
199
+ <View
200
+ gap="m"
201
+ alignItems="center"
202
+ data-timesheets-free-tool-copy
203
+ style={{ minWidth: 0 }}
204
+ >
205
+ <View gap="s" alignItems="center" style={{ width: '100%' }}>
206
+ <Text
207
+ variant={isCoverCopy ? 'display' : 'heading-secondary'}
208
+ as={isCoverCopy ? 'h1' : 'h2'}
209
+ style={isCoverCopy ? { textWrap: 'balance', maxWidth: '100%' } : undefined}
210
+ >
211
+ {heading}
212
+ </Text>
213
+ <Text
214
+ variant={isCoverCopy ? 'heading-tertiary' : 'default'}
215
+ color="secondary"
216
+ style={isCoverCopy ? { maxWidth: '36rem' } : undefined}
217
+ >
218
+ {description}
219
+ </Text>
220
+ </View>
221
+
222
+ <View
223
+ layout="row"
224
+ gap="s"
225
+ alignItems="center"
226
+ justifyContent="center"
227
+ data-timesheets-free-tool-actions
228
+ style={{ flexWrap: 'wrap' }}
229
+ >
230
+ <Dropdown
231
+ trigger={(
232
+ <Button
233
+ variant="secondary"
234
+ prefix="software-download"
235
+ suffix="chevron-down"
236
+ disabled={exportDisabled}
237
+ >
238
+ {exporting ? t('timesheets.exporting') : t('timesheets.export')}
239
+ </Button>
240
+ )}
241
+ >
242
+ <ContextMenu roundness="s" surface="primary">
243
+ <ContextMenu.Item
244
+ prefix="list"
245
+ label="timesheets.exportCsv"
246
+ onClick={() => runExport('csv')}
247
+ />
248
+ <ContextMenu.Item
249
+ prefix="image"
250
+ label="timesheets.exportPng"
251
+ onClick={() => runExport('png')}
252
+ />
253
+ <ContextMenu.Item
254
+ prefix="file-document"
255
+ label="timesheets.exportPdf"
256
+ onClick={() => runExport('pdf')}
257
+ />
258
+ </ContextMenu>
259
+ </Dropdown>
260
+ <Button
261
+ variant="cta"
262
+ prefix={isAuthenticated ? 'check' : 'user-add'}
263
+ disabled={exporting}
264
+ onClick={handleSave}
265
+ label={isAuthenticated ? 'timesheets.sales.enable' : 'timesheets.freeTool.saveCta'}
266
+ {...(isAuthenticated && enableAction
267
+ ? { id: enableAction.id, 'data-service': enableAction['data-service'] }
268
+ : {})}
269
+ />
270
+ </View>
271
+
272
+ <View gap="xs" alignItems="center" style={{ width: '100%' }}>
273
+ <Text variant="small" color="secondary">
274
+ {isAuthenticated
275
+ ? t('timesheets.freeTool.saveHintAuthenticated')
276
+ : t('timesheets.freeTool.saveHint')}
277
+ </Text>
278
+ {!isAuthenticated && (
279
+ <Button variant="link" href={signInHref} label="timesheets.freeTool.signInLink" />
280
+ )}
281
+ </View>
282
+
283
+ {(error || enableError) && (
284
+ <Alert variant="danger">
285
+ {error || enableError}
286
+ </Alert>
287
+ )}
288
+ </View>
289
+
290
+ <div data-timesheets-free-tool-sheet>
291
+ <Timesheet
292
+ lines={lines}
293
+ onChange={setLines}
294
+ disabled={exporting}
295
+ periodStart={periodStart}
296
+ />
297
+ </div>
298
+ </View>
299
+
300
+ <div
301
+ aria-hidden
302
+ style={{
303
+ position: 'fixed',
304
+ left: 0,
305
+ top: 0,
306
+ transform: 'translateX(-200vw)',
307
+ pointerEvents: 'none',
308
+ zIndex: -1,
309
+ }}
310
+ >
311
+ <TimesheetExportCard
312
+ lines={lines}
313
+ periodStart={periodStart}
314
+ captureRef={captureRef}
315
+ />
316
+ </div>
317
+ </div>
318
+ )
319
+ }