@open-mercato/core 0.4.2-canary-ccd610ad18 → 0.4.2-canary-cae9dafa24

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 (54) hide show
  1. package/dist/modules/api_docs/backend/docs/page.js +4 -1
  2. package/dist/modules/api_docs/backend/docs/page.js.map +2 -2
  3. package/dist/modules/auth/lib/setup-app.js +4 -0
  4. package/dist/modules/auth/lib/setup-app.js.map +2 -2
  5. package/dist/modules/sales/acl.js +3 -1
  6. package/dist/modules/sales/acl.js.map +2 -2
  7. package/dist/modules/sales/api/dashboard/widgets/new-orders/route.js +163 -0
  8. package/dist/modules/sales/api/dashboard/widgets/new-orders/route.js.map +7 -0
  9. package/dist/modules/sales/api/dashboard/widgets/new-quotes/route.js +165 -0
  10. package/dist/modules/sales/api/dashboard/widgets/new-quotes/route.js.map +7 -0
  11. package/dist/modules/sales/api/dashboard/widgets/utils.js +38 -0
  12. package/dist/modules/sales/api/dashboard/widgets/utils.js.map +7 -0
  13. package/dist/modules/sales/lib/customerSnapshot.js +21 -0
  14. package/dist/modules/sales/lib/customerSnapshot.js.map +7 -0
  15. package/dist/modules/sales/lib/dateRange.js +39 -0
  16. package/dist/modules/sales/lib/dateRange.js.map +7 -0
  17. package/dist/modules/sales/widgets/dashboard/new-orders/config.js +32 -0
  18. package/dist/modules/sales/widgets/dashboard/new-orders/config.js.map +7 -0
  19. package/dist/modules/sales/widgets/dashboard/new-orders/widget.client.js +252 -0
  20. package/dist/modules/sales/widgets/dashboard/new-orders/widget.client.js.map +7 -0
  21. package/dist/modules/sales/widgets/dashboard/new-orders/widget.js +33 -0
  22. package/dist/modules/sales/widgets/dashboard/new-orders/widget.js.map +7 -0
  23. package/dist/modules/sales/widgets/dashboard/new-quotes/config.js +32 -0
  24. package/dist/modules/sales/widgets/dashboard/new-quotes/config.js.map +7 -0
  25. package/dist/modules/sales/widgets/dashboard/new-quotes/widget.client.js +272 -0
  26. package/dist/modules/sales/widgets/dashboard/new-quotes/widget.client.js.map +7 -0
  27. package/dist/modules/sales/widgets/dashboard/new-quotes/widget.js +33 -0
  28. package/dist/modules/sales/widgets/dashboard/new-quotes/widget.js.map +7 -0
  29. package/package.json +2 -2
  30. package/src/modules/api_docs/backend/docs/page.tsx +2 -1
  31. package/src/modules/auth/lib/setup-app.ts +4 -0
  32. package/src/modules/customers/README.md +2 -1
  33. package/src/modules/entities/README.md +1 -1
  34. package/src/modules/sales/acl.ts +2 -0
  35. package/src/modules/sales/api/dashboard/widgets/new-orders/__tests__/route.test.ts +60 -0
  36. package/src/modules/sales/api/dashboard/widgets/new-orders/route.ts +192 -0
  37. package/src/modules/sales/api/dashboard/widgets/new-quotes/__tests__/route.test.ts +61 -0
  38. package/src/modules/sales/api/dashboard/widgets/new-quotes/route.ts +194 -0
  39. package/src/modules/sales/api/dashboard/widgets/utils.ts +53 -0
  40. package/src/modules/sales/i18n/de.json +32 -1
  41. package/src/modules/sales/i18n/en.json +32 -1
  42. package/src/modules/sales/i18n/es.json +32 -1
  43. package/src/modules/sales/i18n/pl.json +32 -1
  44. package/src/modules/sales/lib/__tests__/dateRange.test.ts +26 -0
  45. package/src/modules/sales/lib/customerSnapshot.ts +17 -0
  46. package/src/modules/sales/lib/dateRange.ts +42 -0
  47. package/src/modules/sales/widgets/dashboard/new-orders/__tests__/config.test.ts +28 -0
  48. package/src/modules/sales/widgets/dashboard/new-orders/config.ts +48 -0
  49. package/src/modules/sales/widgets/dashboard/new-orders/widget.client.tsx +295 -0
  50. package/src/modules/sales/widgets/dashboard/new-orders/widget.ts +33 -0
  51. package/src/modules/sales/widgets/dashboard/new-quotes/__tests__/config.test.ts +28 -0
  52. package/src/modules/sales/widgets/dashboard/new-quotes/config.ts +48 -0
  53. package/src/modules/sales/widgets/dashboard/new-quotes/widget.client.tsx +322 -0
  54. package/src/modules/sales/widgets/dashboard/new-quotes/widget.ts +33 -0
