@open-mercato/core 0.6.6-develop.6386.1.391a1afb9e → 0.6.6-develop.6387.1.f4896ad565
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/modules/customers/api/deals/map/route.js +433 -0
- package/dist/modules/customers/api/deals/map/route.js.map +7 -0
- package/dist/modules/customers/backend/customers/companies/create/page.js +2 -0
- package/dist/modules/customers/backend/customers/companies/create/page.js.map +2 -2
- package/dist/modules/customers/backend/customers/deals/map/components/DealsLocationPanel.js +159 -0
- package/dist/modules/customers/backend/customers/deals/map/components/DealsLocationPanel.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/map/components/DealsMapCanvas.js +26 -0
- package/dist/modules/customers/backend/customers/deals/map/components/DealsMapCanvas.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/map/components/DealsMapCanvasImpl.js +321 -0
- package/dist/modules/customers/backend/customers/deals/map/components/DealsMapCanvasImpl.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/map/components/DealsMapView.js +533 -0
- package/dist/modules/customers/backend/customers/deals/map/components/DealsMapView.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/map/page.js +55 -0
- package/dist/modules/customers/backend/customers/deals/map/page.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/map/page.meta.js +38 -0
- package/dist/modules/customers/backend/customers/deals/map/page.meta.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/FilterBarRow.js +3 -3
- package/dist/modules/customers/backend/customers/deals/pipeline/components/FilterBarRow.js.map +2 -2
- package/dist/modules/customers/backend/customers/deals/pipeline/components/ViewTabsRow.js +22 -1
- package/dist/modules/customers/backend/customers/deals/pipeline/components/ViewTabsRow.js.map +2 -2
- package/dist/modules/customers/backend/customers/people/create/page.js +2 -0
- package/dist/modules/customers/backend/customers/people/create/page.js.map +2 -2
- package/dist/modules/customers/components/AddressEditor.js +34 -2
- package/dist/modules/customers/components/AddressEditor.js.map +2 -2
- package/dist/modules/customers/components/AddressTiles.js +43 -3
- package/dist/modules/customers/components/AddressTiles.js.map +2 -2
- package/dist/modules/customers/components/detail/AddressesSection.js +4 -1
- package/dist/modules/customers/components/detail/AddressesSection.js.map +2 -2
- package/dist/modules/customers/components/formConfig.js +10 -0
- package/dist/modules/customers/components/formConfig.js.map +2 -2
- package/dist/modules/customers/data/validators.js +3 -2
- package/dist/modules/customers/data/validators.js.map +2 -2
- package/dist/modules/customers/lib/dealsMapLocation.js +75 -0
- package/dist/modules/customers/lib/dealsMapLocation.js.map +7 -0
- package/package.json +11 -7
- package/src/modules/customers/api/deals/map/route.ts +549 -0
- package/src/modules/customers/backend/customers/companies/create/page.tsx +2 -0
- package/src/modules/customers/backend/customers/deals/map/components/DealsLocationPanel.tsx +213 -0
- package/src/modules/customers/backend/customers/deals/map/components/DealsMapCanvas.tsx +79 -0
- package/src/modules/customers/backend/customers/deals/map/components/DealsMapCanvasImpl.tsx +407 -0
- package/src/modules/customers/backend/customers/deals/map/components/DealsMapView.tsx +667 -0
- package/src/modules/customers/backend/customers/deals/map/page.meta.ts +36 -0
- package/src/modules/customers/backend/customers/deals/map/page.tsx +77 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/FilterBarRow.tsx +3 -3
- package/src/modules/customers/backend/customers/deals/pipeline/components/ViewTabsRow.tsx +22 -1
- package/src/modules/customers/backend/customers/people/create/page.tsx +2 -0
- package/src/modules/customers/components/AddressEditor.tsx +35 -0
- package/src/modules/customers/components/AddressTiles.tsx +51 -1
- package/src/modules/customers/components/detail/AddressesSection.tsx +3 -0
- package/src/modules/customers/components/formConfig.tsx +10 -0
- package/src/modules/customers/data/validators.ts +13 -2
- package/src/modules/customers/i18n/de.json +24 -0
- package/src/modules/customers/i18n/en.json +24 -0
- package/src/modules/customers/i18n/es.json +24 -0
- package/src/modules/customers/i18n/pl.json +24 -0
- package/src/modules/customers/lib/dealsMapLocation.ts +125 -0
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import { MapPin, MapPinOff } from 'lucide-react'
|
|
5
|
+
import { Button } from '@open-mercato/ui/primitives/button'
|
|
6
|
+
import { EmptyState } from '@open-mercato/ui/primitives/empty-state'
|
|
7
|
+
import {
|
|
8
|
+
Select,
|
|
9
|
+
SelectContent,
|
|
10
|
+
SelectItem,
|
|
11
|
+
SelectTrigger,
|
|
12
|
+
SelectValue,
|
|
13
|
+
} from '@open-mercato/ui/primitives/select'
|
|
14
|
+
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
15
|
+
import { translateWithFallback } from '@open-mercato/shared/lib/i18n/translate'
|
|
16
|
+
import type { FilterOptionTone } from '@open-mercato/shared/lib/query/advanced-filter'
|
|
17
|
+
import { formatCurrency } from '../../../../../components/detail/utils'
|
|
18
|
+
import type { MapDeal, StageMeta } from './DealsMapView'
|
|
19
|
+
import type { MapCenter } from './DealsMapCanvas'
|
|
20
|
+
|
|
21
|
+
const STAGE_BADGE_TONE_CLASS: Record<FilterOptionTone, string> = {
|
|
22
|
+
success: 'bg-status-success-bg text-status-success-text',
|
|
23
|
+
error: 'bg-status-error-bg text-status-error-text',
|
|
24
|
+
warning: 'bg-status-warning-bg text-status-warning-text',
|
|
25
|
+
info: 'bg-status-info-bg text-status-info-text',
|
|
26
|
+
neutral: 'bg-status-neutral-bg text-status-neutral-text',
|
|
27
|
+
brand: 'bg-brand-violet/14 text-brand-violet',
|
|
28
|
+
pink: 'bg-status-pink-bg text-status-pink-text',
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function getStageBadgeClass(tone: FilterOptionTone | null): string {
|
|
32
|
+
if (tone && tone in STAGE_BADGE_TONE_CLASS) return STAGE_BADGE_TONE_CLASS[tone]
|
|
33
|
+
return 'bg-muted text-muted-foreground'
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
type PanelSort = 'proximity' | 'listOrder'
|
|
37
|
+
|
|
38
|
+
function haversineKm(
|
|
39
|
+
a: { latitude: number; longitude: number },
|
|
40
|
+
b: { latitude: number; longitude: number },
|
|
41
|
+
): number {
|
|
42
|
+
const toRadians = (value: number) => (value * Math.PI) / 180
|
|
43
|
+
const earthRadiusKm = 6371
|
|
44
|
+
const dLat = toRadians(b.latitude - a.latitude)
|
|
45
|
+
const dLng = toRadians(b.longitude - a.longitude)
|
|
46
|
+
const sinLat = Math.sin(dLat / 2)
|
|
47
|
+
const sinLng = Math.sin(dLng / 2)
|
|
48
|
+
const h =
|
|
49
|
+
sinLat * sinLat +
|
|
50
|
+
Math.cos(toRadians(a.latitude)) * Math.cos(toRadians(b.latitude)) * sinLng * sinLng
|
|
51
|
+
return 2 * earthRadiusKm * Math.asin(Math.min(1, Math.sqrt(h)))
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
type DealsLocationPanelProps = {
|
|
55
|
+
deals: MapDeal[]
|
|
56
|
+
// Total deals matching the active filters that resolve to a location (the located-only endpoint's
|
|
57
|
+
// `total`). May exceed `deals.length` when the client cap truncates the rendered set.
|
|
58
|
+
locatedCount: number
|
|
59
|
+
stageMetaById: Map<string, StageMeta>
|
|
60
|
+
mapCenter: MapCenter | null
|
|
61
|
+
selectedDealId: string | null
|
|
62
|
+
onSelect: (dealId: string | null) => void
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function DealsLocationPanel({
|
|
66
|
+
deals,
|
|
67
|
+
locatedCount,
|
|
68
|
+
stageMetaById,
|
|
69
|
+
mapCenter,
|
|
70
|
+
selectedDealId,
|
|
71
|
+
onSelect,
|
|
72
|
+
}: DealsLocationPanelProps): React.ReactElement {
|
|
73
|
+
const t = useT()
|
|
74
|
+
const [panelSort, setPanelSort] = React.useState<PanelSort>('proximity')
|
|
75
|
+
|
|
76
|
+
// The map endpoint is located-only, so every deal here carries a location. The guard keeps the
|
|
77
|
+
// proximity comparator total-order-safe against the nullable `MapDeal.location` type.
|
|
78
|
+
const visibleDeals = React.useMemo(() => {
|
|
79
|
+
if (panelSort !== 'proximity' || !mapCenter) return deals
|
|
80
|
+
return deals.slice().sort((a, b) => {
|
|
81
|
+
if (!a.location) return 1
|
|
82
|
+
if (!b.location) return -1
|
|
83
|
+
return haversineKm(mapCenter, a.location) - haversineKm(mapCenter, b.location)
|
|
84
|
+
})
|
|
85
|
+
}, [deals, panelSort, mapCenter])
|
|
86
|
+
|
|
87
|
+
const noAddressLabel = translateWithFallback(
|
|
88
|
+
t,
|
|
89
|
+
'customers.deals.map.panel.noAddress',
|
|
90
|
+
'No address coordinates',
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
<div className="flex max-h-[60vh] w-full shrink-0 flex-col overflow-hidden rounded-xl border border-border bg-card lg:max-h-none lg:w-80">
|
|
95
|
+
<div className="flex flex-col gap-1 border-b border-border px-4 py-3">
|
|
96
|
+
<div className="flex items-baseline justify-between gap-2">
|
|
97
|
+
<h2 className="text-sm font-semibold leading-normal text-foreground">
|
|
98
|
+
{translateWithFallback(t, 'customers.deals.map.panel.title', 'Deals with location')}
|
|
99
|
+
</h2>
|
|
100
|
+
<span className="text-xs leading-normal text-muted-foreground">
|
|
101
|
+
{translateWithFallback(t, 'customers.deals.map.panel.count', '{count} located', {
|
|
102
|
+
count: locatedCount,
|
|
103
|
+
})}
|
|
104
|
+
</span>
|
|
105
|
+
</div>
|
|
106
|
+
<p className="text-xs leading-normal text-muted-foreground">
|
|
107
|
+
{translateWithFallback(
|
|
108
|
+
t,
|
|
109
|
+
'customers.deals.map.panel.hint',
|
|
110
|
+
'Click a deal or a map pin to preview it.',
|
|
111
|
+
)}
|
|
112
|
+
</p>
|
|
113
|
+
<div className="mt-2 flex items-center gap-2">
|
|
114
|
+
<Select value={panelSort} onValueChange={(value) => setPanelSort(value as PanelSort)}>
|
|
115
|
+
<SelectTrigger
|
|
116
|
+
size="sm"
|
|
117
|
+
className="w-auto min-w-36"
|
|
118
|
+
aria-label={translateWithFallback(t, 'customers.deals.map.panel.sort.label', 'Sort deals panel')}
|
|
119
|
+
>
|
|
120
|
+
<SelectValue />
|
|
121
|
+
</SelectTrigger>
|
|
122
|
+
<SelectContent>
|
|
123
|
+
<SelectItem value="proximity">
|
|
124
|
+
{translateWithFallback(t, 'customers.deals.map.panel.sort.proximity', 'Closest first')}
|
|
125
|
+
</SelectItem>
|
|
126
|
+
<SelectItem value="listOrder">
|
|
127
|
+
{translateWithFallback(t, 'customers.deals.map.panel.sort.listOrder', 'List order')}
|
|
128
|
+
</SelectItem>
|
|
129
|
+
</SelectContent>
|
|
130
|
+
</Select>
|
|
131
|
+
</div>
|
|
132
|
+
</div>
|
|
133
|
+
|
|
134
|
+
<div className="flex flex-1 flex-col gap-2 overflow-y-auto p-3">
|
|
135
|
+
{deals.length === 0 ? (
|
|
136
|
+
<EmptyState
|
|
137
|
+
variant="subtle"
|
|
138
|
+
size="sm"
|
|
139
|
+
icon={<MapPinOff className="size-6" aria-hidden="true" />}
|
|
140
|
+
title={translateWithFallback(
|
|
141
|
+
t,
|
|
142
|
+
'customers.deals.map.panel.empty.title',
|
|
143
|
+
'No deals on the map yet',
|
|
144
|
+
)}
|
|
145
|
+
description={translateWithFallback(
|
|
146
|
+
t,
|
|
147
|
+
'customers.deals.map.panel.empty.description',
|
|
148
|
+
'Add latitude and longitude to company or person addresses to plot their deals here.',
|
|
149
|
+
)}
|
|
150
|
+
/>
|
|
151
|
+
) : null}
|
|
152
|
+
{visibleDeals.map((deal) => {
|
|
153
|
+
const stageMeta = deal.pipelineStageId ? stageMetaById.get(deal.pipelineStageId) ?? null : null
|
|
154
|
+
const isSelected = deal.id === selectedDealId
|
|
155
|
+
const locationLine = deal.location
|
|
156
|
+
? [deal.location.city, deal.location.region].filter(Boolean).join(', ') ||
|
|
157
|
+
deal.location.country ||
|
|
158
|
+
null
|
|
159
|
+
: null
|
|
160
|
+
const valueLabel =
|
|
161
|
+
typeof deal.valueAmount === 'number' ? formatCurrency(deal.valueAmount, deal.valueCurrency) : null
|
|
162
|
+
// Compose the full card content for screen readers — the bare title alone dropped the
|
|
163
|
+
// company, value, stage and location that sighted users read off the card.
|
|
164
|
+
const cardAriaLabel =
|
|
165
|
+
[deal.companyLabel, deal.title, valueLabel, stageMeta?.label, locationLine ?? noAddressLabel]
|
|
166
|
+
.filter((part): part is string => typeof part === 'string' && part.length > 0)
|
|
167
|
+
.join(', ') || deal.title
|
|
168
|
+
return (
|
|
169
|
+
<Button
|
|
170
|
+
key={deal.id}
|
|
171
|
+
type="button"
|
|
172
|
+
variant="ghost"
|
|
173
|
+
data-map-panel-card={deal.id}
|
|
174
|
+
aria-label={cardAriaLabel}
|
|
175
|
+
aria-pressed={isSelected}
|
|
176
|
+
onClick={() => onSelect(deal.id)}
|
|
177
|
+
className={`h-auto w-full flex-col items-stretch justify-start gap-1 whitespace-normal rounded-lg border border-border p-3 text-left shadow-none ${
|
|
178
|
+
isSelected ? 'bg-muted' : 'bg-card'
|
|
179
|
+
}`}
|
|
180
|
+
>
|
|
181
|
+
<div className="flex items-baseline justify-between gap-2">
|
|
182
|
+
<span className="truncate text-sm font-medium leading-normal text-foreground">
|
|
183
|
+
{deal.companyLabel ?? '—'}
|
|
184
|
+
</span>
|
|
185
|
+
{valueLabel ? (
|
|
186
|
+
<span className="shrink-0 text-sm font-semibold leading-normal text-foreground">
|
|
187
|
+
{valueLabel}
|
|
188
|
+
</span>
|
|
189
|
+
) : null}
|
|
190
|
+
</div>
|
|
191
|
+
<span className="truncate text-sm leading-normal text-muted-foreground">{deal.title}</span>
|
|
192
|
+
<div className="flex items-center justify-between gap-2">
|
|
193
|
+
<span className="flex min-w-0 items-center gap-1.5 text-xs leading-normal text-muted-foreground">
|
|
194
|
+
<MapPin className="size-3.5 shrink-0" aria-hidden="true" />
|
|
195
|
+
<span className="truncate">{locationLine ?? noAddressLabel}</span>
|
|
196
|
+
</span>
|
|
197
|
+
{stageMeta ? (
|
|
198
|
+
<span
|
|
199
|
+
className={`inline-flex shrink-0 items-center rounded-md px-2 py-0.5 text-xs font-semibold leading-normal ${getStageBadgeClass(stageMeta.tone)}`}
|
|
200
|
+
>
|
|
201
|
+
{stageMeta.label}
|
|
202
|
+
</span>
|
|
203
|
+
) : null}
|
|
204
|
+
</div>
|
|
205
|
+
</Button>
|
|
206
|
+
)
|
|
207
|
+
})}
|
|
208
|
+
</div>
|
|
209
|
+
</div>
|
|
210
|
+
)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export default DealsLocationPanel
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import { Spinner } from '@open-mercato/ui/primitives/spinner'
|
|
5
|
+
import type { FilterOptionTone } from '@open-mercato/shared/lib/query/advanced-filter'
|
|
6
|
+
|
|
7
|
+
export type MapCenter = {
|
|
8
|
+
latitude: number
|
|
9
|
+
longitude: number
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export type DealsMapCanvasDeal = {
|
|
13
|
+
id: string
|
|
14
|
+
latitude: number
|
|
15
|
+
longitude: number
|
|
16
|
+
tone: FilterOptionTone | null
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type DealsMapLegendStage = {
|
|
20
|
+
id: string
|
|
21
|
+
label: string
|
|
22
|
+
tone: FilterOptionTone | null
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type DealsMapPreview = {
|
|
26
|
+
id: string
|
|
27
|
+
title: string
|
|
28
|
+
companyLabel: string | null
|
|
29
|
+
locationLine: string | null
|
|
30
|
+
valueAmount: number | null
|
|
31
|
+
valueCurrency: string | null
|
|
32
|
+
probability: number | null
|
|
33
|
+
expectedCloseAt: string | null
|
|
34
|
+
stageLabel: string | null
|
|
35
|
+
stageTone: FilterOptionTone | null
|
|
36
|
+
ownerName: string | null
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export type DealsMapCanvasProps = {
|
|
40
|
+
deals: DealsMapCanvasDeal[]
|
|
41
|
+
legendStages: DealsMapLegendStage[]
|
|
42
|
+
preview: DealsMapPreview | null
|
|
43
|
+
selectedDealId: string | null
|
|
44
|
+
onSelect: (dealId: string | null) => void
|
|
45
|
+
onCenterChange: (center: MapCenter) => void
|
|
46
|
+
className?: string
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
type DealsMapCanvasComponent = (props: DealsMapCanvasProps) => React.ReactElement
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Lazy shell around the Leaflet implementation. Leaflet touches `window` at import time and ships
|
|
53
|
+
* its own CSS, so the impl chunk (JS + stylesheets) is imported client-side only — on first mount,
|
|
54
|
+
* inside an effect — which keeps it out of SSR while rendering a prop-sized placeholder until it
|
|
55
|
+
* resolves. A single dynamic `import()` drives both the lazy load and the fallback.
|
|
56
|
+
*/
|
|
57
|
+
export function DealsMapCanvas(props: DealsMapCanvasProps): React.ReactElement {
|
|
58
|
+
const [Impl, setImpl] = React.useState<DealsMapCanvasComponent | null>(null)
|
|
59
|
+
React.useEffect(() => {
|
|
60
|
+
let cancelled = false
|
|
61
|
+
void import('./DealsMapCanvasImpl').then((mod) => {
|
|
62
|
+
if (!cancelled) setImpl(() => mod.default)
|
|
63
|
+
})
|
|
64
|
+
return () => {
|
|
65
|
+
cancelled = true
|
|
66
|
+
}
|
|
67
|
+
}, [])
|
|
68
|
+
|
|
69
|
+
if (!Impl) {
|
|
70
|
+
return (
|
|
71
|
+
<div className={`flex items-center justify-center bg-muted/30 ${props.className ?? ''}`.trim()}>
|
|
72
|
+
<Spinner className="size-6 text-muted-foreground" />
|
|
73
|
+
</div>
|
|
74
|
+
)
|
|
75
|
+
}
|
|
76
|
+
return <Impl {...props} />
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export default DealsMapCanvas
|