@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.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@ossy/timesheets",
3
- "description": "Analytics module workspace analytics for website traffic",
4
- "version": "3.0.6",
3
+ "description": "Timesheetsgenerate, review, save, and export monthly work hours",
4
+ "version": "3.0.7",
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.7",
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
- "peerDependencies": {
25
- "@ossy/calendar": "*",
26
- "@ossy/design-system": "*",
27
- "@ossy/router-react": "*",
28
- "react": "*"
29
- },
30
- "gitHead": "3e1c558069c7738dd751e429c69b10e52d247d8c"
41
+ "gitHead": "5010f51795d5b8f4c63ef52495f4b2935b801b3f"
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
- description: 'Time tracking, reporting, and worklog summaries.',
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 { CalendarDaySurfaceStyles, useCalendarMonths } from '@ossy/calendar';
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
- display: 'grid',
17
- gridTemplateColumns: 'repeat(7, 1fr)',
18
- gridAutoRows: 'auto',
19
- gap: '8px',
20
- padding: '24px',
21
- justifyItems: 'center',
22
- alignItems: 'center',
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 calendarDayLayout = {
26
- boxSizing: 'border-box',
27
- display: 'flex',
28
- justifyContent: 'center',
29
- alignItems: 'center',
30
- borderRadius: '50%',
31
- padding: '8px',
32
- fontSize: '.6rem',
33
- margin: 0,
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 monthCard = {
37
- display: 'flex',
38
- flexDirection: 'column',
39
- gap: '16px',
40
- padding: '8px',
41
- boxSizing: 'border-box',
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
- export const Timesheet = () => {
45
- const months = useCalendarMonths()
46
- return (
47
- <>
48
- <CalendarDaySurfaceStyles />
49
- <div style={grid}>
50
-
51
- {months.map(month => (
52
- <div key={month.month} data-surface="primary" style={monthCard}>
53
- <Text variant="heading-tertiary" as="h3">{month.month}</Text>
54
- <View gap="s" style={{ flexGrow: '1' }}>
55
-
56
- </View>
57
-
58
- <div style={calendarGrid}>
59
- {month.days.map(day => (
60
- <Text
61
- key={`${month.month}-${day.monthDay}`}
62
- variant="small"
63
- data-ossy-calendar-day
64
- data-day-off={!day.isWorkday}
65
- style={calendarDayLayout}
66
- >
67
- {day.monthDay}
68
- </Text>
69
- ))}
70
- </div>
71
- </div>
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
- </div>
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
+ }