@open-mercato/core 0.6.7-develop.6751.1.ac823a3d26 → 0.6.7-develop.6758.1.697eade236

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.
Files changed (100) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/attachments/api/route.js +16 -2
  3. package/dist/modules/attachments/api/route.js.map +2 -2
  4. package/dist/modules/attachments/lib/upload-limits.js +33 -0
  5. package/dist/modules/attachments/lib/upload-limits.js.map +2 -2
  6. package/dist/modules/communication_channels/api/post/test-seed/route.js +52 -2
  7. package/dist/modules/communication_channels/api/post/test-seed/route.js.map +2 -2
  8. package/dist/modules/currencies/di.js +4 -0
  9. package/dist/modules/currencies/di.js.map +2 -2
  10. package/dist/modules/currencies/services/baseCurrencyService.js +42 -0
  11. package/dist/modules/currencies/services/baseCurrencyService.js.map +7 -0
  12. package/dist/modules/customers/analytics.js +3 -1
  13. package/dist/modules/customers/analytics.js.map +2 -2
  14. package/dist/modules/customers/api/deals/aggregate/route.js +5 -4
  15. package/dist/modules/customers/api/deals/aggregate/route.js.map +2 -2
  16. package/dist/modules/customers/api/deals/summary/route.js +5 -4
  17. package/dist/modules/customers/api/deals/summary/route.js.map +2 -2
  18. package/dist/modules/customers/lib/optionalBaseCurrency.js +19 -0
  19. package/dist/modules/customers/lib/optionalBaseCurrency.js.map +7 -0
  20. package/dist/modules/dashboards/api/widgets/data/batch/route.js +8 -1
  21. package/dist/modules/dashboards/api/widgets/data/batch/route.js.map +2 -2
  22. package/dist/modules/dashboards/api/widgets/data/route.js +8 -1
  23. package/dist/modules/dashboards/api/widgets/data/route.js.map +2 -2
  24. package/dist/modules/dashboards/api/widgets/data/schema.js +2 -1
  25. package/dist/modules/dashboards/api/widgets/data/schema.js.map +2 -2
  26. package/dist/modules/dashboards/components/UnlabelledAmountNotice.js +33 -0
  27. package/dist/modules/dashboards/components/UnlabelledAmountNotice.js.map +7 -0
  28. package/dist/modules/dashboards/lib/aggregations.js +17 -0
  29. package/dist/modules/dashboards/lib/aggregations.js.map +2 -2
  30. package/dist/modules/dashboards/lib/formatters.js +84 -11
  31. package/dist/modules/dashboards/lib/formatters.js.map +2 -2
  32. package/dist/modules/dashboards/lib/optionalBaseCurrency.js +11 -0
  33. package/dist/modules/dashboards/lib/optionalBaseCurrency.js.map +7 -0
  34. package/dist/modules/dashboards/services/widgetDataService.js +86 -6
  35. package/dist/modules/dashboards/services/widgetDataService.js.map +2 -2
  36. package/dist/modules/dashboards/subscribers/invalidateWidgetDataCache.js +26 -0
  37. package/dist/modules/dashboards/subscribers/invalidateWidgetDataCache.js.map +7 -0
  38. package/dist/modules/dashboards/widgets/dashboard/aov-kpi/widget.client.js +9 -3
  39. package/dist/modules/dashboards/widgets/dashboard/aov-kpi/widget.client.js.map +2 -2
  40. package/dist/modules/dashboards/widgets/dashboard/pipeline-summary/widget.client.js +10 -4
  41. package/dist/modules/dashboards/widgets/dashboard/pipeline-summary/widget.client.js.map +2 -2
  42. package/dist/modules/dashboards/widgets/dashboard/revenue-kpi/widget.client.js +9 -3
  43. package/dist/modules/dashboards/widgets/dashboard/revenue-kpi/widget.client.js.map +2 -2
  44. package/dist/modules/dashboards/widgets/dashboard/revenue-trend/widget.client.js +8 -3
  45. package/dist/modules/dashboards/widgets/dashboard/revenue-trend/widget.client.js.map +2 -2
  46. package/dist/modules/dashboards/widgets/dashboard/sales-by-region/widget.client.js +26 -18
  47. package/dist/modules/dashboards/widgets/dashboard/sales-by-region/widget.client.js.map +2 -2
  48. package/dist/modules/dashboards/widgets/dashboard/top-customers/widget.client.js +22 -14
  49. package/dist/modules/dashboards/widgets/dashboard/top-customers/widget.client.js.map +2 -2
  50. package/dist/modules/dashboards/widgets/dashboard/top-products/widget.client.js +10 -4
  51. package/dist/modules/dashboards/widgets/dashboard/top-products/widget.client.js.map +2 -2
  52. package/dist/modules/notifications/api/route.js +62 -3
  53. package/dist/modules/notifications/api/route.js.map +2 -2
  54. package/dist/modules/sales/analytics.js +7 -3
  55. package/dist/modules/sales/analytics.js.map +2 -2
  56. package/dist/modules/sales/api/document-history/route.js +2 -1
  57. package/dist/modules/sales/api/document-history/route.js.map +2 -2
  58. package/dist/modules/sales/lib/historyHelpers.js +69 -1
  59. package/dist/modules/sales/lib/historyHelpers.js.map +2 -2
  60. package/dist/modules/sales/widgets/injection/document-history/widget.client.js +51 -2
  61. package/dist/modules/sales/widgets/injection/document-history/widget.client.js.map +2 -2
  62. package/package.json +7 -7
  63. package/src/modules/attachments/api/route.ts +16 -2
  64. package/src/modules/attachments/lib/upload-limits.ts +35 -0
  65. package/src/modules/communication_channels/api/post/test-seed/route.ts +69 -2
  66. package/src/modules/currencies/di.ts +4 -1
  67. package/src/modules/currencies/services/baseCurrencyService.ts +59 -0
  68. package/src/modules/customers/analytics.ts +2 -0
  69. package/src/modules/customers/api/deals/aggregate/route.ts +5 -4
  70. package/src/modules/customers/api/deals/summary/route.ts +5 -4
  71. package/src/modules/customers/lib/optionalBaseCurrency.ts +34 -0
  72. package/src/modules/dashboards/api/widgets/data/batch/route.ts +8 -1
  73. package/src/modules/dashboards/api/widgets/data/route.ts +8 -1
  74. package/src/modules/dashboards/api/widgets/data/schema.ts +1 -0
  75. package/src/modules/dashboards/components/UnlabelledAmountNotice.tsx +43 -0
  76. package/src/modules/dashboards/i18n/de.json +2 -0
  77. package/src/modules/dashboards/i18n/en.json +2 -0
  78. package/src/modules/dashboards/i18n/es.json +2 -0
  79. package/src/modules/dashboards/i18n/pl.json +2 -0
  80. package/src/modules/dashboards/lib/aggregations.ts +35 -0
  81. package/src/modules/dashboards/lib/formatters.ts +137 -12
  82. package/src/modules/dashboards/lib/optionalBaseCurrency.ts +22 -0
  83. package/src/modules/dashboards/services/widgetDataService.ts +110 -4
  84. package/src/modules/dashboards/subscribers/invalidateWidgetDataCache.ts +32 -0
  85. package/src/modules/dashboards/widgets/dashboard/aov-kpi/widget.client.tsx +9 -3
  86. package/src/modules/dashboards/widgets/dashboard/pipeline-summary/widget.client.tsx +9 -3
  87. package/src/modules/dashboards/widgets/dashboard/revenue-kpi/widget.client.tsx +9 -3
  88. package/src/modules/dashboards/widgets/dashboard/revenue-trend/widget.client.tsx +7 -2
  89. package/src/modules/dashboards/widgets/dashboard/sales-by-region/widget.client.tsx +25 -15
  90. package/src/modules/dashboards/widgets/dashboard/top-customers/widget.client.tsx +21 -11
  91. package/src/modules/dashboards/widgets/dashboard/top-products/widget.client.tsx +9 -3
  92. package/src/modules/notifications/api/route.ts +88 -3
  93. package/src/modules/sales/analytics.ts +4 -0
  94. package/src/modules/sales/api/document-history/route.ts +1 -0
  95. package/src/modules/sales/i18n/de.json +19 -0
  96. package/src/modules/sales/i18n/en.json +19 -0
  97. package/src/modules/sales/i18n/es.json +19 -0
  98. package/src/modules/sales/i18n/pl.json +19 -0
  99. package/src/modules/sales/lib/historyHelpers.ts +88 -1
  100. package/src/modules/sales/widgets/injection/document-history/widget.client.tsx +64 -3
