@open-mercato/ui 0.6.5-develop.5337.1.534b781eac → 0.6.5
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/.turbo/turbo-build.log +1 -1
- package/dist/ai/AiChat.js.map +1 -1
- package/dist/ai/records/ActivityCard.js +1 -6
- package/dist/ai/records/ActivityCard.js.map +2 -2
- package/dist/ai/records/DealCard.js +2 -23
- package/dist/ai/records/DealCard.js.map +2 -2
- package/dist/ai/records/ProductCard.js +2 -17
- package/dist/ai/records/ProductCard.js.map +2 -2
- package/dist/ai/useAiChat.js.map +1 -1
- package/dist/backend/AppShell.js +15 -8
- package/dist/backend/AppShell.js.map +2 -2
- package/dist/backend/CrudForm.js.map +1 -1
- package/dist/backend/DataTable.js +10 -9
- package/dist/backend/DataTable.js.map +2 -2
- package/dist/backend/ProfileDropdown.js +62 -33
- package/dist/backend/ProfileDropdown.js.map +2 -2
- package/dist/backend/charts/KpiCard.js +22 -14
- package/dist/backend/charts/KpiCard.js.map +2 -2
- package/dist/backend/charts/Sparkline.js +75 -0
- package/dist/backend/charts/Sparkline.js.map +7 -0
- package/dist/backend/charts/index.js +4 -1
- package/dist/backend/charts/index.js.map +2 -2
- package/dist/backend/dashboard/DashboardScreen.js +1 -2
- package/dist/backend/dashboard/DashboardScreen.js.map +2 -2
- package/dist/backend/injection/InjectionSpot.js +23 -6
- package/dist/backend/injection/InjectionSpot.js.map +2 -2
- package/dist/backend/injection/useGuardedMutation.js.map +1 -1
- package/dist/backend/utils/optimisticLock.js.map +1 -1
- package/dist/primitives/avatar.js +2 -2
- package/dist/primitives/avatar.js.map +2 -2
- package/dist/primitives/pagination.js +3 -3
- package/dist/primitives/pagination.js.map +2 -2
- package/dist/utils/format.js +27 -0
- package/dist/utils/format.js.map +7 -0
- package/package.json +9 -6
- package/src/ai/AiChat.tsx +1 -1
- package/src/ai/__tests__/AiChat.task-plan.test.tsx +1 -1
- package/src/ai/records/ActivityCard.tsx +1 -7
- package/src/ai/records/DealCard.tsx +2 -26
- package/src/ai/records/ProductCard.tsx +2 -19
- package/src/ai/useAiChat.ts +1 -1
- package/src/backend/AppShell.tsx +18 -8
- package/src/backend/CrudForm.tsx +1 -1
- package/src/backend/DataTable.tsx +18 -11
- package/src/backend/ProfileDropdown.tsx +35 -3
- package/src/backend/__tests__/AppShell.test.tsx +61 -1
- package/src/backend/__tests__/CrudForm.optimisticLock.test.tsx +52 -0
- package/src/backend/__tests__/DataTable.stickyResponsive.test.tsx +99 -0
- package/src/backend/__tests__/ProfileDropdown.test.tsx +59 -0
- package/src/backend/__tests__/lazy-heavy-libraries.test.ts +8 -0
- package/src/backend/charts/KpiCard.tsx +22 -12
- package/src/backend/charts/Sparkline.tsx +87 -0
- package/src/backend/charts/index.ts +2 -1
- package/src/backend/dashboard/DashboardScreen.tsx +1 -2
- package/src/backend/injection/InjectionSpot.tsx +27 -7
- package/src/backend/injection/__tests__/InjectionSpot.remount.test.tsx +140 -0
- package/src/backend/injection/useGuardedMutation.ts +1 -1
- package/src/backend/utils/optimisticLock.ts +1 -1
- package/src/primitives/__tests__/alert.test.tsx +16 -0
- package/src/primitives/__tests__/avatar.test.tsx +36 -1
- package/src/primitives/__tests__/pagination.test.tsx +31 -0
- package/src/primitives/avatar.tsx +8 -2
- package/src/primitives/pagination.tsx +3 -3
- package/src/utils/__tests__/format.test.ts +58 -0
- package/src/utils/format.ts +40 -0
|
@@ -19,7 +19,13 @@ jest.mock('next/link', () => {
|
|
|
19
19
|
))
|
|
20
20
|
})
|
|
21
21
|
|
|
22
|
-
jest.mock('next/image', () =>
|
|
22
|
+
jest.mock('next/image', () => {
|
|
23
|
+
const React = require('react')
|
|
24
|
+
return (props: any) => {
|
|
25
|
+
const { unoptimized, ...rest } = props
|
|
26
|
+
return <img alt={rest.alt} data-unoptimized={unoptimized ? 'true' : 'false'} {...rest} />
|
|
27
|
+
}
|
|
28
|
+
})
|
|
23
29
|
|
|
24
30
|
jest.mock('next/navigation', () => ({
|
|
25
31
|
usePathname: () => mockPathname,
|
|
@@ -201,6 +207,60 @@ describe('AppShell', () => {
|
|
|
201
207
|
)
|
|
202
208
|
})
|
|
203
209
|
|
|
210
|
+
it('uses backend chrome brand logo when the selected organization has one', async () => {
|
|
211
|
+
const previousFetch = global.fetch
|
|
212
|
+
const previousWindowFetch = window.fetch
|
|
213
|
+
const previousOriginalFetch = (window as Window & { __omOriginalFetch?: typeof fetch }).__omOriginalFetch
|
|
214
|
+
const fetchMock = jest.fn().mockResolvedValue(
|
|
215
|
+
new Response(JSON.stringify({
|
|
216
|
+
brand: {
|
|
217
|
+
name: 'Acme',
|
|
218
|
+
logo: {
|
|
219
|
+
src: '/api/attachments/image/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/acme.png?width=320',
|
|
220
|
+
alt: 'Acme logo',
|
|
221
|
+
},
|
|
222
|
+
},
|
|
223
|
+
groups,
|
|
224
|
+
settingsSections: [],
|
|
225
|
+
settingsPathPrefixes: [],
|
|
226
|
+
profileSections: [],
|
|
227
|
+
profilePathPrefixes: [],
|
|
228
|
+
grantedFeatures: [],
|
|
229
|
+
roles: [],
|
|
230
|
+
}), { status: 200, headers: { 'content-type': 'application/json' } }),
|
|
231
|
+
) as typeof fetch
|
|
232
|
+
global.fetch = fetchMock
|
|
233
|
+
window.fetch = fetchMock
|
|
234
|
+
;(window as Window & { __omOriginalFetch?: typeof fetch }).__omOriginalFetch = fetchMock
|
|
235
|
+
|
|
236
|
+
try {
|
|
237
|
+
renderWithProviders(
|
|
238
|
+
<AppShell
|
|
239
|
+
email="demo@example.com"
|
|
240
|
+
groups={[]}
|
|
241
|
+
adminNavApi="/api/auth/admin/nav-brand-logo"
|
|
242
|
+
>
|
|
243
|
+
<div>Child content</div>
|
|
244
|
+
</AppShell>,
|
|
245
|
+
{ dict },
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
await waitFor(() => {
|
|
249
|
+
const logo = screen.getByAltText('Acme logo')
|
|
250
|
+
expect(logo).toHaveAttribute(
|
|
251
|
+
'src',
|
|
252
|
+
'/api/attachments/image/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/acme.png?width=320',
|
|
253
|
+
)
|
|
254
|
+
expect(logo).toHaveAttribute('data-unoptimized', 'true')
|
|
255
|
+
})
|
|
256
|
+
expect(screen.getByText('Acme')).toBeInTheDocument()
|
|
257
|
+
} finally {
|
|
258
|
+
global.fetch = previousFetch
|
|
259
|
+
window.fetch = previousWindowFetch
|
|
260
|
+
;(window as Window & { __omOriginalFetch?: typeof fetch }).__omOriginalFetch = previousOriginalFetch
|
|
261
|
+
}
|
|
262
|
+
})
|
|
263
|
+
|
|
204
264
|
it('renders nested settings links when settings parent route is active', async () => {
|
|
205
265
|
mockPathname = '/backend/entities/user'
|
|
206
266
|
|
|
@@ -58,6 +58,9 @@ import { dismissRecordConflict, getRecordConflictForTest } from '../conflicts'
|
|
|
58
58
|
|
|
59
59
|
const dict = {
|
|
60
60
|
'ui.forms.actions.save': 'Save',
|
|
61
|
+
'ui.forms.actions.delete': 'Delete',
|
|
62
|
+
'ui.forms.flash.deleteSuccess': 'Record deleted.',
|
|
63
|
+
'ui.forms.flash.deleteError': 'Failed to delete record.',
|
|
61
64
|
'ui.forms.flash.recordModified': 'This record was modified by someone else. Refresh and try again.',
|
|
62
65
|
}
|
|
63
66
|
|
|
@@ -171,6 +174,55 @@ describe('CrudForm — optimisticLockUpdatedAt prop wiring', () => {
|
|
|
171
174
|
'error',
|
|
172
175
|
)
|
|
173
176
|
})
|
|
177
|
+
|
|
178
|
+
it('delete: a 409 conflict surfaces the conflict bar and never shows a success toast (regression #2409)', async () => {
|
|
179
|
+
// Regression guard for #2409: when an optimistic-lock conflict (409) is
|
|
180
|
+
// thrown from onDelete, CrudForm must route it to the unified conflict bar
|
|
181
|
+
// and MUST NOT flash a "Record deleted." success toast. A previous version
|
|
182
|
+
// of the planner Availability Schedule page swallowed the delete error, so
|
|
183
|
+
// CrudForm treated the failed delete as a success and showed a false toast.
|
|
184
|
+
dismissRecordConflict()
|
|
185
|
+
const conflict = Object.assign(new Error('record_modified'), {
|
|
186
|
+
status: 409,
|
|
187
|
+
error: 'record_modified',
|
|
188
|
+
code: OPTIMISTIC_LOCK_CONFLICT_CODE,
|
|
189
|
+
currentUpdatedAt: '2026-05-25T08:42:19.000Z',
|
|
190
|
+
expectedUpdatedAt: '2026-05-25T08:42:18.000Z',
|
|
191
|
+
})
|
|
192
|
+
const onSubmit = jest.fn(async () => undefined)
|
|
193
|
+
const onDelete = jest.fn(async () => {
|
|
194
|
+
throw conflict
|
|
195
|
+
})
|
|
196
|
+
const { container } = renderWithProviders(
|
|
197
|
+
<CrudForm<{ displayName: string; id?: string }>
|
|
198
|
+
fields={fields}
|
|
199
|
+
initialValues={{ id: 'rec-1', displayName: 'old' }}
|
|
200
|
+
onSubmit={onSubmit}
|
|
201
|
+
onDelete={onDelete}
|
|
202
|
+
optimisticLockUpdatedAt="2026-05-25T08:42:18.000Z"
|
|
203
|
+
/>,
|
|
204
|
+
{ dict },
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
const deleteButton = Array.from(container.querySelectorAll('button')).find(
|
|
208
|
+
(button) => button.textContent?.trim() === 'Delete',
|
|
209
|
+
) as HTMLButtonElement
|
|
210
|
+
expect(deleteButton).toBeTruthy()
|
|
211
|
+
|
|
212
|
+
await act(async () => {
|
|
213
|
+
fireEvent.click(deleteButton)
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
await waitFor(() => expect(onDelete).toHaveBeenCalledTimes(1))
|
|
217
|
+
await waitFor(() => {
|
|
218
|
+
const entry = getRecordConflictForTest()
|
|
219
|
+
expect(entry).not.toBeNull()
|
|
220
|
+
expect(entry?.message).toBe('This record was modified by someone else. Refresh and try again.')
|
|
221
|
+
})
|
|
222
|
+
// The false success toast is the actual bug — it must never fire on a 409.
|
|
223
|
+
expect(flashMock).not.toHaveBeenCalledWith('Record deleted.', 'success')
|
|
224
|
+
expect(flashMock).not.toHaveBeenCalledWith(expect.anything(), 'success')
|
|
225
|
+
})
|
|
174
226
|
})
|
|
175
227
|
|
|
176
228
|
type AutoFormValues = { displayName: string; id?: string; updatedAt?: string | null; updated_at?: string | null }
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/** @jest-environment jsdom */
|
|
2
|
+
import * as React from 'react'
|
|
3
|
+
import { DataTable } from '../DataTable'
|
|
4
|
+
import type { ColumnDef } from '@tanstack/react-table'
|
|
5
|
+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
|
6
|
+
import { I18nProvider } from '@open-mercato/shared/lib/i18n/context'
|
|
7
|
+
import { render } from '@testing-library/react'
|
|
8
|
+
import { RowActions } from '../RowActions'
|
|
9
|
+
|
|
10
|
+
jest.mock('next/navigation', () => ({
|
|
11
|
+
useRouter: () => ({ push: jest.fn(), replace: jest.fn(), prefetch: jest.fn() }),
|
|
12
|
+
}))
|
|
13
|
+
|
|
14
|
+
jest.mock('../injection/useInjectionDataWidgets', () => ({
|
|
15
|
+
useInjectionDataWidgets: () => ({ widgets: [], isLoading: false }),
|
|
16
|
+
}))
|
|
17
|
+
|
|
18
|
+
type Row = { id: string; title: string; status: string }
|
|
19
|
+
|
|
20
|
+
const tokensOf = (el: Element | null): string[] =>
|
|
21
|
+
(el?.getAttribute('class') ?? '').split(/\s+/).filter(Boolean)
|
|
22
|
+
|
|
23
|
+
// Pinned columns must never consume the viewport on phones: an unconditionally
|
|
24
|
+
// `sticky` first column (often ~300px wide) plus a sticky actions column can
|
|
25
|
+
// exceed a small screen entirely, leaving the middle columns scrolling
|
|
26
|
+
// invisibly underneath with no reachable window. Pinning (position, offsets,
|
|
27
|
+
// z-index, opaque background, edge shadows) must therefore apply only from the
|
|
28
|
+
// `md` breakpoint up, so narrow viewports fall back to the documented plain
|
|
29
|
+
// horizontal-scroll behavior where every column can be swiped into view.
|
|
30
|
+
describe('DataTable sticky columns are viewport-gated', () => {
|
|
31
|
+
function renderStickyTable() {
|
|
32
|
+
const columns: ColumnDef<Row>[] = [
|
|
33
|
+
{ accessorKey: 'title', header: 'Title' },
|
|
34
|
+
{ accessorKey: 'status', header: 'Status' },
|
|
35
|
+
]
|
|
36
|
+
const queryClient = new QueryClient({ defaultOptions: { queries: { gcTime: 0 } } })
|
|
37
|
+
const result = render(
|
|
38
|
+
<QueryClientProvider client={queryClient}>
|
|
39
|
+
<I18nProvider locale="en" dict={{}}>
|
|
40
|
+
<DataTable
|
|
41
|
+
columns={columns}
|
|
42
|
+
data={[{ id: '1', title: 'Solar rollout', status: 'open' }]}
|
|
43
|
+
stickyFirstColumn
|
|
44
|
+
stickyActionsColumn
|
|
45
|
+
rowActions={() => (
|
|
46
|
+
<RowActions items={[{ id: 'edit', label: 'Edit', onSelect: () => {} }]} />
|
|
47
|
+
)}
|
|
48
|
+
/>
|
|
49
|
+
</I18nProvider>
|
|
50
|
+
</QueryClientProvider>,
|
|
51
|
+
)
|
|
52
|
+
return { ...result, queryClient }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
it('pins the first data column only from md up', () => {
|
|
56
|
+
const { container, queryClient } = renderStickyTable()
|
|
57
|
+
try {
|
|
58
|
+
const headerCells = container.querySelectorAll('thead th')
|
|
59
|
+
const firstHeader = headerCells[0]
|
|
60
|
+
const firstBodyCell = container.querySelector('tbody tr td')
|
|
61
|
+
|
|
62
|
+
for (const cell of [firstHeader, firstBodyCell]) {
|
|
63
|
+
const tokens = tokensOf(cell)
|
|
64
|
+
expect(tokens).toEqual(
|
|
65
|
+
expect.arrayContaining(['md:sticky', 'md:left-0', 'md:bg-background', 'md:after:absolute']),
|
|
66
|
+
)
|
|
67
|
+
expect(tokens).not.toContain('sticky')
|
|
68
|
+
expect(tokens).not.toContain('left-0')
|
|
69
|
+
expect(tokens).not.toContain('bg-background')
|
|
70
|
+
expect(tokens).not.toContain('after:absolute')
|
|
71
|
+
}
|
|
72
|
+
} finally {
|
|
73
|
+
queryClient.clear()
|
|
74
|
+
}
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('pins the actions column only from md up', () => {
|
|
78
|
+
const { container, queryClient } = renderStickyTable()
|
|
79
|
+
try {
|
|
80
|
+
const headerCells = container.querySelectorAll('thead th')
|
|
81
|
+
const actionsHeader = headerCells[headerCells.length - 1]
|
|
82
|
+
const actionsBodyCell = container.querySelector('tbody tr td[data-actions-cell]')
|
|
83
|
+
expect(actionsBodyCell).not.toBeNull()
|
|
84
|
+
|
|
85
|
+
for (const cell of [actionsHeader, actionsBodyCell]) {
|
|
86
|
+
const tokens = tokensOf(cell)
|
|
87
|
+
expect(tokens).toEqual(
|
|
88
|
+
expect.arrayContaining(['md:sticky', 'md:right-0', 'md:bg-background', 'md:before:absolute']),
|
|
89
|
+
)
|
|
90
|
+
expect(tokens).not.toContain('sticky')
|
|
91
|
+
expect(tokens).not.toContain('right-0')
|
|
92
|
+
expect(tokens).not.toContain('bg-background')
|
|
93
|
+
expect(tokens).not.toContain('before:absolute')
|
|
94
|
+
}
|
|
95
|
+
} finally {
|
|
96
|
+
queryClient.clear()
|
|
97
|
+
}
|
|
98
|
+
})
|
|
99
|
+
})
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
import { fireEvent, screen } from '@testing-library/react'
|
|
3
|
+
import { renderWithProviders } from '@open-mercato/shared/lib/testing/renderWithProviders'
|
|
4
|
+
|
|
5
|
+
jest.mock('next/link', () => {
|
|
6
|
+
const MockLink = ({ children, href, ...rest }: { children: React.ReactNode; href: string }) => (
|
|
7
|
+
<a href={href} {...rest}>{children}</a>
|
|
8
|
+
)
|
|
9
|
+
MockLink.displayName = 'MockLink'
|
|
10
|
+
return MockLink
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
jest.mock('../injection/InjectionSpot', () => ({
|
|
14
|
+
InjectionSpot: () => null,
|
|
15
|
+
}))
|
|
16
|
+
|
|
17
|
+
jest.mock('../injection/useInjectedMenuItems', () => ({
|
|
18
|
+
useInjectedMenuItems: () => ({ items: [], isLoading: false }),
|
|
19
|
+
}))
|
|
20
|
+
|
|
21
|
+
jest.mock('@open-mercato/ui/theme', () => ({
|
|
22
|
+
useTheme: () => ({ theme: 'light', resolvedTheme: 'light', setTheme: jest.fn() }),
|
|
23
|
+
}))
|
|
24
|
+
|
|
25
|
+
import { ProfileDropdown } from '../ProfileDropdown'
|
|
26
|
+
|
|
27
|
+
describe('ProfileDropdown', () => {
|
|
28
|
+
it('does not render the menu until the trigger is clicked', () => {
|
|
29
|
+
renderWithProviders(<ProfileDropdown email="user@example.com" />)
|
|
30
|
+
expect(screen.queryByTestId('profile-dropdown')).not.toBeInTheDocument()
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('renders the open menu in a body portal so it escapes the header stacking context', () => {
|
|
34
|
+
renderWithProviders(<ProfileDropdown email="user@example.com" />)
|
|
35
|
+
|
|
36
|
+
fireEvent.click(screen.getByTestId('profile-dropdown-trigger'))
|
|
37
|
+
|
|
38
|
+
const menu = screen.getByTestId('profile-dropdown')
|
|
39
|
+
expect(menu).toBeInTheDocument()
|
|
40
|
+
// createPortal mounts the menu directly under document.body, outside the
|
|
41
|
+
// sticky header's backdrop-blur stacking context (regression: issue #2941).
|
|
42
|
+
expect(menu.parentElement).toBe(document.body)
|
|
43
|
+
// It must use fixed positioning + the popover layer rather than being
|
|
44
|
+
// absolutely positioned inside the header.
|
|
45
|
+
expect(menu.className).toContain('fixed')
|
|
46
|
+
expect(menu.className).toContain('z-popover')
|
|
47
|
+
expect(menu.className).not.toContain('absolute')
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('closes the menu (unmounting the portal) on Escape', () => {
|
|
51
|
+
renderWithProviders(<ProfileDropdown email="user@example.com" />)
|
|
52
|
+
|
|
53
|
+
fireEvent.click(screen.getByTestId('profile-dropdown-trigger'))
|
|
54
|
+
expect(screen.getByTestId('profile-dropdown')).toBeInTheDocument()
|
|
55
|
+
|
|
56
|
+
fireEvent.keyDown(document, { key: 'Escape' })
|
|
57
|
+
expect(screen.queryByTestId('profile-dropdown')).not.toBeInTheDocument()
|
|
58
|
+
})
|
|
59
|
+
})
|
|
@@ -123,4 +123,12 @@ describe('heavy libraries are lazy-loaded', () => {
|
|
|
123
123
|
const source = read('packages/ui/src/backend/schedule/ScheduleCalendar.tsx')
|
|
124
124
|
expect(source).toMatch(/import\s+['"]react-big-calendar\/lib\/css\/react-big-calendar\.css['"]/)
|
|
125
125
|
})
|
|
126
|
+
|
|
127
|
+
it('AttachmentContentPreview does not statically import react-markdown or remark-gfm', () => {
|
|
128
|
+
const source = read(
|
|
129
|
+
'packages/core/src/modules/attachments/components/AttachmentContentPreview.tsx',
|
|
130
|
+
)
|
|
131
|
+
expect(source).not.toMatch(/from\s+['"]react-markdown['"]/)
|
|
132
|
+
expect(source).not.toMatch(/from\s+['"]remark-gfm['"]/)
|
|
133
|
+
})
|
|
126
134
|
})
|
|
@@ -20,6 +20,8 @@ export type KpiCardProps = {
|
|
|
20
20
|
suffix?: string
|
|
21
21
|
className?: string
|
|
22
22
|
headerAction?: React.ReactNode
|
|
23
|
+
footer?: React.ReactNode
|
|
24
|
+
titleClassName?: string
|
|
23
25
|
}
|
|
24
26
|
|
|
25
27
|
function defaultFormatValue(value: number): string {
|
|
@@ -32,17 +34,21 @@ function defaultFormatValue(value: number): string {
|
|
|
32
34
|
return value.toLocaleString(undefined, { maximumFractionDigits: 2 })
|
|
33
35
|
}
|
|
34
36
|
|
|
35
|
-
function formatPercentageChange(value: number): string {
|
|
36
|
-
const
|
|
37
|
-
|
|
37
|
+
function formatPercentageChange(value: number, unit: string = '%'): string {
|
|
38
|
+
const abs = Math.abs(value)
|
|
39
|
+
const formatted = Number.isInteger(abs) ? String(abs) : abs.toFixed(1)
|
|
40
|
+
return `${formatted}${unit}`
|
|
38
41
|
}
|
|
39
42
|
|
|
40
43
|
type BadgeDeltaProps = {
|
|
41
44
|
direction: 'up' | 'down' | 'unchanged'
|
|
42
45
|
value: number
|
|
46
|
+
unit?: string
|
|
47
|
+
className?: string
|
|
48
|
+
title?: string
|
|
43
49
|
}
|
|
44
50
|
|
|
45
|
-
function BadgeDelta({ direction, value }: BadgeDeltaProps) {
|
|
51
|
+
function BadgeDelta({ direction, value, unit = '%', className = '', title = 'Compared to previous period' }: BadgeDeltaProps) {
|
|
46
52
|
const baseClasses = 'inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-xs font-medium'
|
|
47
53
|
|
|
48
54
|
const directionClasses = {
|
|
@@ -71,15 +77,17 @@ function BadgeDelta({ direction, value }: BadgeDeltaProps) {
|
|
|
71
77
|
|
|
72
78
|
return (
|
|
73
79
|
<span
|
|
74
|
-
className={`${baseClasses} ${directionClasses[direction]}`}
|
|
75
|
-
title=
|
|
80
|
+
className={`${baseClasses} ${directionClasses[direction]}${className ? ` ${className}` : ''}`}
|
|
81
|
+
title={title}
|
|
76
82
|
>
|
|
77
83
|
{icons[direction]}
|
|
78
|
-
{formatPercentageChange(value)}
|
|
84
|
+
{formatPercentageChange(value, unit)}
|
|
79
85
|
</span>
|
|
80
86
|
)
|
|
81
87
|
}
|
|
82
88
|
|
|
89
|
+
export const DeltaBadge = BadgeDelta
|
|
90
|
+
|
|
83
91
|
export function KpiCard({
|
|
84
92
|
title,
|
|
85
93
|
value,
|
|
@@ -92,13 +100,15 @@ export function KpiCard({
|
|
|
92
100
|
suffix = '',
|
|
93
101
|
className = '',
|
|
94
102
|
headerAction,
|
|
103
|
+
footer,
|
|
104
|
+
titleClassName,
|
|
95
105
|
}: KpiCardProps) {
|
|
96
106
|
const hasWrapper = !!title
|
|
97
107
|
const wrapperClass = hasWrapper ? `rounded-lg border bg-card p-4 ${className}` : className
|
|
98
108
|
|
|
99
109
|
const headerRow = (title || headerAction) ? (
|
|
100
110
|
<div className="flex items-center justify-between gap-2 mb-2">
|
|
101
|
-
{title && <p className=
|
|
111
|
+
{title && <p className={titleClassName ?? 'text-sm font-medium text-muted-foreground'}>{title}</p>}
|
|
102
112
|
{headerAction}
|
|
103
113
|
</div>
|
|
104
114
|
) : null
|
|
@@ -136,10 +146,9 @@ export function KpiCard({
|
|
|
136
146
|
<div className={wrapperClass}>
|
|
137
147
|
{headerRow}
|
|
138
148
|
<div className="flex items-baseline gap-3">
|
|
139
|
-
<p className="text-2xl sm:text-3xl font-semibold tracking-tight text-card-foreground">
|
|
140
|
-
{prefix}
|
|
141
|
-
{
|
|
142
|
-
{suffix}
|
|
149
|
+
<p className="flex items-baseline gap-1.5 text-2xl sm:text-3xl font-semibold tracking-tight text-card-foreground">
|
|
150
|
+
<span>{prefix}{formatValue(value)}</span>
|
|
151
|
+
{suffix ? <span className="text-sm font-medium text-muted-foreground">{suffix}</span> : null}
|
|
143
152
|
</p>
|
|
144
153
|
{trend && (
|
|
145
154
|
<BadgeDelta direction={trend.direction} value={trend.value} />
|
|
@@ -148,6 +157,7 @@ export function KpiCard({
|
|
|
148
157
|
{trend && comparisonLabel && (
|
|
149
158
|
<p className="mt-1 text-xs text-muted-foreground">{comparisonLabel}</p>
|
|
150
159
|
)}
|
|
160
|
+
{footer != null && <div className="mt-3">{footer}</div>}
|
|
151
161
|
</div>
|
|
152
162
|
)
|
|
153
163
|
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
|
|
5
|
+
export type SparklineProps = {
|
|
6
|
+
values: number[]
|
|
7
|
+
ariaLabel: string
|
|
8
|
+
className?: string
|
|
9
|
+
width?: number
|
|
10
|
+
height?: number
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function buildPoints(values: number[], width: number, height: number): Array<{ x: number; y: number }> {
|
|
14
|
+
const count = values.length
|
|
15
|
+
const padding = 2
|
|
16
|
+
const usableHeight = Math.max(height - padding * 2, 0)
|
|
17
|
+
|
|
18
|
+
if (count === 1) {
|
|
19
|
+
return [{ x: width / 2, y: height / 2 }]
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
let min = values[0]
|
|
23
|
+
let max = values[0]
|
|
24
|
+
for (const value of values) {
|
|
25
|
+
if (value < min) min = value
|
|
26
|
+
if (value > max) max = value
|
|
27
|
+
}
|
|
28
|
+
const range = max - min
|
|
29
|
+
|
|
30
|
+
const stepX = count > 1 ? width / (count - 1) : 0
|
|
31
|
+
|
|
32
|
+
return values.map((value, index) => {
|
|
33
|
+
const x = stepX * index
|
|
34
|
+
const ratio = range === 0 ? 0.5 : (value - min) / range
|
|
35
|
+
const y = padding + (1 - ratio) * usableHeight
|
|
36
|
+
return { x, y }
|
|
37
|
+
})
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function toPath(points: Array<{ x: number; y: number }>): string {
|
|
41
|
+
return points
|
|
42
|
+
.map((point, index) => `${index === 0 ? 'M' : 'L'} ${point.x.toFixed(2)} ${point.y.toFixed(2)}`)
|
|
43
|
+
.join(' ')
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function Sparkline({
|
|
47
|
+
values,
|
|
48
|
+
ariaLabel,
|
|
49
|
+
className = '',
|
|
50
|
+
width = 96,
|
|
51
|
+
height = 28,
|
|
52
|
+
}: SparklineProps) {
|
|
53
|
+
if (!values || values.length === 0) {
|
|
54
|
+
return null
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const points = buildPoints(values, width, height)
|
|
58
|
+
const linePath = toPath(points)
|
|
59
|
+
|
|
60
|
+
const firstPoint = points[0]
|
|
61
|
+
const lastPoint = points[points.length - 1]
|
|
62
|
+
const areaPath = `${linePath} L ${lastPoint.x.toFixed(2)} ${height} L ${firstPoint.x.toFixed(2)} ${height} Z`
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
<svg
|
|
66
|
+
role="img"
|
|
67
|
+
aria-label={ariaLabel}
|
|
68
|
+
width={width}
|
|
69
|
+
height={height}
|
|
70
|
+
viewBox={`0 0 ${width} ${height}`}
|
|
71
|
+
preserveAspectRatio="none"
|
|
72
|
+
className={className}
|
|
73
|
+
>
|
|
74
|
+
<path d={areaPath} fill="currentColor" fillOpacity={0.12} stroke="none" />
|
|
75
|
+
<path
|
|
76
|
+
d={linePath}
|
|
77
|
+
fill="none"
|
|
78
|
+
stroke="currentColor"
|
|
79
|
+
strokeWidth={1.5}
|
|
80
|
+
strokeLinecap="round"
|
|
81
|
+
strokeLinejoin="round"
|
|
82
|
+
/>
|
|
83
|
+
</svg>
|
|
84
|
+
)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export default Sparkline
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
export { KpiCard, type KpiCardProps, type KpiTrend } from './KpiCard'
|
|
1
|
+
export { KpiCard, DeltaBadge, type KpiCardProps, type KpiTrend } from './KpiCard'
|
|
2
|
+
export { Sparkline, type SparklineProps } from './Sparkline'
|
|
2
3
|
export { BarChart, type BarChartProps, type BarChartDataItem } from './BarChart'
|
|
3
4
|
export { LineChart, type LineChartProps, type LineChartDataItem } from './LineChart'
|
|
4
5
|
export { PieChart, type PieChartProps, type PieChartDataItem } from './PieChart'
|
|
@@ -10,7 +10,7 @@ import { apiCall } from '@open-mercato/ui/backend/utils/apiCall'
|
|
|
10
10
|
import { getDashboardWidgets, loadDashboardWidgetModule } from './widgetRegistry'
|
|
11
11
|
import type { DashboardWidgetModule } from '@open-mercato/shared/modules/dashboard/widgets'
|
|
12
12
|
import { cn } from '@open-mercato/shared/lib/utils'
|
|
13
|
-
import { GripVertical,
|
|
13
|
+
import { GripVertical, Plus, RefreshCw, Settings2, Trash2, X, Loader2 } from 'lucide-react'
|
|
14
14
|
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
15
15
|
import { useOrganizationScopeVersion } from '@open-mercato/shared/lib/frontend/useOrganizationScope'
|
|
16
16
|
import { InjectionSpot } from '../injection/InjectionSpot'
|
|
@@ -366,7 +366,6 @@ export function DashboardScreen() {
|
|
|
366
366
|
if (!hasRegisteredWidgets && layout.length === 0) {
|
|
367
367
|
return (
|
|
368
368
|
<Alert variant="info">
|
|
369
|
-
<Info className="h-4 w-4" aria-hidden />
|
|
370
369
|
<AlertTitle>{t('dashboard.empty.noWidgets.title', 'No dashboard widgets yet')}</AlertTitle>
|
|
371
370
|
<AlertDescription>
|
|
372
371
|
{t(
|
|
@@ -76,6 +76,15 @@ export function useInjectionWidgets<TContext = unknown>(
|
|
|
76
76
|
const [error, setError] = React.useState<string | null>(null)
|
|
77
77
|
const [registryVersion, setRegistryVersion] = React.useState(() => getInjectionRegistryVersion())
|
|
78
78
|
const loadedRef = React.useRef(false)
|
|
79
|
+
const contextRef = React.useRef(options?.context)
|
|
80
|
+
const triggerOnLoadRef = React.useRef(options?.triggerOnLoad)
|
|
81
|
+
const onEventRef = React.useRef(options?.onEvent)
|
|
82
|
+
|
|
83
|
+
React.useEffect(() => {
|
|
84
|
+
contextRef.current = options?.context
|
|
85
|
+
triggerOnLoadRef.current = options?.triggerOnLoad
|
|
86
|
+
onEventRef.current = options?.onEvent
|
|
87
|
+
})
|
|
79
88
|
|
|
80
89
|
React.useEffect(() => {
|
|
81
90
|
return subscribeToInjectionRegistryChanges(() => {
|
|
@@ -105,16 +114,16 @@ export function useInjectionWidgets<TContext = unknown>(
|
|
|
105
114
|
placement: w.placement,
|
|
106
115
|
}))
|
|
107
116
|
setWidgets(widgetList)
|
|
108
|
-
|
|
117
|
+
|
|
109
118
|
// Trigger onLoad for all widgets
|
|
110
|
-
if (!loadedRef.current &&
|
|
119
|
+
if (!loadedRef.current && triggerOnLoadRef.current) {
|
|
111
120
|
loadedRef.current = true
|
|
112
121
|
for (const widget of widgetList) {
|
|
113
122
|
if (widget.module.eventHandlers?.onLoad) {
|
|
114
123
|
try {
|
|
115
|
-
const widgetContext = injectSharedStateIntoContext(
|
|
124
|
+
const widgetContext = injectSharedStateIntoContext(contextRef.current as TContext, widget.moduleId)
|
|
116
125
|
await widget.module.eventHandlers.onLoad(widgetContext)
|
|
117
|
-
|
|
126
|
+
onEventRef.current?.('onLoad', widget.widgetId)
|
|
118
127
|
} catch (err) {
|
|
119
128
|
console.error(`[InjectionSpot] Error in onLoad for widget ${widget.widgetId}:`, err)
|
|
120
129
|
}
|
|
@@ -133,7 +142,9 @@ export function useInjectionWidgets<TContext = unknown>(
|
|
|
133
142
|
return () => {
|
|
134
143
|
mounted = false
|
|
135
144
|
}
|
|
136
|
-
|
|
145
|
+
// context/triggerOnLoad/onEvent are read from refs so only a real registry-version bump reloads the spot
|
|
146
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
147
|
+
}, [spotId, registryVersion])
|
|
137
148
|
|
|
138
149
|
return { widgets, loading, error }
|
|
139
150
|
}
|
|
@@ -148,16 +159,25 @@ export function InjectionSpot<TContext = unknown, TData = unknown>({
|
|
|
148
159
|
widgetsOverride,
|
|
149
160
|
}: InjectionSpotProps<TContext, TData>) {
|
|
150
161
|
const useSpotId = widgetsOverride ? null : spotId
|
|
162
|
+
const onEventRef = React.useRef(onEvent)
|
|
163
|
+
React.useEffect(() => {
|
|
164
|
+
onEventRef.current = onEvent
|
|
165
|
+
})
|
|
166
|
+
const hasOnEvent = Boolean(onEvent)
|
|
167
|
+
const stableOnEvent = React.useMemo(
|
|
168
|
+
() => (hasOnEvent ? (event: 'onLoad', id: string) => onEventRef.current?.(event, id) : undefined),
|
|
169
|
+
[hasOnEvent],
|
|
170
|
+
)
|
|
151
171
|
const { widgets, loading, error } = useInjectionWidgets<TContext>(useSpotId, {
|
|
152
172
|
context,
|
|
153
173
|
triggerOnLoad: !widgetsOverride,
|
|
154
|
-
onEvent:
|
|
174
|
+
onEvent: stableOnEvent,
|
|
155
175
|
})
|
|
156
176
|
const effectiveWidgets = widgetsOverride ?? widgets
|
|
157
177
|
const effectiveLoading = widgetsOverride ? false : loading
|
|
158
178
|
const effectiveError = widgetsOverride ? null : error
|
|
159
179
|
|
|
160
|
-
if (effectiveLoading) {
|
|
180
|
+
if (effectiveLoading && effectiveWidgets.length === 0) {
|
|
161
181
|
return null
|
|
162
182
|
}
|
|
163
183
|
|