@open-mercato/core 0.4.2-canary-ba1d84349b → 0.4.2-canary-07dbc98202

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 (235) hide show
  1. package/dist/generated/entities/notification/index.js +57 -0
  2. package/dist/generated/entities/notification/index.js.map +7 -0
  3. package/dist/generated/entities.ids.generated.js +63 -59
  4. package/dist/generated/entities.ids.generated.js.map +2 -2
  5. package/dist/generated/entity-fields-registry.js +2 -0
  6. package/dist/generated/entity-fields-registry.js.map +2 -2
  7. package/dist/modules/api_docs/frontend/docs/api/page.js +3 -2
  8. package/dist/modules/api_docs/frontend/docs/api/page.js.map +2 -2
  9. package/dist/modules/auth/api/admin/nav.js +4 -3
  10. package/dist/modules/auth/api/admin/nav.js.map +2 -2
  11. package/dist/modules/auth/api/profile/route.js +155 -0
  12. package/dist/modules/auth/api/profile/route.js.map +7 -0
  13. package/dist/modules/auth/api/reset/confirm.js +25 -2
  14. package/dist/modules/auth/api/reset/confirm.js.map +2 -2
  15. package/dist/modules/auth/api/reset.js +23 -0
  16. package/dist/modules/auth/api/reset.js.map +2 -2
  17. package/dist/modules/auth/api/sidebar/preferences/route.js +14 -9
  18. package/dist/modules/auth/api/sidebar/preferences/route.js.map +2 -2
  19. package/dist/modules/auth/backend/auth/profile/page.js +99 -0
  20. package/dist/modules/auth/backend/auth/profile/page.js.map +7 -0
  21. package/dist/modules/auth/backend/auth/profile/page.meta.js +12 -0
  22. package/dist/modules/auth/backend/auth/profile/page.meta.js.map +7 -0
  23. package/dist/modules/auth/commands/users.js +55 -0
  24. package/dist/modules/auth/commands/users.js.map +2 -2
  25. package/dist/modules/auth/lib/setup-app.js +1 -0
  26. package/dist/modules/auth/lib/setup-app.js.map +2 -2
  27. package/dist/modules/auth/notifications.js +112 -0
  28. package/dist/modules/auth/notifications.js.map +7 -0
  29. package/dist/modules/auth/services/authService.js +3 -3
  30. package/dist/modules/auth/services/authService.js.map +2 -2
  31. package/dist/modules/business_rules/notifications.js +28 -0
  32. package/dist/modules/business_rules/notifications.js.map +7 -0
  33. package/dist/modules/business_rules/subscribers/rule-execution-failed-notification.js +37 -0
  34. package/dist/modules/business_rules/subscribers/rule-execution-failed-notification.js.map +7 -0
  35. package/dist/modules/catalog/notifications.js +28 -0
  36. package/dist/modules/catalog/notifications.js.map +7 -0
  37. package/dist/modules/catalog/subscribers/low-stock-notification.js +38 -0
  38. package/dist/modules/catalog/subscribers/low-stock-notification.js.map +7 -0
  39. package/dist/modules/configs/cli.js +6 -0
  40. package/dist/modules/configs/cli.js.map +2 -2
  41. package/dist/modules/customers/commands/deals.js +31 -0
  42. package/dist/modules/customers/commands/deals.js.map +2 -2
  43. package/dist/modules/customers/notifications.js +48 -0
  44. package/dist/modules/customers/notifications.js.map +7 -0
  45. package/dist/modules/notifications/acl.js +11 -0
  46. package/dist/modules/notifications/acl.js.map +7 -0
  47. package/dist/modules/notifications/api/[id]/action/route.js +74 -0
  48. package/dist/modules/notifications/api/[id]/action/route.js.map +7 -0
  49. package/dist/modules/notifications/api/[id]/dismiss/route.js +15 -0
  50. package/dist/modules/notifications/api/[id]/dismiss/route.js.map +7 -0
  51. package/dist/modules/notifications/api/[id]/read/route.js +15 -0
  52. package/dist/modules/notifications/api/[id]/read/route.js.map +7 -0
  53. package/dist/modules/notifications/api/[id]/restore/route.js +53 -0
  54. package/dist/modules/notifications/api/[id]/restore/route.js.map +7 -0
  55. package/dist/modules/notifications/api/batch/route.js +17 -0
  56. package/dist/modules/notifications/api/batch/route.js.map +7 -0
  57. package/dist/modules/notifications/api/feature/route.js +17 -0
  58. package/dist/modules/notifications/api/feature/route.js.map +7 -0
  59. package/dist/modules/notifications/api/mark-all-read/route.js +35 -0
  60. package/dist/modules/notifications/api/mark-all-read/route.js.map +7 -0
  61. package/dist/modules/notifications/api/openapi.js +76 -0
  62. package/dist/modules/notifications/api/openapi.js.map +7 -0
  63. package/dist/modules/notifications/api/role/route.js +17 -0
  64. package/dist/modules/notifications/api/role/route.js.map +7 -0
  65. package/dist/modules/notifications/api/route.js +85 -0
  66. package/dist/modules/notifications/api/route.js.map +7 -0
  67. package/dist/modules/notifications/api/settings/route.js +155 -0
  68. package/dist/modules/notifications/api/settings/route.js.map +7 -0
  69. package/dist/modules/notifications/api/unread-count/route.js +38 -0
  70. package/dist/modules/notifications/api/unread-count/route.js.map +7 -0
  71. package/dist/modules/notifications/backend/config/notifications/page.js +10 -0
  72. package/dist/modules/notifications/backend/config/notifications/page.js.map +7 -0
  73. package/dist/modules/notifications/backend/config/notifications/page.meta.js +24 -0
  74. package/dist/modules/notifications/backend/config/notifications/page.meta.js.map +7 -0
  75. package/dist/modules/notifications/cli.js +16 -0
  76. package/dist/modules/notifications/cli.js.map +7 -0
  77. package/dist/modules/notifications/data/entities.js +112 -0
  78. package/dist/modules/notifications/data/entities.js.map +7 -0
  79. package/dist/modules/notifications/data/validators.js +94 -0
  80. package/dist/modules/notifications/data/validators.js.map +7 -0
  81. package/dist/modules/notifications/di.js +13 -0
  82. package/dist/modules/notifications/di.js.map +7 -0
  83. package/dist/modules/notifications/emails/NotificationEmail.js +58 -0
  84. package/dist/modules/notifications/emails/NotificationEmail.js.map +7 -0
  85. package/dist/modules/notifications/frontend/NotificationInboxPageClient.js +44 -0
  86. package/dist/modules/notifications/frontend/NotificationInboxPageClient.js.map +7 -0
  87. package/dist/modules/notifications/frontend/NotificationSettingsPageClient.js +219 -0
  88. package/dist/modules/notifications/frontend/NotificationSettingsPageClient.js.map +7 -0
  89. package/dist/modules/notifications/index.js +14 -0
  90. package/dist/modules/notifications/index.js.map +7 -0
  91. package/dist/modules/notifications/lib/deliveryConfig.js +105 -0
  92. package/dist/modules/notifications/lib/deliveryConfig.js.map +7 -0
  93. package/dist/modules/notifications/lib/events.js +12 -0
  94. package/dist/modules/notifications/lib/events.js.map +7 -0
  95. package/dist/modules/notifications/lib/notificationBuilder.js +66 -0
  96. package/dist/modules/notifications/lib/notificationBuilder.js.map +7 -0
  97. package/dist/modules/notifications/lib/notificationFactory.js +54 -0
  98. package/dist/modules/notifications/lib/notificationFactory.js.map +7 -0
  99. package/dist/modules/notifications/lib/notificationMapper.js +34 -0
  100. package/dist/modules/notifications/lib/notificationMapper.js.map +7 -0
  101. package/dist/modules/notifications/lib/notificationRecipients.js +35 -0
  102. package/dist/modules/notifications/lib/notificationRecipients.js.map +7 -0
  103. package/dist/modules/notifications/lib/notificationService.js +279 -0
  104. package/dist/modules/notifications/lib/notificationService.js.map +7 -0
  105. package/dist/modules/notifications/lib/routeHelpers.js +101 -0
  106. package/dist/modules/notifications/lib/routeHelpers.js.map +7 -0
  107. package/dist/modules/notifications/lib/safeHref.js +24 -0
  108. package/dist/modules/notifications/lib/safeHref.js.map +7 -0
  109. package/dist/modules/notifications/migrations/Migration20260123000001.js +70 -0
  110. package/dist/modules/notifications/migrations/Migration20260123000001.js.map +7 -0
  111. package/dist/modules/notifications/migrations/Migration20260126150000.js +37 -0
  112. package/dist/modules/notifications/migrations/Migration20260126150000.js.map +7 -0
  113. package/dist/modules/notifications/subscribers/deliver-notification.js +139 -0
  114. package/dist/modules/notifications/subscribers/deliver-notification.js.map +7 -0
  115. package/dist/modules/notifications/workers/create-notification.worker.js +70 -0
  116. package/dist/modules/notifications/workers/create-notification.worker.js.map +7 -0
  117. package/dist/modules/sales/commands/documents.js +53 -0
  118. package/dist/modules/sales/commands/documents.js.map +2 -2
  119. package/dist/modules/sales/commands/payments.js +26 -0
  120. package/dist/modules/sales/commands/payments.js.map +2 -2
  121. package/dist/modules/sales/notifications.client.js +51 -0
  122. package/dist/modules/sales/notifications.client.js.map +7 -0
  123. package/dist/modules/sales/notifications.js +88 -0
  124. package/dist/modules/sales/notifications.js.map +7 -0
  125. package/dist/modules/sales/subscribers/quote-expiring-notification.js +38 -0
  126. package/dist/modules/sales/subscribers/quote-expiring-notification.js.map +7 -0
  127. package/dist/modules/sales/widgets/notifications/SalesOrderCreatedRenderer.js +137 -0
  128. package/dist/modules/sales/widgets/notifications/SalesOrderCreatedRenderer.js.map +7 -0
  129. package/dist/modules/sales/widgets/notifications/SalesQuoteCreatedRenderer.js +137 -0
  130. package/dist/modules/sales/widgets/notifications/SalesQuoteCreatedRenderer.js.map +7 -0
  131. package/dist/modules/sales/widgets/notifications/index.js +7 -0
  132. package/dist/modules/sales/widgets/notifications/index.js.map +7 -0
  133. package/dist/modules/sales/widgets/notifications/useSalesDocumentTotals.js +60 -0
  134. package/dist/modules/sales/widgets/notifications/useSalesDocumentTotals.js.map +7 -0
  135. package/dist/modules/staff/commands/leave-requests.js +79 -0
  136. package/dist/modules/staff/commands/leave-requests.js.map +2 -2
  137. package/dist/modules/staff/notifications.js +75 -0
  138. package/dist/modules/staff/notifications.js.map +7 -0
  139. package/dist/modules/workflows/notifications.js +28 -0
  140. package/dist/modules/workflows/notifications.js.map +7 -0
  141. package/dist/modules/workflows/subscribers/task-assigned-notification.js +38 -0
  142. package/dist/modules/workflows/subscribers/task-assigned-notification.js.map +7 -0
  143. package/generated/entities/notification/index.ts +27 -0
  144. package/generated/entities.ids.generated.ts +63 -59
  145. package/generated/entity-fields-registry.ts +2 -0
  146. package/package.json +2 -2
  147. package/src/modules/api_docs/frontend/docs/api/page.tsx +3 -2
  148. package/src/modules/auth/api/admin/nav.ts +10 -6
  149. package/src/modules/auth/api/profile/route.ts +160 -0
  150. package/src/modules/auth/api/reset/confirm.ts +25 -2
  151. package/src/modules/auth/api/reset.ts +23 -0
  152. package/src/modules/auth/api/sidebar/preferences/route.ts +21 -12
  153. package/src/modules/auth/backend/auth/profile/page.meta.ts +8 -0
  154. package/src/modules/auth/backend/auth/profile/page.tsx +127 -0
  155. package/src/modules/auth/commands/users.ts +68 -0
  156. package/src/modules/auth/i18n/de.json +29 -1
  157. package/src/modules/auth/i18n/en.json +29 -1
  158. package/src/modules/auth/i18n/es.json +29 -1
  159. package/src/modules/auth/i18n/pl.json +29 -1
  160. package/src/modules/auth/lib/setup-app.ts +1 -0
  161. package/src/modules/auth/notifications.ts +109 -0
  162. package/src/modules/auth/services/authService.ts +4 -4
  163. package/src/modules/business_rules/i18n/en.json +3 -1
  164. package/src/modules/business_rules/notifications.ts +25 -0
  165. package/src/modules/business_rules/subscribers/rule-execution-failed-notification.ts +50 -0
  166. package/src/modules/catalog/i18n/en.json +3 -1
  167. package/src/modules/catalog/notifications.ts +25 -0
  168. package/src/modules/catalog/subscribers/low-stock-notification.ts +52 -0
  169. package/src/modules/configs/cli.ts +6 -0
  170. package/src/modules/customers/commands/deals.ts +39 -0
  171. package/src/modules/customers/i18n/en.json +5 -1
  172. package/src/modules/customers/notifications.ts +44 -0
  173. package/src/modules/notifications/acl.ts +7 -0
  174. package/src/modules/notifications/api/[id]/action/route.ts +75 -0
  175. package/src/modules/notifications/api/[id]/dismiss/route.ts +12 -0
  176. package/src/modules/notifications/api/[id]/read/route.ts +12 -0
  177. package/src/modules/notifications/api/[id]/restore/route.ts +53 -0
  178. package/src/modules/notifications/api/batch/route.ts +14 -0
  179. package/src/modules/notifications/api/feature/route.ts +14 -0
  180. package/src/modules/notifications/api/mark-all-read/route.ts +34 -0
  181. package/src/modules/notifications/api/openapi.ts +76 -0
  182. package/src/modules/notifications/api/role/route.ts +14 -0
  183. package/src/modules/notifications/api/route.ts +92 -0
  184. package/src/modules/notifications/api/settings/route.ts +157 -0
  185. package/src/modules/notifications/api/unread-count/route.ts +38 -0
  186. package/src/modules/notifications/backend/config/notifications/page.meta.ts +22 -0
  187. package/src/modules/notifications/backend/config/notifications/page.tsx +12 -0
  188. package/src/modules/notifications/cli.ts +18 -0
  189. package/src/modules/notifications/data/entities.ts +99 -0
  190. package/src/modules/notifications/data/validators.ts +110 -0
  191. package/src/modules/notifications/di.ts +11 -0
  192. package/src/modules/notifications/emails/NotificationEmail.tsx +98 -0
  193. package/src/modules/notifications/frontend/NotificationInboxPageClient.tsx +42 -0
  194. package/src/modules/notifications/frontend/NotificationSettingsPageClient.tsx +231 -0
  195. package/src/modules/notifications/i18n/de.json +50 -0
  196. package/src/modules/notifications/i18n/en.json +50 -0
  197. package/src/modules/notifications/i18n/es.json +50 -0
  198. package/src/modules/notifications/i18n/pl.json +50 -0
  199. package/src/modules/notifications/index.ts +12 -0
  200. package/src/modules/notifications/lib/deliveryConfig.ts +145 -0
  201. package/src/modules/notifications/lib/events.ts +48 -0
  202. package/src/modules/notifications/lib/notificationBuilder.ts +121 -0
  203. package/src/modules/notifications/lib/notificationFactory.ts +76 -0
  204. package/src/modules/notifications/lib/notificationMapper.ts +33 -0
  205. package/src/modules/notifications/lib/notificationRecipients.ts +83 -0
  206. package/src/modules/notifications/lib/notificationService.ts +414 -0
  207. package/src/modules/notifications/lib/routeHelpers.ts +151 -0
  208. package/src/modules/notifications/lib/safeHref.ts +29 -0
  209. package/src/modules/notifications/migrations/.snapshot-open-mercato.json +300 -0
  210. package/src/modules/notifications/migrations/Migration20260123000001.ts +73 -0
  211. package/src/modules/notifications/migrations/Migration20260126150000.ts +39 -0
  212. package/src/modules/notifications/subscribers/deliver-notification.ts +175 -0
  213. package/src/modules/notifications/workers/create-notification.worker.ts +122 -0
  214. package/src/modules/sales/commands/documents.ts +65 -0
  215. package/src/modules/sales/commands/payments.ts +33 -0
  216. package/src/modules/sales/i18n/de.json +20 -0
  217. package/src/modules/sales/i18n/en.json +25 -1
  218. package/src/modules/sales/i18n/es.json +20 -0
  219. package/src/modules/sales/i18n/pl.json +20 -0
  220. package/src/modules/sales/notifications.client.ts +65 -0
  221. package/src/modules/sales/notifications.ts +82 -0
  222. package/src/modules/sales/subscribers/quote-expiring-notification.ts +53 -0
  223. package/src/modules/sales/widgets/notifications/SalesOrderCreatedRenderer.tsx +156 -0
  224. package/src/modules/sales/widgets/notifications/SalesQuoteCreatedRenderer.tsx +156 -0
  225. package/src/modules/sales/widgets/notifications/index.ts +2 -0
  226. package/src/modules/sales/widgets/notifications/useSalesDocumentTotals.ts +81 -0
  227. package/src/modules/staff/commands/leave-requests.ts +94 -0
  228. package/src/modules/staff/i18n/de.json +4 -0
  229. package/src/modules/staff/i18n/en.json +9 -1
  230. package/src/modules/staff/i18n/es.json +4 -0
  231. package/src/modules/staff/i18n/pl.json +4 -0
  232. package/src/modules/staff/notifications.ts +71 -0
  233. package/src/modules/workflows/i18n/en.json +3 -1
  234. package/src/modules/workflows/notifications.ts +25 -0
  235. package/src/modules/workflows/subscribers/task-assigned-notification.ts +53 -0
