@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.
@@ -0,0 +1,209 @@
1
+ import React, { useCallback, useEffect, useRef, useState } from 'react'
2
+ import { Text, View, Button, Page, useLocale, Alert, Dropdown, ContextMenu } from '@ossy/design-system'
3
+ import { useRouter } from '@ossy/router-react'
4
+ import { useSdk } from '@ossy/sdk-react'
5
+ import { Timesheet } from './Timesheet.jsx'
6
+ import { TimesheetExportCard } from './TimesheetExportCard.jsx'
7
+ import { downloadTimesheetCsv } from './download-timesheet-csv.js'
8
+ import { downloadTimesheetImage } from './download-timesheet-image.js'
9
+ import { downloadTimesheetPdf } from './download-timesheet-pdf.js'
10
+ import { invokeErrorMessage } from './invoke-error-message.js'
11
+ import { metadata as GetTimesheet } from './get.action.js'
12
+ import { metadata as SaveTimesheet } from './save.action.js'
13
+
14
+ export const metadata = {
15
+ id: 'timesheets/detail',
16
+ path: {
17
+ sv: '/tidrapporter/:timesheetId',
18
+ en: '/timesheets/:timesheetId',
19
+ },
20
+ }
21
+
22
+ export default function TimesheetDetailPage () {
23
+ const { t } = useLocale()
24
+ const sdk = useSdk()
25
+ const router = useRouter()
26
+ const timesheetId = router.params?.timesheetId
27
+ const captureRef = useRef(null)
28
+
29
+ const [timesheet, setTimesheet] = useState(null)
30
+ const [lines, setLines] = useState([])
31
+ const [loading, setLoading] = useState(true)
32
+ const [saving, setSaving] = useState(false)
33
+ const [exporting, setExporting] = useState(false)
34
+ const [error, setError] = useState(null)
35
+ const [message, setMessage] = useState(null)
36
+
37
+ const load = useCallback(async () => {
38
+ if (!timesheetId) return
39
+ setLoading(true)
40
+ setError(null)
41
+ try {
42
+ const data = await sdk.invoke(GetTimesheet, { timesheetId })
43
+ if (!data) {
44
+ setError(t('timesheets.errorNotFound'))
45
+ setTimesheet(null)
46
+ setLines([])
47
+ return
48
+ }
49
+ setTimesheet(data)
50
+ setLines(data.lines ?? [])
51
+ } catch (err) {
52
+ setError(await invokeErrorMessage(err, t('timesheets.errorLoad')))
53
+ setTimesheet(null)
54
+ setLines([])
55
+ } finally {
56
+ setLoading(false)
57
+ }
58
+ }, [sdk, t, timesheetId])
59
+
60
+ useEffect(() => {
61
+ load()
62
+ }, [load])
63
+
64
+ const handleSave = async () => {
65
+ if (!timesheet?.id) return
66
+ setSaving(true)
67
+ setError(null)
68
+ setMessage(null)
69
+ try {
70
+ const data = await sdk.invoke(SaveTimesheet, {
71
+ timesheetId: timesheet.id,
72
+ lines,
73
+ })
74
+ setTimesheet(data)
75
+ setLines(data?.lines ?? lines)
76
+ setMessage(t('timesheets.saved'))
77
+ } catch (err) {
78
+ setError(await invokeErrorMessage(err, t('timesheets.errorSave')))
79
+ } finally {
80
+ setSaving(false)
81
+ }
82
+ }
83
+
84
+ const exportPayload = {
85
+ ...timesheet,
86
+ lines,
87
+ status: timesheet?.status,
88
+ }
89
+
90
+ const runExport = async (kind) => {
91
+ if (!lines.length) return
92
+ setExporting(true)
93
+ setError(null)
94
+ setMessage(null)
95
+ try {
96
+ if (kind === 'csv') {
97
+ downloadTimesheetCsv(exportPayload)
98
+ } else if (kind === 'png') {
99
+ await downloadTimesheetImage(captureRef.current, exportPayload)
100
+ } else if (kind === 'pdf') {
101
+ await downloadTimesheetPdf(captureRef.current, exportPayload)
102
+ }
103
+ } catch (err) {
104
+ setError(await invokeErrorMessage(err, t('timesheets.errorExport')))
105
+ } finally {
106
+ setExporting(false)
107
+ }
108
+ }
109
+
110
+ const homeHref = router.getHref({ id: 'timesheets/home' })
111
+ const exportDisabled = !lines.length || loading || exporting
112
+
113
+ return (
114
+ <Page
115
+ title={(
116
+ <View layout="row" gap="s" alignItems="center">
117
+ <Button
118
+ prefix="chevron-left"
119
+ variant="command"
120
+ href={homeHref || undefined}
121
+ aria-label="timesheets.backToList"
122
+ onClick={homeHref ? undefined : () => router.back()}
123
+ />
124
+ <Text variant="heading-default" as="h1" text="timesheets.detail.title" />
125
+ </View>
126
+ )}
127
+ description="timesheets.detail.description"
128
+ far={(
129
+ <View layout="row" gap="s" alignItems="center">
130
+ <Dropdown
131
+ trigger={(
132
+ <Button
133
+ variant="secondary"
134
+ prefix="software-download"
135
+ suffix="chevron-down"
136
+ disabled={exportDisabled}
137
+ >
138
+ {exporting ? t('timesheets.exporting') : t('timesheets.export')}
139
+ </Button>
140
+ )}
141
+ >
142
+ <ContextMenu roundness="s" surface="primary">
143
+ <ContextMenu.Item
144
+ prefix="list"
145
+ label="timesheets.exportCsv"
146
+ onClick={() => runExport('csv')}
147
+ />
148
+ <ContextMenu.Item
149
+ prefix="image"
150
+ label="timesheets.exportPng"
151
+ onClick={() => runExport('png')}
152
+ />
153
+ <ContextMenu.Item
154
+ prefix="file-document"
155
+ label="timesheets.exportPdf"
156
+ onClick={() => runExport('pdf')}
157
+ />
158
+ </ContextMenu>
159
+ </Dropdown>
160
+ <Button
161
+ id={SaveTimesheet.id}
162
+ variant="cta"
163
+ prefix="check"
164
+ disabled={!timesheet?.id || saving || loading}
165
+ onClick={handleSave}
166
+ >
167
+ {saving ? t('timesheets.saving') : t('timesheets.save')}
168
+ </Button>
169
+ </View>
170
+ )}
171
+ >
172
+ <View gap="m">
173
+ {error && <Alert variant="danger">{error}</Alert>}
174
+ {message && !error && <Alert variant="success">{message}</Alert>}
175
+
176
+ {loading ? (
177
+ <Text color="secondary" text="timesheets.loading" />
178
+ ) : timesheet ? (
179
+ <>
180
+ <Timesheet
181
+ lines={lines}
182
+ onChange={setLines}
183
+ disabled={loading || saving || exporting}
184
+ periodStart={timesheet.periodStart}
185
+ />
186
+ {/* Off-screen light-theme card for PNG/PDF capture (no inputs). */}
187
+ <div
188
+ aria-hidden
189
+ style={{
190
+ position: 'fixed',
191
+ left: 0,
192
+ top: 0,
193
+ transform: 'translateX(-200vw)',
194
+ pointerEvents: 'none',
195
+ zIndex: -1,
196
+ }}
197
+ >
198
+ <TimesheetExportCard
199
+ lines={lines}
200
+ periodStart={timesheet.periodStart}
201
+ captureRef={captureRef}
202
+ />
203
+ </div>
204
+ </>
205
+ ) : null}
206
+ </View>
207
+ </Page>
208
+ )
209
+ }
@@ -0,0 +1,28 @@
1
+ import { ResourcesQueries } from '@ossy/resources/server'
2
+
3
+ /**
4
+ * List timesheet resources by schema id (and optional workspace).
5
+ */
6
+ export function getTimesheetResources ({ schemaId, belongsTo } = {}) {
7
+ const query = { type: schemaId }
8
+ if (belongsTo) query.belongsTo = belongsTo
9
+ return ResourcesQueries.GetResources(query)
10
+ }
11
+
12
+ export function findTimesheetForPeriod (resources, periodStart) {
13
+ return (resources || []).find((r) => r.content?.periodStart === periodStart) ?? null
14
+ }
15
+
16
+ export function toTimesheetDto (resource) {
17
+ if (!resource) return null
18
+ return {
19
+ id: resource.id,
20
+ periodStart: resource.content?.periodStart,
21
+ periodEnd: resource.content?.periodEnd,
22
+ status: resource.content?.status ?? 'draft',
23
+ holidayLocale: resource.content?.holidayLocale ?? null,
24
+ lines: resource.content?.lines ?? [],
25
+ createdAt: resource.createdAt,
26
+ updatedAt: resource.updatedAt,
27
+ }
28
+ }
@@ -0,0 +1,29 @@
1
+ export default {
2
+ name: 'Timesheet',
3
+ id: '@ossy/timesheets/schema/timesheet',
4
+ categoryName: 'Timesheets',
5
+ icon: 'calendar',
6
+ fields: [
7
+ {
8
+ name: 'periodStart',
9
+ type: 'timestamp',
10
+ description: 'UTC midnight of the first day of the period (inclusive)',
11
+ },
12
+ {
13
+ name: 'periodEnd',
14
+ type: 'timestamp',
15
+ description: 'UTC midnight of the last day of the period (inclusive)',
16
+ },
17
+ {
18
+ // draft | saved
19
+ name: 'status',
20
+ type: 'text',
21
+ },
22
+ {
23
+ name: 'holidayLocale',
24
+ type: 'text',
25
+ description: 'ISO 3166-1 alpha-2 country used for workday/public-holiday prefill',
26
+ },
27
+ // `lines` is stored in content (array of day rows) — same nested-content pattern as booking availability.
28
+ ],
29
+ }
@@ -0,0 +1,23 @@
1
+ import { withRedirectLink } from '@ossy/authentication'
2
+
3
+ export const TIMESHEETS_SERVICE = '@ossy/timesheets'
4
+
5
+ /**
6
+ * Path to land on after verify, with enable intent for the sales page.
7
+ * Keep the query value literal here — `withRedirectLink` encodes once.
8
+ *
9
+ * @param {string} timesheetsHomePath locale-aware `/timesheets` or `/tidrapporter`
10
+ */
11
+ export function timesheetsEnableReturnPath (timesheetsHomePath = '/timesheets') {
12
+ const path = (timesheetsHomePath.startsWith('/') ? timesheetsHomePath : `/${timesheetsHomePath}`)
13
+ .split('?')[0]
14
+ return `${path}?enable=${TIMESHEETS_SERVICE}`
15
+ }
16
+
17
+ /**
18
+ * @param {string} authPath `/sign-up` or `/sign-in`
19
+ * @param {string} timesheetsHomePath
20
+ */
21
+ export function timesheetsAuthHref (authPath, timesheetsHomePath) {
22
+ return withRedirectLink(authPath, timesheetsEnableReturnPath(timesheetsHomePath))
23
+ }
@@ -0,0 +1,54 @@
1
+ import { useLocale } from '@ossy/design-system'
2
+ import { useRouter } from '@ossy/router-react'
3
+ import { OpenSignUp } from '@ossy/authentication'
4
+ import { timesheetsAuthHref } from './timesheets-auth-return.js'
5
+
6
+ export function useTimesheetsHomeContent () {
7
+ const { t } = useLocale()
8
+ const router = useRouter()
9
+
10
+ const timesheetsHome = router.getHref('timesheets/home') || '/timesheets'
11
+ const signUpHref = timesheetsAuthHref(
12
+ router.getHref('sign-up') || router.getHref('@sign-up') || '/sign-up',
13
+ timesheetsHome,
14
+ )
15
+
16
+ const cover = {
17
+ title: t('timesheets.home.cover.title'),
18
+ text: t('timesheets.home.cover.text'),
19
+ // CTAs live in the free-tool cover (export + save / enable).
20
+ actions: [
21
+ {
22
+ ...OpenSignUp,
23
+ variant: 'cta',
24
+ href: signUpHref,
25
+ label: 'timesheets.home.cover.ctaPrimary',
26
+ },
27
+ ],
28
+ }
29
+
30
+ const features = [
31
+ {
32
+ title: t('timesheets.home.features.prefill.title'),
33
+ icon: 'calendar',
34
+ text: t('timesheets.home.features.prefill.text'),
35
+ },
36
+ {
37
+ title: t('timesheets.home.features.review.title'),
38
+ icon: 'pen',
39
+ text: t('timesheets.home.features.review.text'),
40
+ },
41
+ {
42
+ title: t('timesheets.home.features.export.title'),
43
+ icon: 'software-download',
44
+ text: t('timesheets.home.features.export.text'),
45
+ },
46
+ {
47
+ title: t('timesheets.home.features.calendar.title'),
48
+ icon: 'globe',
49
+ text: t('timesheets.home.features.calendar.text'),
50
+ },
51
+ ]
52
+
53
+ return { cover, features }
54
+ }
@@ -1,8 +1,12 @@
1
- import React from 'react'
2
- import { Text, View, Button, Page, useLocale, Tags } from '@ossy/design-system'
3
- import { Timesheet } from './Timesheet.jsx'
4
- import { DataLoader } from '@ossy/calendar'
5
- import { Definition } from '@ossy/timesheets'
1
+ import React, { useCallback, useEffect, useMemo, useRef } from 'react'
2
+ import { useApp, useShellWorkspace } from '@ossy/app/shell'
3
+ import { GetWorkspace, EnableService } from '@ossy/workspaces'
4
+ import { isServiceEntitled } from '@ossy/workspaces/entitlements'
5
+ import { AsyncStatus, useSdk, cacheKey } from '@ossy/sdk-react'
6
+ import { useRouter } from '@ossy/router-react'
7
+ import TimesheetsSalesPage from './TimesheetsSalesPage.jsx'
8
+ import TimesheetsProductHome from './TimesheetsProductHome.jsx'
9
+ import { TIMESHEETS_SERVICE } from './timesheets-auth-return.js'
6
10
 
