@open-mercato/core 0.4.2-canary-ccd610ad18 → 0.4.2-canary-92bc12ea91

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 +49 -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 +49 -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,322 @@
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
+ hydrateSalesNewQuotesSettings,
13
+ type SalesNewQuotesSettings,
14
+ } from './config'
15
+ import type { DatePeriodOption } from '../../../lib/dateRange'
16
+
17
+ type QuoteItem = {
18
+ id: string
19
+ quoteNumber: string
20
+ status: string | null
21
+ customerName: string | null
22
+ customerEntityId: string | null
23
+ validFrom: string | null
24
+ validUntil: string | null
25
+ netAmount: string
26
+ grossAmount: string
27
+ currency: string | null
28
+ createdAt: string
29
+ convertedOrderId: string | null
30
+ }
31
+
32
+ function formatCurrency(value: string | null, currency: string | null, locale?: string): string {
33
+ const amount = typeof value === 'string' ? Number(value) : Number.NaN
34
+ if (!Number.isFinite(amount)) return '—'
35
+ const code = currency || 'USD'
36
+ try {
37
+ return new Intl.NumberFormat(locale ?? undefined, { style: 'currency', currency: code }).format(amount)
38
+ } catch {
39
+ return `${amount.toFixed(2)} ${code}`
40
+ }
41
+ }
42
+
43
+ function formatRelativeDate(value: string, locale?: string): string {
44
+ const date = new Date(value)
45
+ if (Number.isNaN(date.getTime())) return ''
46
+ const now = new Date()
47
+ const diffMs = date.getTime() - now.getTime()
48
+ const absMs = Math.abs(diffMs)
49
+ const rtf = new Intl.RelativeTimeFormat(locale ?? undefined, { numeric: 'auto' })
50
+ if (absMs < 60 * 1000) {
51
+ return rtf.format(Math.round(diffMs / 1000), 'second')
52
+ }
53
+ if (absMs < 60 * 60 * 1000) {
54
+ return rtf.format(Math.round(diffMs / (60 * 1000)), 'minute')
55
+ }
56
+ if (absMs < 24 * 60 * 60 * 1000) {
57
+ return rtf.format(Math.round(diffMs / (60 * 60 * 1000)), 'hour')
58
+ }
59
+ return rtf.format(Math.round(diffMs / (24 * 60 * 60 * 1000)), 'day')
60
+ }
61
+
62
+ function formatDate(value: string | null, locale?: string): string {
63
+ if (!value) return ''
64
+ const date = new Date(value)
65
+ if (Number.isNaN(date.getTime())) return ''
66
+ return date.toLocaleDateString(locale ?? undefined, { dateStyle: 'medium' })
67
+ }
68
+
69
+ async function loadQuotes(settings: SalesNewQuotesSettings): Promise<QuoteItem[]> {
70
+ const params = new URLSearchParams({
71
+ limit: String(settings.pageSize),
72
+ datePeriod: settings.datePeriod,
73
+ })
74
+ if (settings.datePeriod === 'custom') {
75
+ if (settings.customFrom) params.set('customFrom', settings.customFrom)
76
+ if (settings.customTo) params.set('customTo', settings.customTo)
77
+ }
78
+ const call = await apiCall<{ items?: unknown[]; error?: string }>(
79
+ `/api/sales/dashboard/widgets/new-quotes?${params.toString()}`,
80
+ )
81
+ if (!call.ok) {
82
+ const message =
83
+ typeof (call.result as Record<string, unknown> | null)?.error === 'string'
84
+ ? ((call.result as Record<string, unknown>).error as string)
85
+ : `Request failed with status ${call.status}`
86
+ throw new Error(message)
87
+ }
88
+ const payload = call.result ?? {}
89
+ const rawItems = Array.isArray((payload as { items?: unknown[] }).items)
90
+ ? (payload as { items: unknown[] }).items
91
+ : []
92
+ return rawItems
93
+ .map((item: unknown): QuoteItem | null => {
94
+ if (!item || typeof item !== 'object') return null
95
+ const data = item as Record<string, unknown>
96
+ if (typeof data.id !== 'string' || typeof data.createdAt !== 'string') return null
97
+ const netAmount = data.netAmount
98
+ const grossAmount = data.grossAmount
99
+ return {
100
+ id: data.id,
101
+ quoteNumber: typeof data.quoteNumber === 'string' ? data.quoteNumber : '',
102
+ status: typeof data.status === 'string' ? data.status : null,
103
+ customerName: typeof data.customerName === 'string' ? data.customerName : null,
104
+ customerEntityId: typeof data.customerEntityId === 'string' ? data.customerEntityId : null,
105
+ validFrom: typeof data.validFrom === 'string' ? data.validFrom : null,
106
+ validUntil: typeof data.validUntil === 'string' ? data.validUntil : null,
107
+ netAmount: typeof netAmount === 'string' ? netAmount : typeof netAmount === 'number' ? String(netAmount) : '0',
108
+ grossAmount:
109
+ typeof grossAmount === 'string' ? grossAmount : typeof grossAmount === 'number' ? String(grossAmount) : '0',
110
+ currency: typeof data.currency === 'string' ? data.currency : null,
111
+ createdAt: data.createdAt,
112
+ convertedOrderId: typeof data.convertedOrderId === 'string' ? data.convertedOrderId : null,
113
+ }
114
+ })
115
+ .filter((item: QuoteItem | null): item is QuoteItem => !!item)
116
+ }
117
+
118
+ const SalesNewQuotesWidget: React.FC<DashboardWidgetComponentProps<SalesNewQuotesSettings>> = ({
119
+ mode,
120
+ settings = DEFAULT_SETTINGS,
121
+ onSettingsChange,
122
+ refreshToken,
123
+ onRefreshStateChange,
124
+ }) => {
125
+ const t = useT()
126
+ const hydrated = React.useMemo(() => hydrateSalesNewQuotesSettings(settings), [settings])
127
+ const [items, setItems] = React.useState<QuoteItem[]>([])
128
+ const [loading, setLoading] = React.useState(true)
129
+ const [error, setError] = React.useState<string | null>(null)
130
+ const [locale, setLocale] = React.useState<string | undefined>(undefined)
131
+
132
+ React.useEffect(() => {
133
+ if (typeof navigator !== 'undefined') {
134
+ setLocale(navigator.language)
135
+ }
136
+ }, [])
137
+
138
+ const refresh = React.useCallback(async () => {
139
+ onRefreshStateChange?.(true)
140
+ setLoading(true)
141
+ setError(null)
142
+ try {
143
+ const data = await loadQuotes(hydrated)
144
+ setItems(data)
145
+ } catch (err) {
146
+ console.error('Failed to load new quotes widget data', err)
147
+ setError(t('sales.widgets.newQuotes.error', 'Failed to load quotes'))
148
+ } finally {
149
+ setLoading(false)
150
+ onRefreshStateChange?.(false)
151
+ }
152
+ }, [hydrated, onRefreshStateChange, t])
153
+
154
+ React.useEffect(() => {
155
+ refresh().catch(() => {})
156
+ }, [refresh, refreshToken])
157
+
158
+ if (mode === 'settings') {
159
+ return (
160
+ <div className="space-y-4 text-sm">
161
+ <div className="space-y-1.5">
162
+ <label
163
+ htmlFor="sales-new-quotes-page-size"
164
+ className="text-xs font-semibold uppercase text-muted-foreground"
165
+ >
166
+ {t('sales.widgets.newQuotes.settings.pageSize', 'Number of Quotes')}
167
+ </label>
168
+ <input
169
+ id="sales-new-quotes-page-size"
170
+ type="number"
171
+ min={1}
172
+ max={20}
173
+ className="w-24 rounded-md border px-2 py-1 text-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
174
+ value={hydrated.pageSize}
175
+ onChange={(event) => {
176
+ const next = Number(event.target.value)
177
+ const value = Number.isFinite(next)
178
+ ? Math.min(20, Math.max(1, Math.floor(next)))
179
+ : hydrated.pageSize
180
+ onSettingsChange({ ...hydrated, pageSize: value })
181
+ }}
182
+ />
183
+ </div>
184
+ <div className="space-y-1.5">
185
+ <label
186
+ htmlFor="sales-new-quotes-date-period"
187
+ className="text-xs font-semibold uppercase text-muted-foreground"
188
+ >
189
+ {t('sales.widgets.newQuotes.settings.datePeriod', 'Date Period')}
190
+ </label>
191
+ <select
192
+ id="sales-new-quotes-date-period"
193
+ className="w-full rounded-md border px-2 py-1 text-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
194
+ value={hydrated.datePeriod}
195
+ onChange={(event) => {
196
+ onSettingsChange({ ...hydrated, datePeriod: event.target.value as DatePeriodOption })
197
+ }}
198
+ >
199
+ <option value="last24h">{t('sales.widgets.newQuotes.settings.last24h', 'Last 24 hours')}</option>
200
+ <option value="last7d">{t('sales.widgets.newQuotes.settings.last7d', 'Last 7 days')}</option>
201
+ <option value="last30d">{t('sales.widgets.newQuotes.settings.last30d', 'Last 30 days')}</option>
202
+ <option value="custom">{t('sales.widgets.newQuotes.settings.custom', 'Custom range')}</option>
203
+ </select>
204
+ </div>
205
+ {hydrated.datePeriod === 'custom' ? (
206
+ <>
207
+ <div className="space-y-1.5">
208
+ <label
209
+ htmlFor="sales-new-quotes-custom-from"
210
+ className="text-xs font-semibold uppercase text-muted-foreground"
211
+ >
212
+ {t('sales.widgets.newQuotes.settings.customFrom', 'From')}
213
+ </label>
214
+ <input
215
+ id="sales-new-quotes-custom-from"
216
+ type="date"
217
+ className="w-full rounded-md border px-2 py-1 text-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
218
+ value={hydrated.customFrom ?? ''}
219
+ onChange={(event) => {
220
+ onSettingsChange({ ...hydrated, customFrom: event.target.value })
221
+ }}
222
+ />
223
+ </div>
224
+ <div className="space-y-1.5">
225
+ <label
226
+ htmlFor="sales-new-quotes-custom-to"
227
+ className="text-xs font-semibold uppercase text-muted-foreground"
228
+ >
229
+ {t('sales.widgets.newQuotes.settings.customTo', 'To')}
230
+ </label>
231
+ <input
232
+ id="sales-new-quotes-custom-to"
233
+ type="date"
234
+ className="w-full rounded-md border px-2 py-1 text-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
235
+ value={hydrated.customTo ?? ''}
236
+ onChange={(event) => {
237
+ onSettingsChange({ ...hydrated, customTo: event.target.value })
238
+ }}
239
+ />
240
+ </div>
241
+ </>
242
+ ) : null}
243
+ </div>
244
+ )
245
+ }
246
+
247
+ if (error) {
248
+ return <p className="text-sm text-destructive">{error}</p>
249
+ }
250
+
251
+ if (loading) {
252
+ return (
253
+ <div className="flex h-32 items-center justify-center">
254
+ <Spinner className="h-6 w-6 text-muted-foreground" />
255
+ </div>
256
+ )
257
+ }
258
+
259
+ if (items.length === 0) {
260
+ return (
261
+ <p className="text-sm text-muted-foreground">
262
+ {t('sales.widgets.newQuotes.empty', 'No quotes found in this period')}
263
+ </p>
264
+ )
265
+ }
266
+
267
+ return (
268
+ <ul className="space-y-3">
269
+ {items.map((quote) => {
270
+ const createdLabel = formatRelativeDate(quote.createdAt, locale)
271
+ const validUntilLabel = formatDate(quote.validUntil, locale)
272
+ const isExpired = quote.validUntil ? new Date(quote.validUntil) < new Date() : false
273
+ return (
274
+ <li key={quote.id} className="rounded-md border p-3">
275
+ <div className="flex items-start justify-between gap-3">
276
+ <div className="space-y-1">
277
+ <div className="flex flex-wrap items-center gap-2">
278
+ <Link
279
+ className="text-sm font-medium text-foreground hover:underline"
280
+ href={`/backend/sales/quotes/${encodeURIComponent(quote.id)}`}
281
+ >
282
+ {quote.quoteNumber}
283
+ </Link>
284
+ {quote.status ? (
285
+ <Badge variant="outline" className="text-[11px]">
286
+ {quote.status}
287
+ </Badge>
288
+ ) : null}
289
+ {quote.convertedOrderId ? (
290
+ <Badge variant="secondary" className="text-[11px]">
291
+ {t('sales.widgets.newQuotes.converted', 'Converted')}
292
+ </Badge>
293
+ ) : null}
294
+ </div>
295
+ <p className="text-xs text-muted-foreground">
296
+ {quote.customerName ?? t('sales.widgets.newQuotes.noCustomer', 'No customer')}
297
+ </p>
298
+ {validUntilLabel ? (
299
+ <p
300
+ className={`text-xs ${isExpired ? 'text-muted-foreground line-through' : 'text-muted-foreground'}`}
301
+ >
302
+ {t('sales.widgets.newQuotes.validUntil', 'Valid until {{date}}', { date: validUntilLabel })}
303
+ </p>
304
+ ) : null}
305
+ <p className="text-xs text-muted-foreground">
306
+ {createdLabel || t('sales.widgets.newQuotes.unknownDate', 'Unknown date')}
307
+ </p>
308
+ </div>
309
+ <div className="text-right">
310
+ <p className="text-sm font-semibold">
311
+ {formatCurrency(quote.grossAmount, quote.currency, locale)}
312
+ </p>
313
+ </div>
314
+ </div>
315
+ </li>
316
+ )
317
+ })}
318
+ </ul>
319
+ )
320
+ }
321
+
322
+ export default SalesNewQuotesWidget
@@ -0,0 +1,33 @@
1
+ import type { DashboardWidgetModule } from '@open-mercato/shared/modules/dashboard/widgets'
2
+ import SalesNewQuotesWidget from './widget.client'
3
+ import {
4
+ DEFAULT_SETTINGS,
5
+ hydrateSalesNewQuotesSettings,
6
+ type SalesNewQuotesSettings,
7
+ } from './config'
8
+
9
+ const widget: DashboardWidgetModule<SalesNewQuotesSettings> = {
10
+ metadata: {
11
+ id: 'sales.dashboard.newQuotes',
12
+ title: 'New Quotes',
13
+ description: 'Displays recently created sales quotes.',
14
+ features: ['dashboards.view', 'sales.widgets.new-quotes'],
15
+ defaultSize: 'md',
16
+ defaultEnabled: true,
17
+ defaultSettings: DEFAULT_SETTINGS,
18
+ tags: ['sales', 'quotes'],
19
+ category: 'sales',
20
+ icon: 'lucide:file-text',
21
+ supportsRefresh: true,
22
+ },
23
+ Widget: SalesNewQuotesWidget,
24
+ hydrateSettings: hydrateSalesNewQuotesSettings,
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