@@ -0,0 +1,156 @@
1
+ 'use client'
2
+
3
+ import * as React from 'react'
4
+ import { ShoppingCart, ExternalLink, DollarSign, User, Calendar } from 'lucide-react'
5
+ import { useRouter } from 'next/navigation'
6
+ import { Button } from '@open-mercato/ui/primitives/button'
7
+ import { cn } from '@open-mercato/shared/lib/utils'
8
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
9
+ import type { NotificationRendererProps } from '@open-mercato/shared/modules/notifications/types'
10
+ import { formatMoney } from '../../components/documents/lineItemUtils'
11
+ import { useSalesDocumentTotals } from './useSalesDocumentTotals'
12
+
13
+ function formatTimeAgo(dateString: string, t: (key: string, fallback?: string) => string): string {
14
+ const date = new Date(dateString)
15
+ const now = new Date()
16
+ const diffMs = now.getTime() - date.getTime()
17
+ const diffMins = Math.floor(diffMs / 60000)
18
+ const diffHours = Math.floor(diffMs / 3600000)
19
+ const diffDays = Math.floor(diffMs / 86400000)
20
+
21
+ if (diffMins < 1) return t('common.time.justNow', 'just now')
22
+ if (diffMins < 60) return t('common.time.minutesAgo', '{count}m ago').replace('{count}', String(diffMins))
23
+ if (diffHours < 24) return t('common.time.hoursAgo', '{count}h ago').replace('{count}', String(diffHours))
24
+ if (diffDays < 7) return t('common.time.daysAgo', '{count}d ago').replace('{count}', String(diffDays))
25
+ return date.toLocaleDateString()
26
+ }
27
+
28
+ function normalizeTotal(value?: string | null): string | null {
29
+ if (!value) return null
30
+ let trimmed = value.trim()
31
+ if (trimmed.startsWith('(') && trimmed.endsWith(')')) {
32
+ trimmed = trimmed.slice(1, -1).trim()
33
+ }
34
+ return trimmed.length ? trimmed : null
35
+ }
36
+
37
+ export function SalesOrderCreatedRenderer({
38
+ notification,
39
+ onAction,
40
+ onDismiss,
41
+ actions = [],
42
+ }: NotificationRendererProps) {
43
+ const t = useT()
44
+ const router = useRouter()
45
+ const [executing, setExecuting] = React.useState(false)
46
+ const isUnread = notification.status === 'unread'
47
+ const orderNumber = notification.bodyVariables?.orderNumber ?? notification.titleVariables?.orderNumber
48
+ const fallbackTotal =
49
+ normalizeTotal(notification.bodyVariables?.totalAmount ?? null) ??
50
+ normalizeTotal(notification.bodyVariables?.total ?? null)
51
+ const { totals } = useSalesDocumentTotals('order', notification.sourceEntityId)
52
+
53
+ const currentTotal =
54
+ totals && typeof totals.grandTotalGrossAmount === 'number'
55
+ ? formatMoney(totals.grandTotalGrossAmount, totals.currencyCode)
56
+ : fallbackTotal
57
+
58
+ const viewAction = actions.find((action) => action.id === 'view') ?? actions[0] ?? null
59
+
60
+ const handleView = async () => {
61
+ if (!viewAction) {
62
+ if (notification.linkHref) router.push(notification.linkHref)
63
+ return
64
+ }
65
+ setExecuting(true)
66
+ try {
67
+ await onAction(viewAction.id)
68
+ } finally {
69
+ setExecuting(false)
70
+ }
71
+ }
72
+
73
+ return (
74
+ <div
75
+ className={cn(
76
+ 'group relative px-4 py-3 hover:bg-muted/50 cursor-pointer transition-colors border-l-4 border-l-blue-500',
77
+ isUnread && 'bg-blue-50/50 dark:bg-blue-950/20'
78
+ )}
79
+ onClick={handleView}
80
+ >
81
+ {isUnread && (
82
+ <div className="absolute left-1.5 top-1/2 -translate-y-1/2 h-2 w-2 rounded-full bg-primary" />
83
+ )}
84
+
85
+ <div className="flex gap-3">
86
+ <div className="flex-shrink-0 mt-0.5">
87
+ <div className="h-10 w-10 rounded-lg bg-blue-100 dark:bg-blue-900/40 flex items-center justify-center">
88
+ <ShoppingCart className="h-5 w-5 text-blue-600 dark:text-blue-400" />
89
+ </div>
90
+ </div>
91
+
92
+ <div className="flex-1 min-w-0">
93
+ <div className="flex items-start justify-between gap-2">
94
+ <div>
95
+ <h4 className={cn('text-sm font-medium', isUnread && 'font-semibold')}>
96
+ {notification.title}
97
+ </h4>
98
+ {orderNumber && (
99
+ <div className="flex items-center gap-1 mt-0.5">
100
+ <span className="text-xs font-mono text-muted-foreground bg-muted px-1.5 py-0.5 rounded">
101
+ #{orderNumber}
102
+ </span>
103
+ </div>
104
+ )}
105
+ </div>
106
+ <span className="flex-shrink-0 text-xs text-muted-foreground flex items-center gap-1">
107
+ <Calendar className="h-3 w-3" />
108
+ {formatTimeAgo(notification.createdAt, t)}
109
+ </span>
110
+ </div>
111
+
112
+ <div className="mt-2 flex items-center gap-4 text-xs text-muted-foreground">
113
+ {currentTotal && (
114
+ <div className="flex items-center gap-1">
115
+ <DollarSign className="h-3 w-3" />
116
+ <span className="font-medium text-foreground">{currentTotal}</span>
117
+ </div>
118
+ )}
119
+ <div className="flex items-center gap-1">
120
+ <User className="h-3 w-3" />
121
+ <span>{t('sales.notifications.renderer.assignedToYou', 'Assigned to you')}</span>
122
+ </div>
123
+ </div>
124
+
125
+ <div className="mt-3 flex gap-2">
126
+ <Button
127
+ variant="default"
128
+ size="sm"
129
+ onClick={(e) => {
130
+ e.stopPropagation()
131
+ handleView()
132
+ }}
133
+ disabled={executing || (!viewAction && !notification.linkHref)}
134
+ className="gap-1"
135
+ >
136
+ <ExternalLink className="h-3 w-3" />
137
+ {t('sales.notifications.renderer.viewOrder', 'View Order')}
138
+ </Button>
139
+ <Button
140
+ variant="ghost"
141
+ size="sm"
142
+ onClick={(e) => {
143
+ e.stopPropagation()
144
+ onDismiss()
145
+ }}
146
+ >
147
+ {t('notifications.actions.dismiss', 'Dismiss')}
148
+ </Button>
149
+ </div>
150
+ </div>
151
+ </div>
152
+ </div>
153
+ )
154
+ }
155
+
156
+ export default SalesOrderCreatedRenderer
@@ -0,0 +1,156 @@
1
+ 'use client'
2
+
3
+ import * as React from 'react'
4
+ import { FileText, ExternalLink, DollarSign, User, Calendar } from 'lucide-react'
5
+ import { useRouter } from 'next/navigation'
6
+ import { Button } from '@open-mercato/ui/primitives/button'
7
+ import { cn } from '@open-mercato/shared/lib/utils'
8
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
9
+ import type { NotificationRendererProps } from '@open-mercato/shared/modules/notifications/types'
10
+ import { formatMoney } from '../../components/documents/lineItemUtils'
11
+ import { useSalesDocumentTotals } from './useSalesDocumentTotals'
12
+
13
+ function formatTimeAgo(dateString: string, t: (key: string, fallback?: string) => string): string {
14
+ const date = new Date(dateString)
15
+ const now = new Date()
16
+ const diffMs = now.getTime() - date.getTime()
17
+ const diffMins = Math.floor(diffMs / 60000)
18
+ const diffHours = Math.floor(diffMs / 3600000)
19
+ const diffDays = Math.floor(diffMs / 86400000)
20
+
21
+ if (diffMins < 1) return t('common.time.justNow', 'just now')
22
+ if (diffMins < 60) return t('common.time.minutesAgo', '{count}m ago').replace('{count}', String(diffMins))
23
+ if (diffHours < 24) return t('common.time.hoursAgo', '{count}h ago').replace('{count}', String(diffHours))
24
+ if (diffDays < 7) return t('common.time.daysAgo', '{count}d ago').replace('{count}', String(diffDays))
25
+ return date.toLocaleDateString()
26
+ }
27
+
28
+ function normalizeTotal(value?: string | null): string | null {
29
+ if (!value) return null
30
+ let trimmed = value.trim()
31
+ if (trimmed.startsWith('(') && trimmed.endsWith(')')) {
32
+ trimmed = trimmed.slice(1, -1).trim()
33
+ }
34
+ return trimmed.length ? trimmed : null
35
+ }
36
+
37
+ export function SalesQuoteCreatedRenderer({
38
+ notification,
39
+ onAction,
40
+ onDismiss,
41
+ actions = [],
42
+ }: NotificationRendererProps) {
43
+ const t = useT()
44
+ const router = useRouter()
45
+ const [executing, setExecuting] = React.useState(false)
46
+ const isUnread = notification.status === 'unread'
47
+ const quoteNumber = notification.bodyVariables?.quoteNumber ?? notification.titleVariables?.quoteNumber
48
+ const fallbackTotal =
49
+ normalizeTotal(notification.bodyVariables?.totalAmount ?? null) ??
50
+ normalizeTotal(notification.bodyVariables?.total ?? null)
51
+ const { totals } = useSalesDocumentTotals('quote', notification.sourceEntityId)
52
+
53
+ const currentTotal =
54
+ totals && typeof totals.grandTotalGrossAmount === 'number'
55
+ ? formatMoney(totals.grandTotalGrossAmount, totals.currencyCode)
56
+ : fallbackTotal
57
+
58
+ const viewAction = actions.find((action) => action.id === 'view') ?? actions[0] ?? null
59
+
60
+ const handleView = async () => {
61
+ if (!viewAction) {
62
+ if (notification.linkHref) router.push(notification.linkHref)
63
+ return
64
+ }
65
+ setExecuting(true)
66
+ try {
67
+ await onAction(viewAction.id)
68
+ } finally {
69
+ setExecuting(false)
70
+ }
71
+ }
72
+
73
+ return (
74
+ <div
75
+ className={cn(
76
+ 'group relative px-4 py-3 hover:bg-muted/50 cursor-pointer transition-colors border-l-4 border-l-amber-500',
77
+ isUnread && 'bg-amber-50/50 dark:bg-amber-950/20'
78
+ )}
79
+ onClick={handleView}
80
+ >
81
+ {isUnread && (
82
+ <div className="absolute left-1.5 top-1/2 -translate-y-1/2 h-2 w-2 rounded-full bg-primary" />
83
+ )}
84
+
85
+ <div className="flex gap-3">
86
+ <div className="flex-shrink-0 mt-0.5">
87
+ <div className="h-10 w-10 rounded-lg bg-amber-100 dark:bg-amber-900/40 flex items-center justify-center">
88
+ <FileText className="h-5 w-5 text-amber-600 dark:text-amber-400" />
89
+ </div>
90
+ </div>
91
+
92
+ <div className="flex-1 min-w-0">
93
+ <div className="flex items-start justify-between gap-2">
94
+ <div>
95
+ <h4 className={cn('text-sm font-medium', isUnread && 'font-semibold')}>
96
+ {notification.title}
97
+ </h4>
98
+ {quoteNumber && (
99
+ <div className="flex items-center gap-1 mt-0.5">
100
+ <span className="text-xs font-mono text-muted-foreground bg-muted px-1.5 py-0.5 rounded">
101
+ #{quoteNumber}
102
+ </span>
103
+ </div>
104
+ )}
105
+ </div>
106
+ <span className="flex-shrink-0 text-xs text-muted-foreground flex items-center gap-1">
107
+ <Calendar className="h-3 w-3" />
108
+ {formatTimeAgo(notification.createdAt, t)}
109
+ </span>
110
+ </div>
111
+
112
+ <div className="mt-2 flex items-center gap-4 text-xs text-muted-foreground">
113
+ {currentTotal && (
114
+ <div className="flex items-center gap-1">
115
+ <DollarSign className="h-3 w-3" />
116
+ <span className="font-medium text-foreground">{currentTotal}</span>
117
+ </div>
118
+ )}
119
+ <div className="flex items-center gap-1">
120
+ <User className="h-3 w-3" />
121
+ <span>{t('sales.notifications.renderer.pendingReview', 'Pending review')}</span>
122
+ </div>
123
+ </div>
124
+
125
+ <div className="mt-3 flex gap-2">
126
+ <Button
127
+ variant="default"
128
+ size="sm"
129
+ onClick={(e) => {
130
+ e.stopPropagation()
131
+ handleView()
132
+ }}
133
+ disabled={executing || (!viewAction && !notification.linkHref)}
134
+ className="gap-1"
135
+ >
136
+ <ExternalLink className="h-3 w-3" />
137
+ {t('sales.notifications.renderer.viewQuote', 'View Quote')}
138
+ </Button>
139
+ <Button
140
+ variant="ghost"
141
+ size="sm"
142
+ onClick={(e) => {
143
+ e.stopPropagation()
144
+ onDismiss()
145
+ }}
146
+ >
147
+ {t('notifications.actions.dismiss', 'Dismiss')}
148
+ </Button>
149
+ </div>
150
+ </div>
151
+ </div>
152
+ </div>
153
+ )
154
+ }
155
+
156
+ export default SalesQuoteCreatedRenderer
@@ -0,0 +1,2 @@
1
+ export { SalesOrderCreatedRenderer } from './SalesOrderCreatedRenderer'
2
+ export { SalesQuoteCreatedRenderer } from './SalesQuoteCreatedRenderer'
@@ -0,0 +1,81 @@
1
+ 'use client'
2
+
3
+ import * as React from 'react'
4
+ import { apiCall } from '@open-mercato/ui/backend/utils/apiCall'
5
+
6
+ type DocumentKind = 'order' | 'quote'
7
+
8
+ type DocumentTotals = {
9
+ grandTotalGrossAmount: number | null
10
+ currencyCode: string | null
11
+ }
12
+
13
+ type DocumentListResponse = {
14
+ items?: Array<{
15
+ grandTotalGrossAmount?: number | string | null
16
+ currencyCode?: string | null
17
+ }>
18
+ }
19
+
20
+ const REFRESH_INTERVAL_MS = 30000
21
+
22
+ function buildDocumentTotalsUrl(kind: DocumentKind, documentId: string) {
23
+ const params = new URLSearchParams({ id: documentId, page: '1', pageSize: '1' })
24
+ const collection = kind === 'order' ? 'orders' : 'quotes'
25
+ return `/api/sales/${collection}?${params.toString()}`
26
+ }
27
+
28
+ function extractTotals(payload: DocumentListResponse | null): DocumentTotals | null {
29
+ const item = payload?.items?.[0]
30
+ if (!item) return null
31
+ const rawAmount = item.grandTotalGrossAmount
32
+ let grandTotalGrossAmount: number | null = null
33
+ if (typeof rawAmount === 'number') {
34
+ grandTotalGrossAmount = Number.isNaN(rawAmount) ? null : rawAmount
35
+ } else if (typeof rawAmount === 'string' && rawAmount.trim().length) {
36
+ const parsed = Number(rawAmount)
37
+ grandTotalGrossAmount = Number.isNaN(parsed) ? null : parsed
38
+ }
39
+ return {
40
+ grandTotalGrossAmount,
41
+ currencyCode: typeof item.currencyCode === 'string' ? item.currencyCode : null,
42
+ }
43
+ }
44
+
45
+ export function useSalesDocumentTotals(kind: DocumentKind, documentId?: string | null) {
46
+ const [totals, setTotals] = React.useState<DocumentTotals | null>(null)
47
+
48
+ React.useEffect(() => {
49
+ if (!documentId) {
50
+ setTotals(null)
51
+ return
52
+ }
53
+
54
+ let active = true
55
+
56
+ const loadTotals = async () => {
57
+ try {
58
+ const call = await apiCall<DocumentListResponse>(buildDocumentTotalsUrl(kind, documentId))
59
+ if (!active) return
60
+ if (call.ok) {
61
+ const nextTotals = extractTotals(call.result ?? null)
62
+ setTotals(nextTotals)
63
+ }
64
+ } catch {
65
+ if (active) {
66
+ setTotals(null)
67
+ }
68
+ }
69
+ }
70
+
71
+ loadTotals()
72
+ const interval = setInterval(loadTotals, REFRESH_INTERVAL_MS)
73
+
74
+ return () => {
75
+ active = false
76
+ clearInterval(interval)
77
+ }
78
+ }, [kind, documentId])
79
+
80
+ return { totals }
81
+ }
@@ -21,6 +21,9 @@ import {
21
21
  } from '../data/validators'