7
11
  export const metadata = {
8
12
  id: 'timesheets/home',
@@ -12,29 +16,84 @@ export const metadata = {
12
16
  },
13
17
  }
14
18
 
15
- export const TimesheetsPage = () => {
16
- const { t } = useLocale()
17
- const statusTags = (Definition.status ?? [])
18
- .map((s) => ({ beta: 'Beta', 'coming-soon': 'Coming Soon' }[s]))
19
- .filter(Boolean)
19
+ export default function TimesheetsHomePage () {
20
+ const app = useApp()
21
+ const router = useRouter()
22
+ const { workspace: shellWorkspace } = useShellWorkspace()
23
+ const { read, invoke, invalidate } = useSdk()
24
+ const isAuthenticated = !!app?.isAuthenticated
25
+ const {
26
+ data: workspaceFromSdk,
27
+ status: workspaceStatus,
28
+ } = read(GetWorkspace, undefined, { enabled: isAuthenticated })
29
+ const autoEnableStarted = useRef(false)
30
+
31
+ // Once GetWorkspace succeeds, trust only the live workspace — cookie/bootstrap
32
+ // can be stale and would skip the sales enable CTA.
33
+ const services = useMemo(() => {
34
+ if (workspaceStatus === AsyncStatus.Success) {
35
+ return workspaceFromSdk?.services ?? {}
36
+ }
37
+ return workspaceFromSdk?.services
38
+ ?? app?.workspaceServices
39
+ ?? shellWorkspace?.services
40
+ }, [
41
+ workspaceStatus,
42
+ workspaceFromSdk?.services,
43
+ app?.workspaceServices,
44
+ shellWorkspace?.services,
45
+ ])
46
+
47
+ const entitled = isServiceEntitled(services, TIMESHEETS_SERVICE)
48
+ const enableRequested = router.searchParams.enable === TIMESHEETS_SERVICE
49
+
50
+ const entitlementsPending = isAuthenticated
51
+ && workspaceStatus !== AsyncStatus.Success
52
+ && workspaceStatus !== AsyncStatus.Error
53
+ && app?.workspaceServices == null
54
+
55
+ const enableService = useCallback(() => {
56
+ return invoke(EnableService, { service: TIMESHEETS_SERVICE })
57
+ .then(() => invalidate(cacheKey(GetWorkspace)))
58
+ }, [invoke, invalidate])
59
+
60
+ useEffect(() => {
61
+ if (!isAuthenticated || !enableRequested || autoEnableStarted.current) return
62
+ if (entitlementsPending) return
63
+ if (entitled) {
64
+ const home = router.getHref('timesheets/home') || '/timesheets'
65
+ window.history.replaceState({}, '', home)
66
+ return
67
+ }
68
+
69
+ autoEnableStarted.current = true
70
+ const home = router.getHref('timesheets/home') || '/timesheets'
71
+ window.history.replaceState({}, '', home)
72
+
73
+ enableService().catch(() => {
74
+ // Sales page surfaces a manual enable CTA + error if this fails.
75
+ })
76
+ }, [
77
+ isAuthenticated,
78
+ enableRequested,
79
+ entitlementsPending,
80
+ entitled,
81
+ enableService,
82
+ router,
83
+ ])
84
+
85
+ if (entitlementsPending) {
86
+ return null
87
+ }
88
+
89
+ if (isAuthenticated && entitled) {
90
+ return <TimesheetsProductHome />
91
+ }
20
92
 
21
93
  return (
22
- <Page
23
- title="timesheets.home.title"
24
- near={statusTags.length > 0 ? <Tags tags={statusTags} size="s" /> : undefined}
25
- description="timesheets.home.description"
26
- far={(
27
- <Button variant="cta" disabled prefix="add">
28
- {t('design-system.add')}
29
- </Button>
30
- )}
31
- >
32
- <View gap="m">
33
- <Text variant="heading-secondary" as="h2" text="design-system.overview" />
34
- <DataLoader content={<Timesheet />} />
35
- </View>
36
- </Page>
94
+ <TimesheetsSalesPage
95
+ isAuthenticated={isAuthenticated}
96
+ onEnable={enableService}
97
+ />
37
98
  )
38
99
  }
39
-
40
- export default TimesheetsPage