@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
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ossy/timesheets",
|
|
3
|
-
"description": "
|
|
4
|
-
"version": "3.0.
|
|
3
|
+
"description": "Timesheets — generate, review, save, and export monthly work hours",
|
|
4
|
+
"version": "3.0.8",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.js",
|
|
7
7
|
"module": "./src/index.js",
|
|
@@ -13,6 +13,23 @@
|
|
|
13
13
|
"ossy": {
|
|
14
14
|
"src": "./src"
|
|
15
15
|
},
|
|
16
|
+
"dependencies": {
|
|
17
|
+
"@ossy/resources": "^3.0.8",
|
|
18
|
+
"html-to-image": "^1.11.13",
|
|
19
|
+
"nanoid": "^5.1.11",
|
|
20
|
+
"pdf-lib": "^1.17.1"
|
|
21
|
+
},
|
|
22
|
+
"peerDependencies": {
|
|
23
|
+
"@ossy/app": "*",
|
|
24
|
+
"@ossy/authentication": "*",
|
|
25
|
+
"@ossy/calendar": "*",
|
|
26
|
+
"@ossy/design-system": "*",
|
|
27
|
+
"@ossy/router-react": "*",
|
|
28
|
+
"@ossy/sdk-react": ">=1.0.0",
|
|
29
|
+
"@ossy/themes": "*",
|
|
30
|
+
"@ossy/workspaces": "*",
|
|
31
|
+
"react": "*"
|
|
32
|
+
},
|
|
16
33
|
"publishConfig": {
|
|
17
34
|
"access": "public",
|
|
18
35
|
"registry": "https://registry.npmjs.org"
|
|
@@ -21,11 +38,5 @@
|
|
|
21
38
|
"/src",
|
|
22
39
|
"README.md"
|
|
23
40
|
],
|
|
24
|
-
"
|
|
25
|
-
"@ossy/calendar": "*",
|
|
26
|
-
"@ossy/design-system": "*",
|
|
27
|
-
"@ossy/router-react": "*",
|
|
28
|
-
"react": "*"
|
|
29
|
-
},
|
|
30
|
-
"gitHead": "3e1c558069c7738dd751e429c69b10e52d247d8c"
|
|
41
|
+
"gitHead": "980b380c9dee0de78ef3ebabe86efcec1c223a9a"
|
|
31
42
|
}
|
package/src/Definition.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
export const Definition = {
|
|
2
2
|
id: 'timesheets',
|
|
3
3
|
title: 'Timesheets',
|
|
4
|
-
|
|
4
|
+
titleKey: 'timesheets.nav.title',
|
|
5
|
+
description: 'Try a monthly timesheet free — edit and export instantly; sign up to save.',
|
|
5
6
|
icon: 'calendar',
|
|
6
7
|
status: ['beta'],
|
|
7
8
|
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { Button, View, PageSection, Text } from '@ossy/design-system'
|
|
3
|
+
|
|
4
|
+
const Cover = ({
|
|
5
|
+
title,
|
|
6
|
+
titleMaxWidth = '1100px',
|
|
7
|
+
text,
|
|
8
|
+
actions = [],
|
|
9
|
+
}) => (
|
|
10
|
+
<View
|
|
11
|
+
gap="l"
|
|
12
|
+
placeContent="center"
|
|
13
|
+
layout="column"
|
|
14
|
+
justifyContent="center"
|
|
15
|
+
style={{
|
|
16
|
+
height: '100%',
|
|
17
|
+
borderRadius: 'var(--space-m)',
|
|
18
|
+
padding: 'var(--space-xl) var(--space-s)',
|
|
19
|
+
}}
|
|
20
|
+
>
|
|
21
|
+
<View gap="m" alignItems="center">
|
|
22
|
+
<Text as="h1" variant="display" style={{ maxWidth: titleMaxWidth, textWrap: 'balance' }}>
|
|
23
|
+
{title}
|
|
24
|
+
</Text>
|
|
25
|
+
<Text as="h2" variant="heading-tertiary" style={{ maxWidth: '800px' }}>
|
|
26
|
+
{text}
|
|
27
|
+
</Text>
|
|
28
|
+
</View>
|
|
29
|
+
{actions.length > 0 && (
|
|
30
|
+
<View gap="m" layout="row" justifyContent="center" style={{ flexWrap: 'wrap' }}>
|
|
31
|
+
{actions.map(({ label, ...props }) => (
|
|
32
|
+
<Button {...props} key={label} label={label} />
|
|
33
|
+
))}
|
|
34
|
+
</View>
|
|
35
|
+
)}
|
|
36
|
+
</View>
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Marketing cover band (`surface="hero"`).
|
|
41
|
+
* Pass `children` to use custom cover content (e.g. free tool); otherwise renders title/text/actions.
|
|
42
|
+
*/
|
|
43
|
+
export const HeroCover = ({
|
|
44
|
+
title,
|
|
45
|
+
titleMaxWidth = '1100px',
|
|
46
|
+
text,
|
|
47
|
+
actions = [],
|
|
48
|
+
children,
|
|
49
|
+
id = 'hero-cover',
|
|
50
|
+
surfaceAs = 'div',
|
|
51
|
+
maxWidth = 'l',
|
|
52
|
+
surface = 'hero',
|
|
53
|
+
...props
|
|
54
|
+
}) => {
|
|
55
|
+
const { style: passthroughStyle, ...pageSectionProps } = props
|
|
56
|
+
const hasChildren = children != null
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
<PageSection
|
|
60
|
+
id={id}
|
|
61
|
+
surfaceAs={surfaceAs}
|
|
62
|
+
maxWidth={maxWidth}
|
|
63
|
+
surface={surface}
|
|
64
|
+
{...pageSectionProps}
|
|
65
|
+
data-rounded
|
|
66
|
+
data-component="@ossy/timesheets/cover"
|
|
67
|
+
data-cover-with-tool={hasChildren ? 'true' : undefined}
|
|
68
|
+
style={{
|
|
69
|
+
backgroundAttachment: 'scroll',
|
|
70
|
+
boxShadow: 'inset 0 1px 0 color-mix(in srgb, var(--foreground) 6%, transparent)',
|
|
71
|
+
...passthroughStyle,
|
|
72
|
+
}}
|
|
73
|
+
>
|
|
74
|
+
<style href="@ossy/timesheets/cover" precedence="high">
|
|
75
|
+
{`
|
|
76
|
+
[data-component="@ossy/timesheets/cover"] {
|
|
77
|
+
gap: var(--space-m);
|
|
78
|
+
min-height: min(42vh, 420px);
|
|
79
|
+
height: auto;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
[data-component="@ossy/timesheets/cover"][data-cover-with-tool="true"] {
|
|
83
|
+
min-height: 0;
|
|
84
|
+
padding-block: var(--space-l);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
[data-rounded] {
|
|
88
|
+
border-radius: var(--space-m);
|
|
89
|
+
overflow: hidden;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
@media (min-width: 900px) {
|
|
93
|
+
[data-component="@ossy/timesheets/cover"]:not([data-cover-with-tool="true"]) {
|
|
94
|
+
min-height: min(46vh, 520px);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
[data-component="@ossy/timesheets/cover"][data-cover-with-tool="true"] {
|
|
98
|
+
padding-block: var(--space-xl);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
`}
|
|
102
|
+
</style>
|
|
103
|
+
{hasChildren ? children : (
|
|
104
|
+
<Cover
|
|
105
|
+
title={title}
|
|
106
|
+
titleMaxWidth={titleMaxWidth}
|
|
107
|
+
text={text}
|
|
108
|
+
actions={actions}
|
|
109
|
+
/>
|
|
110
|
+
)}
|
|
111
|
+
</PageSection>
|
|
112
|
+
)
|
|
113
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { Text, View, Icon, PageSection } from '@ossy/design-system'
|
|
3
|
+
import { HeroCover } from './HeroCover.jsx'
|
|
4
|
+
|
|
5
|
+
export default function SalesSection ({ cover = {}, features, tool = null }) {
|
|
6
|
+
return (
|
|
7
|
+
<PageSection maxWidth="xl" gap="l">
|
|
8
|
+
<View
|
|
9
|
+
gap="l"
|
|
10
|
+
surface="primary"
|
|
11
|
+
style={{ padding: 'var(--space-m) var(--space-l)', height: '100%', overflowY: 'auto' }}
|
|
12
|
+
>
|
|
13
|
+
{tool ? (
|
|
14
|
+
<View
|
|
15
|
+
id="timesheets-free-tool"
|
|
16
|
+
surface="base"
|
|
17
|
+
roundness="m"
|
|
18
|
+
inset="l"
|
|
19
|
+
data-timesheets-free-tool-section
|
|
20
|
+
style={{ width: '100%' }}
|
|
21
|
+
>
|
|
22
|
+
{tool}
|
|
23
|
+
</View>
|
|
24
|
+
) : (
|
|
25
|
+
<HeroCover {...cover} />
|
|
26
|
+
)}
|
|
27
|
+
|
|
28
|
+
<View gap="m" inset="s">
|
|
29
|
+
<Text variant="heading-secondary" as="h2" text="timesheets.sales.overview" />
|
|
30
|
+
<View gap="m" layout="row-wrap">
|
|
31
|
+
{features.map((x) => (
|
|
32
|
+
<View
|
|
33
|
+
key={x.title}
|
|
34
|
+
roundness="m"
|
|
35
|
+
surface="primary"
|
|
36
|
+
inset="m"
|
|
37
|
+
gap="m"
|
|
38
|
+
style={{
|
|
39
|
+
width: 200,
|
|
40
|
+
border: '1px solid var(--separator)',
|
|
41
|
+
}}
|
|
42
|
+
>
|
|
43
|
+
<View justifyContent="center" alignItems="center">
|
|
44
|
+
<Icon name={x.icon} size="m" />
|
|
45
|
+
</View>
|
|
46
|
+
<Text variant="small" style={{ fontWeight: 'bold', textAlign: 'center' }}>{x.title}</Text>
|
|
47
|
+
<Text variant="small" style={{ textAlign: 'center' }}>{x.text}</Text>
|
|
48
|
+
</View>
|
|
49
|
+
))}
|
|
50
|
+
</View>
|
|
51
|
+
</View>
|
|
52
|
+
|
|
53
|
+
<View gap="m" inset="s">
|
|
54
|
+
<Text variant="heading-secondary" as="h2" text="timesheets.sales.features" />
|
|
55
|
+
{features.map((x) => (
|
|
56
|
+
<View
|
|
57
|
+
key={x.title}
|
|
58
|
+
roundness="m"
|
|
59
|
+
gap="m"
|
|
60
|
+
inset="l"
|
|
61
|
+
style={{ border: '1px solid var(--separator)' }}
|
|
62
|
+
>
|
|
63
|
+
<View layout="row" gap="s" alignItems="center">
|
|
64
|
+
<View justifyContent="center" alignItems="center">
|
|
65
|
+
<Icon name={x.icon} size="m" />
|
|
66
|
+
</View>
|
|
67
|
+
<Text variant="heading-tertiary" as="h3">{x.title}</Text>
|
|
68
|
+
</View>
|
|
69
|
+
<Text>{x.text}</Text>
|
|
70
|
+
</View>
|
|
71
|
+
))}
|
|
72
|
+
</View>
|
|
73
|
+
</View>
|
|
74
|
+
</PageSection>
|
|
75
|
+
)
|
|
76
|
+
}
|
package/src/Timesheet.jsx
CHANGED
|
@@ -1,76 +1,191 @@
|
|
|
1
|
-
import React from 'react'
|
|
2
|
-
import { View, Text } from '@ossy/design-system'
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
const grid = {
|
|
6
|
-
width: '100%',
|
|
7
|
-
height: '100vh',
|
|
8
|
-
display: 'grid',
|
|
9
|
-
overflow: 'hidden',
|
|
10
|
-
gridTemplateColumns: 'repeat(6, minmax(390px, 425px))',
|
|
11
|
-
gridTemplateRows: '1fr 1fr',
|
|
12
|
-
gap: '1px',
|
|
13
|
-
}
|
|
1
|
+
import React, { useMemo } from 'react'
|
|
2
|
+
import { View, Text, Input, DateDisplay, Separator, useLocale } from '@ossy/design-system'
|
|
3
|
+
import { buildMonthGrid, dateKeyFromMs } from '@ossy/calendar'
|
|
14
4
|
|
|
15
5
|
const calendarGrid = {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
6
|
+
display: 'grid',
|
|
7
|
+
gridTemplateColumns: 'repeat(7, minmax(44px, 56px))',
|
|
8
|
+
gap: '1px',
|
|
9
|
+
justifyItems: 'stretch',
|
|
10
|
+
alignItems: 'stretch',
|
|
11
|
+
width: 'fit-content',
|
|
12
|
+
maxWidth: '100%',
|
|
23
13
|
}
|
|
24
14
|
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
15
|
+
const dayCell = {
|
|
16
|
+
boxSizing: 'border-box',
|
|
17
|
+
display: 'flex',
|
|
18
|
+
flexDirection: 'column',
|
|
19
|
+
alignItems: 'center',
|
|
20
|
+
justifyContent: 'flex-start',
|
|
21
|
+
gap: 4,
|
|
22
|
+
width: '100%',
|
|
23
|
+
minHeight: 72,
|
|
24
|
+
borderRadius: 'var(--space-xxs, 2px)',
|
|
25
|
+
padding: '6px 4px',
|
|
34
26
|
}
|
|
35
27
|
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
28
|
+
const hoursInput = {
|
|
29
|
+
width: '100%',
|
|
30
|
+
maxWidth: 48,
|
|
31
|
+
minHeight: 32,
|
|
32
|
+
padding: '6px 4px',
|
|
33
|
+
textAlign: 'center',
|
|
34
|
+
fontSize: 13,
|
|
35
|
+
borderRadius: 'var(--space-xxs, 2px)',
|
|
36
|
+
background: 'transparent',
|
|
37
|
+
borderColor: 'transparent',
|
|
38
|
+
borderWidth: 1,
|
|
39
|
+
borderStyle: 'solid',
|
|
40
|
+
boxShadow: 'none',
|
|
41
|
+
outline: 'none',
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
44
|
+
const WEEKDAY_KEYS = [
|
|
45
|
+
'timesheets.weekday.mon',
|
|
46
|
+
'timesheets.weekday.tue',
|
|
47
|
+
'timesheets.weekday.wed',
|
|
48
|
+
'timesheets.weekday.thu',
|
|
49
|
+
'timesheets.weekday.fri',
|
|
50
|
+
'timesheets.weekday.sat',
|
|
51
|
+
'timesheets.weekday.sun',
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Month calendar card for reviewing/editing timesheet hours.
|
|
56
|
+
* Non-workdays use CalendarDaySurfaceStyles (red).
|
|
57
|
+
* Pass `captureRef` only if capturing this live card; prefer {@link TimesheetExportCard} for PNG/PDF.
|
|
58
|
+
*/
|
|
59
|
+
export function Timesheet ({ lines = [], onChange, disabled = false, periodStart, captureRef }) {
|
|
60
|
+
const { t } = useLocale()
|
|
61
|
+
|
|
62
|
+
const lineByKey = useMemo(() => {
|
|
63
|
+
const map = new Map()
|
|
64
|
+
for (const line of lines) {
|
|
65
|
+
map.set(dateKeyFromMs(line.date), line)
|
|
66
|
+
}
|
|
67
|
+
return map
|
|
68
|
+
}, [lines])
|
|
69
|
+
|
|
70
|
+
const anchorMs = periodStart ?? lines[0]?.date ?? Date.now()
|
|
71
|
+
const cells = useMemo(() => buildMonthGrid(anchorMs), [anchorMs])
|
|
72
|
+
|
|
73
|
+
const totalHours = useMemo(
|
|
74
|
+
() => lines.reduce((sum, line) => sum + (Number(line.hours) || 0), 0),
|
|
75
|
+
[lines],
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
const updateHours = (dateMs, hours) => {
|
|
79
|
+
if (disabled || !onChange) return
|
|
80
|
+
onChange(lines.map((line) => (
|
|
81
|
+
line.date === dateMs ? { ...line, hours: Number(hours) || 0 } : line
|
|
82
|
+
)))
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (!lines.length) {
|
|
86
|
+
return <Text color="secondary" text="timesheets.empty" />
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return (
|
|
90
|
+
<>
|
|
91
|
+
<style href="@ossy/timesheets/day-surface" precedence="high">
|
|
92
|
+
{`
|
|
93
|
+
[data-timesheet-root] [data-ossy-calendar-day] {
|
|
94
|
+
background: var(--surface-primary);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
[data-timesheet-root] [data-ossy-calendar-day][data-day-off="true"] {
|
|
98
|
+
background: color-mix(in srgb, hsl(350 32% 58%) 9%, var(--surface-primary));
|
|
99
|
+
color: color-mix(in srgb, hsl(350 28% 46%) 45%, var(--foreground));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
[data-timesheet-root] [data-ossy-calendar-day][data-day-off="true"]:hover {
|
|
103
|
+
background: color-mix(in srgb, hsl(350 32% 55%) 13%, var(--surface-primary));
|
|
104
|
+
}
|
|
105
|
+
`}
|
|
106
|
+
</style>
|
|
107
|
+
<div
|
|
108
|
+
ref={captureRef}
|
|
109
|
+
data-timesheet-root
|
|
110
|
+
style={{ width: 'fit-content', maxWidth: '100%' }}
|
|
111
|
+
>
|
|
112
|
+
<View
|
|
113
|
+
gap="m"
|
|
114
|
+
surface="primary"
|
|
115
|
+
roundness="m"
|
|
116
|
+
inset="m"
|
|
117
|
+
style={{ width: 'fit-content', maxWidth: '100%', boxSizing: 'border-box' }}
|
|
118
|
+
>
|
|
119
|
+
<View gap="s">
|
|
120
|
+
<View layout="row" gap="m" alignItems="baseline" style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
|
|
121
|
+
<DateDisplay
|
|
122
|
+
value={anchorMs}
|
|
123
|
+
formatOptions={{ month: 'long', year: 'numeric' }}
|
|
124
|
+
variant="heading-tertiary"
|
|
125
|
+
as="h3"
|
|
126
|
+
/>
|
|
127
|
+
<Text variant="small" color="secondary">
|
|
128
|
+
{t('timesheets.totalHours', { hours: totalHours })}
|
|
129
|
+
</Text>
|
|
130
|
+
</View>
|
|
131
|
+
<Separator
|
|
132
|
+
variant="accent"
|
|
133
|
+
style={{ borderColor: 'var(--color-accent, #3dcf9a)' }}
|
|
134
|
+
/>
|
|
135
|
+
</View>
|
|
136
|
+
|
|
137
|
+
<div style={calendarGrid}>
|
|
138
|
+
{WEEKDAY_KEYS.map((key) => (
|
|
139
|
+
<Text key={key} variant="small" color="secondary" style={{ textAlign: 'center' }}>
|
|
140
|
+
{t(key)}
|
|
141
|
+
</Text>
|
|
72
142
|
))}
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
143
|
+
</div>
|
|
144
|
+
|
|
145
|
+
<div style={calendarGrid}>
|
|
146
|
+
{cells.map((cell, idx) => {
|
|
147
|
+
if (!cell) return <div key={`pad-${idx}`} />
|
|
148
|
+
|
|
149
|
+
const line = lineByKey.get(cell.dateKey)
|
|
150
|
+
if (!line) return <div key={cell.dateKey} />
|
|
151
|
+
|
|
152
|
+
const off = !line.isWorkday
|
|
153
|
+
|
|
154
|
+
return (
|
|
155
|
+
<div
|
|
156
|
+
key={cell.dateKey}
|
|
157
|
+
data-ossy-calendar-day
|
|
158
|
+
data-day-off={off}
|
|
159
|
+
style={dayCell}
|
|
160
|
+
>
|
|
161
|
+
<Text
|
|
162
|
+
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>
|
|
172
|
+
<Input
|
|
173
|
+
type="number"
|
|
174
|
+
min={0}
|
|
175
|
+
max={24}
|
|
176
|
+
step={0.5}
|
|
177
|
+
value={line.hours}
|
|
178
|
+
disabled={disabled}
|
|
179
|
+
onChange={(e) => updateHours(line.date, e.target.value)}
|
|
180
|
+
style={hoursInput}
|
|
181
|
+
aria-label="timesheets.hours"
|
|
182
|
+
/>
|
|
183
|
+
</div>
|
|
184
|
+
)
|
|
185
|
+
})}
|
|
186
|
+
</div>
|
|
187
|
+
</View>
|
|
188
|
+
</div>
|
|
189
|
+
</>
|
|
190
|
+
)
|
|
76
191
|
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import React, { useMemo } from 'react'
|
|
2
|
+
import { View, Text, DateDisplay, List, Icon, useLocale } from '@ossy/design-system'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* List row for a timesheet period.
|
|
6
|
+
*/
|
|
7
|
+
export function TimesheetCard ({ timesheet, onClick }) {
|
|
8
|
+
const { t } = useLocale()
|
|
9
|
+
const totalHours = useMemo(
|
|
10
|
+
() => (timesheet?.lines ?? []).reduce((sum, line) => sum + (Number(line.hours) || 0), 0),
|
|
11
|
+
[timesheet?.lines],
|
|
12
|
+
)
|
|
13
|
+
const status = timesheet?.status ?? 'draft'
|
|
14
|
+
|
|
15
|
+
return (
|
|
16
|
+
<List.Item
|
|
17
|
+
selectable
|
|
18
|
+
density="comfortable"
|
|
19
|
+
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 })}
|
|
32
|
+
</Text>
|
|
33
|
+
</View>
|
|
34
|
+
</List.Item>
|
|
35
|
+
)
|
|
36
|
+
}
|