22
22
  import { ensureOrganizationScope, ensureTenantScope, extractUndoPayload, requireTeamMember } from './shared'
23
23
  import { E } from '#generated/entities.ids.generated'
24
+ import { resolveNotificationService } from '../../notifications/lib/notificationService'
25
+ import { buildFeatureNotificationFromType, buildNotificationFromType } from '../../notifications/lib/notificationBuilder'
26
+ import { notificationTypes } from '../notifications'
24
27
 
25
28
  const leaveRequestCrudIndexer: CrudIndexerConfig<StaffLeaveRequest> = {
26
29
  entityType: E.staff.staff_leave_request,
@@ -258,6 +261,36 @@ const createLeaveRequestCommand: CommandHandler<StaffLeaveRequestCreateInput, {
258
261
  indexer: leaveRequestCrudIndexer,
259
262
  })
260
263
 
264
+ // Create notification for users who can approve/reject leave requests
265
+ try {
266
+ const notificationService = resolveNotificationService(ctx.container)
267
+ const typeDef = notificationTypes.find((type) => type.type === 'staff.leave_request.pending')
268
+ if (typeDef) {
269
+ const memberName = member.displayName || 'Team member'
270
+ const startDateStr = request.startDate.toLocaleDateString()
271
+ const endDateStr = request.endDate.toLocaleDateString()
272
+
273
+ const notificationInput = buildFeatureNotificationFromType(typeDef, {
274
+ requiredFeature: 'staff.leave_requests.manage',
275
+ bodyVariables: {
276
+ memberName,
277
+ startDate: startDateStr,
278
+ endDate: endDateStr,
279
+ },
280
+ sourceEntityType: 'staff:leave_request',
281
+ sourceEntityId: request.id,
282
+ linkHref: `/backend/staff/leave-requests/${request.id}`,
283
+ })
284
+
285
+ await notificationService.createForFeature(notificationInput, {
286
+ tenantId: request.tenantId,
287
+ organizationId: request.organizationId,
288
+ })
289
+ }
290
+ } catch {
291
+ // Notification creation is non-critical, don't fail the command
292
+ }
293
+
261
294
  return { requestId: request.id }