@@ -0,0 +1,26 @@
1
+ import { parseDateInput, resolveDateRange } from '../dateRange'
2
+
3
+ describe('resolveDateRange', () => {
4
+ it('resolves last24h', () => {
5
+ const now = new Date('2026-01-27T12:00:00.000Z')
6
+ const { from, to } = resolveDateRange('last24h', null, null, now)
7
+ expect(to.toISOString()).toBe(now.toISOString())
8
+ const diff = to.getTime() - from.getTime()
9
+ expect(diff).toBe(24 * 60 * 60 * 1000)
10
+ })
11
+
12
+ it('resolves last7d', () => {
13
+ const now = new Date('2026-01-27T12:00:00.000Z')
14
+ const { from, to } = resolveDateRange('last7d', null, null, now)
15
+ const diff = to.getTime() - from.getTime()
16
+ expect(diff).toBe(7 * 24 * 60 * 60 * 1000)
17
+ })
18
+
19
+ it('resolves custom range', () => {
20
+ const fromInput = parseDateInput('2026-01-20T00:00:00Z')
21
+ const toInput = parseDateInput('2026-01-27T23:59:59Z')
22
+ const { from, to } = resolveDateRange('custom', fromInput, toInput, new Date('2026-01-27T12:00:00Z'))
23
+ expect(from.toISOString()).toBe('2026-01-20T00:00:00.000Z')
24
+ expect(to.toISOString()).toBe('2026-01-27T23:59:59.000Z')
25
+ })
26
+ })
@@ -0,0 +1,17 @@
1
+ export function extractCustomerName(snapshot: unknown): string | null {
2
+ if (!snapshot || typeof snapshot !== 'object') return null
3
+ const data = snapshot as Record<string, unknown>
4
+ const candidates = [
5
+ data.display_name,
6
+ data.displayName,
7
+ data.name,
8
+ data.company_name,
9
+ data.companyName,
10
+ data.full_name,
11
+ data.fullName,
12
+ ]
13
+ for (const candidate of candidates) {
14
+ if (typeof candidate === 'string' && candidate.trim().length > 0) return candidate
15
+ }
16
+ return null
17
+ }
@@ -0,0 +1,42 @@
1
+ export type DatePeriodOption = 'last24h' | 'last7d' | 'last30d' | 'custom'
2
+
3
+ export const DATE_PERIOD_OPTIONS: DatePeriodOption[] = ['last24h', 'last7d', 'last30d', 'custom']
4
+
5
+ export function parseDateInput(value?: string | null): Date | null {
6
+ if (!value || typeof value !== 'string') return null
7
+ const date = new Date(value)
8
+ if (Number.isNaN(date.getTime())) return null
9
+ return date
10
+ }
11
+
12
+ export function resolveDateRange(
13
+ period: DatePeriodOption,
14
+ customFrom?: Date | null,
15
+ customTo?: Date | null,
16
+ now: Date = new Date(),
17
+ ): { from: Date; to: Date } {
18
+ const to = now
19
+ switch (period) {
20
+ case 'last24h': {
21
+ const from = new Date(now.getTime() - 24 * 60 * 60 * 1000)
22
+ return { from, to }
23
+ }
24
+ case 'last7d': {
25
+ const from = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000)
26
+ return { from, to }
27
+ }
28
+ case 'last30d': {
29
+ const from = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000)
30
+ return { from, to }
31
+ }
32
+ case 'custom': {
33
+ const from = customFrom ?? new Date(0)
34
+ const boundedTo = customTo ?? now
35
+ return { from, to: boundedTo }
36
+ }
37
+ default: {
38
+ const from = new Date(now.getTime() - 24 * 60 * 60 * 1000)
39
+ return { from, to }
40
+ }
41
+ }
42
+ }
@@ -0,0 +1,28 @@
1
+ import { DEFAULT_SETTINGS, hydrateSalesNewOrdersSettings } from '../config'
2
+
3
+ describe('hydrateSalesNewOrdersSettings', () => {
4
+ it('returns default settings for null input', () => {
5
+ expect(hydrateSalesNewOrdersSettings(null)).toEqual(DEFAULT_SETTINGS)
6
+ })
7
+
8
+ it('validates and clamps pageSize', () => {
9
+ expect(hydrateSalesNewOrdersSettings({ pageSize: 0 })).toHaveProperty('pageSize', DEFAULT_SETTINGS.pageSize)
10
+ expect(hydrateSalesNewOrdersSettings({ pageSize: 25 })).toHaveProperty('pageSize', 20)
11
+ expect(hydrateSalesNewOrdersSettings({ pageSize: 10 })).toHaveProperty('pageSize', 10)
12
+ })
13
+
14
+ it('validates datePeriod', () => {
15
+ expect(hydrateSalesNewOrdersSettings({ datePeriod: 'invalid' })).toHaveProperty('datePeriod', DEFAULT_SETTINGS.datePeriod)
16
+ expect(hydrateSalesNewOrdersSettings({ datePeriod: 'last7d' })).toHaveProperty('datePeriod', 'last7d')
17
+ })
18
+
19
+ it('includes custom dates only when datePeriod is custom', () => {
20
+ const result = hydrateSalesNewOrdersSettings({
21
+ datePeriod: 'custom',
22
+ customFrom: '2026-01-20T00:00:00Z',
23
+ customTo: '2026-01-27T23:59:59Z',
24
+ })
25
+ expect(result.customFrom).toBe('2026-01-20T00:00:00Z')
26
+ expect(result.customTo).toBe('2026-01-27T23:59:59Z')
27
+ })
28
+ })
@@ -0,0 +1,48 @@
1
+ import type { DatePeriodOption } from '../../../lib/dateRange'
2
+
3
+ export type SalesNewOrdersSettings = {
4
+ pageSize: number
5
+ datePeriod: DatePeriodOption
6
+ customFrom?: string
7
+ customTo?: string
8
+ }
9
+
10
+ export const DEFAULT_SETTINGS: SalesNewOrdersSettings = {
11
+ pageSize: 5,
12
+ datePeriod: 'last24h',
13
+ }
14
+
15
+ export function hydrateSalesNewOrdersSettings(raw: unknown): SalesNewOrdersSettings {
16
+ if (!raw || typeof raw !== 'object') return { ...DEFAULT_SETTINGS }
17
+ const input = raw as Partial<SalesNewOrdersSettings>
18
+ const parsedPageSize = Number(input.pageSize)
19
+ const pageSize = Number.isFinite(parsedPageSize)
20
+ ? Math.min(20, Math.max(1, Math.floor(parsedPageSize)))
21
+ : DEFAULT_SETTINGS.pageSize
22
+
23
+ const datePeriod: DatePeriodOption =
24
+ input.datePeriod === 'last24h' ||
25
+ input.datePeriod === 'last7d' ||
26
+ input.datePeriod === 'last30d' ||
27
+ input.datePeriod === 'custom'
28
+ ? input.datePeriod
29
+ : DEFAULT_SETTINGS.datePeriod
30
+
31
+ let customFrom: string | undefined
32
+ let customTo: string | undefined
33
+ if (datePeriod === 'custom') {
34
+ if (typeof input.customFrom === 'string' && !Number.isNaN(new Date(input.customFrom).getTime())) {
35
+ customFrom = input.customFrom
36
+ }
37
+ if (typeof input.customTo === 'string' && !Number.isNaN(new Date(input.customTo).getTime())) {
38
+ customTo = input.customTo
39
+ }
40
+ }
41
+
42
+ return {
43
+ pageSize,
44
+ datePeriod,
45
+ customFrom,
46
+ customTo,
47
+ }
48
+ }
@@ -0,0 +1,295 @@
1
+ "use client"
2
+
3
+ import * as React from 'react'
4
+ import Link from 'next/link'
5
+ import type { DashboardWidgetComponentProps } from '@open-mercato/shared/modules/dashboard/widgets'
6
+ import { apiCall } from '@open-mercato/ui/backend/utils/apiCall'
7
+ import { Spinner } from '@open-mercato/ui/primitives/spinner'
8
+ import { Badge } from '@open-mercato/ui/primitives/badge'
9
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
10
+ import {
11
+ DEFAULT_SETTINGS,
12
+ hydrateSalesNewOrdersSettings,
13
+ type SalesNewOrdersSettings,
14
+ } from './config'
15
+ import type { DatePeriodOption } from '../../../lib/dateRange'
16
+
17
+ type OrderItem = {
18
+ id: string
19
+ orderNumber: string
20
+ status: string | null
21
+ fulfillmentStatus: string | null
22
+ paymentStatus: string | null
23
+ customerName: string | null
24
+ customerEntityId: string | null
25
+ netAmount: string
26
+ grossAmount: string
27
+ currency: string | null
28
+ createdAt: string
29
+ }
30
+
31
+ function formatCurrency(value: string | null, currency: string | null, locale?: string): string {
32
+ const amount = typeof value === 'string' ? Number(value) : Number.NaN
33
+ if (!Number.isFinite(amount)) return '—'
34
+ const code = currency || 'USD'
35
+ try {
36
+ return new Intl.NumberFormat(locale ?? undefined, { style: 'currency', currency: code }).format(amount)
37
+ } catch {
38
+ return `${amount.toFixed(2)} ${code}`
39
+ }
40
+ }
41
+
42
+ function formatRelativeDate(value: string, locale?: string): string {
43
+ const date = new Date(value)
44
+ if (Number.isNaN(date.getTime())) return ''
45
+ const now = new Date()
46
+ const diffMs = date.getTime() - now.getTime()
47
+ const absMs = Math.abs(diffMs)
48
+ const rtf = new Intl.RelativeTimeFormat(locale ?? undefined, { numeric: 'auto' })
49
+ if (absMs < 60 * 1000) {
50
+ return rtf.format(Math.round(diffMs / 1000), 'second')
51
+ }
52
+ if (absMs < 60 * 60 * 1000) {
53
+ return rtf.format(Math.round(diffMs / (60 * 1000)), 'minute')
54
+ }
55
+ if (absMs < 24 * 60 * 60 * 1000) {
56
+ return rtf.format(Math.round(diffMs / (60 * 60 * 1000)), 'hour')
57
+ }
58
+ return rtf.format(Math.round(diffMs / (24 * 60 * 60 * 1000)), 'day')
59
+ }
60
+
61
+ async function loadOrders(settings: SalesNewOrdersSettings): Promise<OrderItem[]> {
62
+ const params = new URLSearchParams({
63
+ limit: String(settings.pageSize),
64
+ datePeriod: settings.datePeriod,
65
+ })
66
+ if (settings.datePeriod === 'custom') {
67
+ if (settings.customFrom) params.set('customFrom', settings.customFrom)
68
+ if (settings.customTo) params.set('customTo', settings.customTo)
69
+ }
70
+ const call = await apiCall<{ items?: unknown[]; error?: string }>(
71
+ `/api/sales/dashboard/widgets/new-orders?${params.toString()}`,
72
+ )
73
+ if (!call.ok) {
74
+ const message =
75
+ typeof (call.result as Record<string, unknown> | null)?.error === 'string'
76
+ ? ((call.result as Record<string, unknown>).error as string)
77
+ : `Request failed with status ${call.status}`
78
+ throw new Error(message)
79
+ }
80
+ const payload = call.result ?? {}
81
+ const rawItems = Array.isArray((payload as { items?: unknown[] }).items)
82
+ ? (payload as { items: unknown[] }).items
83
+ : []
84
+ return rawItems
85
+ .map((item: unknown): OrderItem | null => {
86
+ if (!item || typeof item !== 'object') return null
87
+ const data = item as any
88
+ return {
89
+ id: typeof data.id === 'string' ? data.id : null,
90
+ orderNumber: typeof data.orderNumber === 'string' ? data.orderNumber : '',
91
+ status: typeof data.status === 'string' ? data.status : null,
92
+ fulfillmentStatus: typeof data.fulfillmentStatus === 'string' ? data.fulfillmentStatus : null,
93
+ paymentStatus: typeof data.paymentStatus === 'string' ? data.paymentStatus : null,
94
+ customerName: typeof data.customerName === 'string' ? data.customerName : null,
95
+ customerEntityId: typeof data.customerEntityId === 'string' ? data.customerEntityId : null,
96
+ netAmount: typeof data.netAmount === 'string' ? data.netAmount : '0',
97
+ grossAmount: typeof data.grossAmount === 'string' ? data.grossAmount : '0',
98
+ currency: typeof data.currency === 'string' ? data.currency : null,
99
+ createdAt: typeof data.createdAt === 'string' ? data.createdAt : '',
100
+ }
101
+ })
102
+ .filter((item: OrderItem | null): item is OrderItem => !!item && !!item.id && !!item.createdAt)
103
+ }
104
+
105
+ const SalesNewOrdersWidget: React.FC<DashboardWidgetComponentProps<SalesNewOrdersSettings>> = ({
106
+ mode,
107
+ settings = DEFAULT_SETTINGS,
108
+ onSettingsChange,
109
+ refreshToken,
110
+ onRefreshStateChange,
111
+ }) => {
112
+ const t = useT()
113
+ const hydrated = React.useMemo(() => hydrateSalesNewOrdersSettings(settings), [settings])
114
+ const [items, setItems] = React.useState<OrderItem[]>([])
115
+ const [loading, setLoading] = React.useState(true)
116
+ const [error, setError] = React.useState<string | null>(null)
117
+ const [locale, setLocale] = React.useState<string | undefined>(undefined)
118
+
119
+ React.useEffect(() => {
120
+ if (typeof navigator !== 'undefined') {
121
+ setLocale(navigator.language)
122
+ }
123
+ }, [])
124
+
125
+ const refresh = React.useCallback(async () => {
126
+ onRefreshStateChange?.(true)
127
+ setLoading(true)
128
+ setError(null)
129
+ try {
130
+ const data = await loadOrders(hydrated)
131
+ setItems(data)
132
+ } catch (err) {
133
+ console.error('Failed to load new orders widget data', err)
134
+ setError(t('sales.widgets.newOrders.error', 'Failed to load orders'))
135
+ } finally {
136
+ setLoading(false)
137
+ onRefreshStateChange?.(false)
138
+ }
139
+ }, [hydrated, onRefreshStateChange, t])
140
+
141
+ React.useEffect(() => {
142
+ refresh().catch(() => {})
143
+ }, [refresh, refreshToken])
144
+
145
+ if (mode === 'settings') {
146
+ return (
147
+ <div className="space-y-4 text-sm">
148
+ <div className="space-y-1.5">
149
+ <label
150
+ htmlFor="sales-new-orders-page-size"
151
+ className="text-xs font-semibold uppercase text-muted-foreground"
152
+ >
153
+ {t('sales.widgets.newOrders.settings.pageSize', 'Number of Orders')}
154
+ </label>
155
+ <input
156
+ id="sales-new-orders-page-size"
157
+ type="number"
158
+ min={1}
159
+ max={20}
160
+ className="w-24 rounded-md border px-2 py-1 text-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
161
+ value={hydrated.pageSize}
162
+ onChange={(event) => {
163
+ const next = Number(event.target.value)
164
+ const value = Number.isFinite(next)
165
+ ? Math.min(20, Math.max(1, Math.floor(next)))
166
+ : hydrated.pageSize
167
+ onSettingsChange({ ...hydrated, pageSize: value })
168
+ }}
169
+ />
170
+ </div>
171
+ <div className="space-y-1.5">
172
+ <label
173
+ htmlFor="sales-new-orders-date-period"
174
+ className="text-xs font-semibold uppercase text-muted-foreground"
175
+ >
176
+ {t('sales.widgets.newOrders.settings.datePeriod', 'Date Period')}
177
+ </label>
178
+ <select
179
+ id="sales-new-orders-date-period"
180
+ className="w-full rounded-md border px-2 py-1 text-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
181
+ value={hydrated.datePeriod}
182
+ onChange={(event) => {
183
+ onSettingsChange({ ...hydrated, datePeriod: event.target.value as DatePeriodOption })
184
+ }}
185
+ >
186
+ <option value="last24h">{t('sales.widgets.newOrders.settings.last24h', 'Last 24 hours')}</option>
187
+ <option value="last7d">{t('sales.widgets.newOrders.settings.last7d', 'Last 7 days')}</option>
188
+ <option value="last30d">{t('sales.widgets.newOrders.settings.last30d', 'Last 30 days')}</option>
189
+ <option value="custom">{t('sales.widgets.newOrders.settings.custom', 'Custom range')}</option>
190
+ </select>
191
+ </div>
192
+ {hydrated.datePeriod === 'custom' ? (
193
+ <>
194
+ <div className="space-y-1.5">
195
+ <label
196
+ htmlFor="sales-new-orders-custom-from"
197
+ className="text-xs font-semibold uppercase text-muted-foreground"
198
+ >
199
+ {t('sales.widgets.newOrders.settings.customFrom', 'From')}
200
+ </label>
201
+ <input
202
+ id="sales-new-orders-custom-from"
203
+ type="date"
204
+ className="w-full rounded-md border px-2 py-1 text-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
205
+ value={hydrated.customFrom ?? ''}
206
+ onChange={(event) => {
207
+ onSettingsChange({ ...hydrated, customFrom: event.target.value })
208
+ }}
209
+ />
210
+ </div>
211
+ <div className="space-y-1.5">
212
+ <label
213
+ htmlFor="sales-new-orders-custom-to"
214
+ className="text-xs font-semibold uppercase text-muted-foreground"
215
+ >
216
+ {t('sales.widgets.newOrders.settings.customTo', 'To')}
217
+ </label>
218
+ <input
219
+ id="sales-new-orders-custom-to"
220
+ type="date"
221
+ className="w-full rounded-md border px-2 py-1 text-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
222
+ value={hydrated.customTo ?? ''}
223
+ onChange={(event) => {
224
+ onSettingsChange({ ...hydrated, customTo: event.target.value })
225
+ }}
226
+ />
227
+ </div>
228
+ </>
229
+ ) : null}
230
+ </div>
231
+ )
232
+ }
233
+
234
+ if (error) {
235
+ return <p className="text-sm text-destructive">{error}</p>
236
+ }
237
+
238
+ if (loading) {
239
+ return (
240
+ <div className="flex h-32 items-center justify-center">
241
+ <Spinner className="h-6 w-6 text-muted-foreground" />
242
+ </div>
243
+ )
244
+ }
245
+
246
+ if (items.length === 0) {
247
+ return (
248
+ <p className="text-sm text-muted-foreground">
249
+ {t('sales.widgets.newOrders.empty', 'No orders found in this period')}
250
+ </p>
251
+ )
252
+ }
253
+
254
+ return (
255
+ <ul className="space-y-3">
256
+ {items.map((order) => {
257
+ const createdLabel = formatRelativeDate(order.createdAt, locale)
258
+ return (
259
+ <li key={order.id} className="rounded-md border p-3">
260
+ <div className="flex items-start justify-between gap-3">
261
+ <div className="space-y-1">
262
+ <div className="flex items-center gap-2">
263
+ <Link
264
+ className="text-sm font-medium text-foreground hover:underline"
265
+ href={`/backend/sales/orders/${encodeURIComponent(order.id)}`}
266
+ >
267
+ {order.orderNumber}
268
+ </Link>
269
+ {order.status ? (
270
+ <Badge variant="outline" className="text-[11px]">
271
+ {order.status}
272
+ </Badge>
273
+ ) : null}
274
+ </div>
275
+ <p className="text-xs text-muted-foreground">
276
+ {order.customerName ?? t('sales.widgets.newOrders.noCustomer', 'No customer')}
277
+ </p>
278
+ <p className="text-xs text-muted-foreground">
279
+ {createdLabel || t('sales.widgets.newOrders.unknownDate', 'Unknown date')}
280
+ </p>
281
+ </div>
282
+ <div className="text-right">
283
+ <p className="text-sm font-semibold">
284
+ {formatCurrency(order.grossAmount, order.currency, locale)}
285
+ </p>
286
+ </div>
287
+ </div>
288
+ </li>
289
+ )
290
+ })}
291
+ </ul>
292
+ )
293
+ }
294
+
295
+ export default SalesNewOrdersWidget
@@ -0,0 +1,33 @@
1
+ import type { DashboardWidgetModule } from '@open-mercato/shared/modules/dashboard/widgets'
2
+ import SalesNewOrdersWidget from './widget.client'
3
+ import {
4
+ DEFAULT_SETTINGS,
5
+ hydrateSalesNewOrdersSettings,
6
+ type SalesNewOrdersSettings,
7
+ } from './config'
8
+
9
+ const widget: DashboardWidgetModule<SalesNewOrdersSettings> = {
10
+ metadata: {
11
+ id: 'sales.dashboard.newOrders',
12
+ title: 'New Orders',
13
+ description: 'Displays recently created sales orders.',
14
+ features: ['dashboards.view', 'sales.widgets.new-orders'],
15
+ defaultSize: 'md',
16
+ defaultEnabled: true,
17
+ defaultSettings: DEFAULT_SETTINGS,
18
+ tags: ['sales', 'orders'],
19
+ category: 'sales',
20
+ icon: 'lucide:shopping-cart',
21
+ supportsRefresh: true,
22
+ },
23
+ Widget: SalesNewOrdersWidget,
24
+ hydrateSettings: hydrateSalesNewOrdersSettings,
25
+ dehydrateSettings: (settings) => ({
26
+ pageSize: settings.pageSize,
27
+ datePeriod: settings.datePeriod,
28
+ customFrom: settings.customFrom,
29
+ customTo: settings.customTo,
30
+ }),
31
+ }
32
+
33
+ export default widget
@@ -0,0 +1,28 @@
1
+ import { DEFAULT_SETTINGS, hydrateSalesNewQuotesSettings } from '../config'
2
+
3
+ describe('hydrateSalesNewQuotesSettings', () => {
4
+ it('returns default settings for null input', () => {
5
+ expect(hydrateSalesNewQuotesSettings(null)).toEqual(DEFAULT_SETTINGS)
6
+ })
7
+
8
+ it('validates and clamps pageSize', () => {
9
+ expect(hydrateSalesNewQuotesSettings({ pageSize: 0 })).toHaveProperty('pageSize', DEFAULT_SETTINGS.pageSize)
10
+ expect(hydrateSalesNewQuotesSettings({ pageSize: 25 })).toHaveProperty('pageSize', 20)
11
+ expect(hydrateSalesNewQuotesSettings({ pageSize: 10 })).toHaveProperty('pageSize', 10)
12
+ })
13
+
14
+ it('validates datePeriod', () => {
15
+ expect(hydrateSalesNewQuotesSettings({ datePeriod: 'invalid' })).toHaveProperty('datePeriod', DEFAULT_SETTINGS.datePeriod)
16
+ expect(hydrateSalesNewQuotesSettings({ datePeriod: 'last30d' })).toHaveProperty('datePeriod', 'last30d')
17
+ })
18
+
19
+ it('includes custom dates only when datePeriod is custom', () => {
20
+ const result = hydrateSalesNewQuotesSettings({
21
+ datePeriod: 'custom',
22
+ customFrom: '2026-01-20T00:00:00Z',
23
+ customTo: '2026-01-27T23:59:59Z',
24
+ })
25
+ expect(result.customFrom).toBe('2026-01-20T00:00:00Z')
26
+ expect(result.customTo).toBe('2026-01-27T23:59:59Z')
27
+ })
28
+ })
@@ -0,0 +1,48 @@
1
+ import type { DatePeriodOption } from '../../../lib/dateRange'
2
+
3
+ export type SalesNewQuotesSettings = {
4
+ pageSize: number
5
+ datePeriod: DatePeriodOption
6
+ customFrom?: string
7
+ customTo?: string
8
+ }
9
+
10
+ export const DEFAULT_SETTINGS: SalesNewQuotesSettings = {
11
+ pageSize: 5,
12
+ datePeriod: 'last24h',
13
+ }
14
+
15
+ export function hydrateSalesNewQuotesSettings(raw: unknown): SalesNewQuotesSettings {
16
+ if (!raw || typeof raw !== 'object') return { ...DEFAULT_SETTINGS }
17
+ const input = raw as Partial<SalesNewQuotesSettings>
18
+ const parsedPageSize = Number(input.pageSize)
19
+ const pageSize = Number.isFinite(parsedPageSize)
20
+ ? Math.min(20, Math.max(1, Math.floor(parsedPageSize)))
21
+ : DEFAULT_SETTINGS.pageSize
22
+
23
+ const datePeriod: DatePeriodOption =
24
+ input.datePeriod === 'last24h' ||
25
+ input.datePeriod === 'last7d' ||
26
+ input.datePeriod === 'last30d' ||
27
+ input.datePeriod === 'custom'
28
+ ? input.datePeriod
29
+ : DEFAULT_SETTINGS.datePeriod
30
+
31
+ let customFrom: string | undefined
32
+ let customTo: string | undefined
33
+ if (datePeriod === 'custom') {
34
+ if (typeof input.customFrom === 'string' && !Number.isNaN(new Date(input.customFrom).getTime())) {
35
+ customFrom = input.customFrom
36
+ }
37
+ if (typeof input.customTo === 'string' && !Number.isNaN(new Date(input.customTo).getTime())) {
38
+ customTo = input.customTo
39
+ }
40
+ }
41
+
42
+ return {
43
+ pageSize,
44
+ datePeriod,
45
+ customFrom,
46
+ customTo,
47
+ }
48
+ }