@open-mercato/core 0.6.6-develop.6385.1.9a81faa5f0 → 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.
Files changed (69) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/attachments/lib/scope.js +26 -0
  3. package/dist/modules/attachments/lib/scope.js.map +7 -0
  4. package/dist/modules/catalog/commands/variants.js +4 -2
  5. package/dist/modules/catalog/commands/variants.js.map +2 -2
  6. package/dist/modules/customers/api/deals/map/route.js +433 -0
  7. package/dist/modules/customers/api/deals/map/route.js.map +7 -0
  8. package/dist/modules/customers/backend/customers/companies/create/page.js +2 -0
  9. package/dist/modules/customers/backend/customers/companies/create/page.js.map +2 -2
  10. package/dist/modules/customers/backend/customers/deals/map/components/DealsLocationPanel.js +159 -0
  11. package/dist/modules/customers/backend/customers/deals/map/components/DealsLocationPanel.js.map +7 -0
  12. package/dist/modules/customers/backend/customers/deals/map/components/DealsMapCanvas.js +26 -0
  13. package/dist/modules/customers/backend/customers/deals/map/components/DealsMapCanvas.js.map +7 -0
  14. package/dist/modules/customers/backend/customers/deals/map/components/DealsMapCanvasImpl.js +321 -0
  15. package/dist/modules/customers/backend/customers/deals/map/components/DealsMapCanvasImpl.js.map +7 -0
  16. package/dist/modules/customers/backend/customers/deals/map/components/DealsMapView.js +533 -0
  17. package/dist/modules/customers/backend/customers/deals/map/components/DealsMapView.js.map +7 -0
  18. package/dist/modules/customers/backend/customers/deals/map/page.js +55 -0
  19. package/dist/modules/customers/backend/customers/deals/map/page.js.map +7 -0
  20. package/dist/modules/customers/backend/customers/deals/map/page.meta.js +38 -0
  21. package/dist/modules/customers/backend/customers/deals/map/page.meta.js.map +7 -0
  22. package/dist/modules/customers/backend/customers/deals/pipeline/components/FilterBarRow.js +3 -3
  23. package/dist/modules/customers/backend/customers/deals/pipeline/components/FilterBarRow.js.map +2 -2
  24. package/dist/modules/customers/backend/customers/deals/pipeline/components/ViewTabsRow.js +22 -1
  25. package/dist/modules/customers/backend/customers/deals/pipeline/components/ViewTabsRow.js.map +2 -2
  26. package/dist/modules/customers/backend/customers/people/create/page.js +2 -0
  27. package/dist/modules/customers/backend/customers/people/create/page.js.map +2 -2
  28. package/dist/modules/customers/components/AddressEditor.js +34 -2
  29. package/dist/modules/customers/components/AddressEditor.js.map +2 -2
  30. package/dist/modules/customers/components/AddressTiles.js +43 -3
  31. package/dist/modules/customers/components/AddressTiles.js.map +2 -2
  32. package/dist/modules/customers/components/detail/AddressesSection.js +4 -1
  33. package/dist/modules/customers/components/detail/AddressesSection.js.map +2 -2
  34. package/dist/modules/customers/components/formConfig.js +10 -0
  35. package/dist/modules/customers/components/formConfig.js.map +2 -2
  36. package/dist/modules/customers/data/validators.js +3 -2
  37. package/dist/modules/customers/data/validators.js.map +2 -2
  38. package/dist/modules/customers/lib/dealsMapLocation.js +75 -0
  39. package/dist/modules/customers/lib/dealsMapLocation.js.map +7 -0
  40. package/dist/modules/messages/commands/messages.js +0 -1
  41. package/dist/modules/messages/commands/messages.js.map +2 -2
  42. package/dist/modules/messages/lib/attachments.js +13 -7
  43. package/dist/modules/messages/lib/attachments.js.map +2 -2
  44. package/package.json +11 -7
  45. package/src/modules/attachments/lib/scope.ts +42 -0
  46. package/src/modules/catalog/commands/variants.ts +7 -2
  47. package/src/modules/customers/api/deals/map/route.ts +549 -0
  48. package/src/modules/customers/backend/customers/companies/create/page.tsx +2 -0
  49. package/src/modules/customers/backend/customers/deals/map/components/DealsLocationPanel.tsx +213 -0
  50. package/src/modules/customers/backend/customers/deals/map/components/DealsMapCanvas.tsx +79 -0
  51. package/src/modules/customers/backend/customers/deals/map/components/DealsMapCanvasImpl.tsx +407 -0
  52. package/src/modules/customers/backend/customers/deals/map/components/DealsMapView.tsx +667 -0
  53. package/src/modules/customers/backend/customers/deals/map/page.meta.ts +36 -0
  54. package/src/modules/customers/backend/customers/deals/map/page.tsx +77 -0
  55. package/src/modules/customers/backend/customers/deals/pipeline/components/FilterBarRow.tsx +3 -3
  56. package/src/modules/customers/backend/customers/deals/pipeline/components/ViewTabsRow.tsx +22 -1
  57. package/src/modules/customers/backend/customers/people/create/page.tsx +2 -0
  58. package/src/modules/customers/components/AddressEditor.tsx +35 -0
  59. package/src/modules/customers/components/AddressTiles.tsx +51 -1
  60. package/src/modules/customers/components/detail/AddressesSection.tsx +3 -0
  61. package/src/modules/customers/components/formConfig.tsx +10 -0
  62. package/src/modules/customers/data/validators.ts +13 -2
  63. package/src/modules/customers/i18n/de.json +24 -0
  64. package/src/modules/customers/i18n/en.json +24 -0
  65. package/src/modules/customers/i18n/es.json +24 -0
  66. package/src/modules/customers/i18n/pl.json +24 -0
  67. package/src/modules/customers/lib/dealsMapLocation.ts +125 -0
  68. package/src/modules/messages/commands/messages.ts +0 -1
  69. package/src/modules/messages/lib/attachments.ts +15 -7