262
295
  },
263
296
  captureAfter: async (_input, result, ctx) => {
@@ -575,6 +608,36 @@ const acceptLeaveRequestCommand: CommandHandler<StaffLeaveRequestDecisionInput,
575
608
  ruleIds: createdRuleIds,
576
609
  })
577
610
 
611
+ // Send notification to the requester
612
+ if (request.submittedByUserId) {
613
+ try {
614
+ const notificationService = resolveNotificationService(ctx.container)
615
+ const typeDef = notificationTypes.find((type) => type.type === 'staff.leave_request.approved')
616
+ if (typeDef) {
617
+ const startDateStr = request.startDate.toLocaleDateString()
618
+ const endDateStr = request.endDate.toLocaleDateString()
619
+
620
+ const notificationInput = buildNotificationFromType(typeDef, {
621
+ recipientUserId: request.submittedByUserId,
622
+ bodyVariables: {
623
+ startDate: startDateStr,
624
+ endDate: endDateStr,
625
+ },
626
+ sourceEntityType: 'staff:leave_request',
627
+ sourceEntityId: request.id,
628
+ linkHref: `/backend/staff/leave-requests/${request.id}`,
629
+ })
630
+
631
+ await notificationService.create(notificationInput, {
632
+ tenantId: request.tenantId,
633
+ organizationId: request.organizationId,
634
+ })
635
+ }
636
+ } catch {
637
+ // Notification creation is non-critical, don't fail the command
638
+ }
639
+ }
640
+
578
641
  return { requestId: request.id, ruleIds: createdRuleIds }
