@open-mercato/core 0.6.3-develop.3635.1.e4a05c16e8 → 0.6.3-develop.3653.1.b8558737ae

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 (160) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/generated/entities/customer_settings/index.js +2 -0
  3. package/dist/generated/entities/customer_settings/index.js.map +2 -2
  4. package/dist/generated/entity-fields-registry.js +1 -0
  5. package/dist/generated/entity-fields-registry.js.map +2 -2
  6. package/dist/modules/customers/api/deals/aggregate/route.js +332 -0
  7. package/dist/modules/customers/api/deals/aggregate/route.js.map +7 -0
  8. package/dist/modules/customers/api/deals/bulk-update-owner/route.js +188 -0
  9. package/dist/modules/customers/api/deals/bulk-update-owner/route.js.map +7 -0
  10. package/dist/modules/customers/api/deals/bulk-update-stage/route.js +188 -0
  11. package/dist/modules/customers/api/deals/bulk-update-stage/route.js.map +7 -0
  12. package/dist/modules/customers/api/deals/route.js +255 -122
  13. package/dist/modules/customers/api/deals/route.js.map +2 -2
  14. package/dist/modules/customers/api/settings/stuck-threshold/route.js +169 -0
  15. package/dist/modules/customers/api/settings/stuck-threshold/route.js.map +7 -0
  16. package/dist/modules/customers/backend/config/customers/deals/page.js +181 -0
  17. package/dist/modules/customers/backend/config/customers/deals/page.js.map +7 -0
  18. package/dist/modules/customers/backend/config/customers/deals/page.meta.js +38 -0
  19. package/dist/modules/customers/backend/config/customers/deals/page.meta.js.map +7 -0
  20. package/dist/modules/customers/backend/customers/deals/create/page.js +17 -6
  21. package/dist/modules/customers/backend/customers/deals/create/page.js.map +2 -2
  22. package/dist/modules/customers/backend/customers/deals/page.js +2 -0
  23. package/dist/modules/customers/backend/customers/deals/page.js.map +2 -2
  24. package/dist/modules/customers/backend/customers/deals/pipeline/components/ActivityComposerDialog.js +217 -0
  25. package/dist/modules/customers/backend/customers/deals/pipeline/components/ActivityComposerDialog.js.map +7 -0
  26. package/dist/modules/customers/backend/customers/deals/pipeline/components/AddStageDialog.js +335 -0
  27. package/dist/modules/customers/backend/customers/deals/pipeline/components/AddStageDialog.js.map +7 -0
  28. package/dist/modules/customers/backend/customers/deals/pipeline/components/AddStageLane.js +29 -0
  29. package/dist/modules/customers/backend/customers/deals/pipeline/components/AddStageLane.js.map +7 -0
  30. package/dist/modules/customers/backend/customers/deals/pipeline/components/BulkActionsBar.js +160 -0
  31. package/dist/modules/customers/backend/customers/deals/pipeline/components/BulkActionsBar.js.map +7 -0
  32. package/dist/modules/customers/backend/customers/deals/pipeline/components/ChangeOwnerDialog.js +157 -0
  33. package/dist/modules/customers/backend/customers/deals/pipeline/components/ChangeOwnerDialog.js.map +7 -0
  34. package/dist/modules/customers/backend/customers/deals/pipeline/components/ChangeStageDialog.js +114 -0
  35. package/dist/modules/customers/backend/customers/deals/pipeline/components/ChangeStageDialog.js.map +7 -0
  36. package/dist/modules/customers/backend/customers/deals/pipeline/components/ChipButton.js +36 -0
  37. package/dist/modules/customers/backend/customers/deals/pipeline/components/ChipButton.js.map +7 -0
  38. package/dist/modules/customers/backend/customers/deals/pipeline/components/CloseDateFilterPopover.js +199 -0
  39. package/dist/modules/customers/backend/customers/deals/pipeline/components/CloseDateFilterPopover.js.map +7 -0
  40. package/dist/modules/customers/backend/customers/deals/pipeline/components/CurrencyBreakdownTable.js +71 -0
  41. package/dist/modules/customers/backend/customers/deals/pipeline/components/CurrencyBreakdownTable.js.map +7 -0
  42. package/dist/modules/customers/backend/customers/deals/pipeline/components/CurrencyFilterPopover.js +233 -0
  43. package/dist/modules/customers/backend/customers/deals/pipeline/components/CurrencyFilterPopover.js.map +7 -0
  44. package/dist/modules/customers/backend/customers/deals/pipeline/components/CustomizeViewDialog.js +126 -0
  45. package/dist/modules/customers/backend/customers/deals/pipeline/components/CustomizeViewDialog.js.map +7 -0
  46. package/dist/modules/customers/backend/customers/deals/pipeline/components/DashedTileButton.js +31 -0
  47. package/dist/modules/customers/backend/customers/deals/pipeline/components/DashedTileButton.js.map +7 -0
  48. package/dist/modules/customers/backend/customers/deals/pipeline/components/DealCard.js +389 -0
  49. package/dist/modules/customers/backend/customers/deals/pipeline/components/DealCard.js.map +7 -0
  50. package/dist/modules/customers/backend/customers/deals/pipeline/components/DealCardMenu.js +181 -0
  51. package/dist/modules/customers/backend/customers/deals/pipeline/components/DealCardMenu.js.map +7 -0
  52. package/dist/modules/customers/backend/customers/deals/pipeline/components/EntityFilterPopover.js +199 -0
  53. package/dist/modules/customers/backend/customers/deals/pipeline/components/EntityFilterPopover.js.map +7 -0
  54. package/dist/modules/customers/backend/customers/deals/pipeline/components/FilterBarRow.js +44 -0
  55. package/dist/modules/customers/backend/customers/deals/pipeline/components/FilterBarRow.js.map +7 -0
  56. package/dist/modules/customers/backend/customers/deals/pipeline/components/FilterPopoverShell.js +70 -0
  57. package/dist/modules/customers/backend/customers/deals/pipeline/components/FilterPopoverShell.js.map +7 -0
  58. package/dist/modules/customers/backend/customers/deals/pipeline/components/Lane.js +278 -0
  59. package/dist/modules/customers/backend/customers/deals/pipeline/components/Lane.js.map +7 -0
  60. package/dist/modules/customers/backend/customers/deals/pipeline/components/LaneCurrencyBreakdown.js +135 -0
  61. package/dist/modules/customers/backend/customers/deals/pipeline/components/LaneCurrencyBreakdown.js.map +7 -0
  62. package/dist/modules/customers/backend/customers/deals/pipeline/components/LaneResizeHandle.js +83 -0
  63. package/dist/modules/customers/backend/customers/deals/pipeline/components/LaneResizeHandle.js.map +7 -0
  64. package/dist/modules/customers/backend/customers/deals/pipeline/components/PipelineFilterPopover.js +155 -0
  65. package/dist/modules/customers/backend/customers/deals/pipeline/components/PipelineFilterPopover.js.map +7 -0
  66. package/dist/modules/customers/backend/customers/deals/pipeline/components/QuickDealDialog.js +318 -0
  67. package/dist/modules/customers/backend/customers/deals/pipeline/components/QuickDealDialog.js.map +7 -0
  68. package/dist/modules/customers/backend/customers/deals/pipeline/components/SortByPopover.js +138 -0
  69. package/dist/modules/customers/backend/customers/deals/pipeline/components/SortByPopover.js.map +7 -0
  70. package/dist/modules/customers/backend/customers/deals/pipeline/components/StatusFilterPopover.js +126 -0
  71. package/dist/modules/customers/backend/customers/deals/pipeline/components/StatusFilterPopover.js.map +7 -0
  72. package/dist/modules/customers/backend/customers/deals/pipeline/components/ViewTabsRow.js +71 -0
  73. package/dist/modules/customers/backend/customers/deals/pipeline/components/ViewTabsRow.js.map +7 -0
  74. package/dist/modules/customers/backend/customers/deals/pipeline/components/constants.js +7 -0
  75. package/dist/modules/customers/backend/customers/deals/pipeline/components/constants.js.map +7 -0
  76. package/dist/modules/customers/backend/customers/deals/pipeline/page.js +2330 -415
  77. package/dist/modules/customers/backend/customers/deals/pipeline/page.js.map +3 -3
  78. package/dist/modules/customers/commands/deals.js +6 -0
  79. package/dist/modules/customers/commands/deals.js.map +2 -2
  80. package/dist/modules/customers/commands/pipeline-stages.js +48 -13
  81. package/dist/modules/customers/commands/pipeline-stages.js.map +3 -3
  82. package/dist/modules/customers/commands/settings.js +45 -5
  83. package/dist/modules/customers/commands/settings.js.map +2 -2
  84. package/dist/modules/customers/data/enrichers.js +130 -1
  85. package/dist/modules/customers/data/enrichers.js.map +2 -2
  86. package/dist/modules/customers/data/entities.js +4 -0
  87. package/dist/modules/customers/data/entities.js.map +2 -2
  88. package/dist/modules/customers/data/validators.js +29 -2
  89. package/dist/modules/customers/data/validators.js.map +2 -2
  90. package/dist/modules/customers/lib/bulkDeals.js +150 -0
  91. package/dist/modules/customers/lib/bulkDeals.js.map +7 -0
  92. package/dist/modules/customers/lib/kysely.js +12 -0
  93. package/dist/modules/customers/lib/kysely.js.map +7 -0
  94. package/dist/modules/customers/lib/stuckDeals.js +32 -0
  95. package/dist/modules/customers/lib/stuckDeals.js.map +7 -0
  96. package/dist/modules/customers/migrations/Migration20260513203311_customers.js +13 -0
  97. package/dist/modules/customers/migrations/Migration20260513203311_customers.js.map +7 -0
  98. package/dist/modules/customers/migrations/Migration20260519120000_pipeline_stage_color_tones.js +52 -0
  99. package/dist/modules/customers/migrations/Migration20260519120000_pipeline_stage_color_tones.js.map +7 -0
  100. package/dist/modules/customers/workers/deals-bulk-update-owner.js +41 -0
  101. package/dist/modules/customers/workers/deals-bulk-update-owner.js.map +7 -0
  102. package/dist/modules/customers/workers/deals-bulk-update-stage.js +41 -0
  103. package/dist/modules/customers/workers/deals-bulk-update-stage.js.map +7 -0
  104. package/generated/entities/customer_settings/index.ts +1 -0
  105. package/generated/entity-fields-registry.ts +1 -0
  106. package/package.json +7 -7
  107. package/src/modules/customers/api/deals/aggregate/route.ts +438 -0
  108. package/src/modules/customers/api/deals/bulk-update-owner/route.ts +216 -0
  109. package/src/modules/customers/api/deals/bulk-update-stage/route.ts +228 -0
  110. package/src/modules/customers/api/deals/route.ts +330 -135
  111. package/src/modules/customers/api/settings/stuck-threshold/route.ts +195 -0
  112. package/src/modules/customers/backend/config/customers/deals/page.meta.ts +36 -0
  113. package/src/modules/customers/backend/config/customers/deals/page.tsx +205 -0
  114. package/src/modules/customers/backend/customers/deals/create/page.tsx +25 -6
  115. package/src/modules/customers/backend/customers/deals/page.tsx +2 -0
  116. package/src/modules/customers/backend/customers/deals/pipeline/components/ActivityComposerDialog.tsx +277 -0
  117. package/src/modules/customers/backend/customers/deals/pipeline/components/AddStageDialog.tsx +425 -0
  118. package/src/modules/customers/backend/customers/deals/pipeline/components/AddStageLane.tsx +31 -0
  119. package/src/modules/customers/backend/customers/deals/pipeline/components/BulkActionsBar.tsx +171 -0
  120. package/src/modules/customers/backend/customers/deals/pipeline/components/ChangeOwnerDialog.tsx +196 -0
  121. package/src/modules/customers/backend/customers/deals/pipeline/components/ChangeStageDialog.tsx +138 -0
  122. package/src/modules/customers/backend/customers/deals/pipeline/components/ChipButton.tsx +47 -0
  123. package/src/modules/customers/backend/customers/deals/pipeline/components/CloseDateFilterPopover.tsx +232 -0
  124. package/src/modules/customers/backend/customers/deals/pipeline/components/CurrencyBreakdownTable.tsx +110 -0
  125. package/src/modules/customers/backend/customers/deals/pipeline/components/CurrencyFilterPopover.tsx +310 -0
  126. package/src/modules/customers/backend/customers/deals/pipeline/components/CustomizeViewDialog.tsx +169 -0
  127. package/src/modules/customers/backend/customers/deals/pipeline/components/DashedTileButton.tsx +54 -0
  128. package/src/modules/customers/backend/customers/deals/pipeline/components/DealCard.tsx +562 -0
  129. package/src/modules/customers/backend/customers/deals/pipeline/components/DealCardMenu.tsx +214 -0
  130. package/src/modules/customers/backend/customers/deals/pipeline/components/EntityFilterPopover.tsx +257 -0
  131. package/src/modules/customers/backend/customers/deals/pipeline/components/FilterBarRow.tsx +62 -0
  132. package/src/modules/customers/backend/customers/deals/pipeline/components/FilterPopoverShell.tsx +101 -0
  133. package/src/modules/customers/backend/customers/deals/pipeline/components/Lane.tsx +378 -0
  134. package/src/modules/customers/backend/customers/deals/pipeline/components/LaneCurrencyBreakdown.tsx +192 -0
  135. package/src/modules/customers/backend/customers/deals/pipeline/components/LaneResizeHandle.tsx +107 -0
  136. package/src/modules/customers/backend/customers/deals/pipeline/components/PipelineFilterPopover.tsx +196 -0
  137. package/src/modules/customers/backend/customers/deals/pipeline/components/QuickDealDialog.tsx +408 -0
  138. package/src/modules/customers/backend/customers/deals/pipeline/components/SortByPopover.tsx +164 -0
  139. package/src/modules/customers/backend/customers/deals/pipeline/components/StatusFilterPopover.tsx +171 -0
  140. package/src/modules/customers/backend/customers/deals/pipeline/components/ViewTabsRow.tsx +84 -0
  141. package/src/modules/customers/backend/customers/deals/pipeline/components/constants.ts +16 -0
  142. package/src/modules/customers/backend/customers/deals/pipeline/page.tsx +2841 -550
  143. package/src/modules/customers/commands/deals.ts +19 -0
  144. package/src/modules/customers/commands/pipeline-stages.ts +68 -13
  145. package/src/modules/customers/commands/settings.ts +64 -5
  146. package/src/modules/customers/data/enrichers.ts +209 -2
  147. package/src/modules/customers/data/entities.ts +4 -1
  148. package/src/modules/customers/data/validators.ts +36 -2
  149. package/src/modules/customers/i18n/de.json +244 -0
  150. package/src/modules/customers/i18n/en.json +244 -0
  151. package/src/modules/customers/i18n/es.json +244 -0
  152. package/src/modules/customers/i18n/pl.json +244 -0
  153. package/src/modules/customers/lib/bulkDeals.ts +231 -0
  154. package/src/modules/customers/lib/kysely.ts +60 -0
  155. package/src/modules/customers/lib/stuckDeals.ts +88 -0
  156. package/src/modules/customers/migrations/.snapshot-open-mercato.json +16 -0
  157. package/src/modules/customers/migrations/Migration20260513203311_customers.ts +13 -0
  158. package/src/modules/customers/migrations/Migration20260519120000_pipeline_stage_color_tones.ts +71 -0
  159. package/src/modules/customers/workers/deals-bulk-update-owner.ts +46 -0
  160. package/src/modules/customers/workers/deals-bulk-update-stage.ts +46 -0
