@flamingo-stack/openframe-frontend-core 0.0.277 → 0.0.278
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/dist/{chunk-LDEXTWMK.js → chunk-5IJ46KAV.js} +2 -1
- package/dist/{chunk-MHQPAA3W.js → chunk-5NL62LIF.js} +4 -4
- package/dist/{chunk-KBKW4LVO.js → chunk-737ZLQ2T.js} +3 -3
- package/dist/{chunk-LALAS7OH.cjs → chunk-7LTWNUHB.cjs} +1465 -1115
- package/dist/chunk-7LTWNUHB.cjs.map +1 -0
- package/dist/{chunk-2GLHJ7ZY.cjs → chunk-7XUBAK5K.cjs} +12 -12
- package/dist/{chunk-2GLHJ7ZY.cjs.map → chunk-7XUBAK5K.cjs.map} +1 -1
- package/dist/{chunk-L5P4IFPK.cjs → chunk-BBDJCXHW.cjs} +10 -10
- package/dist/{chunk-L5P4IFPK.cjs.map → chunk-BBDJCXHW.cjs.map} +1 -1
- package/dist/{chunk-NCJENC5G.cjs → chunk-DO55GWNT.cjs} +19 -19
- package/dist/{chunk-NCJENC5G.cjs.map → chunk-DO55GWNT.cjs.map} +1 -1
- package/dist/{chunk-P6Y3S6QK.js → chunk-GO45CXT5.js} +2239 -1889
- package/dist/chunk-GO45CXT5.js.map +1 -0
- package/dist/{chunk-LH7R56RR.cjs → chunk-HR4CVAVH.cjs} +20 -18
- package/dist/chunk-HR4CVAVH.cjs.map +1 -0
- package/dist/{chunk-ZSPPV2SX.cjs → chunk-L4BJU5CE.cjs} +38 -38
- package/dist/{chunk-ZSPPV2SX.cjs.map → chunk-L4BJU5CE.cjs.map} +1 -1
- package/dist/{chunk-DMAPXENZ.js → chunk-MDHNLZME.js} +4 -4
- package/dist/{chunk-6A5GGUIA.js → chunk-MVUN35FH.js} +3 -3
- package/dist/{chunk-UCX75VEM.js → chunk-R6SHY6ZQ.js} +2 -2
- package/dist/{chunk-ZS4XNIZK.js → chunk-RGCVYGA2.js} +5 -3
- package/dist/chunk-RGCVYGA2.js.map +1 -0
- package/dist/{chunk-27NKVNWD.cjs → chunk-WYQ5VFXA.cjs} +29 -29
- package/dist/{chunk-27NKVNWD.cjs.map → chunk-WYQ5VFXA.cjs.map} +1 -1
- package/dist/{chunk-N5UOJC3V.cjs → chunk-XXI7BNB6.cjs} +3 -2
- package/dist/chunk-XXI7BNB6.cjs.map +1 -0
- package/dist/components/chat/index.cjs +4 -4
- package/dist/components/chat/index.js +3 -3
- package/dist/components/contact/index.cjs +5 -5
- package/dist/components/contact/index.js +4 -4
- package/dist/components/faq/index.cjs +5 -5
- package/dist/components/faq/index.js +4 -4
- package/dist/components/features/index.cjs +16 -4
- package/dist/components/features/index.cjs.map +1 -1
- package/dist/components/features/index.d.ts +1 -0
- package/dist/components/features/index.d.ts.map +1 -1
- package/dist/components/features/index.js +16 -4
- package/dist/components/features/time-tracker/index.d.ts +10 -0
- package/dist/components/features/time-tracker/index.d.ts.map +1 -0
- package/dist/components/features/time-tracker/time-tracker-context.d.ts +17 -0
- package/dist/components/features/time-tracker/time-tracker-context.d.ts.map +1 -0
- package/dist/components/features/time-tracker/time-tracker-header-button.d.ts +13 -0
- package/dist/components/features/time-tracker/time-tracker-header-button.d.ts.map +1 -0
- package/dist/components/features/time-tracker/time-tracker-panel.d.ts +7 -0
- package/dist/components/features/time-tracker/time-tracker-panel.d.ts.map +1 -0
- package/dist/components/features/time-tracker/types.d.ts +54 -0
- package/dist/components/features/time-tracker/types.d.ts.map +1 -0
- package/dist/components/features/time-tracker/use-tracker-clock.d.ts +14 -0
- package/dist/components/features/time-tracker/use-tracker-clock.d.ts.map +1 -0
- package/dist/components/index.cjs +114 -102
- package/dist/components/index.cjs.map +1 -1
- package/dist/components/index.js +19 -7
- package/dist/components/index.js.map +1 -1
- package/dist/components/navigation/app-header.d.ts +2 -0
- package/dist/components/navigation/app-header.d.ts.map +1 -1
- package/dist/components/navigation/index.cjs +4 -4
- package/dist/components/navigation/index.js +3 -3
- package/dist/components/onboarding-guides/index.cjs +23 -23
- package/dist/components/onboarding-guides/index.js +3 -3
- package/dist/components/related-content/index.cjs +5 -5
- package/dist/components/related-content/index.js +4 -4
- package/dist/components/tickets/index.cjs +72 -72
- package/dist/components/tickets/index.js +5 -5
- package/dist/components/ui/autocomplete.d.ts.map +1 -1
- package/dist/components/ui/file-manager/index.cjs +20 -20
- package/dist/components/ui/file-manager/index.js +1 -1
- package/dist/components/ui/hidden-tags-popup.d.ts.map +1 -1
- package/dist/components/ui/index.cjs +4 -4
- package/dist/components/ui/index.js +3 -3
- package/dist/index.cjs +24 -4
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +23 -3
- package/dist/utils/index.cjs +37 -4
- package/dist/utils/index.cjs.map +1 -1
- package/dist/utils/index.d.ts +1 -1
- package/dist/utils/index.d.ts.map +1 -1
- package/dist/utils/index.js +34 -5
- package/dist/utils/index.js.map +1 -1
- package/package.json +1 -1
- package/src/components/features/index.ts +1 -0
- package/src/components/features/time-tracker/index.ts +18 -0
- package/src/components/features/time-tracker/time-tracker-context.tsx +45 -0
- package/src/components/features/time-tracker/time-tracker-header-button.tsx +87 -0
- package/src/components/features/time-tracker/time-tracker-panel.tsx +270 -0
- package/src/components/features/time-tracker/types.ts +61 -0
- package/src/components/features/time-tracker/use-tracker-clock.ts +39 -0
- package/src/components/navigation/app-header.tsx +9 -0
- package/src/components/ui/autocomplete.tsx +2 -7
- package/src/components/ui/hidden-tags-popup.tsx +3 -1
- package/src/stories/TimeTracker.stories.tsx +355 -0
- package/src/utils/index.ts +13 -1
- package/dist/chunk-LALAS7OH.cjs.map +0 -1
- package/dist/chunk-LH7R56RR.cjs.map +0 -1
- package/dist/chunk-N5UOJC3V.cjs.map +0 -1
- package/dist/chunk-P6Y3S6QK.js.map +0 -1
- package/dist/chunk-ZS4XNIZK.js.map +0 -1
- /package/dist/{chunk-LDEXTWMK.js.map → chunk-5IJ46KAV.js.map} +0 -0
- /package/dist/{chunk-MHQPAA3W.js.map → chunk-5NL62LIF.js.map} +0 -0
- /package/dist/{chunk-KBKW4LVO.js.map → chunk-737ZLQ2T.js.map} +0 -0
- /package/dist/{chunk-DMAPXENZ.js.map → chunk-MDHNLZME.js.map} +0 -0
- /package/dist/{chunk-6A5GGUIA.js.map → chunk-MVUN35FH.js.map} +0 -0
- /package/dist/{chunk-UCX75VEM.js.map → chunk-R6SHY6ZQ.js.map} +0 -0
package/package.json
CHANGED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export {
|
|
2
|
+
TimeTrackerProvider,
|
|
3
|
+
useTimeTracker,
|
|
4
|
+
useOptionalTimeTracker,
|
|
5
|
+
} from './time-tracker-context'
|
|
6
|
+
export type { TimeTrackerProviderProps } from './time-tracker-context'
|
|
7
|
+
export { TimeTrackerPanel } from './time-tracker-panel'
|
|
8
|
+
export type { TimeTrackerPanelProps } from './time-tracker-panel'
|
|
9
|
+
export { TimeTrackerHeaderButton } from './time-tracker-header-button'
|
|
10
|
+
export type { TimeTrackerHeaderButtonProps } from './time-tracker-header-button'
|
|
11
|
+
export { useTrackerClock } from './use-tracker-clock'
|
|
12
|
+
export type { UseTrackerClockArgs } from './use-tracker-clock'
|
|
13
|
+
export type {
|
|
14
|
+
TimeTrackerStatus,
|
|
15
|
+
TimeTrackerTicketOption,
|
|
16
|
+
TimeTrackerEntry,
|
|
17
|
+
TimeTrackerData,
|
|
18
|
+
} from './types'
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import type { TimeTrackerData } from './types'
|
|
5
|
+
|
|
6
|
+
interface TimeTrackerContextValue extends TimeTrackerData {
|
|
7
|
+
isOpen: boolean
|
|
8
|
+
open: () => void
|
|
9
|
+
close: () => void
|
|
10
|
+
toggle: () => void
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const TimeTrackerContext = React.createContext<TimeTrackerContextValue | null>(null)
|
|
14
|
+
|
|
15
|
+
export interface TimeTrackerProviderProps extends TimeTrackerData {
|
|
16
|
+
children: React.ReactNode
|
|
17
|
+
defaultOpen?: boolean
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function TimeTrackerProvider({ children, defaultOpen = false, ...data }: TimeTrackerProviderProps) {
|
|
21
|
+
const [isOpen, setIsOpen] = React.useState(defaultOpen)
|
|
22
|
+
|
|
23
|
+
const open = React.useCallback(() => setIsOpen(true), [])
|
|
24
|
+
const close = React.useCallback(() => setIsOpen(false), [])
|
|
25
|
+
const toggle = React.useCallback(() => setIsOpen((v) => !v), [])
|
|
26
|
+
|
|
27
|
+
// `data` is a fresh object each render, so the value identity tracks the host's
|
|
28
|
+
// render — the per-second timer tick lives in `useTrackerClock` (local state),
|
|
29
|
+
// not here, so consumers don't re-render on every tick.
|
|
30
|
+
const value: TimeTrackerContextValue = { ...data, isOpen, open, close, toggle }
|
|
31
|
+
|
|
32
|
+
return <TimeTrackerContext.Provider value={value}>{children}</TimeTrackerContext.Provider>
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function useTimeTracker(): TimeTrackerContextValue {
|
|
36
|
+
const ctx = React.useContext(TimeTrackerContext)
|
|
37
|
+
if (!ctx) {
|
|
38
|
+
throw new Error('useTimeTracker must be used inside <TimeTrackerProvider>')
|
|
39
|
+
}
|
|
40
|
+
return ctx
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function useOptionalTimeTracker(): TimeTrackerContextValue | null {
|
|
44
|
+
return React.useContext(TimeTrackerContext)
|
|
45
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import * as PopoverPrimitive from '@radix-ui/react-popover'
|
|
4
|
+
import { cn } from '../../../utils/cn'
|
|
5
|
+
import { ClockHistoryIcon } from '../../icons-v2-generated/date-and-time/clock-history-icon'
|
|
6
|
+
import { HeaderButton } from '../../navigation/header-button'
|
|
7
|
+
import { TimeTrackerPanel } from './time-tracker-panel'
|
|
8
|
+
import { useOptionalTimeTracker } from './time-tracker-context'
|
|
9
|
+
import { useTrackerClock } from './use-tracker-clock'
|
|
10
|
+
|
|
11
|
+
export interface TimeTrackerHeaderButtonProps {
|
|
12
|
+
/** Dimming/disabled passthrough from the header. */
|
|
13
|
+
className?: string
|
|
14
|
+
disabled?: boolean
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Header affordance for the time tracker. Renders nothing unless wrapped in a
|
|
19
|
+
* `<TimeTrackerProvider>`. The trigger is the standard `HeaderButton`; when a
|
|
20
|
+
* session is active it also shows the live elapsed time. The popup is a Radix
|
|
21
|
+
* Popover anchored under the button (not a modal/drawer).
|
|
22
|
+
*/
|
|
23
|
+
export function TimeTrackerHeaderButton({ className, disabled }: TimeTrackerHeaderButtonProps) {
|
|
24
|
+
const ctx = useOptionalTimeTracker()
|
|
25
|
+
const elapsedLabel = useTrackerClock({
|
|
26
|
+
status: ctx?.status ?? 'ready',
|
|
27
|
+
runningSince: ctx?.runningSince,
|
|
28
|
+
accumulatedMs: ctx?.accumulatedMs,
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
if (!ctx) return null
|
|
32
|
+
|
|
33
|
+
const { isOpen, open, close, status } = ctx
|
|
34
|
+
const isPaused = status === 'paused'
|
|
35
|
+
const isActive = status === 'tracking' || isPaused
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<PopoverPrimitive.Root open={isOpen} onOpenChange={(next) => (next ? open() : close())}>
|
|
39
|
+
<PopoverPrimitive.Trigger asChild>
|
|
40
|
+
<HeaderButton
|
|
41
|
+
aria-label="Time tracker"
|
|
42
|
+
isActive={isOpen}
|
|
43
|
+
disabled={disabled}
|
|
44
|
+
className={cn(
|
|
45
|
+
isActive && 'w-auto gap-[var(--spacing-system-xsf)] px-[var(--spacing-system-sf)] md:w-auto',
|
|
46
|
+
className,
|
|
47
|
+
)}
|
|
48
|
+
icon={
|
|
49
|
+
<>
|
|
50
|
+
<ClockHistoryIcon
|
|
51
|
+
className={cn(
|
|
52
|
+
'h-4 w-4 md:h-6 md:w-6',
|
|
53
|
+
isPaused ? 'text-ods-text-secondary' : isActive && 'text-ods-accent',
|
|
54
|
+
)}
|
|
55
|
+
/>
|
|
56
|
+
{isActive && (
|
|
57
|
+
<span
|
|
58
|
+
className={cn(
|
|
59
|
+
'text-h5 tabular-nums md:text-h4',
|
|
60
|
+
isPaused ? 'text-ods-text-secondary' : 'text-ods-text-primary',
|
|
61
|
+
)}
|
|
62
|
+
>
|
|
63
|
+
{elapsedLabel}
|
|
64
|
+
</span>
|
|
65
|
+
)}
|
|
66
|
+
</>
|
|
67
|
+
}
|
|
68
|
+
/>
|
|
69
|
+
</PopoverPrimitive.Trigger>
|
|
70
|
+
<PopoverPrimitive.Portal>
|
|
71
|
+
<PopoverPrimitive.Content
|
|
72
|
+
align="end"
|
|
73
|
+
sideOffset={0}
|
|
74
|
+
collisionPadding={8}
|
|
75
|
+
className={cn(
|
|
76
|
+
'z-[1300] w-[460px] max-w-[calc(100vw-1rem)] outline-none',
|
|
77
|
+
'data-[state=open]:animate-in data-[state=closed]:animate-out',
|
|
78
|
+
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
|
79
|
+
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
|
|
80
|
+
)}
|
|
81
|
+
>
|
|
82
|
+
<TimeTrackerPanel {...ctx} onClose={close} />
|
|
83
|
+
</PopoverPrimitive.Content>
|
|
84
|
+
</PopoverPrimitive.Portal>
|
|
85
|
+
</PopoverPrimitive.Root>
|
|
86
|
+
)
|
|
87
|
+
}
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from 'react'
|
|
4
|
+
import { cn } from '../../../utils/cn'
|
|
5
|
+
import { Autocomplete, type AutocompleteOption } from '../../ui/autocomplete'
|
|
6
|
+
import { Button } from '../../ui/button/button'
|
|
7
|
+
import { SplitButton } from '../../ui/button/split-button'
|
|
8
|
+
import { Tag } from '../../ui/tag'
|
|
9
|
+
import { Textarea } from '../../ui/textarea'
|
|
10
|
+
import {
|
|
11
|
+
ArrowRightUpIcon,
|
|
12
|
+
CheckCircleIcon,
|
|
13
|
+
Chevron02RightIcon,
|
|
14
|
+
DotsLoaderIcon,
|
|
15
|
+
PauseIcon,
|
|
16
|
+
PlayIcon,
|
|
17
|
+
PlusCircleIcon,
|
|
18
|
+
XmarkIcon,
|
|
19
|
+
} from '../../icons-v2-generated'
|
|
20
|
+
import { useTrackerClock } from './use-tracker-clock'
|
|
21
|
+
import type { TimeTrackerData, TimeTrackerEntry, TimeTrackerStatus } from './types'
|
|
22
|
+
|
|
23
|
+
export interface TimeTrackerPanelProps extends TimeTrackerData {
|
|
24
|
+
onClose: () => void
|
|
25
|
+
className?: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function TimeTrackerPanel({
|
|
29
|
+
status,
|
|
30
|
+
runningSince,
|
|
31
|
+
accumulatedMs,
|
|
32
|
+
ticketOptions,
|
|
33
|
+
selectedTicketIds,
|
|
34
|
+
onSelectedTicketsChange,
|
|
35
|
+
onTicketSearch,
|
|
36
|
+
ticketsLoading,
|
|
37
|
+
notes,
|
|
38
|
+
onNotesChange,
|
|
39
|
+
lastEntries,
|
|
40
|
+
onStart,
|
|
41
|
+
onPause,
|
|
42
|
+
onResume,
|
|
43
|
+
onCancel,
|
|
44
|
+
onSubmit,
|
|
45
|
+
onManualEntry,
|
|
46
|
+
onOpenMyTime,
|
|
47
|
+
onOpenMyTimeMenu,
|
|
48
|
+
onEntryClick,
|
|
49
|
+
isStarting,
|
|
50
|
+
isSubmitting,
|
|
51
|
+
onClose,
|
|
52
|
+
className,
|
|
53
|
+
}: TimeTrackerPanelProps) {
|
|
54
|
+
const elapsedLabel = useTrackerClock({ status, runningSince, accumulatedMs })
|
|
55
|
+
|
|
56
|
+
const [showValidationError, setShowValidationError] = useState(false)
|
|
57
|
+
|
|
58
|
+
const isRunning = status === 'tracking'
|
|
59
|
+
const isActive = status !== 'ready'
|
|
60
|
+
const hasContent = selectedTicketIds.length > 0 || notes.trim() !== ''
|
|
61
|
+
const showFieldError = isActive && showValidationError && !hasContent
|
|
62
|
+
|
|
63
|
+
// Reset the validation flag once a session ends so the next one starts clean.
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
if (status === 'ready') setShowValidationError(false)
|
|
66
|
+
}, [status])
|
|
67
|
+
|
|
68
|
+
const handleSubmit = () => {
|
|
69
|
+
if (!hasContent) {
|
|
70
|
+
setShowValidationError(true)
|
|
71
|
+
return
|
|
72
|
+
}
|
|
73
|
+
onSubmit()
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const ticketAutocompleteOptions: AutocompleteOption[] = ticketOptions.map((t) => ({
|
|
77
|
+
label: t.label,
|
|
78
|
+
value: t.id,
|
|
79
|
+
}))
|
|
80
|
+
|
|
81
|
+
const visibleEntries = lastEntries.slice(0, 3)
|
|
82
|
+
|
|
83
|
+
return (
|
|
84
|
+
<div
|
|
85
|
+
className={cn(
|
|
86
|
+
'relative flex max-h-[calc(100vh-6rem)] w-full flex-col gap-[var(--spacing-system-l)] overflow-y-auto rounded-md border border-ods-border bg-ods-card p-[var(--spacing-system-m)]',
|
|
87
|
+
className,
|
|
88
|
+
)}
|
|
89
|
+
>
|
|
90
|
+
<Button
|
|
91
|
+
variant="transparent"
|
|
92
|
+
size="icon-sm"
|
|
93
|
+
onClick={onClose}
|
|
94
|
+
aria-label="Close time tracker"
|
|
95
|
+
className="absolute right-[var(--spacing-system-sf)] top-[var(--spacing-system-sf)] text-ods-text-secondary hover:text-ods-text-primary [&_svg]:!size-6"
|
|
96
|
+
>
|
|
97
|
+
<XmarkIcon />
|
|
98
|
+
</Button>
|
|
99
|
+
|
|
100
|
+
{/* Status + timer + ticket assignment */}
|
|
101
|
+
<div className="flex flex-col gap-[var(--spacing-system-m)]">
|
|
102
|
+
<div className="flex items-center gap-[var(--spacing-system-s)]">
|
|
103
|
+
<StatusTag status={status} />
|
|
104
|
+
{isActive && (
|
|
105
|
+
<Button
|
|
106
|
+
variant="transparent"
|
|
107
|
+
onClick={onCancel}
|
|
108
|
+
className="h-auto p-0 text-h6 font-medium text-ods-text-secondary underline hover:bg-transparent hover:text-ods-text-primary"
|
|
109
|
+
>
|
|
110
|
+
Cancel Entry
|
|
111
|
+
</Button>
|
|
112
|
+
)}
|
|
113
|
+
</div>
|
|
114
|
+
|
|
115
|
+
<div className="flex items-stretch overflow-hidden rounded-md border border-ods-border">
|
|
116
|
+
<div className="flex flex-1 items-center border-r border-ods-border bg-ods-bg px-[var(--spacing-system-m)] py-[var(--spacing-system-s)]">
|
|
117
|
+
<span
|
|
118
|
+
className={cn(
|
|
119
|
+
'text-h3 font-bold tabular-nums',
|
|
120
|
+
isActive ? 'text-ods-text-primary' : 'text-ods-text-secondary',
|
|
121
|
+
)}
|
|
122
|
+
>
|
|
123
|
+
{elapsedLabel}
|
|
124
|
+
</span>
|
|
125
|
+
</div>
|
|
126
|
+
|
|
127
|
+
<Button
|
|
128
|
+
size="icon"
|
|
129
|
+
variant={isActive ? 'transparent' : 'accent'}
|
|
130
|
+
onClick={status === 'tracking' ? onPause : status === 'paused' ? onResume : onStart}
|
|
131
|
+
disabled={isStarting}
|
|
132
|
+
aria-label={isRunning ? 'Pause tracking' : isActive ? 'Resume tracking' : 'Start tracking'}
|
|
133
|
+
className="h-auto w-auto rounded-none [&_svg]:!size-6"
|
|
134
|
+
>
|
|
135
|
+
{isRunning ? <PauseIcon /> : <PlayIcon />}
|
|
136
|
+
</Button>
|
|
137
|
+
|
|
138
|
+
{isActive && (
|
|
139
|
+
<Button
|
|
140
|
+
size="icon"
|
|
141
|
+
variant="accent"
|
|
142
|
+
onClick={handleSubmit}
|
|
143
|
+
disabled={isSubmitting}
|
|
144
|
+
aria-label="Finish and save tracking"
|
|
145
|
+
className="h-auto w-auto rounded-none border-l border-ods-border [&_svg]:!size-6"
|
|
146
|
+
>
|
|
147
|
+
<CheckCircleIcon />
|
|
148
|
+
</Button>
|
|
149
|
+
)}
|
|
150
|
+
</div>
|
|
151
|
+
|
|
152
|
+
<Autocomplete
|
|
153
|
+
multiple
|
|
154
|
+
value={selectedTicketIds}
|
|
155
|
+
onChange={onSelectedTicketsChange}
|
|
156
|
+
options={ticketAutocompleteOptions}
|
|
157
|
+
placeholder="Assign Ticket"
|
|
158
|
+
loading={ticketsLoading}
|
|
159
|
+
invalid={showFieldError}
|
|
160
|
+
error={showFieldError ? 'Assign a ticket or add notes to finish' : undefined}
|
|
161
|
+
disableClientFilter={!!onTicketSearch}
|
|
162
|
+
onInputChange={(value, reason) => {
|
|
163
|
+
if (reason === 'input') onTicketSearch?.(value)
|
|
164
|
+
}}
|
|
165
|
+
/>
|
|
166
|
+
</div>
|
|
167
|
+
|
|
168
|
+
<Textarea
|
|
169
|
+
value={notes}
|
|
170
|
+
onChange={(e) => onNotesChange(e.target.value)}
|
|
171
|
+
invalid={showFieldError}
|
|
172
|
+
placeholder="Additional Notes (optional if ticket selected)"
|
|
173
|
+
className="max-h-[280px]"
|
|
174
|
+
/>
|
|
175
|
+
|
|
176
|
+
{/* Last entries + footer */}
|
|
177
|
+
<div className="flex flex-col gap-[var(--spacing-system-m)]">
|
|
178
|
+
<div className="flex flex-col gap-[var(--spacing-system-xs)]">
|
|
179
|
+
<p className="font-mono text-h6 uppercase tracking-wide text-ods-text-secondary">Last Entries</p>
|
|
180
|
+
<div className="overflow-hidden rounded-md border border-ods-border bg-ods-card">
|
|
181
|
+
{visibleEntries.length === 0 ? (
|
|
182
|
+
<div className="flex items-center justify-center px-[var(--spacing-system-m)] py-[var(--spacing-system-l)]">
|
|
183
|
+
<p className="text-h6 text-ods-text-secondary">No tracked sessions yet</p>
|
|
184
|
+
</div>
|
|
185
|
+
) : (
|
|
186
|
+
visibleEntries.map((entry) => (
|
|
187
|
+
<LastEntryRow key={entry.id} entry={entry} onClick={onEntryClick} />
|
|
188
|
+
))
|
|
189
|
+
)}
|
|
190
|
+
</div>
|
|
191
|
+
</div>
|
|
192
|
+
|
|
193
|
+
<div className="flex w-full flex-wrap gap-[var(--spacing-system-m)]">
|
|
194
|
+
<Button
|
|
195
|
+
variant="outline"
|
|
196
|
+
className="min-w-0 flex-1"
|
|
197
|
+
onClick={onManualEntry}
|
|
198
|
+
disabled={!onManualEntry}
|
|
199
|
+
leftIcon={<PlusCircleIcon className="text-ods-text-secondary" />}
|
|
200
|
+
>
|
|
201
|
+
Manual Entry
|
|
202
|
+
</Button>
|
|
203
|
+
<SplitButton
|
|
204
|
+
variant="outline"
|
|
205
|
+
onClick={onOpenMyTime}
|
|
206
|
+
disabled={!onOpenMyTime && !onOpenMyTimeMenu}
|
|
207
|
+
mainDisabled={!onOpenMyTime}
|
|
208
|
+
groupAriaLabel="Open my time"
|
|
209
|
+
iconAction={{
|
|
210
|
+
icon: <ArrowRightUpIcon size={24} />,
|
|
211
|
+
'aria-label': 'Open my time in a new view',
|
|
212
|
+
onClick: onOpenMyTimeMenu,
|
|
213
|
+
disabled: !onOpenMyTimeMenu,
|
|
214
|
+
}}
|
|
215
|
+
>
|
|
216
|
+
Open My Time
|
|
217
|
+
</SplitButton>
|
|
218
|
+
</div>
|
|
219
|
+
</div>
|
|
220
|
+
</div>
|
|
221
|
+
)
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const STATUS_TAG_SKIN = 'gap-[var(--spacing-system-xs)] font-mono font-medium uppercase tracking-tight'
|
|
225
|
+
|
|
226
|
+
function StatusTag({ status }: { status: TimeTrackerStatus }) {
|
|
227
|
+
if (status === 'tracking') {
|
|
228
|
+
return (
|
|
229
|
+
<Tag variant="success" className={STATUS_TAG_SKIN} icon={<DotsLoaderIcon className="h-4 w-4" />} label="Tracking" />
|
|
230
|
+
)
|
|
231
|
+
}
|
|
232
|
+
if (status === 'paused') {
|
|
233
|
+
return <Tag variant="warning" className={STATUS_TAG_SKIN} icon={<PauseIcon className="h-4 w-4" />} label="Paused" />
|
|
234
|
+
}
|
|
235
|
+
return <Tag variant="grey" className={STATUS_TAG_SKIN} label="Ready to Track" />
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
interface LastEntryRowProps {
|
|
239
|
+
entry: TimeTrackerEntry
|
|
240
|
+
onClick?: (entry: TimeTrackerEntry) => void
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function LastEntryRow({ entry, onClick }: LastEntryRowProps) {
|
|
244
|
+
return (
|
|
245
|
+
<div className="flex items-center gap-[var(--spacing-system-m)] border-b border-ods-border bg-ods-bg px-[var(--spacing-system-m)] py-[var(--spacing-system-sf)] last:border-b-0">
|
|
246
|
+
<div className="flex w-[88px] shrink-0 flex-col justify-center">
|
|
247
|
+
<span className="truncate text-h4 font-medium text-ods-text-primary">{entry.durationLabel}</span>
|
|
248
|
+
<span className="truncate text-h6 text-ods-text-secondary">{entry.dateLabel}</span>
|
|
249
|
+
</div>
|
|
250
|
+
|
|
251
|
+
<div className="flex min-w-0 flex-1 flex-col justify-center">
|
|
252
|
+
<span className="truncate text-h4 font-medium text-ods-text-primary">{entry.title}</span>
|
|
253
|
+
{entry.description && (
|
|
254
|
+
<span className="truncate text-h6 text-ods-text-secondary">{entry.description}</span>
|
|
255
|
+
)}
|
|
256
|
+
</div>
|
|
257
|
+
|
|
258
|
+
<Button
|
|
259
|
+
variant="outline"
|
|
260
|
+
size="icon"
|
|
261
|
+
onClick={() => onClick?.(entry)}
|
|
262
|
+
disabled={!onClick}
|
|
263
|
+
aria-label={`Open entry: ${entry.title}`}
|
|
264
|
+
className="h-12 w-12 shrink-0 [&_svg]:!size-6"
|
|
265
|
+
>
|
|
266
|
+
<Chevron02RightIcon />
|
|
267
|
+
</Button>
|
|
268
|
+
</div>
|
|
269
|
+
)
|
|
270
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
export type TimeTrackerStatus = 'ready' | 'tracking' | 'paused'
|
|
2
|
+
|
|
3
|
+
export interface TimeTrackerTicketOption {
|
|
4
|
+
id: string
|
|
5
|
+
label: string
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface TimeTrackerEntry {
|
|
9
|
+
id: string
|
|
10
|
+
/** Pre-formatted duration label, e.g. "00:08:17" (host formats it). */
|
|
11
|
+
durationLabel: string
|
|
12
|
+
/** Pre-formatted date label, e.g. "07/12/25". */
|
|
13
|
+
dateLabel: string
|
|
14
|
+
title: string
|
|
15
|
+
description?: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Everything the host feeds into the purely-UI time tracker.
|
|
20
|
+
* The UI never performs API calls or owns business state — it renders `status` and
|
|
21
|
+
* the live timer (derived from `runningSince` + `accumulatedMs`) and emits callbacks.
|
|
22
|
+
*/
|
|
23
|
+
export interface TimeTrackerData {
|
|
24
|
+
status: TimeTrackerStatus
|
|
25
|
+
/**
|
|
26
|
+
* Epoch ms when the current running segment started. Only meaningful while
|
|
27
|
+
* `status === 'tracking'`. The UI ticks a local interval to display elapsed time
|
|
28
|
+
* from this value — the backend remains the source of truth.
|
|
29
|
+
*/
|
|
30
|
+
runningSince?: number | null
|
|
31
|
+
/** Elapsed ms accrued before the current running segment (paused/resumed sessions). */
|
|
32
|
+
accumulatedMs?: number
|
|
33
|
+
|
|
34
|
+
ticketOptions: TimeTrackerTicketOption[]
|
|
35
|
+
selectedTicketIds: string[]
|
|
36
|
+
onSelectedTicketsChange: (ids: string[]) => void
|
|
37
|
+
/** Called as the user types in the ticket field; host performs the search. */
|
|
38
|
+
onTicketSearch?: (query: string) => void
|
|
39
|
+
ticketsLoading?: boolean
|
|
40
|
+
|
|
41
|
+
notes: string
|
|
42
|
+
onNotesChange: (value: string) => void
|
|
43
|
+
|
|
44
|
+
/** Previous tracked sessions; the UI renders at most three. */
|
|
45
|
+
lastEntries: TimeTrackerEntry[]
|
|
46
|
+
|
|
47
|
+
onStart: () => void
|
|
48
|
+
onPause: () => void
|
|
49
|
+
onResume: () => void
|
|
50
|
+
/** Discard the in-progress session entirely (reset to idle). Shown while tracking/paused. */
|
|
51
|
+
onCancel: () => void
|
|
52
|
+
/** Finish the session. The UI only enables this once at least one field is filled. */
|
|
53
|
+
onSubmit: () => void
|
|
54
|
+
onManualEntry?: () => void
|
|
55
|
+
onOpenMyTime?: () => void
|
|
56
|
+
onOpenMyTimeMenu?: () => void
|
|
57
|
+
onEntryClick?: (entry: TimeTrackerEntry) => void
|
|
58
|
+
|
|
59
|
+
isStarting?: boolean
|
|
60
|
+
isSubmitting?: boolean
|
|
61
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from 'react'
|
|
4
|
+
import type { TimeTrackerStatus } from './types'
|
|
5
|
+
|
|
6
|
+
export interface UseTrackerClockArgs {
|
|
7
|
+
status: TimeTrackerStatus
|
|
8
|
+
/** Epoch ms the current running segment started (only used while tracking). */
|
|
9
|
+
runningSince?: number | null
|
|
10
|
+
/** Elapsed ms accrued before the current running segment. */
|
|
11
|
+
accumulatedMs?: number
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function formatElapsed(totalMs: number): string {
|
|
15
|
+
const totalSeconds = Math.max(0, Math.floor(totalMs / 1000))
|
|
16
|
+
const hours = Math.floor(totalSeconds / 3600)
|
|
17
|
+
const minutes = Math.floor((totalSeconds % 3600) / 60)
|
|
18
|
+
const seconds = totalSeconds % 60
|
|
19
|
+
const pad = (n: number) => String(n).padStart(2, '0')
|
|
20
|
+
return `${pad(hours)}:${pad(minutes)}:${pad(seconds)}`
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Display-only timer. Ticks once per second while `status === 'tracking'`; otherwise
|
|
25
|
+
* renders the static `accumulatedMs`. Returns a formatted `HH:MM:SS` string.
|
|
26
|
+
*/
|
|
27
|
+
export function useTrackerClock({ status, runningSince, accumulatedMs = 0 }: UseTrackerClockArgs): string {
|
|
28
|
+
const [now, setNow] = useState(() => Date.now())
|
|
29
|
+
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
if (status !== 'tracking') return
|
|
32
|
+
setNow(Date.now())
|
|
33
|
+
const id = setInterval(() => setNow(Date.now()), 1000)
|
|
34
|
+
return () => clearInterval(id)
|
|
35
|
+
}, [status, runningSince])
|
|
36
|
+
|
|
37
|
+
const liveMs = status === 'tracking' && runningSince ? now - runningSince : 0
|
|
38
|
+
return formatElapsed(accumulatedMs + liveMs)
|
|
39
|
+
}
|
|
@@ -4,6 +4,7 @@ import React from 'react'
|
|
|
4
4
|
import { useMdUp } from '../../hooks/ui/use-media-query'
|
|
5
5
|
import { cn } from '../../utils/cn'
|
|
6
6
|
import { useOptionalNotifications } from '../features/notifications/notifications-context'
|
|
7
|
+
import { TimeTrackerHeaderButton } from '../features/time-tracker'
|
|
7
8
|
import { LogOutIcon, OpenFrameLogo, OpenFrameText, UserIcon } from '../icons'
|
|
8
9
|
import { BellIcon } from '../icons-v2-generated/interface/bell-icon'
|
|
9
10
|
import { Menu01Icon, SearchIcon, XmarkIcon } from '../icons-v2-generated'
|
|
@@ -22,6 +23,8 @@ export interface AppHeaderProps {
|
|
|
22
23
|
onOrgChange?: (id: string) => void
|
|
23
24
|
showNotifications?: boolean
|
|
24
25
|
unreadCount?: number
|
|
26
|
+
/** Render the time-tracker button + popup. Requires wrapping the app in `<TimeTrackerProvider>`. */
|
|
27
|
+
showTimeTracker?: boolean
|
|
25
28
|
/** Render the "Mingo AI" launcher button (drawer-style trigger for an
|
|
26
29
|
* in-layout `AppLayoutDrawer` hosting the chat panel). Defaults to off. */
|
|
27
30
|
showMingoAI?: boolean
|
|
@@ -57,6 +60,7 @@ export const AppHeader = React.memo(function AppHeader({
|
|
|
57
60
|
onOrgChange,
|
|
58
61
|
showNotifications,
|
|
59
62
|
unreadCount = 0,
|
|
63
|
+
showTimeTracker = false,
|
|
60
64
|
showMingoAI = false,
|
|
61
65
|
onMingoAI,
|
|
62
66
|
isMingoAIActive = false,
|
|
@@ -129,6 +133,11 @@ export const AppHeader = React.memo(function AppHeader({
|
|
|
129
133
|
/>
|
|
130
134
|
)}
|
|
131
135
|
|
|
136
|
+
{/* Time tracker button */}
|
|
137
|
+
{showTimeTracker && (
|
|
138
|
+
<TimeTrackerHeaderButton disabled={disabled} className={dimmedClass} />
|
|
139
|
+
)}
|
|
140
|
+
|
|
132
141
|
{/* Notifications button */}
|
|
133
142
|
{showNotifications && (
|
|
134
143
|
<NotificationsHeaderButton
|
|
@@ -635,18 +635,13 @@ function AutocompleteInner<T = string>(
|
|
|
635
635
|
<div className="relative" ref={containerRef}>
|
|
636
636
|
{popover}
|
|
637
637
|
|
|
638
|
-
{/* Hidden tags popup — outside overflow-hidden
|
|
638
|
+
{/* Hidden tags popup — outside overflow-hidden; right-anchored to the field */}
|
|
639
639
|
{multiple && showHiddenTags && hiddenTagsCount > 0 && (
|
|
640
640
|
<HiddenTagsPopup
|
|
641
641
|
ref={hiddenTagsPopupRef}
|
|
642
642
|
items={hiddenTags}
|
|
643
643
|
disabled={disabled}
|
|
644
|
-
|
|
645
|
-
left: autoLimitTags.badgeRef.current
|
|
646
|
-
? autoLimitTags.badgeRef.current.getBoundingClientRect().left -
|
|
647
|
-
(containerRef.current?.getBoundingClientRect().left ?? 0)
|
|
648
|
-
: 0,
|
|
649
|
-
}}
|
|
644
|
+
className="left-auto right-0 max-w-full"
|
|
650
645
|
onRemove={(value) => {
|
|
651
646
|
const newValue = valueArray.filter(v => v !== value)
|
|
652
647
|
;(props as AutocompleteMultipleProps<T>).onChange(newValue)
|
|
@@ -26,6 +26,8 @@ export const HiddenTagsPopup = forwardRef(function HiddenTagsPopup(
|
|
|
26
26
|
ref={ref}
|
|
27
27
|
style={style}
|
|
28
28
|
className={cn(
|
|
29
|
+
// Base positioning is neutral (left-anchored); consumers override via
|
|
30
|
+
// `style.left` (search-input, tag-search-input) or `className` (autocomplete).
|
|
29
31
|
"absolute top-full left-0 mt-1 z-50 min-w-[200px]",
|
|
30
32
|
"bg-ods-card border border-ods-border rounded-[6px] shadow-lg",
|
|
31
33
|
"animate-in fade-in-0 zoom-in-95 duration-150",
|
|
@@ -40,7 +42,7 @@ export const HiddenTagsPopup = forwardRef(function HiddenTagsPopup(
|
|
|
40
42
|
"border-b border-ods-border last:border-b-0",
|
|
41
43
|
)}
|
|
42
44
|
>
|
|
43
|
-
<span className="text-h5 truncate uppercase text-ods-text-primary" title={typeof item.label === 'string' ? item.label : undefined}>
|
|
45
|
+
<span className="min-w-0 flex-1 text-h5 truncate uppercase text-ods-text-primary" title={typeof item.label === 'string' ? item.label : undefined}>
|
|
44
46
|
{item.label}
|
|
45
47
|
</span>
|
|
46
48
|
{!disabled && onRemove && (
|