579
642
  },
580
643
  buildLog: async ({ result, ctx, snapshots }) => {
@@ -696,6 +759,37 @@ const rejectLeaveRequestCommand: CommandHandler<StaffLeaveRequestDecisionInput,
696
759
  indexer: leaveRequestCrudIndexer,
697
760
  })
698
761
 
762
+ // Send notification to the requester
763
+ if (request.submittedByUserId) {
764
+ try {
765
+ const notificationService = resolveNotificationService(ctx.container)
766
+ const typeDef = notificationTypes.find((type) => type.type === 'staff.leave_request.rejected')
767
+ if (typeDef) {
768
+ const startDateStr = request.startDate.toLocaleDateString()
769
+ const endDateStr = request.endDate.toLocaleDateString()
770
+
771
+ const notificationInput = buildNotificationFromType(typeDef, {
772
+ recipientUserId: request.submittedByUserId,
773
+ bodyVariables: {
774
+ startDate: startDateStr,
775
+ endDate: endDateStr,
776
+ reason: request.decisionComment ?? '',
777
+ },
778
+ sourceEntityType: 'staff:leave_request',
779
+ sourceEntityId: request.id,
780
+ linkHref: `/backend/staff/leave-requests/${request.id}`,
781
+ })
782
+
783
+ await notificationService.create(notificationInput, {
784
+ tenantId: request.tenantId,
785
+ organizationId: request.organizationId,
786
+ })
787
+ }
788
+ } catch {
789
+ // Notification creation is non-critical, don't fail the command
790
+ }
791
+ }
792
+
699
793
  return { requestId: request.id }