@@ -0,0 +1,77 @@
1
+ "use client"
2
+
3
+ import * as React from 'react'
4
+ import Link from 'next/link'
5
+ import { Page, PageBody } from '@open-mercato/ui/backend/Page'
6
+ import {
7
+ Breadcrumb,
8
+ BreadcrumbItem,
9
+ BreadcrumbLink,
10
+ BreadcrumbList,
11
+ BreadcrumbPage,
12
+ BreadcrumbSeparator,
13
+ } from '@open-mercato/ui/primitives/breadcrumb'
14
+ import { SearchInput } from '@open-mercato/ui/primitives/search-input'
15
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
16
+ import { translateWithFallback } from '@open-mercato/shared/lib/i18n/translate'
17
+ import { ViewTabsRow } from '../pipeline/components/ViewTabsRow'
18
+ import { DealsMapView } from './components/DealsMapView'
19
+
20
+ export default function DealsMapPage(): React.ReactElement {
21
+ const t = useT()
22
+ const [search, setSearch] = React.useState('')
23
+
24
+ return (
25
+ <Page>
26
+ <PageBody>
27
+ <div className="flex flex-col gap-2">
28
+ <Breadcrumb>
29
+ <BreadcrumbList>
30
+ <BreadcrumbItem>
31
+ <BreadcrumbLink asChild>
32
+ <Link href="/backend">
33
+ {translateWithFallback(t, 'customers.deals.kanban.breadcrumb.dashboard', 'Dashboard')}
34
+ </Link>
35
+ </BreadcrumbLink>
36
+ </BreadcrumbItem>
37
+ <BreadcrumbSeparator />
38
+ <BreadcrumbItem>
39
+ <BreadcrumbLink asChild>
40
+ <Link href="/backend/customers/deals">
41
+ {translateWithFallback(t, 'customers.deals.kanban.breadcrumb.deals', 'Deals')}
42
+ </Link>
43
+ </BreadcrumbLink>
44
+ </BreadcrumbItem>
45
+ <BreadcrumbSeparator />
46
+ <BreadcrumbItem>
47
+ <BreadcrumbPage>
48
+ {translateWithFallback(t, 'customers.nav.deals.map', 'Deals Map')}
49
+ </BreadcrumbPage>
50
+ </BreadcrumbItem>
51
+ </BreadcrumbList>
52
+ </Breadcrumb>
53
+
54
+ <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
55
+ <h1 className="text-xl font-semibold leading-tight text-foreground sm:text-2xl">
56
+ {translateWithFallback(t, 'customers.nav.deals.map', 'Deals Map')}
57
+ </h1>
58
+ <SearchInput
59
+ value={search}
60
+ onChange={setSearch}
61
+ placeholder={translateWithFallback(
62
+ t,
63
+ 'customers.deals.kanban.search.placeholder',
64
+ 'Search deals…',
65
+ )}
66
+ className="w-64"
67
+ />
68
+ </div>
69
+ </div>
70
+
71
+ <ViewTabsRow active="map" className="mt-4" />
72
+
73
+ <DealsMapView search={search} />
74
+ </PageBody>
75
+ </Page>
76
+ )
77
+ }
@@ -33,9 +33,9 @@ export function FilterBarRow({
33
33
  // That picker is not wired up in this slice (it would need AdvancedFilterPanel integration),
34
34
  // so the affordance is intentionally omitted instead of shipping a "coming next" toast.
35
35
  return (
36
- <div className="flex flex-wrap items-center justify-between gap-y-2 rounded-lg bg-muted/40 px-3.5 py-3">
36
+ <div className="flex flex-col gap-2 rounded-lg bg-muted/40 px-3.5 py-3 sm:flex-row sm:flex-wrap sm:items-center sm:justify-between sm:gap-y-2">
37
37
  <div className="flex flex-wrap items-center gap-2">
38
- <span className="text-overline font-bold uppercase leading-normal text-muted-foreground">
38
+ <span className="text-overline w-full font-bold uppercase leading-normal text-muted-foreground sm:w-auto">
39
39
  {translateWithFallback(t, 'customers.deals.kanban.filter.label', 'Filter')}:
40
40
  </span>
41
41
  {leadingChips}
@@ -54,7 +54,7 @@ export function FilterBarRow({
54
54
  />
55
55
  ))}
56
56
  </div>
57
- <div className="ml-auto">{sortNode}</div>
57
+ <div className="sm:ml-auto">{sortNode}</div>
58
58
  </div>
59
59
  )
60
60
  }