@@ -3,13 +3,14 @@
3
3
  import * as React from 'react'
4
4
  import type { DashboardWidgetComponentProps } from '@open-mercato/shared/modules/dashboard/widgets'
5
5
  import { useWidgetData, type WidgetDataFetcher } from '@open-mercato/ui/backend/dashboard/widgetData'
6
- import { useT } from '@open-mercato/shared/lib/i18n/context'
6
+ import { useLocale, useT } from '@open-mercato/shared/lib/i18n/context'
7
7
  import { BarChart, type BarChartDataItem } from '@open-mercato/ui/backend/charts'
8
8
  import { DateRangeSelect, type DateRangePreset } from '@open-mercato/ui/backend/date-range'
9
9
  import { Input } from '@open-mercato/ui/primitives/input'
10
10
  import { DEFAULT_SETTINGS, hydrateSettings, type SalesByRegionSettings } from './config'
11
11
  import type { WidgetDataResponse } from '../../../services/widgetDataService'
12
- import { formatCurrencyCompact } from '../../../lib/formatters'
12
+ import { createCurrencyFormatters } from '../../../lib/formatters'
13
+ import { UnlabelledAmountNotice } from '../../../components/UnlabelledAmountNotice'
13
14
  import { createLogger } from '@open-mercato/shared/lib/logger'
14
15
 
15
16
  const logger = createLogger('dashboards').child({ component: 'sales-by-region' })
@@ -42,10 +43,13 @@ const SalesByRegionWidget: React.FC<DashboardWidgetComponentProps<SalesByRegionS
42
43
  onRefreshStateChange,