700
794
  },
701
795
  async prepare(rawInput, ctx) {
@@ -797,6 +797,10 @@
797
797
  "staff.teams.tabs.details": "Details",
798
798
  "staff.teams.tabs.label": "Teamabschnitte",
799
799
  "staff.teams.tabs.members": "Teammitglieder",
800
+ "staff.notifications.leaveRequest.pending.title": "Urlaubsantrag ausstehend",
801
+ "staff.notifications.leaveRequest.pending.body": "{memberName} hat Urlaub vom {startDate} bis {endDate} beantragt",
802
+ "staff.notifications.leaveRequest.actions.approve": "Genehmigen",
803
+ "staff.notifications.leaveRequest.actions.reject": "Ablehnen",
800
804
  "staff.leaveRequests.page.title": "Urlaubsantr\u00e4ge",
801
805
  "staff.leaveRequests.page.description": "Urlaubsantr\u00e4ge des Teams pr\u00fcfen.",
802
806
  "staff.leaveRequests.my.title": "Meine Urlaubsantr\u00e4ge",
@@ -797,6 +797,10 @@
797
797
  "staff.teams.tabs.details": "Details",
798
798
  "staff.teams.tabs.label": "Team sections",
799
799
  "staff.teams.tabs.members": "Team members",
800
+ "staff.notifications.leaveRequest.pending.title": "Leave Request Pending",
801
+ "staff.notifications.leaveRequest.pending.body": "{memberName} has requested leave from {startDate} to {endDate}",
802
+ "staff.notifications.leaveRequest.actions.approve": "Approve",
803
+ "staff.notifications.leaveRequest.actions.reject": "Reject",
800
804
  "staff.leaveRequests.page.title": "Leave requests",
801
805
  "staff.leaveRequests.page.description": "Review leave requests from your team.",
802
806
  "staff.leaveRequests.my.title": "My leave requests",
@@ -874,5 +878,9 @@
874
878
  "staff.myAvailability.readOnly.body": "Use leave requests to request changes.",
875
879
  "staff.teamMembers.self.createTitle": "Create my profile",
876
880
  "staff.teamMembers.self.created": "Profile created.",
877
- "staff.teamMembers.self.exists": "Team member profile already exists."
881
+ "staff.teamMembers.self.exists": "Team member profile already exists.",
882
+ "staff.notifications.leaveRequest.approved.title": "Leave Request Approved",
883
+ "staff.notifications.leaveRequest.approved.body": "Your leave request from {startDate} to {endDate} has been approved",
884
+ "staff.notifications.leaveRequest.rejected.title": "Leave Request Rejected",
885
+ "staff.notifications.leaveRequest.rejected.body": "Your leave request from {startDate} to {endDate} has been rejected{reason, select, other { - {reason}}}"
878
886
  }