@@ -5,7 +5,7 @@ import Link from 'next/link'
5
5
  import { useT } from '@open-mercato/shared/lib/i18n/context'
6
6
  import { translateWithFallback } from '@open-mercato/shared/lib/i18n/translate'
7
7
 
8
- export type KanbanView = 'kanban' | 'list'
8
+ export type KanbanView = 'kanban' | 'list' | 'map'
9
9
 
10
10
  type ViewTabsRowProps = {
11
11
  active: KanbanView
@@ -14,12 +14,14 @@ type ViewTabsRowProps = {
14
14
 
15
15
  const VIEW_KANBAN_HREF = '/backend/customers/deals/pipeline'
16
16
  const VIEW_LIST_HREF = '/backend/customers/deals'
17
+ const VIEW_MAP_HREF = '/backend/customers/deals/map'
17
18
 
18
19
  export function ViewTabsRow({ active, className }: ViewTabsRowProps): React.ReactElement {
19
20
  const t = useT()
20
21
  const labels = {
21
22
  kanban: translateWithFallback(t, 'customers.deals.kanban.view.kanban', 'Kanban'),
22
23
  list: translateWithFallback(t, 'customers.deals.kanban.view.list', 'List'),
24
+ map: translateWithFallback(t, 'customers.deals.kanban.view.map', 'Map'),
23
25
  }
24
26
 
25
27
  const baseTab =
@@ -34,6 +36,7 @@ export function ViewTabsRow({ active, className }: ViewTabsRowProps): React.Reac
34
36
  // navigate back to /pipeline.
35
37
  const isKanbanActive = active === 'kanban'
36
38
  const isListActive = active === 'list'
39
+ const isMapActive = active === 'map'
37
40
 
38
41
  return (
39
42
  <div
@@ -77,6 +80,24 @@ export function ViewTabsRow({ active, className }: ViewTabsRowProps): React.Reac
77
80
  {labels.list}
78
81
  </Link>
79
82
  )}
83
+ {isMapActive ? (
84
+ <span
85
+ role="tab"
86
+ aria-selected={true}
87
+ className={`${baseTab} ${activeTab}`}
88
+ >
89
+ {labels.map}
90
+ </span>
91
+ ) : (
92
+ <Link
93
+ href={VIEW_MAP_HREF}
94
+ role="tab"
95
+ aria-selected={false}
96
+ className={`${baseTab} ${inactiveTab}`}
97
+ >
98
+ {labels.map}
99
+ </Link>
100
+ )}
80
101
  </div>
81
102
  )
82
103
  }
