@ossy/timesheets 3.0.6 → 3.0.8
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 +20 -9
- package/src/Definition.js +2 -1
- package/src/HeroCover.jsx +113 -0
- package/src/SalesSection.jsx +76 -0
- package/src/Timesheet.jsx +181 -66
- package/src/TimesheetCard.jsx +36 -0
- package/src/TimesheetExportCard.jsx +212 -0
- package/src/TimesheetsFreeTool.jsx +319 -0
- package/src/TimesheetsProductHome.jsx +229 -0
- package/src/TimesheetsSalesPage.jsx +50 -0
- package/src/build-prefilled-lines.js +60 -0
- package/src/download-timesheet-csv.js +35 -0
- package/src/download-timesheet-image.js +15 -0
- package/src/download-timesheet-pdf.js +49 -0
- package/src/en.translations.json +69 -1
- package/src/export-capture.js +106 -0
- package/src/generate.action.js +5 -0
- package/src/generate.task.js +103 -0
- package/src/get.action.js +4 -0
- package/src/get.task.js +56 -0
- package/src/holiday-locale.js +64 -0
- package/src/index.js +22 -0
- package/src/invoke-error-message.js +10 -0
- package/src/list.action.js +4 -0
- package/src/list.task.js +37 -0
- package/src/locations.js +11 -0
- package/src/open-new.action.js +5 -0
- package/src/sample-showcase-timesheet.js +34 -0
- package/src/save.action.js +5 -0
- package/src/save.task.js +62 -0
- package/src/schema-ids.js +4 -0
- package/src/sv.translations.json +69 -1
- package/src/timesheet-detail.page.jsx +209 -0
- package/src/timesheet-resources.js +28 -0
- package/src/timesheet.schema.js +29 -0
- package/src/timesheets-auth-return.js +23 -0
- package/src/timesheets-home-content.js +54 -0
- package/src/timesheets.page.jsx +86 -27
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
|
2
|
+
import { Text, View, Button, Page, useLocale, Tags, Alert, Select, List, Overlay } from '@ossy/design-system'
|
|
3
|
+
import { useRouter } from '@ossy/router-react'
|
|
4
|
+
import { useSdk } from '@ossy/sdk-react'
|
|
5
|
+
import { Definition } from './Definition.js'
|
|
6
|
+
import { TimesheetCard } from './TimesheetCard.jsx'
|
|
7
|
+
import { monthPeriodBounds } from './build-prefilled-lines.js'
|
|
8
|
+
import { invokeErrorMessage } from './invoke-error-message.js'
|
|
9
|
+
import { metadata as ListTimesheets } from './list.action.js'
|
|
10
|
+
import { metadata as GenerateTimesheet } from './generate.action.js'
|
|
11
|
+
import { metadata as OpenNewTimesheet } from './open-new.action.js'
|
|
12
|
+
|
|
13
|
+
function buildMonthOptions (language, yearsBack = 1, yearsForward = 0) {
|
|
14
|
+
let locale = 'en-GB'
|
|
15
|
+
try {
|
|
16
|
+
locale = new Intl.Locale(language || 'en').toString()
|
|
17
|
+
} catch {
|
|
18
|
+
locale = language === 'sv' ? 'sv-SE' : 'en-GB'
|
|
19
|
+
}
|
|
20
|
+
const now = new Date()
|
|
21
|
+
const options = []
|
|
22
|
+
|
|
23
|
+
for (let y = now.getFullYear() - yearsBack; y <= now.getFullYear() + yearsForward; y++) {
|
|
24
|
+
for (let m = 0; m < 12; m++) {
|
|
25
|
+
const label = new Intl.DateTimeFormat(locale, { month: 'long', year: 'numeric' })
|
|
26
|
+
.format(new Date(y, m, 1))
|
|
27
|
+
options.push({
|
|
28
|
+
value: `${y}-${m}`,
|
|
29
|
+
label: label.charAt(0).toUpperCase() + label.slice(1),
|
|
30
|
+
year: y,
|
|
31
|
+
monthIndex: m,
|
|
32
|
+
})
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return options
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export default function TimesheetsProductHome () {
|
|
40
|
+
const { t, language } = useLocale()
|
|
41
|
+
const sdk = useSdk()
|
|
42
|
+
const router = useRouter()
|
|
43
|
+
|
|
44
|
+
const monthOptions = useMemo(() => buildMonthOptions(language), [language])
|
|
45
|
+
const [periodKey, setPeriodKey] = useState(() => {
|
|
46
|
+
const n = new Date()
|
|
47
|
+
return `${n.getFullYear()}-${n.getMonth()}`
|
|
48
|
+
})
|
|
49
|
+
const [creating, setCreating] = useState(false)
|
|
50
|
+
const [sheets, setSheets] = useState([])
|
|
51
|
+
const [loading, setLoading] = useState(true)
|
|
52
|
+
const [generating, setGenerating] = useState(false)
|
|
53
|
+
const [error, setError] = useState(null)
|
|
54
|
+
const [createError, setCreateError] = useState(null)
|
|
55
|
+
|
|
56
|
+
const selected = useMemo(
|
|
57
|
+
() => monthOptions.find((o) => o.value === periodKey) ?? monthOptions[0],
|
|
58
|
+
[monthOptions, periodKey],
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
const existingPeriodStarts = useMemo(
|
|
62
|
+
() => new Set(sheets.map((s) => s.periodStart)),
|
|
63
|
+
[sheets],
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
const statusTags = (Definition.status ?? [])
|
|
67
|
+
.map((s) => ({ beta: 'Beta', 'coming-soon': 'Coming Soon' }[s]))
|
|
68
|
+
.filter(Boolean)
|
|
69
|
+
|
|
70
|
+
const loadList = useCallback(async () => {
|
|
71
|
+
setLoading(true)
|
|
72
|
+
setError(null)
|
|
73
|
+
try {
|
|
74
|
+
const data = await sdk.invoke(ListTimesheets, {})
|
|
75
|
+
setSheets(Array.isArray(data) ? data : [])
|
|
76
|
+
} catch (err) {
|
|
77
|
+
setError(await invokeErrorMessage(err, t('timesheets.errorLoad')))
|
|
78
|
+
setSheets([])
|
|
79
|
+
} finally {
|
|
80
|
+
setLoading(false)
|
|
81
|
+
}
|
|
82
|
+
}, [sdk, t])
|
|
83
|
+
|
|
84
|
+
useEffect(() => {
|
|
85
|
+
loadList()
|
|
86
|
+
}, [loadList])
|
|
87
|
+
|
|
88
|
+
const openDetail = (timesheetId) => {
|
|
89
|
+
const href = router.getHref({
|
|
90
|
+
id: 'timesheets/detail',
|
|
91
|
+
params: { timesheetId },
|
|
92
|
+
})
|
|
93
|
+
if (href) window.location.href = href
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const closeCreateModal = () => {
|
|
97
|
+
if (generating) return
|
|
98
|
+
setCreating(false)
|
|
99
|
+
setCreateError(null)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const openCreateModal = () => {
|
|
103
|
+
setCreating(true)
|
|
104
|
+
setCreateError(null)
|
|
105
|
+
const n = new Date()
|
|
106
|
+
setPeriodKey(`${n.getFullYear()}-${n.getMonth()}`)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const handleGenerate = async () => {
|
|
110
|
+
if (!selected) return
|
|
111
|
+
setGenerating(true)
|
|
112
|
+
setCreateError(null)
|
|
113
|
+
try {
|
|
114
|
+
const data = await sdk.invoke(GenerateTimesheet, {
|
|
115
|
+
year: selected.year,
|
|
116
|
+
monthIndex: selected.monthIndex,
|
|
117
|
+
locale: language,
|
|
118
|
+
})
|
|
119
|
+
if (data?.id) {
|
|
120
|
+
openDetail(data.id)
|
|
121
|
+
return
|
|
122
|
+
}
|
|
123
|
+
await loadList()
|
|
124
|
+
setCreating(false)
|
|
125
|
+
} catch (err) {
|
|
126
|
+
setCreateError(await invokeErrorMessage(err, t('timesheets.errorGenerate')))
|
|
127
|
+
} finally {
|
|
128
|
+
setGenerating(false)
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const periodAlreadyExists = selected
|
|
133
|
+
&& existingPeriodStarts.has(monthPeriodBounds(selected.year, selected.monthIndex).periodStart)
|
|
134
|
+
|
|
135
|
+
return (
|
|
136
|
+
<Page
|
|
137
|
+
title="timesheets.home.title"
|
|
138
|
+
near={statusTags.length > 0 ? <Tags tags={statusTags} size="s" /> : undefined}
|
|
139
|
+
description="timesheets.home.description"
|
|
140
|
+
far={(
|
|
141
|
+
<Button
|
|
142
|
+
id={OpenNewTimesheet.id}
|
|
143
|
+
variant="cta"
|
|
144
|
+
prefix="add"
|
|
145
|
+
label="timesheets.new"
|
|
146
|
+
onClick={openCreateModal}
|
|
147
|
+
/>
|
|
148
|
+
)}
|
|
149
|
+
>
|
|
150
|
+
<View gap="m">
|
|
151
|
+
{error && <Alert variant="danger">{error}</Alert>}
|
|
152
|
+
|
|
153
|
+
<View gap="s">
|
|
154
|
+
<Text variant="heading-secondary" as="h2" text="timesheets.listTitle" />
|
|
155
|
+
{loading ? (
|
|
156
|
+
<Text color="secondary" text="timesheets.loading" />
|
|
157
|
+
) : sheets.length === 0 ? (
|
|
158
|
+
<Text color="secondary" text="timesheets.listEmpty" />
|
|
159
|
+
) : (
|
|
160
|
+
<View gap="s">
|
|
161
|
+
<List>
|
|
162
|
+
{sheets.map((sheet) => (
|
|
163
|
+
<TimesheetCard
|
|
164
|
+
key={sheet.id}
|
|
165
|
+
timesheet={sheet}
|
|
166
|
+
onClick={() => openDetail(sheet.id)}
|
|
167
|
+
/>
|
|
168
|
+
))}
|
|
169
|
+
</List>
|
|
170
|
+
</View>
|
|
171
|
+
)}
|
|
172
|
+
</View>
|
|
173
|
+
</View>
|
|
174
|
+
|
|
175
|
+
<Overlay isVisible={creating} onClose={closeCreateModal}>
|
|
176
|
+
<View layout="off-center-m" style={{ height: '100%' }}>
|
|
177
|
+
<View data-region="content" style={{ width: 'min(28rem, calc(100vw - 2rem))' }}>
|
|
178
|
+
<View surface="primary" roundness="m" inset="l" gap="m">
|
|
179
|
+
<View gap="xs">
|
|
180
|
+
<Text variant="heading-tertiary" as="h3" text="timesheets.createTitle" />
|
|
181
|
+
<Text color="secondary" text="timesheets.createDescription" />
|
|
182
|
+
</View>
|
|
183
|
+
|
|
184
|
+
{createError && <Alert variant="danger">{createError}</Alert>}
|
|
185
|
+
|
|
186
|
+
<View gap="xs">
|
|
187
|
+
<Text variant="small" weight="medium" text="timesheets.period" />
|
|
188
|
+
<Select
|
|
189
|
+
value={periodKey}
|
|
190
|
+
onChange={(e) => setPeriodKey(e.target.value)}
|
|
191
|
+
disabled={generating}
|
|
192
|
+
>
|
|
193
|
+
{monthOptions.map((o) => (
|
|
194
|
+
<option key={o.value} value={o.value}>{o.label}</option>
|
|
195
|
+
))}
|
|
196
|
+
</Select>
|
|
197
|
+
</View>
|
|
198
|
+
|
|
199
|
+
{periodAlreadyExists && (
|
|
200
|
+
<Text variant="small" color="secondary" text="timesheets.periodExistsHint" />
|
|
201
|
+
)}
|
|
202
|
+
|
|
203
|
+
<View layout="row" gap="s" justifyContent="flex-end" style={{ flexWrap: 'wrap' }}>
|
|
204
|
+
<Button
|
|
205
|
+
variant="neutral"
|
|
206
|
+
disabled={generating}
|
|
207
|
+
label="timesheets.cancel"
|
|
208
|
+
onClick={closeCreateModal}
|
|
209
|
+
/>
|
|
210
|
+
<Button
|
|
211
|
+
id={GenerateTimesheet.id}
|
|
212
|
+
variant="cta"
|
|
213
|
+
disabled={generating || !selected}
|
|
214
|
+
onClick={handleGenerate}
|
|
215
|
+
>
|
|
216
|
+
{generating
|
|
217
|
+
? t('timesheets.loading')
|
|
218
|
+
: periodAlreadyExists
|
|
219
|
+
? t('timesheets.openExisting')
|
|
220
|
+
: t('timesheets.generate')}
|
|
221
|
+
</Button>
|
|
222
|
+
</View>
|
|
223
|
+
</View>
|
|
224
|
+
</View>
|
|
225
|
+
</View>
|
|
226
|
+
</Overlay>
|
|
227
|
+
</Page>
|
|
228
|
+
)
|
|
229
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import React, { useState, useCallback } from 'react'
|
|
2
|
+
import { EnableService } from '@ossy/workspaces'
|
|
3
|
+
import { View, useLocale } from '@ossy/design-system'
|
|
4
|
+
import SalesSection from './SalesSection.jsx'
|
|
5
|
+
import { TimesheetsFreeTool } from './TimesheetsFreeTool.jsx'
|
|
6
|
+
import { useTimesheetsHomeContent } from './timesheets-home-content.js'
|
|
7
|
+
import { TIMESHEETS_SERVICE } from './timesheets-auth-return.js'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Public sales surface. Anonymous → sign up; signed-in but not entitled → enable.
|
|
11
|
+
*/
|
|
12
|
+
export default function TimesheetsSalesPage ({ isAuthenticated, onEnable }) {
|
|
13
|
+
const { t } = useLocale()
|
|
14
|
+
const [enableError, setEnableError] = useState(null)
|
|
15
|
+
const { cover, features } = useTimesheetsHomeContent()
|
|
16
|
+
|
|
17
|
+
const handleEnable = useCallback(() => {
|
|
18
|
+
if (typeof onEnable !== 'function') return Promise.resolve()
|
|
19
|
+
setEnableError(null)
|
|
20
|
+
return onEnable().catch(() => {
|
|
21
|
+
setEnableError(t('timesheets.sales.enableError'))
|
|
22
|
+
})
|
|
23
|
+
}, [onEnable, t])
|
|
24
|
+
|
|
25
|
+
const enableAction = {
|
|
26
|
+
...EnableService,
|
|
27
|
+
'data-service': TIMESHEETS_SERVICE,
|
|
28
|
+
variant: 'cta',
|
|
29
|
+
label: 'timesheets.sales.enable',
|
|
30
|
+
onClick: handleEnable,
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<View data-timesheets-status="off">
|
|
35
|
+
<SalesSection
|
|
36
|
+
features={features}
|
|
37
|
+
tool={(
|
|
38
|
+
<TimesheetsFreeTool
|
|
39
|
+
title={cover.title}
|
|
40
|
+
text={cover.text}
|
|
41
|
+
isAuthenticated={!!isAuthenticated}
|
|
42
|
+
onEnable={handleEnable}
|
|
43
|
+
enableAction={enableAction}
|
|
44
|
+
enableError={enableError}
|
|
45
|
+
/>
|
|
46
|
+
)}
|
|
47
|
+
/>
|
|
48
|
+
</View>
|
|
49
|
+
)
|
|
50
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { CalendarClient } from '@ossy/calendar'
|
|
2
|
+
import { DEFAULT_HOLIDAY_LOCALE, resolveHolidayLocale } from './holiday-locale.js'
|
|
3
|
+
|
|
4
|
+
/** Prefill hours for a workday (SPEC). */
|
|
5
|
+
export const WORKDAY_HOURS = 8
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Calendar-month period bounds as local midnights, returned as Unix ms.
|
|
9
|
+
* @param {number} year
|
|
10
|
+
* @param {number} monthIndex 0–11
|
|
11
|
+
*/
|
|
12
|
+
export function monthPeriodBounds (year, monthIndex) {
|
|
13
|
+
const start = new Date(year, monthIndex, 1)
|
|
14
|
+
const end = new Date(year, monthIndex + 1, 0)
|
|
15
|
+
start.setHours(0, 0, 0, 0)
|
|
16
|
+
end.setHours(0, 0, 0, 0)
|
|
17
|
+
return {
|
|
18
|
+
periodStart: start.getTime(),
|
|
19
|
+
periodEnd: end.getTime(),
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Build prefilled timesheet lines for an inclusive period.
|
|
25
|
+
* Workdays → 8h; non-workdays → 0h. `isWorkday` is snapshotted.
|
|
26
|
+
*
|
|
27
|
+
* @param {number} periodStartMs
|
|
28
|
+
* @param {number} periodEndMs
|
|
29
|
+
* @param {string} [locale] ISO country code or BCP 47 tag (resolved via holiday-locale)
|
|
30
|
+
*/
|
|
31
|
+
export function buildPrefilledLines (periodStartMs, periodEndMs, locale) {
|
|
32
|
+
const holidayLocale = resolveHolidayLocale(locale, DEFAULT_HOLIDAY_LOCALE)
|
|
33
|
+
const days = CalendarClient.getAllDaysInDateSpan(
|
|
34
|
+
new Date(periodStartMs),
|
|
35
|
+
new Date(periodEndMs),
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
return days.map((day) => {
|
|
39
|
+
const date = new Date(day)
|
|
40
|
+
date.setHours(0, 0, 0, 0)
|
|
41
|
+
const isWorkday = CalendarClient.isWorkDay(date, holidayLocale)
|
|
42
|
+
return {
|
|
43
|
+
date: date.getTime(),
|
|
44
|
+
hours: isWorkday ? WORKDAY_HOURS : 0,
|
|
45
|
+
isWorkday,
|
|
46
|
+
}
|
|
47
|
+
})
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* @param {number} ms
|
|
52
|
+
* @returns {string} YYYY-MM-DD in local time
|
|
53
|
+
*/
|
|
54
|
+
export function formatDateKey (ms) {
|
|
55
|
+
const d = new Date(ms)
|
|
56
|
+
const y = d.getFullYear()
|
|
57
|
+
const m = String(d.getMonth() + 1).padStart(2, '0')
|
|
58
|
+
const day = String(d.getDate()).padStart(2, '0')
|
|
59
|
+
return `${y}-${m}-${day}`
|
|
60
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { formatDateKey } from './build-prefilled-lines.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Build a CSV string for a timesheet and trigger a browser download.
|
|
5
|
+
* @param {{ periodStart?: number, periodEnd?: number, status?: string, lines?: Array<{ date: number, hours: number }> }} timesheet
|
|
6
|
+
* @param {string} [filename]
|
|
7
|
+
*/
|
|
8
|
+
export function downloadTimesheetCsv (timesheet, filename) {
|
|
9
|
+
const lines = timesheet?.lines ?? []
|
|
10
|
+
const headerRows = []
|
|
11
|
+
|
|
12
|
+
if (timesheet?.periodStart != null && timesheet?.periodEnd != null) {
|
|
13
|
+
headerRows.push(`# period,${formatDateKey(timesheet.periodStart)},${formatDateKey(timesheet.periodEnd)}`)
|
|
14
|
+
headerRows.push(`# status,${timesheet.status ?? ''}`)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const rows = [
|
|
18
|
+
...headerRows,
|
|
19
|
+
'date,hours',
|
|
20
|
+
...lines.map((line) =>
|
|
21
|
+
[formatDateKey(line.date), line.hours ?? 0].join(','),
|
|
22
|
+
),
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
const blob = new Blob([rows.join('\n')], { type: 'text/csv;charset=utf-8' })
|
|
26
|
+
const url = URL.createObjectURL(blob)
|
|
27
|
+
const a = document.createElement('a')
|
|
28
|
+
a.href = url
|
|
29
|
+
a.download = filename
|
|
30
|
+
?? `timesheet-${formatDateKey(timesheet?.periodStart ?? Date.now())}.csv`
|
|
31
|
+
document.body.appendChild(a)
|
|
32
|
+
a.click()
|
|
33
|
+
a.remove()
|
|
34
|
+
URL.revokeObjectURL(url)
|
|
35
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { captureNodeAsPng, timesheetExportFilename, triggerDownload } from './export-capture.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Capture the timesheet card DOM and download as PNG.
|
|
5
|
+
* @param {HTMLElement} node
|
|
6
|
+
* @param {{ periodStart?: number }} [timesheet]
|
|
7
|
+
* @param {string} [filename]
|
|
8
|
+
*/
|
|
9
|
+
export async function downloadTimesheetImage (node, timesheet = {}, filename) {
|
|
10
|
+
const dataUrl = await captureNodeAsPng(node)
|
|
11
|
+
triggerDownload(
|
|
12
|
+
dataUrl,
|
|
13
|
+
filename ?? timesheetExportFilename(timesheet.periodStart, 'png'),
|
|
14
|
+
)
|
|
15
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { PDFDocument } from 'pdf-lib'
|
|
2
|
+
import { captureNodeAsPng, timesheetExportFilename, triggerDownload } from './export-capture.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Capture the timesheet card DOM and download as a single-page PDF.
|
|
6
|
+
* @param {HTMLElement} node
|
|
7
|
+
* @param {{ periodStart?: number }} [timesheet]
|
|
8
|
+
* @param {string} [filename]
|
|
9
|
+
*/
|
|
10
|
+
export async function downloadTimesheetPdf (node, timesheet = {}, filename) {
|
|
11
|
+
const dataUrl = await captureNodeAsPng(node)
|
|
12
|
+
const pngBytes = dataUrlToUint8Array(dataUrl)
|
|
13
|
+
|
|
14
|
+
const pdf = await PDFDocument.create()
|
|
15
|
+
const image = await pdf.embedPng(pngBytes)
|
|
16
|
+
|
|
17
|
+
const pageWidth = 595.28 // A4 pt
|
|
18
|
+
const pageHeight = 841.89
|
|
19
|
+
const margin = 36
|
|
20
|
+
const maxWidth = pageWidth - margin * 2
|
|
21
|
+
const maxHeight = pageHeight - margin * 2
|
|
22
|
+
const scale = Math.min(maxWidth / image.width, maxHeight / image.height, 1)
|
|
23
|
+
const drawWidth = image.width * scale
|
|
24
|
+
const drawHeight = image.height * scale
|
|
25
|
+
|
|
26
|
+
const page = pdf.addPage([pageWidth, pageHeight])
|
|
27
|
+
page.drawImage(image, {
|
|
28
|
+
x: (pageWidth - drawWidth) / 2,
|
|
29
|
+
y: pageHeight - margin - drawHeight,
|
|
30
|
+
width: drawWidth,
|
|
31
|
+
height: drawHeight,
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
const pdfBytes = await pdf.save()
|
|
35
|
+
const blob = new Blob([pdfBytes], { type: 'application/pdf' })
|
|
36
|
+
triggerDownload(
|
|
37
|
+
blob,
|
|
38
|
+
filename ?? timesheetExportFilename(timesheet.periodStart, 'pdf'),
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function dataUrlToUint8Array (dataUrl) {
|
|
43
|
+
const base64 = dataUrl.split(',')[1]
|
|
44
|
+
if (!base64) throw new Error('Invalid image data')
|
|
45
|
+
const binary = atob(base64)
|
|
46
|
+
const bytes = new Uint8Array(binary.length)
|
|
47
|
+
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i)
|
|
48
|
+
return bytes
|
|
49
|
+
}
|
package/src/en.translations.json
CHANGED
|
@@ -1,5 +1,73 @@
|
|
|
1
1
|
{
|
|
2
2
|
"timesheets/home.documentTitle": "Timesheets",
|
|
3
|
+
"timesheets/detail.documentTitle": "Timesheet",
|
|
3
4
|
"timesheets.home.title": "Timesheets",
|
|
4
|
-
"timesheets.
|
|
5
|
+
"timesheets.nav.title": "Timesheets",
|
|
6
|
+
"timesheets.home.description": "Your monthly timesheets. Create one, review hours on the calendar, save, and export.",
|
|
7
|
+
"timesheets.catalog.pitch": "Try free — edit and export monthly hours; sign up to save",
|
|
8
|
+
"timesheets.home.cover.title": "Monthly timesheets that are pre filled",
|
|
9
|
+
"timesheets.home.cover.text": "Generate a month of hours, adjust what differs, then save and export as CSV, image, or PDF.",
|
|
10
|
+
"timesheets.home.cover.ctaPrimary": "Get started free",
|
|
11
|
+
"timesheets.home.cover.ctaSecondary": "Try it free",
|
|
12
|
+
"timesheets.freeTool.sectionTitle": "Try it free",
|
|
13
|
+
"timesheets.freeTool.sectionDescription": "Edit this month’s hours and export as CSV, image, or PDF. Nothing is saved until you sign up.",
|
|
14
|
+
"timesheets.freeTool.saveCta": "Sign up to save",
|
|
15
|
+
"timesheets.freeTool.saveHint": "Edits stay in this browser until you refresh. Export anytime — save needs an account.",
|
|
16
|
+
"timesheets.freeTool.saveHintAuthenticated": "Export anytime. Enable timesheets in your workspace to save months permanently.",
|
|
17
|
+
"timesheets.freeTool.signInLink": "Already have an account? Sign in",
|
|
18
|
+
"timesheets.home.features.prefill.title": "Prefill the month",
|
|
19
|
+
"timesheets.home.features.prefill.text": "Workdays start at 8 hours. Weekends and holidays are zeroed and marked — no blank spreadsheet.",
|
|
20
|
+
"timesheets.home.features.review.title": "Review on a calendar",
|
|
21
|
+
"timesheets.home.features.review.text": "Edit hours day by day on a month card. Change only what differs from the default.",
|
|
22
|
+
"timesheets.home.features.export.title": "Export when ready",
|
|
23
|
+
"timesheets.home.features.export.text": "Download CSV for spreadsheets, or PNG and PDF that match the calendar card.",
|
|
24
|
+
"timesheets.home.features.calendar.title": "Calendar-aware",
|
|
25
|
+
"timesheets.home.features.calendar.text": "Workdays and public holidays are detected for your locale — weekends stay clear by default.",
|
|
26
|
+
"timesheets.sales.overview": "Overview",
|
|
27
|
+
"timesheets.sales.features": "Features",
|
|
28
|
+
"timesheets.sales.enable": "Enable timesheets",
|
|
29
|
+
"timesheets.sales.enableDescription": "Activate timesheets for your workspace to generate, save, and export monthly work hours.",
|
|
30
|
+
"timesheets.sales.enableError": "Could not enable timesheets. Try again or contact support.",
|
|
31
|
+
"timesheets.detail.title": "Timesheet",
|
|
32
|
+
"timesheets.detail.description": "Review and adjust hours, then save or export.",
|
|
33
|
+
"timesheets.new": "New",
|
|
34
|
+
"timesheets.createTitle": "New timesheet",
|
|
35
|
+
"timesheets.createDescription": "Choose a month to generate a prefilled timesheet.",
|
|
36
|
+
"timesheets.period": "Period",
|
|
37
|
+
"timesheets.generate": "Generate",
|
|
38
|
+
"timesheets.openExisting": "Open",
|
|
39
|
+
"timesheets.periodExistsHint": "A timesheet already exists for this period — Open loads it.",
|
|
40
|
+
"timesheets.cancel": "Cancel",
|
|
41
|
+
"timesheets.save": "Save",
|
|
42
|
+
"timesheets.saving": "Saving…",
|
|
43
|
+
"timesheets.loading": "Loading…",
|
|
44
|
+
"timesheets.export": "Export",
|
|
45
|
+
"timesheets.exporting": "Exporting…",
|
|
46
|
+
"timesheets.exportCsv": "CSV",
|
|
47
|
+
"timesheets.exportPng": "Image (PNG)",
|
|
48
|
+
"timesheets.exportPdf": "PDF",
|
|
49
|
+
"timesheets.hours": "Hours",
|
|
50
|
+
"timesheets.empty": "No days in this timesheet.",
|
|
51
|
+
"timesheets.listTitle": "Your timesheets",
|
|
52
|
+
"timesheets.listEmpty": "No timesheets yet. Click New to generate one for a month.",
|
|
53
|
+
"timesheets.open": "Open",
|
|
54
|
+
"timesheets.backToList": "← All timesheets",
|
|
55
|
+
"timesheets.saved": "Timesheet saved.",
|
|
56
|
+
"timesheets.unsaved": "Unsaved changes",
|
|
57
|
+
"timesheets.statusLabel": "Status: {status}",
|
|
58
|
+
"timesheets.status.draft": "Draft",
|
|
59
|
+
"timesheets.status.saved": "Saved",
|
|
60
|
+
"timesheets.totalHours": "Total: {hours} h",
|
|
61
|
+
"timesheets.weekday.mon": "Mon",
|
|
62
|
+
"timesheets.weekday.tue": "Tue",
|
|
63
|
+
"timesheets.weekday.wed": "Wed",
|
|
64
|
+
"timesheets.weekday.thu": "Thu",
|
|
65
|
+
"timesheets.weekday.fri": "Fri",
|
|
66
|
+
"timesheets.weekday.sat": "Sat",
|
|
67
|
+
"timesheets.weekday.sun": "Sun",
|
|
68
|
+
"timesheets.errorLoad": "Could not load timesheet.",
|
|
69
|
+
"timesheets.errorNotFound": "Timesheet not found.",
|
|
70
|
+
"timesheets.errorGenerate": "Could not generate timesheet.",
|
|
71
|
+
"timesheets.errorSave": "Could not save timesheet.",
|
|
72
|
+
"timesheets.errorExport": "Could not export timesheet."
|
|
5
73
|
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { formatDateKey } from './build-prefilled-lines.js'
|
|
2
|
+
|
|
3
|
+
/** Export canvas — plain white so PNG/PDF match a clean screenshot, not a themed card. */
|
|
4
|
+
export const TIMESHEET_EXPORT_BASE_BG = '#ffffff'
|
|
5
|
+
|
|
6
|
+
/** Matches Cloud Light / theme token stack. */
|
|
7
|
+
export const TIMESHEET_EXPORT_FONT_FAMILY = 'Roboto, Helvetica, Arial, sans-serif'
|
|
8
|
+
|
|
9
|
+
const ROBOTO_STYLESHEET_HREF =
|
|
10
|
+
'https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;600;700&display=swap'
|
|
11
|
+
const ROBOTO_LINK_ID = 'ossy-timesheet-export-fonts'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @param {number | undefined} periodStart
|
|
15
|
+
* @param {string} ext
|
|
16
|
+
*/
|
|
17
|
+
export function timesheetExportFilename (periodStart, ext) {
|
|
18
|
+
return `timesheet-${formatDateKey(periodStart ?? Date.now())}.${ext}`
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Trigger a browser download from a Blob or data URL.
|
|
23
|
+
* @param {Blob | string} data
|
|
24
|
+
* @param {string} filename
|
|
25
|
+
*/
|
|
26
|
+
export function triggerDownload (data, filename) {
|
|
27
|
+
const url = typeof data === 'string' ? data : URL.createObjectURL(data)
|
|
28
|
+
const a = document.createElement('a')
|
|
29
|
+
a.href = url
|
|
30
|
+
a.download = filename
|
|
31
|
+
document.body.appendChild(a)
|
|
32
|
+
a.click()
|
|
33
|
+
a.remove()
|
|
34
|
+
if (typeof data !== 'string') URL.revokeObjectURL(url)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Theme tokens name Roboto but the app never loads it as a webfont.
|
|
39
|
+
* html-to-image clones into SVG foreignObject — without @font-face embeds,
|
|
40
|
+
* the PNG falls back to a different system font than the live page.
|
|
41
|
+
*/
|
|
42
|
+
async function ensureExportFonts () {
|
|
43
|
+
if (typeof document === 'undefined') return
|
|
44
|
+
|
|
45
|
+
let link = document.getElementById(ROBOTO_LINK_ID)
|
|
46
|
+
if (!link) {
|
|
47
|
+
link = document.createElement('link')
|
|
48
|
+
link.id = ROBOTO_LINK_ID
|
|
49
|
+
link.rel = 'stylesheet'
|
|
50
|
+
link.href = ROBOTO_STYLESHEET_HREF
|
|
51
|
+
document.head.appendChild(link)
|
|
52
|
+
await new Promise((resolve) => {
|
|
53
|
+
link.onload = () => resolve()
|
|
54
|
+
link.onerror = () => resolve()
|
|
55
|
+
setTimeout(resolve, 4000)
|
|
56
|
+
})
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (!document.fonts?.load) return
|
|
60
|
+
try {
|
|
61
|
+
await Promise.all([
|
|
62
|
+
document.fonts.load(`400 16px Roboto`),
|
|
63
|
+
document.fonts.load(`500 16px Roboto`),
|
|
64
|
+
document.fonts.load(`600 16px Roboto`),
|
|
65
|
+
document.fonts.load(`700 16px Roboto`),
|
|
66
|
+
])
|
|
67
|
+
await document.fonts.ready
|
|
68
|
+
} catch {
|
|
69
|
+
// Export still proceeds with system fallbacks.
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Capture a DOM node as a PNG data URL (high-DPI).
|
|
75
|
+
* Client-side snapshot via html-to-image (not a server render).
|
|
76
|
+
* Always uses Cloud Light base background.
|
|
77
|
+
* @param {HTMLElement} node
|
|
78
|
+
* @returns {Promise<string>}
|
|
79
|
+
*/
|
|
80
|
+
export async function captureNodeAsPng (node) {
|
|
81
|
+
if (!node) throw new Error('Nothing to capture')
|
|
82
|
+
|
|
83
|
+
await ensureExportFonts()
|
|
84
|
+
|
|
85
|
+
const { toPng, getFontEmbedCSS } = await import('html-to-image')
|
|
86
|
+
|
|
87
|
+
let fontEmbedCSS
|
|
88
|
+
try {
|
|
89
|
+
fontEmbedCSS = await getFontEmbedCSS(node)
|
|
90
|
+
} catch {
|
|
91
|
+
fontEmbedCSS = undefined
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return toPng(node, {
|
|
95
|
+
cacheBust: true,
|
|
96
|
+
pixelRatio: 2,
|
|
97
|
+
backgroundColor: TIMESHEET_EXPORT_BASE_BG,
|
|
98
|
+
preferredFontFormat: 'woff2',
|
|
99
|
+
...(fontEmbedCSS ? { fontEmbedCSS } : {}),
|
|
100
|
+
// Off-screen export card uses translateX(-200vw); include full layout box.
|
|
101
|
+
style: {
|
|
102
|
+
transform: 'none',
|
|
103
|
+
fontFamily: TIMESHEET_EXPORT_FONT_FAMILY,
|
|
104
|
+
},
|
|
105
|
+
})
|
|
106
|
+
}
|