@@ -797,6 +797,10 @@
797
797
  "staff.teams.tabs.details": "Detalles",
798
798
  "staff.teams.tabs.label": "Secciones del equipo",
799
799
  "staff.teams.tabs.members": "Miembros del equipo",
800
+ "staff.notifications.leaveRequest.pending.title": "Solicitud de ausencia pendiente",
801
+ "staff.notifications.leaveRequest.pending.body": "{memberName} ha solicitado ausencia del {startDate} al {endDate}",
802
+ "staff.notifications.leaveRequest.actions.approve": "Aprobar",
803
+ "staff.notifications.leaveRequest.actions.reject": "Rechazar",
800
804
  "staff.leaveRequests.page.title": "Solicitudes de ausencia",
801
805
  "staff.leaveRequests.page.description": "Revisa las solicitudes de ausencia del equipo.",
802
806
  "staff.leaveRequests.my.title": "Mis solicitudes de ausencia",
@@ -797,6 +797,10 @@
797
797
  "staff.teams.tabs.details": "Szczeg\u00f3\u0142y",
798
798
  "staff.teams.tabs.label": "Sekcje zespo\u0142u",
799
799
  "staff.teams.tabs.members": "Cz\u0142onkowie zespo\u0142u",
800
+ "staff.notifications.leaveRequest.pending.title": "Wniosek urlopowy oczekuje",
801
+ "staff.notifications.leaveRequest.pending.body": "{memberName} zło\u017cył wniosek o urlop od {startDate} do {endDate}",
802
+ "staff.notifications.leaveRequest.actions.approve": "Zatwierd\u017a",
803
+ "staff.notifications.leaveRequest.actions.reject": "Odrzu\u0107",
800
804
  "staff.leaveRequests.page.title": "Wnioski urlopowe",