@@ -110,6 +110,8 @@ export default function CreatePersonPage() {
110
110
  if (postalCode !== undefined) body.postalCode = postalCode
111
111
  const country = normalize(entry.country)
112
112
  if (country !== undefined) body.country = country.toUpperCase()
113
+ if (typeof entry.latitude === 'number') body.latitude = entry.latitude
114
+ if (typeof entry.longitude === 'number') body.longitude = entry.longitude
113
115
  try {
114
116
  await createCrud('customers/addresses', body)
115
117
  } catch (addressErr) {
@@ -41,6 +41,8 @@ export type AddressEditorDraft = {
41
41
  region: string
42
42
  postalCode: string
43
43
  country: string
44
+ latitude?: string
45
+ longitude?: string
44
46
  isPrimary: boolean
45
47
  }
46
48
 
@@ -56,6 +58,8 @@ export type AddressEditorField =
56
58
  | 'region'
57
59
  | 'postalCode'
58
60
  | 'country'
61
+ | 'latitude'
62
+ | 'longitude'
59
63
  | 'isPrimary'
60
64
 
61
65
  type AddressEditorProps = {
@@ -67,6 +71,7 @@ type AddressEditorProps = {
67
71
  errors?: Partial<Record<AddressEditorField, string>>
68
72
  hidePrimaryToggle?: boolean
69
73
  showFormatHint?: boolean
74
+ showCoordinateFields?: boolean
70
75
  }
71
76
 
72
77
  export function AddressEditor({
@@ -78,6 +83,7 @@ export function AddressEditor({
78
83
  errors = {},
79
84
  hidePrimaryToggle = false,
80
85
  showFormatHint = true,
86
+ showCoordinateFields = false,
81
87
  }: AddressEditorProps) {
82
88
  const pathname = usePathname()
83
89
  const searchParams = useSearchParams()
@@ -108,6 +114,9 @@ export function AddressEditor({
108
114
  region: value.region ?? '',
109
115
  postalCode: value.postalCode ?? '',
110
116
  country: value.country ?? '',
117
+ ...(showCoordinateFields
118
+ ? { latitude: value.latitude ?? '', longitude: value.longitude ?? '' }
119
+ : {}),
111
120
  isPrimary: value.isPrimary ?? false,
112
121
  }
113
122
 
@@ -437,6 +446,32 @@ export function AddressEditor({
437
446
  </DialogContent>
438
447
  </Dialog>
439
448
  {errors.country ? <p className="text-xs text-destructive">{errors.country}</p> : null}
449
+ {showCoordinateFields ? (
450
+ <>
451
+ <Input
452
+ className={inputClass('latitude')}
453
+ placeholder={t('customers.people.detail.addresses.fields.latitude', 'Latitude')}
454
+ aria-label={t('customers.people.detail.addresses.fields.latitude', 'Latitude')}
455
+ inputMode="decimal"
456
+ value={current.latitude ?? ''}
457
+ onChange={(evt) => update('latitude', evt.target.value)}
458
+ disabled={disabled}
459
+ aria-invalid={errors.latitude ? 'true' : undefined}
460
+ />
461
+ {errors.latitude ? <p className="text-xs text-destructive">{errors.latitude}</p> : null}
462
+ <Input
463
+ className={inputClass('longitude')}
464
+ placeholder={t('customers.people.detail.addresses.fields.longitude', 'Longitude')}
465
+ aria-label={t('customers.people.detail.addresses.fields.longitude', 'Longitude')}
466
+ inputMode="decimal"
467
+ value={current.longitude ?? ''}
468
+ onChange={(evt) => update('longitude', evt.target.value)}
469
+ disabled={disabled}
470
+ aria-invalid={errors.longitude ? 'true' : undefined}
471
+ />
472
+ {errors.longitude ? <p className="text-xs text-destructive">{errors.longitude}</p> : null}
473
+ </>
474
+ ) : null}
440
475
  </div>
441
476
  {!hidePrimaryToggle ? (
442
477
  <label className="inline-flex items-center gap-2 text-sm">
@@ -8,6 +8,7 @@ import { TabEmptyState } from '@open-mercato/ui/backend/detail'
8
8
  import { apiCall } from '@open-mercato/ui/backend/utils/apiCall'
9
9
  import { useOrganizationScopeVersion } from '@open-mercato/shared/lib/frontend/useOrganizationScope'
10
10
  import { AddressView, formatAddressJson, formatAddressString, type AddressFormatStrategy } from '../utils/addressFormat'
11
+ import { normalizeCoordinateInput, validateCoordinateInput } from '@open-mercato/shared/lib/location/coordinates'
11
12
  import AddressEditor from './AddressEditor'
12
13
  import { useAddressTypes } from './detail/hooks/useAddressTypes'
13
14
  import {
@@ -40,6 +41,8 @@ export type CustomerAddressInput = {
40
41
  region?: string
41
42
  postalCode?: string
42
43
  country?: string
44
+ latitude?: number | null
45
+ longitude?: number | null
43
46
  isPrimary?: boolean
44
47
  }
45
48
 
@@ -47,6 +50,8 @@ export type CustomerAddressValue = CustomerAddressInput & {
47
50
  id: string
48
51
  purpose?: string | null
49
52
  companyName?: string | null
53
+ latitude?: number | null
54
+ longitude?: number | null
50
55
  }
51
56
 
52
57
  type CustomerAddressTilesProps = {
@@ -62,6 +67,7 @@ type CustomerAddressTilesProps = {
62
67
  onAddActionChange?: (action: { openCreateForm: () => void; addDisabled: boolean } | null) => void
63
68
  emptyStateTitle?: string
64
69
  emptyStateActionLabel?: string
70
+ showCoordinateFields?: boolean
65
71
  }
66
72
 
67
73
  type DraftAddressState = {
@@ -76,6 +82,8 @@ type DraftAddressState = {
76
82
  region: string
77
83
  postalCode: string
78
84
  country: string
85
+ latitude: string
86
+ longitude: string
79
87
  isPrimary: boolean
80
88
  }
81
89
 
@@ -102,6 +110,8 @@ const defaultDraft: DraftAddressState = {
102
110
  region: '',
103
111
  postalCode: '',
104
112
  country: '',
113
+ latitude: '',
114
+ longitude: '',
105
115
  isPrimary: false,
106
116
  }
107
117
 
@@ -117,6 +127,8 @@ const serverFieldMap: Record<string, DraftFieldKey> = {
117
127
  region: 'region',
118
128
  postalCode: 'postalCode',
119
129
  country: 'country',
130
+ latitude: 'latitude',
131
+ longitude: 'longitude',
120
132
  isPrimary: 'isPrimary',
121
133
  }
122
134
 
@@ -173,6 +185,7 @@ export function CustomerAddressTiles({
173
185
  onAddActionChange,
174
186
  emptyStateTitle,
175
187
  emptyStateActionLabel,
188
+ showCoordinateFields = false,
176
189
  }: CustomerAddressTilesProps) {
177
190
  const scopeVersion = useOrganizationScopeVersion()
178
191
  const queryClient = useQueryClient()
@@ -201,6 +214,8 @@ export function CustomerAddressTiles({
201
214
  region: t('customers.people.detail.addresses.fields.region'),
202
215
  postalCode: t('customers.people.detail.addresses.fields.postalCode'),
203
216
  country: t('customers.people.detail.addresses.fields.country'),
217
+ latitude: t('customers.people.detail.addresses.fields.latitude', 'Latitude'),
218
+ longitude: t('customers.people.detail.addresses.fields.longitude', 'Longitude'),
204
219
  isPrimary: t('customers.people.detail.addresses.fields.primary'),
205
220
  }),
206
221
  [t]
@@ -281,6 +296,8 @@ export function CustomerAddressTiles({
281
296
  region: address.region ?? '',
282
297
  postalCode: address.postalCode ?? '',
283
298
  country: address.country ? address.country.toUpperCase() : '',
299
+ latitude: address.latitude != null ? String(address.latitude) : '',
300
+ longitude: address.longitude != null ? String(address.longitude) : '',
284
301
  isPrimary: address.isPrimary ?? false,
285
302
  })
286
303
  setFieldErrors({})
@@ -309,6 +326,29 @@ export function CustomerAddressTiles({
309
326
  return
310
327
  }
311
328
 
329
+ const coordinateErrors: Partial<Record<DraftFieldKey, string>> = {}
330
+ for (const key of ['latitude', 'longitude'] as const) {
331
+ const result = validateCoordinateInput(key, draft[key])
332
+ if (result.status === 'invalid') {
333
+ coordinateErrors[key] = t(
334
+ 'customers.people.detail.addresses.validation.invalid',
335
+ undefined,
336
+ { field: fieldLabels[key] }
337
+ )
338
+ } else if (result.status === 'outOfRange') {
339
+ coordinateErrors[key] = t(
340
+ 'customers.people.detail.addresses.validation.coordinateRange',
341
+ undefined,
342
+ { field: fieldLabels[key], min: result.min, max: result.max }
343
+ )
344
+ }
345
+ }
346
+ if (Object.keys(coordinateErrors).length) {
347
+ setFieldErrors((prev) => ({ ...prev, ...coordinateErrors }))
348
+ setGeneralError(Object.values(coordinateErrors)[0] ?? null)
349
+ return
350
+ }
351
+
312
352
  const payload: CustomerAddressInput = {
313
353
  addressLine1: trimmedLine1,
314
354
  isPrimary: draft.isPrimary,
@@ -334,6 +374,14 @@ export function CustomerAddressTiles({
334
374
  if (postal !== undefined) payload.postalCode = postal
335
375
  const country = normalizeOptional(draft.country)
336
376
  if (country !== undefined) payload.country = country.toUpperCase()
377
+ // On edit, an emptied coordinate must be sent as `null` to clear the stored value; omitting it
378
+ // makes the partial-update handler keep the old coordinate. On create there is nothing to clear.
379
+ const latitude = normalizeCoordinateInput(draft.latitude)
380
+ if (latitude !== undefined) payload.latitude = latitude
381
+ else if (editingId) payload.latitude = null
382
+ const longitude = normalizeCoordinateInput(draft.longitude)
383
+ if (longitude !== undefined) payload.longitude = longitude
384
+ else if (editingId) payload.longitude = null
337
385
 
338
386
  setSaving(true)
339
387
  setGeneralError(null)
@@ -450,7 +498,7 @@ export function CustomerAddressTiles({
450
498
  <AddressEditor
451
499
  value={draft}
452
500
  onChange={(next) => {
453
- setDraft(next)
501
+ setDraft({ ...next, latitude: next.latitude ?? '', longitude: next.longitude ?? '' })
454
502
  if (Object.keys(fieldErrors).length) {
455
503
  const nextErrors = { ...fieldErrors }
456
504
  ;(Object.keys(nextErrors) as DraftFieldKey[]).forEach((key) => {
@@ -467,6 +515,7 @@ export function CustomerAddressTiles({
467
515
  disabled={disableActions}
468
516
  errors={fieldErrors}
469
517
  showFormatHint={!formatLoading}
518
+ showCoordinateFields={showCoordinateFields}
470
519
  />
471
520
  {generalError ? <p className="text-xs text-status-error-text">{generalError}</p> : null}
472
521
  <div className="flex flex-wrap justify-end gap-2">
@@ -502,6 +551,7 @@ export function CustomerAddressTiles({
502
551
  handleSave,
503
552
  generalError,
504
553
  saving,
554
+ showCoordinateFields,
505
555
  t,
506
556
  ]
507
557
  )
@@ -95,6 +95,8 @@ export function AddressesSection({
95
95
  ? record.postalCode
96
96
  : null,
97
97
  country: typeof record.country === 'string' ? record.country : null,
98
+ latitude: typeof record.latitude === 'number' && Number.isFinite(record.latitude) ? record.latitude : null,
99
+ longitude: typeof record.longitude === 'number' && Number.isFinite(record.longitude) ? record.longitude : null,
98
100
  isPrimary:
99
101
  typeof record.is_primary === 'boolean'
100
102
  ? record.is_primary
@@ -242,6 +244,7 @@ export function AddressesSection({
242
244
  dataAdapter={dataAdapter}
243
245
  addressTypesAdapter={addressTypesAdapter}
244
246
  loadFormat={loadFormat}
247
+ showCoordinateFields
245
248
  />
246
249
  )
247
250
  }
@@ -955,6 +955,7 @@ export const createPersonFormFields = (t: Translator): CrudField[] => {
955
955
  t={t}
956
956
  emptyLabel={t('customers.people.detail.empty.addresses')}
957
957
  gridClassName="grid gap-4 min-[480px]:grid-cols-1 xl:grid-cols-2"
958
+ showCoordinateFields
958
959
  onCreate={async (payload: CustomerAddressInput) => {
959
960
  const nextId =
960
961
  typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
@@ -973,6 +974,8 @@ export const createPersonFormFields = (t: Translator): CrudField[] => {
973
974
  region: payload.region ?? undefined,
974
975
  postalCode: payload.postalCode ?? undefined,
975
976
  country: payload.country ?? undefined,
977
+ latitude: payload.latitude ?? undefined,
978
+ longitude: payload.longitude ?? undefined,
976
979
  isPrimary: payload.isPrimary ?? false,
977
980
  }
978
981
  const current = Array.isArray(addresses) ? addresses : []
@@ -1001,6 +1004,8 @@ export const createPersonFormFields = (t: Translator): CrudField[] => {
1001
1004
  region: payload.region ?? null,
1002
1005
  postalCode: payload.postalCode ?? null,
1003
1006
  country: payload.country ?? null,
1007
+ latitude: payload.latitude ?? null,
1008
+ longitude: payload.longitude ?? null,
1004
1009
  isPrimary: payload.isPrimary ?? false,
1005
1010
  }
1006
1011
  })
@@ -1316,6 +1321,7 @@ export const createCompanyFormFields = (t: Translator): CrudField[] => {
1316
1321
  t={t}
1317
1322
  emptyLabel={t('customers.companies.detail.empty.addresses')}
1318
1323
  gridClassName="grid gap-4 min-[480px]:grid-cols-1 xl:grid-cols-2"
1324
+ showCoordinateFields
1319
1325
  onCreate={async (payload: CustomerAddressInput) => {
1320
1326
  const nextId =
1321
1327
  typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
@@ -1334,6 +1340,8 @@ export const createCompanyFormFields = (t: Translator): CrudField[] => {
1334
1340
  region: payload.region ?? undefined,
1335
1341
  postalCode: payload.postalCode ?? undefined,
1336
1342
  country: payload.country ?? undefined,
1343
+ latitude: payload.latitude ?? undefined,
1344
+ longitude: payload.longitude ?? undefined,
1337
1345
  isPrimary: payload.isPrimary ?? false,
1338
1346
  }
1339
1347
  const current = Array.isArray(addresses) ? addresses : []
@@ -1362,6 +1370,8 @@ export const createCompanyFormFields = (t: Translator): CrudField[] => {
1362
1370
  region: payload.region ?? null,
1363
1371
  postalCode: payload.postalCode ?? null,
1364
1372
  country: payload.country ?? null,
1373
+ latitude: payload.latitude ?? null,
1374
+ longitude: payload.longitude ?? null,
1365
1375
  isPrimary: payload.isPrimary ?? false,
1366
1376
  }
1367
1377
  })
@@ -1,5 +1,6 @@
1
1
  import { z } from 'zod'
2
2
  import { isValidPhoneNumber } from '@open-mercato/shared/lib/phone'
3
+ import { COORDINATE_RANGES } from '@open-mercato/shared/lib/location/coordinates'
3
4
  import { dictionaryEntrySortModeSchema } from '@open-mercato/core/modules/dictionaries/lib/entrySort'
4
5
 
5
6
  const uuid = () => z.string().uuid()
@@ -264,8 +265,18 @@ export const addressCreateSchema = scopedSchema.extend({
264
265
  region: z.string().max(150).optional(),
265
266
  postalCode: z.string().max(30).optional(),
266
267
  country: z.string().max(150).optional(),
267
- latitude: z.coerce.number().optional(),
268
- longitude: z.coerce.number().optional(),
268
+ latitude: z.coerce
269
+ .number()
270
+ .min(COORDINATE_RANGES.latitude.min)
271
+ .max(COORDINATE_RANGES.latitude.max)
272
+ .nullable()
273
+ .optional(),
274
+ longitude: z.coerce
275
+ .number()
276
+ .min(COORDINATE_RANGES.longitude.min)
277
+ .max(COORDINATE_RANGES.longitude.max)
278
+ .nullable()
279
+ .optional(),
269
280
  isPrimary: z.boolean().optional(),
270
281
  })
271
282
 
@@ -1339,6 +1339,7 @@
1339
1339
  "customers.deals.kanban.sort.title": "Sort by",
1340
1340
  "customers.deals.kanban.view.kanban": "Kanban",
1341
1341
  "customers.deals.kanban.view.list": "List",
1342
+ "customers.deals.kanban.view.map": "Karte",
1342
1343
  "customers.deals.kanban.view.tablistLabel": "Deals views",
1343
1344
  "customers.deals.list.actions.delete": "Löschen",
1344
1345
  "customers.deals.list.actions.deleting": "Wird gelöscht…",
@@ -1424,6 +1425,25 @@
1424
1425
  "customers.deals.list.unknownOwner": "Unbekannter Inhaber",
1425
1426
  "customers.deals.list.unnamedCompany": "Unbenanntes Unternehmen",
1426
1427
  "customers.deals.list.unnamedPerson": "Unbenannte Person",
1428
+ "customers.deals.map.canvas.label": "Deal-Karte",
1429
+ "customers.deals.map.canvas.loading": "Karte wird geladen…",
1430
+ "customers.deals.map.legend.title": "Phasen",
1431
+ "customers.deals.map.loadError": "Deals für die Karte konnten nicht geladen werden.",
1432
+ "customers.deals.map.panel.count": "{count} mit Standort",
1433
+ "customers.deals.map.panel.empty.description": "Füge Breiten- und Längengrad zu Firmen- oder Personenadressen hinzu, um ihre Deals hier anzuzeigen.",
1434
+ "customers.deals.map.panel.empty.title": "Noch keine Deals auf der Karte",
1435
+ "customers.deals.map.panel.hint": "Klicke auf einen Deal oder eine Kartennadel, um die Vorschau zu öffnen.",
1436
+ "customers.deals.map.panel.noAddress": "Keine Adresskoordinaten",
1437
+ "customers.deals.map.panel.sort.label": "Deal-Panel sortieren",
1438
+ "customers.deals.map.panel.sort.listOrder": "Listenreihenfolge",
1439
+ "customers.deals.map.panel.sort.proximity": "Nächstgelegene zuerst",
1440
+ "customers.deals.map.panel.title": "Deals mit Standort",
1441
+ "customers.deals.map.preview.close": "Vorschau schließen",
1442
+ "customers.deals.map.preview.closeShort": "Abschluss {date}",
1443
+ "customers.deals.map.preview.openDeal": "Deal öffnen",
1444
+ "customers.deals.map.preview.probabilityShort": "{value}%",
1445
+ "customers.deals.map.retry": "Erneut versuchen",
1446
+ "customers.deals.map.truncated": "Es werden die ersten {count} von {total} verorteten Deals angezeigt — verfeinere die Filter, um die Karte einzugrenzen.",
1427
1447
  "customers.deals.pipeline.actions.openDeal": "Deal öffnen",
1428
1448
  "customers.deals.pipeline.card.expectedClose": "Abschlussdatum",
1429
1449
  "customers.deals.pipeline.card.probability": "Wahrscheinlichkeit",
@@ -1651,6 +1671,7 @@
1651
1671
  "customers.nav.companies": "Unternehmen",
1652
1672
  "customers.nav.companies.create": "Neues Unternehmen",
1653
1673
  "customers.nav.deals": "Geschäfte",
1674
+ "customers.nav.deals.map": "Deal-Karte",
1654
1675
  "customers.nav.deals.pipeline": "Sales-Pipeline",
1655
1676
  "customers.nav.group": "Kunden",
1656
1677
  "customers.nav.people": "Personen",
@@ -1766,8 +1787,10 @@
1766
1787
  "customers.people.detail.addresses.fields.country": "Land",
1767
1788
  "customers.people.detail.addresses.fields.flatNumber": "Wohnung / Einheit",
1768
1789
  "customers.people.detail.addresses.fields.label": "Bezeichnung",
1790
+ "customers.people.detail.addresses.fields.latitude": "Breitengrad",
1769
1791
  "customers.people.detail.addresses.fields.line1": "Adresszeile 1",
1770
1792
  "customers.people.detail.addresses.fields.line2": "Adresszeile 2",
1793
+ "customers.people.detail.addresses.fields.longitude": "Längengrad",
1771
1794
  "customers.people.detail.addresses.fields.postalCode": "Postleitzahl",
1772
1795
  "customers.people.detail.addresses.fields.primary": "Als primär markieren",
1773
1796
  "customers.people.detail.addresses.fields.region": "Bundesland / Region",
@@ -1799,6 +1822,7 @@
1799
1822
  "customers.people.detail.addresses.types.saveError": "Adresstyp konnte nicht gespeichert werden.",
1800
1823
  "customers.people.detail.addresses.update": "Adresse aktualisieren ⌘⏎ / Ctrl+Enter",
1801
1824
  "customers.people.detail.addresses.updating": "Aktualisieren…",
1825
+ "customers.people.detail.addresses.validation.coordinateRange": "{{field}} muss zwischen {{min}} und {{max}} liegen",
1802
1826
  "customers.people.detail.addresses.validation.generic": "Bitte prüfen Sie {{field}}",
1803
1827
  "customers.people.detail.addresses.validation.invalid": "Ungültiger Wert für {{field}}",
1804
1828
  "customers.people.detail.addresses.validation.required": "Geben Sie {{field}} ein",
@@ -1339,6 +1339,7 @@
1339
1339
  "customers.deals.kanban.sort.title": "Sort by",
1340
1340
  "customers.deals.kanban.view.kanban": "Kanban",
1341
1341
  "customers.deals.kanban.view.list": "List",
1342
+ "customers.deals.kanban.view.map": "Map",
1342
1343
  "customers.deals.kanban.view.tablistLabel": "Deals views",
1343
1344
  "customers.deals.list.actions.delete": "Delete",
1344
1345
  "customers.deals.list.actions.deleting": "Deleting…",
@@ -1424,6 +1425,25 @@
1424
1425
  "customers.deals.list.unknownOwner": "Unknown owner",
1425
1426
  "customers.deals.list.unnamedCompany": "Unnamed company",
1426
1427
  "customers.deals.list.unnamedPerson": "Unnamed person",
1428
+ "customers.deals.map.canvas.label": "Deals map",
1429
+ "customers.deals.map.canvas.loading": "Loading map…",
1430
+ "customers.deals.map.legend.title": "Stages",
1431
+ "customers.deals.map.loadError": "Failed to load deals for the map.",
1432
+ "customers.deals.map.panel.count": "{count} located",
1433
+ "customers.deals.map.panel.empty.description": "Add latitude and longitude to company or person addresses to plot their deals here.",
1434
+ "customers.deals.map.panel.empty.title": "No deals on the map yet",
1435
+ "customers.deals.map.panel.hint": "Click a deal or a map pin to preview it.",
1436
+ "customers.deals.map.panel.noAddress": "No address coordinates",
1437
+ "customers.deals.map.panel.sort.label": "Sort deals panel",
1438
+ "customers.deals.map.panel.sort.listOrder": "List order",
1439
+ "customers.deals.map.panel.sort.proximity": "Closest first",
1440
+ "customers.deals.map.panel.title": "Deals with location",
1441
+ "customers.deals.map.preview.close": "Close preview",
1442
+ "customers.deals.map.preview.closeShort": "Close {date}",
1443
+ "customers.deals.map.preview.openDeal": "Open deal",
1444
+ "customers.deals.map.preview.probabilityShort": "{value}%",
1445
+ "customers.deals.map.retry": "Retry",
1446
+ "customers.deals.map.truncated": "Showing first {count} of {total} located deals — refine filters to narrow the map.",
1427
1447
  "customers.deals.pipeline.actions.openDeal": "Open deal",
1428
1448
  "customers.deals.pipeline.card.expectedClose": "Expected close",
1429
1449
  "customers.deals.pipeline.card.probability": "Probability",
@@ -1651,6 +1671,7 @@
1651
1671
  "customers.nav.companies": "Companies",
1652
1672
  "customers.nav.companies.create": "New company",
1653
1673
  "customers.nav.deals": "Deals",
1674
+ "customers.nav.deals.map": "Deals Map",
1654
1675
  "customers.nav.deals.pipeline": "Sales Pipeline",
1655
1676
  "customers.nav.group": "Customers",
1656
1677
  "customers.nav.people": "People",
@@ -1766,8 +1787,10 @@
1766
1787
  "customers.people.detail.addresses.fields.country": "Country",
1767
1788
  "customers.people.detail.addresses.fields.flatNumber": "Flat / apartment",
1768
1789
  "customers.people.detail.addresses.fields.label": "Label",
1790
+ "customers.people.detail.addresses.fields.latitude": "Latitude",
1769
1791
  "customers.people.detail.addresses.fields.line1": "Address line 1",
1770
1792
  "customers.people.detail.addresses.fields.line2": "Address line 2",
1793
+ "customers.people.detail.addresses.fields.longitude": "Longitude",
1771
1794
  "customers.people.detail.addresses.fields.postalCode": "Postal code",
1772
1795
  "customers.people.detail.addresses.fields.primary": "Set as primary",
1773
1796
  "customers.people.detail.addresses.fields.region": "Region / State",
@@ -1799,6 +1822,7 @@
1799
1822
  "customers.people.detail.addresses.types.saveError": "Failed to save address type.",
1800
1823
  "customers.people.detail.addresses.update": "Update address ⌘⏎ / Ctrl+Enter",
1801
1824
  "customers.people.detail.addresses.updating": "Updating…",
1825
+ "customers.people.detail.addresses.validation.coordinateRange": "{{field}} must be between {{min}} and {{max}}",
1802
1826
  "customers.people.detail.addresses.validation.generic": "Check {{field}}",
1803
1827
  "customers.people.detail.addresses.validation.invalid": "Invalid value for {{field}}",
1804
1828
  "customers.people.detail.addresses.validation.required": "Enter {{field}}",