@open-mercato/core 0.6.3-develop.3651.1.d3a548a5f1 → 0.6.3-develop.3660.1.53b73d5aa4
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.
- package/.turbo/turbo-build.log +1 -1
- package/dist/generated/entities/customer_settings/index.js +2 -0
- package/dist/generated/entities/customer_settings/index.js.map +2 -2
- package/dist/generated/entity-fields-registry.js +1 -0
- package/dist/generated/entity-fields-registry.js.map +2 -2
- package/dist/modules/customers/api/deals/aggregate/route.js +332 -0
- package/dist/modules/customers/api/deals/aggregate/route.js.map +7 -0
- package/dist/modules/customers/api/deals/bulk-update-owner/route.js +188 -0
- package/dist/modules/customers/api/deals/bulk-update-owner/route.js.map +7 -0
- package/dist/modules/customers/api/deals/bulk-update-stage/route.js +188 -0
- package/dist/modules/customers/api/deals/bulk-update-stage/route.js.map +7 -0
- package/dist/modules/customers/api/deals/route.js +255 -122
- package/dist/modules/customers/api/deals/route.js.map +2 -2
- package/dist/modules/customers/api/settings/stuck-threshold/route.js +169 -0
- package/dist/modules/customers/api/settings/stuck-threshold/route.js.map +7 -0
- package/dist/modules/customers/backend/config/customers/deals/page.js +181 -0
- package/dist/modules/customers/backend/config/customers/deals/page.js.map +7 -0
- package/dist/modules/customers/backend/config/customers/deals/page.meta.js +38 -0
- package/dist/modules/customers/backend/config/customers/deals/page.meta.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/create/page.js +17 -6
- package/dist/modules/customers/backend/customers/deals/create/page.js.map +2 -2
- package/dist/modules/customers/backend/customers/deals/page.js +2 -0
- package/dist/modules/customers/backend/customers/deals/page.js.map +2 -2
- package/dist/modules/customers/backend/customers/deals/pipeline/components/ActivityComposerDialog.js +217 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/ActivityComposerDialog.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/AddStageDialog.js +335 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/AddStageDialog.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/AddStageLane.js +29 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/AddStageLane.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/BulkActionsBar.js +160 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/BulkActionsBar.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/ChangeOwnerDialog.js +157 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/ChangeOwnerDialog.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/ChangeStageDialog.js +114 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/ChangeStageDialog.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/ChipButton.js +36 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/ChipButton.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/CloseDateFilterPopover.js +199 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/CloseDateFilterPopover.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/CurrencyBreakdownTable.js +71 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/CurrencyBreakdownTable.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/CurrencyFilterPopover.js +233 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/CurrencyFilterPopover.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/CustomizeViewDialog.js +126 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/CustomizeViewDialog.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/DashedTileButton.js +31 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/DashedTileButton.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/DealCard.js +389 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/DealCard.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/DealCardMenu.js +181 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/DealCardMenu.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/EntityFilterPopover.js +199 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/EntityFilterPopover.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/FilterBarRow.js +44 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/FilterBarRow.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/FilterPopoverShell.js +70 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/FilterPopoverShell.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/Lane.js +278 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/Lane.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/LaneCurrencyBreakdown.js +135 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/LaneCurrencyBreakdown.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/LaneResizeHandle.js +83 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/LaneResizeHandle.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/PipelineFilterPopover.js +155 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/PipelineFilterPopover.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/QuickDealDialog.js +318 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/QuickDealDialog.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/SortByPopover.js +138 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/SortByPopover.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/StatusFilterPopover.js +126 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/StatusFilterPopover.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/ViewTabsRow.js +71 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/ViewTabsRow.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/constants.js +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/constants.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/page.js +2330 -415
- package/dist/modules/customers/backend/customers/deals/pipeline/page.js.map +3 -3
- package/dist/modules/customers/commands/deals.js +6 -0
- package/dist/modules/customers/commands/deals.js.map +2 -2
- package/dist/modules/customers/commands/pipeline-stages.js +48 -13
- package/dist/modules/customers/commands/pipeline-stages.js.map +3 -3
- package/dist/modules/customers/commands/settings.js +45 -5
- package/dist/modules/customers/commands/settings.js.map +2 -2
- package/dist/modules/customers/data/enrichers.js +130 -1
- package/dist/modules/customers/data/enrichers.js.map +2 -2
- package/dist/modules/customers/data/entities.js +4 -0
- package/dist/modules/customers/data/entities.js.map +2 -2
- package/dist/modules/customers/data/validators.js +29 -2
- package/dist/modules/customers/data/validators.js.map +2 -2
- package/dist/modules/customers/lib/bulkDeals.js +150 -0
- package/dist/modules/customers/lib/bulkDeals.js.map +7 -0
- package/dist/modules/customers/lib/kysely.js +12 -0
- package/dist/modules/customers/lib/kysely.js.map +7 -0
- package/dist/modules/customers/lib/stuckDeals.js +32 -0
- package/dist/modules/customers/lib/stuckDeals.js.map +7 -0
- package/dist/modules/customers/migrations/Migration20260513203311_customers.js +13 -0
- package/dist/modules/customers/migrations/Migration20260513203311_customers.js.map +7 -0
- package/dist/modules/customers/migrations/Migration20260519120000_pipeline_stage_color_tones.js +52 -0
- package/dist/modules/customers/migrations/Migration20260519120000_pipeline_stage_color_tones.js.map +7 -0
- package/dist/modules/customers/workers/deals-bulk-update-owner.js +41 -0
- package/dist/modules/customers/workers/deals-bulk-update-owner.js.map +7 -0
- package/dist/modules/customers/workers/deals-bulk-update-stage.js +41 -0
- package/dist/modules/customers/workers/deals-bulk-update-stage.js.map +7 -0
- package/generated/entities/customer_settings/index.ts +1 -0
- package/generated/entity-fields-registry.ts +1 -0
- package/package.json +7 -7
- package/src/modules/customers/api/deals/aggregate/route.ts +438 -0
- package/src/modules/customers/api/deals/bulk-update-owner/route.ts +216 -0
- package/src/modules/customers/api/deals/bulk-update-stage/route.ts +228 -0
- package/src/modules/customers/api/deals/route.ts +330 -135
- package/src/modules/customers/api/settings/stuck-threshold/route.ts +195 -0
- package/src/modules/customers/backend/config/customers/deals/page.meta.ts +36 -0
- package/src/modules/customers/backend/config/customers/deals/page.tsx +205 -0
- package/src/modules/customers/backend/customers/deals/create/page.tsx +25 -6
- package/src/modules/customers/backend/customers/deals/page.tsx +2 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/ActivityComposerDialog.tsx +277 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/AddStageDialog.tsx +425 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/AddStageLane.tsx +31 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/BulkActionsBar.tsx +171 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/ChangeOwnerDialog.tsx +196 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/ChangeStageDialog.tsx +138 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/ChipButton.tsx +47 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/CloseDateFilterPopover.tsx +232 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/CurrencyBreakdownTable.tsx +110 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/CurrencyFilterPopover.tsx +310 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/CustomizeViewDialog.tsx +169 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/DashedTileButton.tsx +54 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/DealCard.tsx +562 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/DealCardMenu.tsx +214 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/EntityFilterPopover.tsx +257 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/FilterBarRow.tsx +62 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/FilterPopoverShell.tsx +101 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/Lane.tsx +378 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/LaneCurrencyBreakdown.tsx +192 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/LaneResizeHandle.tsx +107 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/PipelineFilterPopover.tsx +196 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/QuickDealDialog.tsx +408 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/SortByPopover.tsx +164 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/StatusFilterPopover.tsx +171 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/ViewTabsRow.tsx +84 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/constants.ts +16 -0
- package/src/modules/customers/backend/customers/deals/pipeline/page.tsx +2841 -550
- package/src/modules/customers/commands/deals.ts +19 -0
- package/src/modules/customers/commands/pipeline-stages.ts +68 -13
- package/src/modules/customers/commands/settings.ts +64 -5
- package/src/modules/customers/data/enrichers.ts +209 -2
- package/src/modules/customers/data/entities.ts +4 -1
- package/src/modules/customers/data/validators.ts +36 -2
- package/src/modules/customers/i18n/de.json +244 -0
- package/src/modules/customers/i18n/en.json +244 -0
- package/src/modules/customers/i18n/es.json +244 -0
- package/src/modules/customers/i18n/pl.json +244 -0
- package/src/modules/customers/lib/bulkDeals.ts +231 -0
- package/src/modules/customers/lib/kysely.ts +60 -0
- package/src/modules/customers/lib/stuckDeals.ts +88 -0
- package/src/modules/customers/migrations/.snapshot-open-mercato.json +16 -0
- package/src/modules/customers/migrations/Migration20260513203311_customers.ts +13 -0
- package/src/modules/customers/migrations/Migration20260519120000_pipeline_stage_color_tones.ts +71 -0
- package/src/modules/customers/workers/deals-bulk-update-owner.ts +46 -0
- package/src/modules/customers/workers/deals-bulk-update-stage.ts +46 -0
package/src/modules/customers/backend/customers/deals/pipeline/components/PipelineFilterPopover.tsx
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import {
|
|
5
|
+
Popover,
|
|
6
|
+
PopoverContent,
|
|
7
|
+
PopoverTrigger,
|
|
8
|
+
} from '@open-mercato/ui/primitives/popover'
|
|
9
|
+
import { Button } from '@open-mercato/ui/primitives/button'
|
|
10
|
+
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
11
|
+
import { translateWithFallback } from '@open-mercato/shared/lib/i18n/translate'
|
|
12
|
+
import { ChipButton } from './ChipButton'
|
|
13
|
+
import { FilterPopoverShell } from './FilterPopoverShell'
|
|
14
|
+
|
|
15
|
+
export type PipelineFilterOption = {
|
|
16
|
+
id: string
|
|
17
|
+
name: string
|
|
18
|
+
dealCount?: number
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
type PipelineFilterPopoverProps = {
|
|
22
|
+
pipelines: PipelineFilterOption[]
|
|
23
|
+
selectedPipelineId: string | null
|
|
24
|
+
onApply: (pipelineId: string | null) => void
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Single radio button matching the Figma spec at 1045:11917 — an outlined 16px circle that
|
|
29
|
+
* fills with the accent-indigo when selected. Reused for every row + the "All pipelines"
|
|
30
|
+
* sentinel so the visual is consistent.
|
|
31
|
+
*/
|
|
32
|
+
function RadioDot({ selected }: { selected: boolean }): React.ReactElement {
|
|
33
|
+
return (
|
|
34
|
+
<span
|
|
35
|
+
className={`flex size-4 shrink-0 items-center justify-center rounded-full border ${
|
|
36
|
+
selected ? 'border-accent-indigo' : 'border-input bg-card'
|
|
37
|
+
}`}
|
|
38
|
+
aria-hidden="true"
|
|
39
|
+
>
|
|
40
|
+
{selected ? <span className="size-2 rounded-full bg-accent-indigo" /> : null}
|
|
41
|
+
</span>
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function PipelineFilterPopover({
|
|
46
|
+
pipelines,
|
|
47
|
+
selectedPipelineId,
|
|
48
|
+
onApply,
|
|
49
|
+
}: PipelineFilterPopoverProps): React.ReactElement {
|
|
50
|
+
const t = useT()
|
|
51
|
+
const [open, setOpen] = React.useState(false)
|
|
52
|
+
const [draft, setDraft] = React.useState<string | null>(selectedPipelineId)
|
|
53
|
+
|
|
54
|
+
React.useEffect(() => {
|
|
55
|
+
if (open) setDraft(selectedPipelineId)
|
|
56
|
+
}, [open, selectedPipelineId])
|
|
57
|
+
|
|
58
|
+
const activePipeline = pipelines.find((pipeline) => pipeline.id === selectedPipelineId)
|
|
59
|
+
const totalCount = pipelines.reduce((sum, pipeline) => sum + (pipeline.dealCount ?? 0), 0)
|
|
60
|
+
const chipLabel = translateWithFallback(t, 'customers.deals.kanban.filter.pipeline', 'Pipeline')
|
|
61
|
+
const chipValue = activePipeline
|
|
62
|
+
? activePipeline.name
|
|
63
|
+
: translateWithFallback(t, 'customers.deals.kanban.filter.allCount', 'All ({count})', {
|
|
64
|
+
count: pipelines.length,
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
const handleApply = () => {
|
|
68
|
+
onApply(draft)
|
|
69
|
+
setOpen(false)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Cmd/Ctrl+Enter from anywhere inside the popover confirms — parity with the dialog
|
|
73
|
+
// primary-action shortcut (`AGENTS.md` UI Interaction rules).
|
|
74
|
+
const handleKeyDown = (event: React.KeyboardEvent) => {
|
|
75
|
+
if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
|
|
76
|
+
event.preventDefault()
|
|
77
|
+
handleApply()
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const allSelected = draft === null
|
|
82
|
+
const allRowClass = allSelected ? 'bg-muted' : 'bg-card'
|
|
83
|
+
|
|
84
|
+
const footerLeft = draft ? (
|
|
85
|
+
<span>
|
|
86
|
+
{translateWithFallback(
|
|
87
|
+
t,
|
|
88
|
+
'customers.deals.kanban.filter.pipeline.selectedOne',
|
|
89
|
+
'1 selected · {name}',
|
|
90
|
+
{ name: pipelines.find((pipeline) => pipeline.id === draft)?.name ?? '' },
|
|
91
|
+
)}
|
|
92
|
+
</span>
|
|
93
|
+
) : (
|
|
94
|
+
<span>
|
|
95
|
+
{translateWithFallback(
|
|
96
|
+
t,
|
|
97
|
+
'customers.deals.kanban.filter.pipeline.selectedNone',
|
|
98
|
+
'No pipeline filter',
|
|
99
|
+
)}
|
|
100
|
+
</span>
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
return (
|
|
104
|
+
<Popover open={open} onOpenChange={setOpen}>
|
|
105
|
+
<PopoverTrigger asChild>
|
|
106
|
+
<ChipButton label={chipLabel} value={chipValue} active={!!selectedPipelineId} />
|
|
107
|
+
</PopoverTrigger>
|
|
108
|
+
<PopoverContent
|
|
109
|
+
className="w-96 rounded-2xl border-border bg-transparent p-0 shadow-xl"
|
|
110
|
+
align="start"
|
|
111
|
+
onKeyDown={handleKeyDown}
|
|
112
|
+
>
|
|
113
|
+
<FilterPopoverShell
|
|
114
|
+
title={translateWithFallback(t, 'customers.deals.kanban.filter.pipeline.title', 'Filter · Pipeline')}
|
|
115
|
+
onClose={() => setOpen(false)}
|
|
116
|
+
onCancel={() => setOpen(false)}
|
|
117
|
+
onApply={handleApply}
|
|
118
|
+
footerLeft={footerLeft}
|
|
119
|
+
>
|
|
120
|
+
{/*
|
|
121
|
+
"All pipelines" sentinel row. Per Figma it shows the total deal count in
|
|
122
|
+
accent-indigo (active emphasis), regardless of whether it's selected.
|
|
123
|
+
*/}
|
|
124
|
+
<Button
|
|
125
|
+
type="button"
|
|
126
|
+
variant="ghost"
|
|
127
|
+
role="radio"
|
|
128
|
+
aria-checked={allSelected}
|
|
129
|
+
onClick={() => setDraft(null)}
|
|
130
|
+
className={`h-auto w-full justify-start gap-3 px-4 py-2.5 text-left font-normal ${allRowClass}`}
|
|
131
|
+
>
|
|
132
|
+
<RadioDot selected={allSelected} />
|
|
133
|
+
<div className="flex min-w-0 flex-1 flex-col gap-px">
|
|
134
|
+
<span
|
|
135
|
+
className={`text-sm leading-normal text-foreground ${
|
|
136
|
+
allSelected ? 'font-semibold' : 'font-normal'
|
|
137
|
+
}`}
|
|
138
|
+
>
|
|
139
|
+
{translateWithFallback(t, 'customers.deals.kanban.filter.pipeline.all', 'All pipelines')}
|
|
140
|
+
</span>
|
|
141
|
+
<span className="text-xs font-normal leading-normal text-muted-foreground">
|
|
142
|
+
{translateWithFallback(
|
|
143
|
+
t,
|
|
144
|
+
'customers.deals.kanban.filter.pipeline.allHelper',
|
|
145
|
+
'{count} pipelines',
|
|
146
|
+
{ count: pipelines.length },
|
|
147
|
+
)}
|
|
148
|
+
</span>
|
|
149
|
+
</div>
|
|
150
|
+
<span className="text-xs font-semibold leading-normal text-accent-indigo">
|
|
151
|
+
{totalCount}
|
|
152
|
+
</span>
|
|
153
|
+
</Button>
|
|
154
|
+
|
|
155
|
+
{pipelines.map((pipeline) => {
|
|
156
|
+
const isSelected = draft === pipeline.id
|
|
157
|
+
const rowClass = isSelected ? 'bg-muted' : 'bg-card'
|
|
158
|
+
return (
|
|
159
|
+
<Button
|
|
160
|
+
key={pipeline.id}
|
|
161
|
+
type="button"
|
|
162
|
+
variant="ghost"
|
|
163
|
+
role="radio"
|
|
164
|
+
aria-checked={isSelected}
|
|
165
|
+
onClick={() => setDraft(pipeline.id)}
|
|
166
|
+
className={`h-auto w-full justify-start gap-3 px-4 py-2.5 text-left font-normal ${rowClass}`}
|
|
167
|
+
>
|
|
168
|
+
<RadioDot selected={isSelected} />
|
|
169
|
+
<span
|
|
170
|
+
className={`flex-1 truncate text-sm leading-normal text-foreground ${
|
|
171
|
+
isSelected ? 'font-semibold' : 'font-normal'
|
|
172
|
+
}`}
|
|
173
|
+
>
|
|
174
|
+
{pipeline.name}
|
|
175
|
+
</span>
|
|
176
|
+
{typeof pipeline.dealCount === 'number' ? (
|
|
177
|
+
<span
|
|
178
|
+
className={`text-xs leading-normal ${
|
|
179
|
+
isSelected
|
|
180
|
+
? 'font-semibold text-accent-indigo'
|
|
181
|
+
: 'font-normal text-muted-foreground'
|
|
182
|
+
}`}
|
|
183
|
+
>
|
|
184
|
+
{pipeline.dealCount}
|
|
185
|
+
</span>
|
|
186
|
+
) : null}
|
|
187
|
+
</Button>
|
|
188
|
+
)
|
|
189
|
+
})}
|
|
190
|
+
</FilterPopoverShell>
|
|
191
|
+
</PopoverContent>
|
|
192
|
+
</Popover>
|
|
193
|
+
)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export default PipelineFilterPopover
|
|
@@ -0,0 +1,408 @@
|
|
|
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 { Avatar } from '@open-mercato/ui/primitives/avatar'
|
|
13
|
+
import { CrudForm, type CrudField, type CrudFormGroup } from '@open-mercato/ui/backend/CrudForm'
|
|
14
|
+
import { ComboboxInput } from '@open-mercato/ui/backend/inputs/ComboboxInput'
|
|
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
|
+
// Deal status values written by the kanban flow. These intentionally do NOT include 'closed'
|
|
23
|
+
// because the rest of the app only persists 'loose' for lost deals (see StatusFilterPopover
|
|
24
|
+
// notes). 'in_progress' is kept for backwards compatibility with quick-deals created from
|
|
25
|
+
// the previous version of this dialog. The labels are looked up via i18n at render time so
|
|
26
|
+
// the historic typo 'loose' doesn't leak to operators — the storage value stays unchanged.
|
|
27
|
+
const STATUS_OPTIONS = ['open', 'in_progress', 'win', 'loose'] as const
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Tenant currency option. The page resolves these from the currencies module (see
|
|
31
|
+
* `useCurrencyDictionary`) and passes them in; we no longer hardcode the 4-currency
|
|
32
|
+
* list that previously locked tenants on CZK/SEK/HUF/etc. out of quick-add.
|
|
33
|
+
*/
|
|
34
|
+
export type QuickDealCurrencyOption = {
|
|
35
|
+
code: string
|
|
36
|
+
label?: string
|
|
37
|
+
isBase?: boolean
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const FALLBACK_CURRENCIES: QuickDealCurrencyOption[] = [
|
|
41
|
+
{ code: 'USD', isBase: true },
|
|
42
|
+
{ code: 'EUR' },
|
|
43
|
+
{ code: 'GBP' },
|
|
44
|
+
{ code: 'PLN' },
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
export type QuickDealContext = {
|
|
48
|
+
pipelineId: string
|
|
49
|
+
pipelineName: string
|
|
50
|
+
pipelineStageId: string
|
|
51
|
+
pipelineStageLabel: string
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export type QuickDealCompanyOption = {
|
|
55
|
+
id: string
|
|
56
|
+
label: string
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
type QuickDealDialogProps = {
|
|
60
|
+
open: boolean
|
|
61
|
+
context: QuickDealContext | null
|
|
62
|
+
onClose: () => void
|
|
63
|
+
onCreated: () => void
|
|
64
|
+
currentUserId?: string
|
|
65
|
+
currentUserLabel?: string
|
|
66
|
+
companies?: QuickDealCompanyOption[]
|
|
67
|
+
/**
|
|
68
|
+
* Tenant currency list. When omitted (e.g. dictionary not yet loaded), we render a
|
|
69
|
+
* conservative fallback so the dialog still works end-to-end. The default selection is
|
|
70
|
+
* always the entry with `isBase: true`, falling back to the first item.
|
|
71
|
+
*/
|
|
72
|
+
currencies?: QuickDealCurrencyOption[]
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
type QuickDealFormValues = {
|
|
76
|
+
title: string
|
|
77
|
+
valueAmount: number | null
|
|
78
|
+
valueCurrency: string
|
|
79
|
+
companyId: string
|
|
80
|
+
status: (typeof STATUS_OPTIONS)[number]
|
|
81
|
+
probability: number | null
|
|
82
|
+
expectedCloseAt: string
|
|
83
|
+
description: string
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const QUICK_DEAL_CONTEXT_ID = 'customers-deals-kanban:quick-deal'
|
|
87
|
+
|
|
88
|
+
export function QuickDealDialog({
|
|
89
|
+
open,
|
|
90
|
+
context,
|
|
91
|
+
onClose,
|
|
92
|
+
onCreated,
|
|
93
|
+
currentUserId,
|
|
94
|
+
currentUserLabel,
|
|
95
|
+
companies = [],
|
|
96
|
+
currencies,
|
|
97
|
+
}: QuickDealDialogProps): React.ReactElement | null {
|
|
98
|
+
const t = useT()
|
|
99
|
+
// Re-mount CrudForm whenever the dialog opens so cleared state is consistently fresh.
|
|
100
|
+
// We also key on `context?.pipelineStageId` so reopening from a different stage doesn't
|
|
101
|
+
// reuse stale field values (the page passes a fresh stage in `context` each time).
|
|
102
|
+
const [formInstanceKey, setFormInstanceKey] = React.useState(0)
|
|
103
|
+
React.useEffect(() => {
|
|
104
|
+
if (open) setFormInstanceKey((current) => current + 1)
|
|
105
|
+
}, [open])
|
|
106
|
+
|
|
107
|
+
const { runMutation, retryLastMutation } = useGuardedMutation<{
|
|
108
|
+
formId: string
|
|
109
|
+
resourceKind: string
|
|
110
|
+
retryLastMutation: () => Promise<boolean>
|
|
111
|
+
}>({
|
|
112
|
+
contextId: QUICK_DEAL_CONTEXT_ID,
|
|
113
|
+
blockedMessage: translateWithFallback(t, 'ui.forms.flash.saveBlocked', 'Save blocked by validation'),
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
const titleRequiredMessage = translateWithFallback(
|
|
117
|
+
t,
|
|
118
|
+
'customers.deals.kanban.quickDeal.title.required',
|
|
119
|
+
'Title is required.',
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
// Default currency selection: tenant base currency, else first available, else 'USD' as
|
|
123
|
+
// a last-resort literal that satisfies the form validator (which now accepts any 3-char
|
|
124
|
+
// code rather than a hardcoded enum).
|
|
125
|
+
const resolvedCurrencies = React.useMemo(
|
|
126
|
+
() => (currencies && currencies.length > 0 ? currencies : FALLBACK_CURRENCIES),
|
|
127
|
+
[currencies],
|
|
128
|
+
)
|
|
129
|
+
const defaultCurrencyCode = React.useMemo(() => {
|
|
130
|
+
const base = resolvedCurrencies.find((currency) => currency.isBase)
|
|
131
|
+
return base?.code ?? resolvedCurrencies[0]?.code ?? 'USD'
|
|
132
|
+
}, [resolvedCurrencies])
|
|
133
|
+
|
|
134
|
+
const formSchema = React.useMemo(
|
|
135
|
+
() =>
|
|
136
|
+
z.object({
|
|
137
|
+
title: z.string().trim().min(1, titleRequiredMessage),
|
|
138
|
+
valueAmount: z.number().min(0).nullable().optional(),
|
|
139
|
+
// The set of valid currencies is tenant-defined and resolved at runtime; we accept
|
|
140
|
+
// any non-empty string here and let the server (which knows the canonical list)
|
|
141
|
+
// reject anything bogus.
|
|
142
|
+
valueCurrency: z.string().min(1),
|
|
143
|
+
companyId: z.string().optional(),
|
|
144
|
+
status: z.enum(STATUS_OPTIONS),
|
|
145
|
+
probability: z.number().min(0).max(100).nullable().optional(),
|
|
146
|
+
expectedCloseAt: z.string().optional(),
|
|
147
|
+
description: z.string().optional(),
|
|
148
|
+
}),
|
|
149
|
+
// Cast widens to QuickDealFormValues so CrudForm's TValues generic stays exact.
|
|
150
|
+
[titleRequiredMessage],
|
|
151
|
+
) as unknown as z.ZodType<QuickDealFormValues>
|
|
152
|
+
|
|
153
|
+
const initialValues = React.useMemo<Partial<QuickDealFormValues>>(
|
|
154
|
+
() => ({
|
|
155
|
+
title: '',
|
|
156
|
+
valueAmount: null,
|
|
157
|
+
valueCurrency: defaultCurrencyCode,
|
|
158
|
+
companyId: '',
|
|
159
|
+
status: 'open',
|
|
160
|
+
probability: 25,
|
|
161
|
+
expectedCloseAt: '',
|
|
162
|
+
description: '',
|
|
163
|
+
}),
|
|
164
|
+
[defaultCurrencyCode],
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
const fields = React.useMemo<CrudField[]>(
|
|
168
|
+
() => {
|
|
169
|
+
const list: CrudField[] = [
|
|
170
|
+
{
|
|
171
|
+
// CrudForm auto-focuses the first field of the first group on mount; we don't
|
|
172
|
+
// need (and CrudField doesn't accept) an explicit `autoFocus` prop here.
|
|
173
|
+
id: 'title',
|
|
174
|
+
label: translateWithFallback(t, 'customers.deals.kanban.quickDeal.title.label', 'Deal title'),
|
|
175
|
+
type: 'text',
|
|
176
|
+
required: true,
|
|
177
|
+
placeholder: translateWithFallback(
|
|
178
|
+
t,
|
|
179
|
+
'customers.deals.kanban.quickDeal.title.placeholder',
|
|
180
|
+
'e.g. Q3 Expansion — Lighting Package',
|
|
181
|
+
),
|
|
182
|
+
},
|
|
183
|
+
{
|
|
184
|
+
id: 'valueAmount',
|
|
185
|
+
label: translateWithFallback(t, 'customers.deals.kanban.quickDeal.value', 'Value'),
|
|
186
|
+
type: 'number',
|
|
187
|
+
layout: 'half',
|
|
188
|
+
placeholder: '0',
|
|
189
|
+
},
|
|
190
|
+
{
|
|
191
|
+
id: 'valueCurrency',
|
|
192
|
+
label: translateWithFallback(t, 'customers.deals.kanban.quickDeal.currency', 'Currency'),
|
|
193
|
+
type: 'select',
|
|
194
|
+
layout: 'half',
|
|
195
|
+
options: resolvedCurrencies.map((currency) => ({
|
|
196
|
+
value: currency.code,
|
|
197
|
+
label: currency.label && currency.label !== currency.code
|
|
198
|
+
? `${currency.code} — ${currency.label}`
|
|
199
|
+
: currency.code,
|
|
200
|
+
})),
|
|
201
|
+
},
|
|
202
|
+
{
|
|
203
|
+
// Company is a typeahead (ComboboxInput) rather than a plain <select>: tenants
|
|
204
|
+
// with hundreds of companies get an unusable dropdown otherwise. The `seedOptions`
|
|
205
|
+
// carry the most-recent companies the page already loaded; an async loader could
|
|
206
|
+
// be added later to hit the companies search endpoint for full coverage.
|
|
207
|
+
id: 'companyId',
|
|
208
|
+
label: translateWithFallback(t, 'customers.deals.kanban.quickDeal.company', 'Company'),
|
|
209
|
+
type: 'custom',
|
|
210
|
+
component: ({ value, setValue }) => (
|
|
211
|
+
<ComboboxInput
|
|
212
|
+
value={typeof value === 'string' ? value : ''}
|
|
213
|
+
onChange={(next) => setValue(next)}
|
|
214
|
+
seedOptions={companies.map((company) => ({ value: company.id, label: company.label }))}
|
|
215
|
+
placeholder={translateWithFallback(
|
|
216
|
+
t,
|
|
217
|
+
'customers.deals.kanban.quickDeal.companyPh',
|
|
218
|
+
'Pick a company or add new…',
|
|
219
|
+
)}
|
|
220
|
+
allowCustomValues={false}
|
|
221
|
+
/>
|
|
222
|
+
),
|
|
223
|
+
},
|
|
224
|
+
]
|
|
225
|
+
// Owner is read-only display of the current user (mirrors original UX). Hidden when
|
|
226
|
+
// we don't know the user — avoids confusing empty avatar cell.
|
|
227
|
+
if (currentUserLabel) {
|
|
228
|
+
list.push({
|
|
229
|
+
id: 'owner',
|
|
230
|
+
label: translateWithFallback(t, 'customers.deals.kanban.quickDeal.owner', 'Owner'),
|
|
231
|
+
type: 'custom',
|
|
232
|
+
readOnly: true,
|
|
233
|
+
component: () => (
|
|
234
|
+
<div className="flex h-9 items-center gap-2 rounded-md border border-input bg-card px-3 text-sm">
|
|
235
|
+
<Avatar label={currentUserLabel} size="sm" />
|
|
236
|
+
<span>{currentUserLabel}</span>
|
|
237
|
+
</div>
|
|
238
|
+
),
|
|
239
|
+
})
|
|
240
|
+
}
|
|
241
|
+
// Progressive disclosure: status + probability + close date + description live in
|
|
242
|
+
// a separate "More details" group with `defaultExpanded: false` (configured via
|
|
243
|
+
// collapsibleGroups on the host CrudForm).
|
|
244
|
+
list.push(
|
|
245
|
+
{
|
|
246
|
+
id: 'status',
|
|
247
|
+
label: translateWithFallback(t, 'customers.deals.kanban.quickDeal.status', 'Status'),
|
|
248
|
+
type: 'select',
|
|
249
|
+
// Storage value is preserved (`loose` etc.) — only the displayed label is
|
|
250
|
+
// translated, so we never write a localised string back to the DB.
|
|
251
|
+
options: STATUS_OPTIONS.map((statusValue) => ({
|
|
252
|
+
value: statusValue,
|
|
253
|
+
label: translateWithFallback(
|
|
254
|
+
t,
|
|
255
|
+
`customers.deals.kanban.quickDeal.status.${statusValue}`,
|
|
256
|
+
statusValue,
|
|
257
|
+
),
|
|
258
|
+
})),
|
|
259
|
+
},
|
|
260
|
+
{
|
|
261
|
+
id: 'probability',
|
|
262
|
+
label: translateWithFallback(t, 'customers.deals.kanban.quickDeal.probability', 'Probability'),
|
|
263
|
+
type: 'number',
|
|
264
|
+
layout: 'half',
|
|
265
|
+
placeholder: '25',
|
|
266
|
+
},
|
|
267
|
+
{
|
|
268
|
+
id: 'expectedCloseAt',
|
|
269
|
+
label: translateWithFallback(t, 'customers.deals.kanban.quickDeal.expectedClose', 'Expected close'),
|
|
270
|
+
type: 'date',
|
|
271
|
+
layout: 'half',
|
|
272
|
+
},
|
|
273
|
+
{
|
|
274
|
+
id: 'description',
|
|
275
|
+
label: translateWithFallback(t, 'customers.deals.kanban.quickDeal.description', 'Description'),
|
|
276
|
+
type: 'textarea',
|
|
277
|
+
rows: 3,
|
|
278
|
+
placeholder: translateWithFallback(
|
|
279
|
+
t,
|
|
280
|
+
'customers.deals.kanban.quickDeal.description.placeholder',
|
|
281
|
+
'Short context, decision makers or next steps. Markdown supported.',
|
|
282
|
+
),
|
|
283
|
+
},
|
|
284
|
+
)
|
|
285
|
+
return list
|
|
286
|
+
},
|
|
287
|
+
[companies, currentUserLabel, resolvedCurrencies, t],
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
const groups = React.useMemo<CrudFormGroup[]>(
|
|
291
|
+
() => [
|
|
292
|
+
{
|
|
293
|
+
id: 'basic',
|
|
294
|
+
// No title — keep the basic section flush with the dialog header.
|
|
295
|
+
fields: ['title', 'valueAmount', 'valueCurrency', 'companyId', ...(currentUserLabel ? ['owner'] : [])],
|
|
296
|
+
},
|
|
297
|
+
{
|
|
298
|
+
id: 'more',
|
|
299
|
+
title: translateWithFallback(
|
|
300
|
+
t,
|
|
301
|
+
'customers.deals.kanban.quickDeal.more',
|
|
302
|
+
'More details (probability, close date, description)',
|
|
303
|
+
),
|
|
304
|
+
fields: ['status', 'probability', 'expectedCloseAt', 'description'],
|
|
305
|
+
},
|
|
306
|
+
],
|
|
307
|
+
[currentUserLabel, t],
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
const handleSubmit = React.useCallback(
|
|
311
|
+
async (values: QuickDealFormValues) => {
|
|
312
|
+
if (!context) return
|
|
313
|
+
const trimmedTitle = values.title.trim()
|
|
314
|
+
if (!trimmedTitle.length) {
|
|
315
|
+
throw createCrudFormError(titleRequiredMessage, { title: titleRequiredMessage })
|
|
316
|
+
}
|
|
317
|
+
const payload: Record<string, unknown> = {
|
|
318
|
+
title: trimmedTitle,
|
|
319
|
+
pipelineId: context.pipelineId,
|
|
320
|
+
pipelineStageId: context.pipelineStageId,
|
|
321
|
+
valueCurrency: values.valueCurrency,
|
|
322
|
+
}
|
|
323
|
+
if (typeof values.valueAmount === 'number' && Number.isFinite(values.valueAmount)) {
|
|
324
|
+
payload.valueAmount = values.valueAmount
|
|
325
|
+
}
|
|
326
|
+
if (currentUserId) payload.ownerUserId = currentUserId
|
|
327
|
+
if (values.companyId) payload.companyIds = [values.companyId]
|
|
328
|
+
if (values.status) payload.status = values.status
|
|
329
|
+
if (typeof values.probability === 'number' && Number.isFinite(values.probability)) {
|
|
330
|
+
payload.probability = values.probability
|
|
331
|
+
}
|
|
332
|
+
if (values.expectedCloseAt && values.expectedCloseAt.trim().length) {
|
|
333
|
+
payload.expectedCloseAt = values.expectedCloseAt
|
|
334
|
+
}
|
|
335
|
+
if (values.description && values.description.trim().length) {
|
|
336
|
+
payload.description = values.description
|
|
337
|
+
}
|
|
338
|
+
const operation = () =>
|
|
339
|
+
createCrud('customers/deals', payload, {
|
|
340
|
+
errorMessage: translateWithFallback(t, 'customers.deals.create.error', 'Failed to create deal.'),
|
|
341
|
+
})
|
|
342
|
+
await runMutation({
|
|
343
|
+
operation,
|
|
344
|
+
context: {
|
|
345
|
+
formId: QUICK_DEAL_CONTEXT_ID,
|
|
346
|
+
resourceKind: 'customers.deal',
|
|
347
|
+
retryLastMutation,
|
|
348
|
+
},
|
|
349
|
+
})
|
|
350
|
+
flash(
|
|
351
|
+
translateWithFallback(
|
|
352
|
+
t,
|
|
353
|
+
'customers.deals.kanban.quickDeal.success',
|
|
354
|
+
'Deal added to {stage}.',
|
|
355
|
+
{ stage: context.pipelineStageLabel },
|
|
356
|
+
),
|
|
357
|
+
'success',
|
|
358
|
+
)
|
|
359
|
+
onCreated()
|
|
360
|
+
onClose()
|
|
361
|
+
},
|
|
362
|
+
[context, currentUserId, onClose, onCreated, retryLastMutation, runMutation, t, titleRequiredMessage],
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
if (!context) return null
|
|
366
|
+
|
|
367
|
+
return (
|
|
368
|
+
<Dialog
|
|
369
|
+
open={open}
|
|
370
|
+
onOpenChange={(next) => {
|
|
371
|
+
if (!next) onClose()
|
|
372
|
+
}}
|
|
373
|
+
>
|
|
374
|
+
<DialogContent>
|
|
375
|
+
<DialogHeader>
|
|
376
|
+
<DialogTitle>
|
|
377
|
+
{translateWithFallback(t, 'customers.deals.kanban.quickDeal.title', 'Quick deal')}
|
|
378
|
+
</DialogTitle>
|
|
379
|
+
<DialogDescription>
|
|
380
|
+
{translateWithFallback(
|
|
381
|
+
t,
|
|
382
|
+
'customers.deals.kanban.quickDeal.context',
|
|
383
|
+
'Pipeline: {pipeline} · Stage: {stage}',
|
|
384
|
+
{ pipeline: context.pipelineName, stage: context.pipelineStageLabel },
|
|
385
|
+
)}
|
|
386
|
+
</DialogDescription>
|
|
387
|
+
</DialogHeader>
|
|
388
|
+
|
|
389
|
+
<CrudForm<QuickDealFormValues>
|
|
390
|
+
key={`${context.pipelineStageId}:${formInstanceKey}`}
|
|
391
|
+
embedded
|
|
392
|
+
fields={fields}
|
|
393
|
+
groups={groups}
|
|
394
|
+
// The "More details" group starts collapsed; users can expand to set
|
|
395
|
+
// status / probability / close date / description. Mirrors the previous
|
|
396
|
+
// "+ More details" toggle.
|
|
397
|
+
collapsibleGroups={{ pageType: 'customers.deals.kanban.quickDeal' }}
|
|
398
|
+
initialValues={initialValues}
|
|
399
|
+
schema={formSchema}
|
|
400
|
+
submitLabel={translateWithFallback(t, 'customers.deals.kanban.quickDeal.submit', 'Add deal')}
|
|
401
|
+
onSubmit={handleSubmit}
|
|
402
|
+
/>
|
|
403
|
+
</DialogContent>
|
|
404
|
+
</Dialog>
|
|
405
|
+
)
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
export default QuickDealDialog
|