801
805
  "staff.leaveRequests.page.description": "Przegl\u0105daj wnioski urlopowe zespo\u0142u.",
802
806
  "staff.leaveRequests.my.title": "Moje wnioski urlopowe",
@@ -0,0 +1,71 @@
1
+ import type { NotificationTypeDefinition } from '@open-mercato/shared/modules/notifications/types'
2
+
3
+ export const notificationTypes: NotificationTypeDefinition[] = [
4
+ {
5
+ type: 'staff.leave_request.pending',
6
+ module: 'staff',
7
+ titleKey: 'staff.notifications.leaveRequest.pending.title',
8
+ bodyKey: 'staff.notifications.leaveRequest.pending.body',
9
+ icon: 'calendar-off',
10
+ severity: 'warning',
11
+ actions: [
12
+ {
13
+ id: 'approve',
14
+ labelKey: 'staff.notifications.leaveRequest.actions.approve',
15
+ variant: 'default',
16
+ icon: 'check',
17
+ commandId: 'staff.leave-requests.accept',
18
+ },
19
+ {
20
+ id: 'reject',
21
+ labelKey: 'staff.notifications.leaveRequest.actions.reject',
22
+ variant: 'destructive',
23
+ icon: 'x',
24
+ commandId: 'staff.leave-requests.reject',
25
+ },
26
+ ],
27
+ primaryActionId: 'approve',
28
+ linkHref: '/backend/staff/leave-requests/{sourceEntityId}',
29
+ expiresAfterHours: 168,
30
+ },
31
+ {
32
+ type: 'staff.leave_request.approved',
33
+ module: 'staff',
34
+ titleKey: 'staff.notifications.leaveRequest.approved.title',
35
+ bodyKey: 'staff.notifications.leaveRequest.approved.body',
36
+ icon: 'calendar-check',
37
+ severity: 'success',
38
+ actions: [
39
+ {
40
+ id: 'view',
41
+ labelKey: 'common.view',
42
+ variant: 'outline',
43
+ href: '/backend/staff/leave-requests/{sourceEntityId}',
44
+ icon: 'external-link',
45
+ },
46
+ ],
47
+ linkHref: '/backend/staff/leave-requests/{sourceEntityId}',
48
+ expiresAfterHours: 168, // 7 days
49
+ },
50
+ {
51
+ type: 'staff.leave_request.rejected',
52
+ module: 'staff',
53
+ titleKey: 'staff.notifications.leaveRequest.rejected.title',
54
+ bodyKey: 'staff.notifications.leaveRequest.rejected.body',
55
+ icon: 'calendar-x',
56
+ severity: 'warning',
57
+ actions: [
58
+ {
59
+ id: 'view',
60
+ labelKey: 'common.view',
61
+ variant: 'outline',
62
+ href: '/backend/staff/leave-requests/{sourceEntityId}',
63
+ icon: 'external-link',
64
+ },
65
+ ],
66
+ linkHref: '/backend/staff/leave-requests/{sourceEntityId}',
67
+ expiresAfterHours: 168, // 7 days
68
+ },
69
+ ]
70
+
71
+ export default notificationTypes
@@ -676,5 +676,7 @@
676
676
  "success": "Sub-workflow completed successfully",
677
677
  "failed": "Sub-workflow failed"
678
678
  }
679
- }
679
+ },
680
+ "workflows.notifications.task.assigned.title": "Task Assigned",
681
+ "workflows.notifications.task.assigned.body": "You have been assigned to task \"{taskName}\" in workflow \"{workflowName}\"{dueDate, select, other { (due: {dueDate})}}"
680
682
  }