43
44
  }) => {
44
45
  const t = useT()
46
+ const locale = useLocale()
45
47
  const hydrated = React.useMemo(() => hydrateSettings(settings), [settings])
46
48
  const [data, setData] = React.useState<BarChartDataItem[]>([])
49
+ const [currency, setCurrency] = React.useState<string | null>(null)
47
50
  const [loading, setLoading] = React.useState(true)
48
51
  const [error, setError] = React.useState<string | null>(null)
52
+ const money = React.useMemo(() => createCurrencyFormatters(currency, '--', locale), [currency, locale])
49
53
 
50
54
  const fetchWidgetData = useWidgetData()
51
55
  const refresh = React.useCallback(async () => {
@@ -59,6 +63,7 @@ const SalesByRegionWidget: React.FC<DashboardWidgetComponentProps<SalesByRegionS
59
63
  Revenue: item.value ?? 0,
60
64
  }))
61
65
  setData(chartData)
66
+ setCurrency(result.metadata?.currency ?? null)
62
67
  } catch (err) {
63
68
  logger.error('Failed to load sales by region data', { err })
64
69
  setError(t('dashboards.analytics.widgets.salesByRegion.error', 'Failed to load data'))
@@ -106,19 +111,24 @@ const SalesByRegionWidget: React.FC<DashboardWidgetComponentProps<SalesByRegionS
106
111
  }
107
112
 
108
113
  return (
109
- <BarChart
110
- data={data}
111
- index="region"
112
- categories={['Revenue']}
113
- categoryLabels={{ Revenue: t('dashboards.analytics.widgets.topCustomers.column.revenue', 'Revenue') }}
114
- loading={loading}
115
- error={error}
116
- layout="horizontal"
117
- valueFormatter={formatCurrencyCompact}
118
- colors={['cyan']}
119
- showLegend={false}
120
- emptyMessage={t('dashboards.analytics.widgets.salesByRegion.empty', 'No regional sales data for this period')}
121
- />
114
+ <div className="flex flex-col h-full">
115
+ <div className="flex-1 min-h-0">
116
+ <BarChart
117
+ data={data}
118
+ index="region"
119
+ categories={['Revenue']}
120
+ categoryLabels={{ Revenue: t('dashboards.analytics.widgets.topCustomers.column.revenue', 'Revenue') }}
121
+ loading={loading}
122
+ error={error}
123
+ layout="horizontal"
124
+ valueFormatter={money.formatCompact}
125
+ colors={['cyan']}
126
+ showLegend={false}
127
+ emptyMessage={t('dashboards.analytics.widgets.salesByRegion.empty', 'No regional sales data for this period')}
128
+ />
129
+ </div>
130
+ <UnlabelledAmountNotice currency={currency} loading={loading} error={error} />
131
+ </div>
122
132
  )
123
133
  }
124
134
 
@@ -3,13 +3,14 @@
3
3
  import * as React from 'react'
4
4
  import type { DashboardWidgetComponentProps } from '@open-mercato/shared/modules/dashboard/widgets'
5
5
  import { useWidgetData, type WidgetDataFetcher } from '@open-mercato/ui/backend/dashboard/widgetData'
6
- import { useT } from '@open-mercato/shared/lib/i18n/context'
6
+ import { useLocale, useT } from '@open-mercato/shared/lib/i18n/context'
7
7
  import { TopNTable, type TopNTableColumn } from '@open-mercato/ui/backend/charts'
8
8
  import { DateRangeSelect, type DateRangePreset } from '@open-mercato/ui/backend/date-range'
9
9
  import { Input } from '@open-mercato/ui/primitives/input'
10
10
  import { DEFAULT_SETTINGS, hydrateSettings, type TopCustomersSettings } from './config'
11
11
  import type { WidgetDataResponse } from '../../../services/widgetDataService'
12
- import { formatCurrencySafe } from '../../../lib/formatters'
12
+ import { createCurrencyFormatters } from '../../../lib/formatters'
13
+ import { UnlabelledAmountNotice } from '../../../components/UnlabelledAmountNotice'
13
14
  import { createLogger } from '@open-mercato/shared/lib/logger'
14
15
 
15
16
  const logger = createLogger('dashboards').child({ component: 'top-customers' })
@@ -54,10 +55,13 @@ const TopCustomersWidget: React.FC<DashboardWidgetComponentProps<TopCustomersSet
54
55
  onRefreshStateChange,
55
56
  }) => {
56
57
  const t = useT()
58
+ const locale = useLocale()
57
59
  const hydrated = React.useMemo(() => hydrateSettings(settings), [settings])
58
60
  const [data, setData] = React.useState<CustomerRow[]>([])
61
+ const [currency, setCurrency] = React.useState<string | null>(null)
59
62
  const [loading, setLoading] = React.useState(true)
60
63
  const [error, setError] = React.useState<string | null>(null)
64
+ const money = React.useMemo(() => createCurrencyFormatters(currency, '--', locale), [currency, locale])
61
65
 
62
66
  const unknownLabel = t('dashboards.analytics.labels.unknown', 'Unknown')
63
67
  const columns: TopNTableColumn<CustomerRow>[] = React.useMemo(
@@ -76,10 +80,10 @@ const TopCustomersWidget: React.FC<DashboardWidgetComponentProps<TopCustomersSet
76
80
  key: 'revenue',
77
81
  header: t('dashboards.analytics.widgets.topCustomers.column.revenue', 'Revenue'),
78
82
  align: 'right',
79
- formatter: (value: unknown) => formatCurrencySafe(value),
83
+ formatter: (value: unknown) => money.formatSafe(value),
80
84
  },
81
85
  ],