@@ -0,0 +1,277 @@
1
+ "use client"
2
+
3
+ import * as React from 'react'
4
+ import { Mail, Phone, StickyNote, type LucideIcon } from 'lucide-react'
5
+ import { z } from 'zod'
6
+ import {
7
+ Dialog,
8
+ DialogContent,
9
+ DialogDescription,
10
+ DialogHeader,
11
+ DialogTitle,
12
+ } from '@open-mercato/ui/primitives/dialog'
13
+ import { Button } from '@open-mercato/ui/primitives/button'
14
+ import { CrudForm, type CrudField } from '@open-mercato/ui/backend/CrudForm'
15
+ import { createCrud } from '@open-mercato/ui/backend/utils/crud'
16
+ import { createCrudFormError } from '@open-mercato/ui/backend/utils/serverErrors'
17
+ import { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'
18
+ import { flash } from '@open-mercato/ui/backend/FlashMessages'
19
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
20
+ import { translateWithFallback } from '@open-mercato/shared/lib/i18n/translate'
21
+
22
+ export type ActivityComposerType = 'call' | 'email' | 'note'
23
+
24
+ export type ActivityComposerContext = {
25
+ dealId: string
26
+ dealTitle: string
27
+ type: ActivityComposerType
28
+ entityId: string
29
+ }
30
+
31
+ type ActivityComposerDialogProps = {
32
+ open: boolean
33
+ context: ActivityComposerContext | null
34
+ onClose: () => void
35
+ onCreated: () => void
36
+ }
37
+
38
+ type ActivityComposerFormValues = {
39
+ title: string
40
+ phoneNumber: string
41
+ body: string
42
+ }
43
+
44
+ const ACTIVITY_CONTEXT_ID = 'customers-deals-kanban:activity-composer'
45
+
46
+ const TYPE_META: Record<
47
+ ActivityComposerType,
48
+ {
49
+ titleKey: string
50
+ titleFallback: string
51
+ interactionType: string
52
+ icon: LucideIcon
53
+ }
54
+ > = {
55
+ call: {
56
+ titleKey: 'customers.deals.kanban.activityComposer.call',
57
+ titleFallback: 'Log a call',
58
+ interactionType: 'call',
59
+ icon: Phone,
60
+ },
61
+ email: {
62
+ titleKey: 'customers.deals.kanban.activityComposer.email',
63
+ titleFallback: 'Send email',
64
+ interactionType: 'email',
65
+ icon: Mail,
66
+ },
67
+ note: {
68
+ titleKey: 'customers.deals.kanban.activityComposer.note',
69
+ titleFallback: 'Add a note',
70
+ interactionType: 'note',
71
+ icon: StickyNote,
72
+ },
73
+ }
74
+
75
+ export function ActivityComposerDialog({
76
+ open,
77
+ context,
78
+ onClose,
79
+ onCreated,
80
+ }: ActivityComposerDialogProps): React.ReactElement | null {
81
+ const t = useT()
82
+ // Re-mount CrudForm whenever the dialog opens, or when the deal/type changes — these
83
+ // identify what we're logging activity FOR, and CrudForm's internal state must reset.
84
+ const [formInstanceKey, setFormInstanceKey] = React.useState(0)
85
+ React.useEffect(() => {
86
+ if (open) setFormInstanceKey((current) => current + 1)
87
+ }, [open])
88
+
89
+ const { runMutation, retryLastMutation } = useGuardedMutation<{
90
+ formId: string
91
+ resourceKind: string
92
+ resourceId: string
93
+ retryLastMutation: () => Promise<boolean>
94
+ }>({
95
+ contextId: ACTIVITY_CONTEXT_ID,
96
+ blockedMessage: translateWithFallback(t, 'ui.forms.flash.saveBlocked', 'Save blocked by validation'),
97
+ })
98
+
99
+ // Email mode shows the field as "Subject" (see TYPE_META + the field config below), so
100
+ // the refine error must reference "subject" instead of "title" to stay coherent. Call
101
+ // and Note modes keep the "title or body" copy that matches their Subject-labelled field.
102
+ const eitherRequiredMessage = React.useMemo(
103
+ () =>
104
+ context?.type === 'email'
105
+ ? translateWithFallback(
106
+ t,
107
+ 'customers.deals.kanban.activityComposer.required.email',
108
+ 'Add a subject or body before saving.',
109
+ )
110
+ : translateWithFallback(
111
+ t,
112
+ 'customers.deals.kanban.activityComposer.required.titleOrBody',
113
+ 'Add a title or body before saving.',
114
+ ),
115
+ [context?.type, t],
116
+ )
117
+
118
+ const formSchema = React.useMemo(
119
+ () =>
120
+ z
121
+ .object({
122
+ title: z.string().optional(),
123
+ phoneNumber: z.string().optional(),
124
+ body: z.string().optional(),
125
+ })
126
+ // Activity must carry at least a title OR a body — purely empty saves are
127
+ // useless and they previously surfaced as a confusing inline error.
128
+ .refine(
129
+ (values) => (values.title?.trim().length || values.body?.trim().length) ? true : false,
130
+ {
131
+ message: eitherRequiredMessage,
132
+ path: ['title'],
133
+ },
134
+ ),
135
+ [eitherRequiredMessage],
136
+ ) as unknown as z.ZodType<ActivityComposerFormValues>
137
+
138
+ const initialValues = React.useMemo<Partial<ActivityComposerFormValues>>(
139
+ () => ({ title: '', phoneNumber: '', body: '' }),
140
+ [],
141
+ )
142
+
143
+ const isCall = context?.type === 'call'
144
+
145
+ const fields = React.useMemo<CrudField[]>(() => {
146
+ const list: CrudField[] = [
147
+ {
148
+ // CrudForm auto-focuses the first field on mount; explicit `autoFocus` is rejected
149
+ // by CrudBuiltinField's type and would shadow CrudForm's own focus management.
150
+ id: 'title',
151
+ label: translateWithFallback(t, 'customers.deals.kanban.activityComposer.title', 'Subject'),
152
+ type: 'text',
153
+ placeholder: translateWithFallback(
154
+ t,
155
+ 'customers.deals.kanban.activityComposer.title.placeholder',
156
+ 'Short summary (optional)',
157
+ ),
158
+ },
159
+ ]
160
+ if (isCall) {
161
+ list.push({
162
+ id: 'phoneNumber',
163
+ label: translateWithFallback(t, 'customers.deals.kanban.activityComposer.phone', 'Phone number'),
164
+ type: 'text',
165
+ placeholder: '+48 ...',
166
+ })
167
+ }
168
+ list.push({
169
+ id: 'body',
170
+ label: translateWithFallback(t, 'customers.deals.kanban.activityComposer.body', 'Notes'),
171
+ type: 'textarea',
172
+ rows: 4,
173
+ placeholder: translateWithFallback(
174
+ t,
175
+ 'customers.deals.kanban.activityComposer.body.placeholder',
176
+ 'What happened? Markdown supported.',
177
+ ),
178
+ })
179
+ return list
180
+ }, [isCall, t])
181
+
182
+ const handleSubmit = React.useCallback(
183
+ async (values: ActivityComposerFormValues) => {
184
+ if (!context) return
185
+ const trimmedTitle = (values.title ?? '').trim()
186
+ const trimmedBody = (values.body ?? '').trim()
187
+ if (!trimmedTitle.length && !trimmedBody.length) {
188
+ // Zod refine already handles this, but we surface a field-level error too so the
189
+ // inline indicator stays anchored on the Subject input.
190
+ throw createCrudFormError(eitherRequiredMessage, { title: eitherRequiredMessage })
191
+ }
192
+ const meta = TYPE_META[context.type]
193
+ const payload: Record<string, unknown> = {
194
+ entityId: context.entityId,
195
+ interactionType: meta.interactionType,
196
+ dealId: context.dealId,
197
+ status: 'planned',
198
+ }
199
+ if (trimmedTitle.length) payload.title = trimmedTitle
200
+ if (trimmedBody.length) payload.body = trimmedBody
201
+ if (context.type === 'call' && (values.phoneNumber ?? '').trim().length) {
202
+ payload.phoneNumber = values.phoneNumber!.trim()
203
+ }
204
+ const operation = () =>
205
+ createCrud('customers/interactions', payload, {
206
+ errorMessage: translateWithFallback(
207
+ t,
208
+ 'customers.deals.kanban.activityComposer.error',
209
+ 'Failed to save activity.',
210
+ ),
211
+ })
212
+ await runMutation({
213
+ operation,
214
+ context: {
215
+ formId: ACTIVITY_CONTEXT_ID,
216
+ resourceKind: 'customers.interaction',
217
+ resourceId: context.dealId,
218
+ retryLastMutation,
219
+ },
220
+ })
221
+ flash(
222
+ translateWithFallback(t, 'customers.deals.kanban.activityComposer.success', 'Activity saved.'),
223
+ 'success',
224
+ )
225
+ onCreated()
226
+ onClose()
227
+ },
228
+ [context, eitherRequiredMessage, onClose, onCreated, retryLastMutation, runMutation, t],
229
+ )
230
+
231
+ if (!context) return null
232
+ const meta = TYPE_META[context.type]
233
+ const Icon = meta.icon
234
+ const dialogTitle = translateWithFallback(t, meta.titleKey, meta.titleFallback)
235
+
236
+ return (
237
+ <Dialog
238
+ open={open}
239
+ onOpenChange={(next) => {
240
+ if (!next) onClose()
241
+ }}
242
+ >
243
+ <DialogContent>
244
+ <DialogHeader>
245
+ <DialogTitle className="flex items-center gap-2">
246
+ <Icon className="size-4" aria-hidden="true" />
247
+ {dialogTitle}
248
+ </DialogTitle>
249
+ <DialogDescription>
250
+ {translateWithFallback(
251
+ t,
252
+ 'customers.deals.kanban.activityComposer.context',
253
+ 'Deal: {title}',
254
+ { title: context.dealTitle },
255
+ )}
256
+ </DialogDescription>
257
+ </DialogHeader>
258
+
259
+ <CrudForm<ActivityComposerFormValues>
260
+ key={`${context.dealId}:${context.type}:${formInstanceKey}`}
261
+ embedded
262
+ fields={fields}
263
+ initialValues={initialValues}
264
+ schema={formSchema}
265
+ submitLabel={translateWithFallback(
266
+ t,
267
+ 'customers.deals.kanban.activityComposer.submit',
268
+ 'Save activity',
269
+ )}
270
+ onSubmit={handleSubmit}
271
+ />
272
+ </DialogContent>
273
+ </Dialog>
274
+ )
275
+ }
276
+
277
+ export default ActivityComposerDialog
@@ -0,0 +1,425 @@
1
+ "use client"
2
+
3
+ import * as React from 'react'
4
+ import { z } from 'zod'
5
+ import {
6
+ Dialog,
7
+ DialogContent,
8
+ DialogDescription,
9
+ DialogHeader,
10
+ DialogTitle,
11
+ } from '@open-mercato/ui/primitives/dialog'
12
+ import { Button } from '@open-mercato/ui/primitives/button'
13
+ import { CrudForm, type CrudField } from '@open-mercato/ui/backend/CrudForm'
14
+ import { createCrud } from '@open-mercato/ui/backend/utils/crud'
15
+ import { createCrudFormError } from '@open-mercato/ui/backend/utils/serverErrors'
16
+ import { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'
17
+ import { flash } from '@open-mercato/ui/backend/FlashMessages'
18
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
19
+ import { translateWithFallback } from '@open-mercato/shared/lib/i18n/translate'
20
+
21
+ const ADD_STAGE_CONTEXT_ID = 'customers-deals-kanban:add-stage'
22
+
23
+ // Stage color palette — semantic tone identifiers matching Lane.tsx's ACCENT_TONE_CLASS
24
+ // map. We switched from arbitrary hex values to canonical tones (success / warning / info
25
+ // / error / neutral / brand / pink) so the picker can't silently fall through to gray
26
+ // when the implicit hex→tone mapping doesn't recognise a value. Empty string = no color
27
+ // (lane falls back to the position-based rotation). Forward-only migration 2026-05-19
28
+ // backfills existing hex values to their nearest tone.
29
+ const TONE_OPTIONS = [
30
+ {
31
+ value: '',
32
+ swatchClass: 'border border-dashed border-border',
33
+ labelKey: 'customers.deals.kanban.addStage.color.none',
34
+ labelFallback: 'No color',
35
+ },
36
+ {
37
+ value: 'success',
38
+ swatchClass: 'bg-status-success-icon',
39
+ labelKey: 'customers.deals.kanban.addStage.color.success',
40
+ labelFallback: 'Green',
41
+ },
42
+ {
43
+ value: 'warning',
44
+ swatchClass: 'bg-status-warning-icon',
45
+ labelKey: 'customers.deals.kanban.addStage.color.warning',
46
+ labelFallback: 'Amber',
47
+ },
48
+ {
49
+ value: 'info',
50
+ swatchClass: 'bg-status-info-icon',
51
+ labelKey: 'customers.deals.kanban.addStage.color.info',
52
+ labelFallback: 'Blue',
53
+ },
54
+ {
55
+ value: 'error',
56
+ swatchClass: 'bg-status-error-icon',
57
+ labelKey: 'customers.deals.kanban.addStage.color.error',
58
+ labelFallback: 'Red',
59
+ },
60
+ {
61
+ value: 'neutral',
62
+ swatchClass: 'bg-status-neutral-icon',
63
+ labelKey: 'customers.deals.kanban.addStage.color.neutral',
64
+ labelFallback: 'Gray',
65
+ },
66
+ {
67
+ value: 'brand',
68
+ swatchClass: 'bg-brand-violet',
69
+ labelKey: 'customers.deals.kanban.addStage.color.brand',
70
+ labelFallback: 'Violet',
71
+ },
72
+ {
73
+ value: 'pink',
74
+ swatchClass: 'bg-status-pink-icon',
75
+ labelKey: 'customers.deals.kanban.addStage.color.pink',
76
+ labelFallback: 'Pink',
77
+ },
78
+ ] as const
79
+
80
+ /**
81
+ * Identifier values for the "Position" picker:
82
+ * - `end` — append after every existing stage (the default; matches the previous
83
+ * always-at-end behavior so the dialog stays familiar)
84
+ * - `start` — insert at order 0; existing stages shift right
85
+ * - `after:<stageId>` — insert directly after the given stage; everything to its right shifts
86
+ *
87
+ * We use a single-string discriminated value rather than a separate "mode" + "afterId"
88
+ * pair because CrudForm's built-in `select` field type expects scalar option values, and
89
+ * the backend payload computation is trivial off this single string.
90
+ */
91
+ const POSITION_END = 'end' as const
92
+ const POSITION_START = 'start' as const
93
+ const POSITION_AFTER_PREFIX = 'after:'
94
+
95
+ export type AddStageContext = {
96
+ pipelineId: string
97
+ pipelineName: string
98
+ /**
99
+ * Snapshot of every stage already in the pipeline, ordered by their `order` field.
100
+ * Drives the "Position" picker. `order` is the server-side position used to build
101
+ * the create payload (we send `existingOrder + 1` for "after this stage").
102
+ */
103
+ existingStages: ReadonlyArray<{ id: string; label: string; order: number }>
104
+ }
105
+
106
+ type AddStageDialogProps = {
107
+ open: boolean
108
+ context: AddStageContext | null
109
+ onClose: () => void
110
+ onCreated: () => void
111
+ }
112
+
113
+ type AddStageFormValues = {
114
+ label: string
115
+ color: string
116
+ position: string
117
+ }
118
+
119
+ /**
120
+ * Resolve the picker's discriminated string to the integer `order` we send to the API.
121
+ * Returns `undefined` when the operator picked "At end" — the backend treats undefined
122
+ * as "append" so the legacy default behavior is preserved bit-for-bit.
123
+ */
124
+ function resolveInsertOrder(
125
+ position: string,
126
+ existingStages: ReadonlyArray<{ id: string; label: string; order: number }>,
127
+ ): number | undefined {
128
+ if (position === POSITION_END || position === '') return undefined
129
+ if (position === POSITION_START) return 0
130
+ if (position.startsWith(POSITION_AFTER_PREFIX)) {
131
+ const afterId = position.slice(POSITION_AFTER_PREFIX.length)
132
+ const idx = existingStages.findIndex((stage) => stage.id === afterId)
133
+ if (idx < 0) return undefined
134
+ // Insert position is the existing stage's order + 1; the backend will shift the
135
+ // remainder of the stages by +1 to keep ordering dense and unique.
136
+ return existingStages[idx].order + 1
137
+ }
138
+ return undefined
139
+ }
140
+
141
+ export function AddStageDialog({
142
+ open,
143
+ context,
144
+ onClose,
145
+ onCreated,
146
+ }: AddStageDialogProps): React.ReactElement | null {
147
+ const t = useT()
148
+ // Re-mount CrudForm whenever the dialog opens so cleared state is consistently fresh and
149
+ // any stale field errors from a previous open don't carry over.
150
+ const [formInstanceKey, setFormInstanceKey] = React.useState(0)
151
+ React.useEffect(() => {
152
+ if (open) setFormInstanceKey((current) => current + 1)
153
+ }, [open])
154
+
155
+ const { runMutation, retryLastMutation } = useGuardedMutation<{
156
+ formId: string
157
+ resourceKind: string
158
+ retryLastMutation: () => Promise<boolean>
159
+ }>({
160
+ contextId: ADD_STAGE_CONTEXT_ID,
161
+ blockedMessage: translateWithFallback(t, 'ui.forms.flash.saveBlocked', 'Save blocked by validation'),
162
+ })
163
+
164
+ const labelRequiredMessage = translateWithFallback(
165
+ t,
166
+ 'customers.deals.kanban.addStage.label.required',
167
+ 'Stage name is required.',
168
+ )
169
+
170
+ // Cast widens to AddStageFormValues so CrudForm's TValues generic stays exact — Zod's
171
+ // inferred type is structurally identical but the generic parameters carry extra
172
+ // metadata that doesn't match `z.ZodType<TValues>` literally.
173
+ const formSchema = React.useMemo(
174
+ () =>
175
+ z.object({
176
+ label: z.string().trim().min(1, labelRequiredMessage),
177
+ color: z.string().optional(),
178
+ position: z.string().optional(),
179
+ }) as unknown as z.ZodType<AddStageFormValues>,
180
+ [labelRequiredMessage],
181
+ )
182
+
183
+ const positionOptions = React.useMemo(() => {
184
+ const existingStages = context?.existingStages ?? []
185
+ const opts: Array<{ value: string; label: string }> = [
186
+ {
187
+ value: POSITION_END,
188
+ label: translateWithFallback(
189
+ t,
190
+ 'customers.deals.kanban.addStage.position.end',
191
+ 'At the end (default)',
192
+ ),
193
+ },
194
+ {
195
+ value: POSITION_START,
196
+ label: translateWithFallback(
197
+ t,
198
+ 'customers.deals.kanban.addStage.position.start',
199
+ 'At the start',
200
+ ),
201
+ },
202
+ ]
203
+ for (const stage of existingStages) {
204
+ opts.push({
205
+ value: `${POSITION_AFTER_PREFIX}${stage.id}`,
206
+ label: translateWithFallback(
207
+ t,
208
+ 'customers.deals.kanban.addStage.position.after',
209
+ 'After {label}',
210
+ { label: stage.label },
211
+ ),
212
+ })
213
+ }
214
+ return opts
215
+ }, [context?.existingStages, t])
216
+
217
+ const fields = React.useMemo<CrudField[]>(
218
+ () => [
219
+ {
220
+ // CrudForm auto-focuses the first field of the first group on mount, so we don't
221
+ // need an explicit `autoFocus: true` here — and adding one would be a type error
222
+ // because `autoFocus` is only valid on CrudCustomFieldRenderProps.
223
+ id: 'label',
224
+ label: translateWithFallback(t, 'customers.deals.kanban.addStage.label', 'Stage name'),
225
+ type: 'text',
226
+ required: true,
227
+ placeholder: translateWithFallback(
228
+ t,
229
+ 'customers.deals.kanban.addStage.label.placeholder',
230
+ 'e.g. Discovery',
231
+ ),
232
+ },
233
+ {
234
+ // Custom inline radio-style list instead of a Radix Select — when the dialog sits
235
+ // near the bottom of the viewport, Radix's Select dropdown clips to the available
236
+ // vertical space (`--radix-select-content-available-height`), which can leave only
237
+ // 1–2 visible options and hide the rest behind a small scroll affordance the
238
+ // operator doesn't notice. Rendering the picker inline inside the dialog body
239
+ // gives it a deterministic max-height + clearly scrollable area, and "After {label}"
240
+ // entries are always visible without portal clipping.
241
+ id: 'position',
242
+ label: translateWithFallback(
243
+ t,
244
+ 'customers.deals.kanban.addStage.position',
245
+ 'Position in pipeline',
246
+ ),
247
+ type: 'custom',
248
+ description: translateWithFallback(
249
+ t,
250
+ 'customers.deals.kanban.addStage.position.help',
251
+ 'Choose where the new stage appears on the kanban. Existing stages after the chosen position shift one step to the right.',
252
+ ),
253
+ component: ({ value, setValue }) => {
254
+ const current = typeof value === 'string' && value.length > 0 ? value : POSITION_END
255
+ return (
256
+ <div className="flex max-h-52 flex-col gap-1 overflow-y-auto rounded-md border border-input bg-card p-1">
257
+ {positionOptions.map((option) => {
258
+ const selected = current === option.value
259
+ return (
260
+ <Button
261
+ variant="ghost"
262
+ size="sm"
263
+ key={option.value}
264
+ type="button"
265
+ onClick={() => setValue(option.value)}
266
+ aria-pressed={selected}
267
+ className={`flex items-center justify-between gap-2 rounded-sm px-3 py-2 text-left text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 ${
268
+ selected
269
+ ? 'bg-muted font-medium text-foreground'
270
+ : 'text-muted-foreground hover:bg-muted/60 hover:text-foreground'
271
+ }`}
272
+ >
273
+ <span className="flex-1 truncate">{option.label}</span>
274
+ <span
275
+ className={`inline-flex size-4 shrink-0 items-center justify-center rounded-full border ${
276
+ selected ? 'border-primary bg-primary' : 'border-input'
277
+ }`}
278
+ aria-hidden="true"
279
+ >
280
+ {selected ? (
281
+ <span className="size-1.5 rounded-full bg-primary-foreground" />
282
+ ) : null}
283
+ </span>
284
+ </Button>
285
+ )
286
+ })}
287
+ </div>
288
+ )
289
+ },
290
+ },
291
+ {
292
+ // Custom render — built-in `select` can't render a swatch alongside each label.
293
+ // Each swatch uses the same DS class the lane bar renders, so the picker is a
294
+ // true preview of the kanban appearance instead of a separate hex translation.
295
+ id: 'color',
296
+ label: translateWithFallback(t, 'customers.deals.kanban.addStage.color', 'Color'),
297
+ type: 'custom',
298
+ component: ({ value, setValue }) => {
299
+ const current = typeof value === 'string' ? value : ''
300
+ return (
301
+ <div className="flex flex-wrap gap-2">
302
+ {TONE_OPTIONS.map((option) => {
303
+ const selected = current === option.value
304
+ const label = translateWithFallback(t, option.labelKey, option.labelFallback)
305
+ return (
306
+ <Button
307
+ variant="outline"
308
+ size="sm"
309
+ key={option.value || 'none'}
310
+ type="button"
311
+ onClick={() => setValue(option.value)}
312
+ aria-pressed={selected}
313
+ title={label}
314
+ className={`inline-flex items-center gap-2 rounded-md border px-3 py-1.5 text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 ${
315
+ selected
316
+ ? 'border-primary bg-muted text-foreground'
317
+ : 'border-input bg-card text-muted-foreground hover:bg-muted hover:text-foreground'
318
+ }`}
319
+ >
320
+ <span
321
+ className={`size-3 rounded-sm ${option.swatchClass}`}
322
+ aria-hidden="true"
323
+ />
324
+ <span>{label}</span>
325
+ </Button>
326
+ )
327
+ })}
328
+ </div>
329
+ )
330
+ },
331
+ },
332
+ ],
333
+ [positionOptions, t],
334
+ )
335
+
336
+ const initialValues = React.useMemo<Partial<AddStageFormValues>>(
337
+ () => ({ label: '', color: '', position: POSITION_END }),
338
+ [],
339
+ )
340
+
341
+ const handleSubmit = React.useCallback(
342
+ async (values: AddStageFormValues) => {
343
+ if (!context) return
344
+ const trimmedLabel = values.label.trim()
345
+ if (!trimmedLabel.length) {
346
+ // Zod schema should already catch this — but we re-verify defensively because the
347
+ // Zod check uses `trim().min(1)` and the field renders trimmed values consistently.
348
+ throw createCrudFormError(labelRequiredMessage, { label: labelRequiredMessage })
349
+ }
350
+ const payload: Record<string, unknown> = {
351
+ pipelineId: context.pipelineId,
352
+ label: trimmedLabel,
353
+ }
354
+ if (values.color) payload.color = values.color
355
+ const insertOrder = resolveInsertOrder(values.position ?? POSITION_END, context.existingStages)
356
+ // Only send `order` when the operator explicitly chose a non-default position. Letting
357
+ // it stay undefined preserves the legacy "append to end" behavior on the backend
358
+ // (and keeps payloads clean / debugger-friendly for typical add flows).
359
+ if (insertOrder !== undefined) payload.order = insertOrder
360
+ const operation = () =>
361
+ createCrud('customers/pipeline-stages', payload, {
362
+ errorMessage: translateWithFallback(
363
+ t,
364
+ 'customers.deals.kanban.addStage.error',
365
+ 'Failed to add stage.',
366
+ ),
367
+ })
368
+ await runMutation({
369
+ operation,
370
+ context: {
371
+ formId: ADD_STAGE_CONTEXT_ID,
372
+ resourceKind: 'customers.pipeline_stage',
373
+ retryLastMutation,
374
+ },
375
+ })
376
+ flash(translateWithFallback(t, 'customers.deals.kanban.addStage.success', 'Stage added.'), 'success')
377
+ onCreated()
378
+ onClose()
379
+ },
380
+ [context, labelRequiredMessage, onClose, onCreated, retryLastMutation, runMutation, t],
381
+ )
382
+
383
+ if (!context) return null
384
+
385
+ return (
386
+ <Dialog
387
+ open={open}
388
+ onOpenChange={(next) => {
389
+ if (!next) onClose()
390
+ }}
391
+ >
392
+ <DialogContent className="sm:max-w-md">
393
+ <DialogHeader>
394
+ <DialogTitle>
395
+ {translateWithFallback(t, 'customers.deals.kanban.addStage.title', 'New stage')}
396
+ </DialogTitle>
397
+ <DialogDescription>
398
+ {translateWithFallback(
399
+ t,
400
+ 'customers.deals.kanban.addStage.context',
401
+ 'Pipeline: {pipeline}',
402
+ { pipeline: context.pipelineName },
403
+ )}
404
+ </DialogDescription>
405
+ </DialogHeader>
406
+
407
+ <CrudForm<AddStageFormValues>
408
+ key={`${context.pipelineId}:${formInstanceKey}`}
409
+ embedded
410
+ fields={fields}
411
+ initialValues={initialValues}
412
+ schema={formSchema}
413
+ submitLabel={translateWithFallback(
414
+ t,
415
+ 'customers.deals.kanban.addStage.submit',
416
+ 'Add stage',
417
+ )}
418
+ onSubmit={handleSubmit}
419
+ />
420
+ </DialogContent>
421
+ </Dialog>
422
+ )
423
+ }
424
+
425
+ export default AddStageDialog