82
- [t, unknownLabel],
86
+ [t, unknownLabel, money],
83
87
  )
84
88
 
85
89
  const fetchWidgetData = useWidgetData()
@@ -95,6 +99,7 @@ const TopCustomersWidget: React.FC<DashboardWidgetComponentProps<TopCustomersSet
95
99
  revenue: item.value ?? 0,
96
100
  }))
97
101
  setData(tableData)
102
+ setCurrency(result.metadata?.currency ?? null)
98
103
  } catch (err) {
99
104
  logger.error('Failed to load top customers data', { err })
100
105
  setError(t('dashboards.analytics.widgets.topCustomers.error', 'Failed to load data'))
@@ -142,13 +147,18 @@ const TopCustomersWidget: React.FC<DashboardWidgetComponentProps<TopCustomersSet
142
147
  }
143
148
 
144
149
  return (
145
- <TopNTable
146
- data={data}
147
- columns={columns}
148
- loading={loading}
149
- error={error}
150
- emptyMessage={t('dashboards.analytics.widgets.topCustomers.empty', 'No customer data for this period')}
151
- />
150
+ <div className="flex flex-col h-full">
151
+ <div className="flex-1 min-h-0">
152
+ <TopNTable
153
+ data={data}
154
+ columns={columns}
155
+ loading={loading}
156
+ error={error}
157
+ emptyMessage={t('dashboards.analytics.widgets.topCustomers.empty', 'No customer data for this period')}
158
+ />
159
+ </div>
160
+ <UnlabelledAmountNotice currency={currency} loading={loading} error={error} />
161
+ </div>
152
162
  )
153
163
  }
154
164
 
@@ -3,7 +3,7 @@
3
3
  import * as React from 'react'
4
4
  import type { DashboardWidgetComponentProps } from '@open-mercato/shared/modules/dashboard/widgets'
5
5
  import { useWidgetData, type WidgetDataFetcher } from '@open-mercato/ui/backend/dashboard/widgetData'
6
- import { useT } from '@open-mercato/shared/lib/i18n/context'
6
+ import { useLocale, useT } from '@open-mercato/shared/lib/i18n/context'
7
7
  import { BarChart, type BarChartDataItem } from '@open-mercato/ui/backend/charts'
8
8
  import { DateRangeSelect, InlineDateRangeSelect, type DateRangePreset } from '@open-mercato/ui/backend/date-range'
9
9
  import { Input } from '@open-mercato/ui/primitives/input'
@@ -16,7 +16,8 @@ import {
16
16
  } from '@open-mercato/ui/primitives/select'
17
17
  import { DEFAULT_SETTINGS, hydrateSettings, type TopProductsSettings } from './config'
18
18
  import type { WidgetDataResponse } from '../../../services/widgetDataService'
19
- import { formatCurrencyCompact } from '../../../lib/formatters'
19
+ import { createCurrencyFormatters } from '../../../lib/formatters'
20
+ import { UnlabelledAmountNotice } from '../../../components/UnlabelledAmountNotice'
20
21
  import { createLogger } from '@open-mercato/shared/lib/logger'
21
22
 
22
23
  const logger = createLogger('dashboards').child({ component: 'top-products' })
@@ -68,11 +69,14 @@ const TopProductsWidget: React.FC<DashboardWidgetComponentProps<TopProductsSetti
68
69
  onRefreshStateChange,
69
70
  }) => {
70
71
  const t = useT()
72
+ const locale = useLocale()
71
73
  const hydrated = React.useMemo(() => hydrateSettings(settings), [settings])
72
74
  const [data, setData] = React.useState<BarChartDataItem[]>([])
75
+ const [currency, setCurrency] = React.useState<string | null>(null)
73
76
  const [loading, setLoading] = React.useState(true)
74
77
  const [error, setError] = React.useState<string | null>(null)
75
78
  const fetchingRef = React.useRef(false)
79
+ const money = React.useMemo(() => createCurrencyFormatters(currency, '--', locale), [currency, locale])
76
80
 
77
81
  const fetchWidgetData = useWidgetData()
78
82
  const refresh = React.useCallback(async () => {
@@ -88,6 +92,7 @@ const TopProductsWidget: React.FC<DashboardWidgetComponentProps<TopProductsSetti
88
92
  Revenue: item.value ?? 0,
89
93
  }))
90
94
  setData(chartData)
95
+ setCurrency(result.metadata?.currency ?? null)
91
96
  } catch (err) {
92
97
  logger.error('Failed to load top products data', { err })
93
98
  setError(t('dashboards.analytics.widgets.topProducts.error', 'Failed to load data'))
@@ -172,12 +177,13 @@ const TopProductsWidget: React.FC<DashboardWidgetComponentProps<TopProductsSetti
172
177
  loading={loading}
173
178
  error={error}
174
179
  layout={hydrated.layout}
175
- valueFormatter={formatCurrencyCompact}
180
+ valueFormatter={money.formatCompact}
176
181
  colors={['emerald']}
177
182
  showLegend={false}
178
183
  emptyMessage={t('dashboards.analytics.widgets.topProducts.empty', 'No product sales data for this period')}
179
184
  />
180
185
  </div>
186
+ <UnlabelledAmountNotice currency={currency} loading={loading} error={error} />
181
187
  </div>
182
188
  )
183
189
  }
@@ -1,5 +1,12 @@
1
1
  import { z } from 'zod'
2
2
  import type { EntityManager } from '@mikro-orm/core'
3
+ import { runWithCacheTenant } from '@open-mercato/cache'
4
+ import {
5
+ buildCollectionTags,
6
+ debugCrudCache,
7
+ isCrudCacheEnabled,
8
+ resolveCrudCache,
9
+ } from '@open-mercato/shared/lib/crud/cache'
3
10
  import { Notification } from '../data/entities'
4
11
  import { listNotificationsSchema, createNotificationSchema } from '../data/validators'
5
12
  import { toNotificationDto } from '../lib/notificationMapper'
@@ -22,6 +29,49 @@ export const metadata = {
22
29
  POST: { requireAuth: true, requireFeatures: ['notifications.create'] },
23
30
  }
24
31
 
32
+ const NOTIFICATIONS_LIST_TTL_MS = 10_000
33
+ const NOTIFICATIONS_LIST_CACHE_VERSION = 1
34
+ const NOTIFICATIONS_LIST_RESOURCE = 'notifications.notification'
35
+
36
+ type NotificationsListPayload = {
37
+ items: ReturnType<typeof toNotificationDto>[]
38
+ total: number
39
+ page: number
40
+ pageSize: number
41
+ totalPages: number
42
+ }
43
+
44
+ function buildNotificationsListCacheKey(
45
+ userId: string,
46
+ input: z.infer<typeof listNotificationsSchema>,
47
+ ): string {
48
+ const filterSignature = JSON.stringify({
49
+ status: Array.isArray(input.status)
50
+ ? [...input.status].sort((left, right) => left.localeCompare(right))
51
+ : input.status ?? null,
52
+ type: input.type ?? null,
53
+ severity: input.severity ?? null,
54
+ sourceEntityType: input.sourceEntityType ?? null,
55
+ sourceEntityId: input.sourceEntityId ?? null,
56
+ since: input.since ?? null,
57
+ page: input.page,
58
+ pageSize: input.pageSize,
59
+ })
60
+ return `notifications:list:v${NOTIFICATIONS_LIST_CACHE_VERSION}:u=${userId}:filters=${filterSignature}`
61
+ }
62
+
63
+ function isNotificationsListPayload(value: unknown): value is NotificationsListPayload {
64
+ if (!value || typeof value !== 'object') return false
65
+ const payload = value as Partial<NotificationsListPayload>
66
+ return (
67
+ Array.isArray(payload.items)
68
+ && typeof payload.total === 'number'
69
+ && typeof payload.page === 'number'
70
+ && typeof payload.pageSize === 'number'
71
+ && typeof payload.totalPages === 'number'
72
+ )
73
+ }
74
+
25
75
  export async function GET(req: Request) {
26
76
  const { ctx, scope } = await resolveNotificationContext(req)
27
77
  const em = ctx.container.resolve('em') as EntityManager
@@ -29,9 +79,27 @@ export async function GET(req: Request) {
29
79
  const url = new URL(req.url)
30
80
  const queryParams = Object.fromEntries(url.searchParams.entries())
31
81
  const input = listNotificationsSchema.parse(queryParams)
82
+ const userId = scope.userId
83
+ const cache = userId && isCrudCacheEnabled()
84
+ ? resolveCrudCache(ctx.container)
85
+ : null
86
+ const cacheKey = cache && userId ? buildNotificationsListCacheKey(userId, input) : null
87
+
88
+ if (cache && cacheKey) {
89
+ try {
90
+ const cached = await runWithCacheTenant(scope.tenantId, () => cache.get(cacheKey))
91
+ if (isNotificationsListPayload(cached)) {
92
+ return Response.json(cached)
93
+ }
94
+ } catch (error) {
95
+ debugCrudCache('notifications-list-cache-read-failed', {
96
+ error: error instanceof Error ? error.message : String(error),
97
+ })
98
+ }
99
+ }
32
100
 
33
101
  const filters: Record<string, unknown> = {
34
- recipientUserId: scope.userId,
102
+ recipientUserId: userId,
35
103
  tenantId: scope.tenantId,
36
104
  ...buildNotificationReadScopeWhere(scope),
37
105
  }
@@ -68,13 +136,30 @@ export async function GET(req: Request) {
68
136
 
69
137
  const items = notifications.map(toNotificationDto)
70
138
 
71
- return Response.json({
139
+ const payload: NotificationsListPayload = {
72
140
  items,
73
141
  total,
74
142
  page: input.page,
75
143
  pageSize: input.pageSize,
76
144
  totalPages: Math.ceil(total / input.pageSize),
77
- })
145
+ }
146
+
147
+ if (cache && cacheKey) {
148
+ try {
149
+ await runWithCacheTenant(scope.tenantId, () =>
150
+ cache.set(cacheKey, payload, {
151
+ ttl: NOTIFICATIONS_LIST_TTL_MS,
152
+ tags: buildCollectionTags(NOTIFICATIONS_LIST_RESOURCE, scope.tenantId, [null]),
153
+ }),
154
+ )
155
+ } catch (error) {
156
+ debugCrudCache('notifications-list-cache-write-failed', {
157
+ error: error instanceof Error ? error.message : String(error),
158
+ })
159
+ }
160
+ }
161
+
162
+ return Response.json(payload)
78
163
  }
79
164
 
80
165
  export async function POST(req: Request) {
@@ -9,6 +9,7 @@ export const analyticsConfig: AnalyticsModuleConfig = {
9
9
  tableName: 'sales_orders',
10
10
  dateField: 'placed_at',
11
11
  defaultScopeFields: ['tenant_id', 'organization_id'],
12
+ currencyField: 'currencyCode',
12
13
  },
13
14
  fieldMappings: {
14
15
  id: { dbColumn: 'id', type: 'uuid' },
@@ -40,9 +41,11 @@ export const analyticsConfig: AnalyticsModuleConfig = {
40
41
  tableName: 'sales_order_lines',
41
42
  dateField: 'created_at',
42
43
  defaultScopeFields: ['tenant_id', 'organization_id'],
44
+ currencyField: 'currencyCode',
43
45
  },
44
46
  fieldMappings: {
45
47
  id: { dbColumn: 'id', type: 'uuid' },
48
+ currencyCode: { dbColumn: 'currency_code', type: 'text' },
46
49
  totalGrossAmount: { dbColumn: 'total_gross_amount', type: 'numeric' },
47
50
  totalNetAmount: { dbColumn: 'total_net_amount', type: 'numeric' },
48
51
  unitGrossPrice: { dbColumn: 'unit_gross_price', type: 'numeric' },
@@ -64,6 +67,7 @@ export const analyticsConfig: AnalyticsModuleConfig = {
64
67
  tableName: 'sales_quotes',
65
68
  dateField: 'created_at',
66
69
  defaultScopeFields: ['tenant_id', 'organization_id'],
70
+ currencyField: 'currencyCode',
67
71
  },
68
72
  fieldMappings: {
69
73
  id: { dbColumn: 'id', type: 'uuid' },
@@ -171,6 +171,7 @@ const historyEntrySchema = z.object({
171
171
  statusTo: z.string().nullable().optional(),
172
172
  documentKind: z.enum(['order', 'quote']).optional(),
173
173
  commandId: z.string().optional(),
174
+ changedFields: z.array(z.string()).optional(),
174
175
  }).optional(),
175
176
  })
176
177
 
@@ -927,8 +927,27 @@
927
927
  "sales.documents.form.submit": "Erstellen",
928
928
  "sales.documents.form.success": "Verkaufsdokument erstellt.",
929
929
  "sales.documents.form.title": "Verkaufsdokument erstellen",
930
+ "sales.documents.history.actor.system": "System",
931
+ "sales.documents.history.changedFields": "Geänderte Felder: {fields}",
930
932
  "sales.documents.history.empty": "Noch keine Verlaufseinträge.",
931
933
  "sales.documents.history.error": "Verlauf konnte nicht geladen werden.",
934
+ "sales.documents.history.fields.comment": "Kommentar",
935
+ "sales.documents.history.fields.configuration": "Konfiguration",
936
+ "sales.documents.history.fields.currency": "Währung",
937
+ "sales.documents.history.fields.customFields": "Benutzerdefinierte Felder",
938
+ "sales.documents.history.fields.description": "Beschreibung",
939
+ "sales.documents.history.fields.discountAmount": "Rabattbetrag",
940
+ "sales.documents.history.fields.discountPercent": "Rabattprozentsatz",
941
+ "sales.documents.history.fields.name": "Name",
942
+ "sales.documents.history.fields.product": "Produkt",
943
+ "sales.documents.history.fields.promotionCode": "Aktionscode",
944
+ "sales.documents.history.fields.quantity": "Menge",
945
+ "sales.documents.history.fields.quantityUnit": "Mengeneinheit",
946
+ "sales.documents.history.fields.status": "Status",
947
+ "sales.documents.history.fields.taxRate": "Steuerklasse",
948
+ "sales.documents.history.fields.unitPriceGross": "Bruttostückpreis",
949
+ "sales.documents.history.fields.unitPriceNet": "Nettostückpreis",
950
+ "sales.documents.history.fields.variant": "Variante",
932
951
  "sales.documents.history.filter.actions": "Aktionen",
933
952
  "sales.documents.history.filter.all": "Alle",
934
953
  "sales.documents.history.filter.comments": "Kommentare",
@@ -927,8 +927,27 @@
927
927
  "sales.documents.form.submit": "Create",
928
928
  "sales.documents.form.success": "Sales document created.",
929
929
  "sales.documents.form.title": "Create sales document",
930
+ "sales.documents.history.actor.system": "System",
931
+ "sales.documents.history.changedFields": "Changed fields: {fields}",
930
932
  "sales.documents.history.empty": "No history entries yet.",
931
933
  "sales.documents.history.error": "Failed to load history.",
934
+ "sales.documents.history.fields.comment": "Comment",
935
+ "sales.documents.history.fields.configuration": "Configuration",
936
+ "sales.documents.history.fields.currency": "Currency",
937
+ "sales.documents.history.fields.customFields": "Custom fields",
938
+ "sales.documents.history.fields.description": "Description",
939
+ "sales.documents.history.fields.discountAmount": "Discount amount",
940
+ "sales.documents.history.fields.discountPercent": "Discount percent",
941
+ "sales.documents.history.fields.name": "Name",
942
+ "sales.documents.history.fields.product": "Product",
943
+ "sales.documents.history.fields.promotionCode": "Promotion code",
944
+ "sales.documents.history.fields.quantity": "Quantity",
945
+ "sales.documents.history.fields.quantityUnit": "Quantity unit",
946
+ "sales.documents.history.fields.status": "Status",
947
+ "sales.documents.history.fields.taxRate": "Tax class",
948
+ "sales.documents.history.fields.unitPriceGross": "Gross unit price",
949
+ "sales.documents.history.fields.unitPriceNet": "Net unit price",
950
+ "sales.documents.history.fields.variant": "Variant",
932
951
  "sales.documents.history.filter.actions": "Actions",
933
952
  "sales.documents.history.filter.all": "All",
934
953
  "sales.documents.history.filter.comments": "Comments",
@@ -927,8 +927,27 @@
927
927
  "sales.documents.form.submit": "Crear",
928
928
  "sales.documents.form.success": "Documento de ventas creado.",
929
929
  "sales.documents.form.title": "Crear documento de ventas",
930
+ "sales.documents.history.actor.system": "Sistema",
931
+ "sales.documents.history.changedFields": "Campos modificados: {fields}",
930
932
  "sales.documents.history.empty": "Aún no hay entradas en el historial.",
931
933
  "sales.documents.history.error": "No se pudo cargar el historial.",
934
+ "sales.documents.history.fields.comment": "Comentario",
935
+ "sales.documents.history.fields.configuration": "Configuración",
936
+ "sales.documents.history.fields.currency": "Moneda",
937
+ "sales.documents.history.fields.customFields": "Campos personalizados",
938
+ "sales.documents.history.fields.description": "Descripción",
939
+ "sales.documents.history.fields.discountAmount": "Importe de descuento",
940
+ "sales.documents.history.fields.discountPercent": "Porcentaje de descuento",
941
+ "sales.documents.history.fields.name": "Nombre",
942
+ "sales.documents.history.fields.product": "Producto",
943
+ "sales.documents.history.fields.promotionCode": "Código promocional",
944
+ "sales.documents.history.fields.quantity": "Cantidad",
945
+ "sales.documents.history.fields.quantityUnit": "Unidad de cantidad",
946
+ "sales.documents.history.fields.status": "Estado",
947
+ "sales.documents.history.fields.taxRate": "Clase fiscal",
948
+ "sales.documents.history.fields.unitPriceGross": "Precio unitario bruto",
949
+ "sales.documents.history.fields.unitPriceNet": "Precio unitario neto",
950
+ "sales.documents.history.fields.variant": "Variante",
932
951
  "sales.documents.history.filter.actions": "Acciones",
933
952
  "sales.documents.history.filter.all": "Todos",
934
953
  "sales.documents.history.filter.comments": "Comentarios",
@@ -927,8 +927,27 @@
927
927
  "sales.documents.form.submit": "Utwórz",
928
928
  "sales.documents.form.success": "Dokument sprzedaży utworzony.",
929
929
  "sales.documents.form.title": "Utwórz dokument sprzedaży",
930
+ "sales.documents.history.actor.system": "System",
931
+ "sales.documents.history.changedFields": "Zmienione pola: {fields}",
930
932
  "sales.documents.history.empty": "Brak wpisów w historii.",
931
933
  "sales.documents.history.error": "Nie udało się załadować historii.",
934
+ "sales.documents.history.fields.comment": "Komentarz",
935
+ "sales.documents.history.fields.configuration": "Konfiguracja",
936
+ "sales.documents.history.fields.currency": "Waluta",
937
+ "sales.documents.history.fields.customFields": "Pola niestandardowe",
938
+ "sales.documents.history.fields.description": "Opis",
939
+ "sales.documents.history.fields.discountAmount": "Kwota rabatu",
940
+ "sales.documents.history.fields.discountPercent": "Procent rabatu",
941
+ "sales.documents.history.fields.name": "Nazwa",
942
+ "sales.documents.history.fields.product": "Produkt",
943
+ "sales.documents.history.fields.promotionCode": "Kod promocyjny",
944
+ "sales.documents.history.fields.quantity": "Ilość",
945
+ "sales.documents.history.fields.quantityUnit": "Jednostka ilości",
946
+ "sales.documents.history.fields.status": "Status",
947
+ "sales.documents.history.fields.taxRate": "Klasa podatkowa",
948
+ "sales.documents.history.fields.unitPriceGross": "Cena jednostkowa brutto",
949
+ "sales.documents.history.fields.unitPriceNet": "Cena jednostkowa netto",
950
+ "sales.documents.history.fields.variant": "Wariant",
932
951
  "sales.documents.history.filter.actions": "Akcje",
933
952
  "sales.documents.history.filter.all": "Wszystkie",
934
953
  "sales.documents.history.filter.comments": "Komentarze",
@@ -1,4 +1,5 @@
1
1
  import type { ActionLog } from '@open-mercato/core/modules/audit_logs/data/entities'
2
+ import { isRecord } from '@open-mercato/shared/lib/utils'
2
3
  import type { SalesNote } from '../data/entities'
3
4
 
4
5
  export type HistoryEntry = {
@@ -13,9 +14,90 @@ export type HistoryEntry = {
13
14
  statusTo?: string | null
14
15
  documentKind?: 'order' | 'quote'
15
16
  commandId?: string
17
+ changedFields?: string[]
16
18
  }
17
19
  }
18
20
 
21
+ const DOCUMENT_LINE_UPSERT_COMMANDS = new Set([
22
+ 'sales.orders.lines.upsert',
23
+ 'sales.quotes.lines.upsert',
24
+ ])
25
+
26
+ const USER_EDITABLE_LINE_FIELDS = [
27
+ 'productId',
28
+ 'productVariantId',
29
+ 'name',
30
+ 'description',
31
+ 'comment',
32
+ 'quantity',
33
+ 'quantityUnit',
34
+ 'currencyCode',
35
+ 'unitPriceNet',
36
+ 'unitPriceGross',
37
+ 'discountAmount',
38
+ 'discountPercent',
39
+ 'taxRate',
40
+ 'configuration',
41
+ 'promotionCode',
42
+ 'customFields',
43
+ 'statusEntryId',
44
+ ] as const
45
+
46
+ function readSnapshotLines(snapshot: unknown): Array<Record<string, unknown>> {
47
+ if (!isRecord(snapshot) || !Array.isArray(snapshot.lines)) return []
48
+ return snapshot.lines.filter(isRecord)
49
+ }
50
+
51
+ function historyValuesEqual(left: unknown, right: unknown): boolean {
52
+ if (Object.is(left, right)) return true
53
+ try {
54
+ return JSON.stringify(left) === JSON.stringify(right)
55
+ } catch {
56
+ return false
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Derives a concise, user-editable field list for document line updates.
62
+ * Calculated totals and normalized UoM fields are intentionally excluded so
63
+ * the timeline describes the user's edit rather than downstream recalculation.
64
+ */
65
+ function deriveDocumentLineChangedFields(log: ActionLog): string[] {
66
+ if (!DOCUMENT_LINE_UPSERT_COMMANDS.has(log.commandId)) return []
67
+
68
+ const beforeById = new Map(
69
+ readSnapshotLines(log.snapshotBefore)
70
+ .filter((line) => typeof line.id === 'string')
71
+ .map((line) => [line.id as string, line]),
72
+ )
73
+ const afterLines = readSnapshotLines(log.snapshotAfter)
74
+
75
+ const changedFields = new Set<string>()
76
+ for (const afterLine of afterLines) {
77
+ if (typeof afterLine.id !== 'string') continue
78
+ const beforeLine = beforeById.get(afterLine.id)
79
+ if (!beforeLine) continue
80
+
81
+ for (const field of USER_EDITABLE_LINE_FIELDS) {
82
+ if (!historyValuesEqual(beforeLine[field], afterLine[field])) {
83
+ changedFields.add(field)
84
+ }
85
+ }
86
+ }
87
+
88
+ return USER_EDITABLE_LINE_FIELDS.filter((field) => changedFields.has(field))
89
+ }
90
+
91
+ export function deriveHistoryChangedFields(log: ActionLog): string[] {
92
+ if (DOCUMENT_LINE_UPSERT_COMMANDS.has(log.commandId)) {
93
+ return deriveDocumentLineChangedFields(log)
94
+ }
95
+ const persisted = Array.isArray(log.changedFields)
96
+ ? log.changedFields.filter((field): field is string => typeof field === 'string' && field.trim().length > 0)
97
+ : []
98
+ return Array.from(new Set(persisted))
99
+ }
100
+
19
101
  function extractStatusFromSnapshot(snapshot: unknown): string | null {
20
102
  if (!snapshot || typeof snapshot !== 'object') return null
21
103
  const s = snapshot as Record<string, unknown>
@@ -64,9 +146,14 @@ export function normalizeActionLogToHistoryEntry(
64
146
  displayUsers?: Record<string, string>,
65
147
  ): HistoryEntry {
66
148
  const statusChange = detectStatusChange(log)
149
+ const changedFields = deriveHistoryChangedFields(log)
67
150
  let entryKind: 'status' | 'action' = 'action'
68
151
  let action = log.actionLabel || log.commandId
69
- let metadata: HistoryEntry['metadata'] = { documentKind: kind, commandId: log.commandId }
152
+ let metadata: HistoryEntry['metadata'] = {
153
+ documentKind: kind,
154
+ commandId: log.commandId,
155
+ ...(changedFields.length > 0 ? { changedFields } : {}),
156
+ }
70
157
  if (statusChange) {
71
158
  const hasStatusValues = statusChange.statusFrom != null || statusChange.statusTo != null
72
159
  if (hasStatusValues) {