@open-mercato/core 0.7.1-develop.7123.1.25365e718d → 0.7.1-develop.7130.1.fef2396fd8
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/backend/customers/deals/[id]/hooks/useDealAssociations.js +5 -117
- package/dist/modules/customers/backend/customers/deals/[id]/hooks/useDealAssociations.js.map +2 -2
- package/dist/modules/customers/backend/customers/deals/[id]/page.js +1 -3
- package/dist/modules/customers/backend/customers/deals/[id]/page.js.map +2 -2
- package/dist/modules/customers/commands/deals.js +33 -9
- package/dist/modules/customers/commands/deals.js.map +3 -3
- package/dist/modules/customers/components/AddressEditor.js +71 -1
- package/dist/modules/customers/components/AddressEditor.js.map +2 -2
- package/dist/modules/customers/utils/addressFormat.js +18 -2
- package/dist/modules/customers/utils/addressFormat.js.map +2 -2
- package/dist/modules/sales/components/documents/AddressesSection.js +17 -2
- package/dist/modules/sales/components/documents/AddressesSection.js.map +2 -2
- package/dist/modules/sales/components/documents/SalesDocumentForm.js +7 -24
- package/dist/modules/sales/components/documents/SalesDocumentForm.js.map +2 -2
- package/dist/modules/sales/components/documents/normalizeAddressDraft.js +29 -0
- package/dist/modules/sales/components/documents/normalizeAddressDraft.js.map +7 -0
- package/package.json +7 -7
- package/src/modules/customers/backend/customers/deals/[id]/hooks/types.ts +0 -23
- package/src/modules/customers/backend/customers/deals/[id]/hooks/useDealAssociations.ts +11 -172
- package/src/modules/customers/backend/customers/deals/[id]/page.tsx +1 -1
- package/src/modules/customers/commands/deals.ts +84 -9
- package/src/modules/customers/components/AddressEditor.tsx +116 -1
- package/src/modules/customers/i18n/de.json +6 -0
- package/src/modules/customers/i18n/en.json +6 -0
- package/src/modules/customers/i18n/es.json +6 -0
- package/src/modules/customers/i18n/ko.json +6 -0
- package/src/modules/customers/i18n/pl.json +6 -0
- package/src/modules/customers/utils/addressFormat.tsx +59 -1
- package/src/modules/sales/components/documents/AddressesSection.tsx +48 -5
- package/src/modules/sales/components/documents/SalesDocumentForm.tsx +5 -23
- package/src/modules/sales/components/documents/normalizeAddressDraft.ts +29 -0
|
@@ -6,29 +6,6 @@ export type DealAssociation = {
|
|
|
6
6
|
isPrimary?: boolean
|
|
7
7
|
}
|
|
8
8
|
|
|
9
|
-
export type PersonAssociationApiRecord = {
|
|
10
|
-
id?: string
|
|
11
|
-
displayName?: string | null
|
|
12
|
-
display_name?: string | null
|
|
13
|
-
primaryEmail?: string | null
|
|
14
|
-
primary_email?: string | null
|
|
15
|
-
primaryPhone?: string | null
|
|
16
|
-
primary_phone?: string | null
|
|
17
|
-
personProfile?: { jobTitle?: string | null } | null
|
|
18
|
-
person_profile?: { jobTitle?: string | null } | null
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
export type CompanyAssociationApiRecord = {
|
|
22
|
-
id?: string
|
|
23
|
-
displayName?: string | null
|
|
24
|
-
display_name?: string | null
|
|
25
|
-
domain?: string | null
|
|
26
|
-
websiteUrl?: string | null
|
|
27
|
-
website_url?: string | null
|
|
28
|
-
companyProfile?: { domain?: string | null; websiteUrl?: string | null } | null
|
|
29
|
-
company_profile?: { domain?: string | null; websiteUrl?: string | null } | null
|
|
30
|
-
}
|
|
31
|
-
|
|
32
9
|
export type PipelineStageInfo = {
|
|
33
10
|
id: string
|
|
34
11
|
label: string
|
|
@@ -6,81 +6,11 @@ import { readApiResultOrThrow, withScopedApiRequestHeaders } from '@open-mercato
|
|
|
6
6
|
import { buildOptimisticLockHeader } from '@open-mercato/ui/backend/utils/optimisticLock'
|
|
7
7
|
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
8
8
|
import type {
|
|
9
|
-
CompanyAssociationApiRecord,
|
|
10
9
|
DealAssociation,
|
|
11
10
|
DealDetailPayload,
|
|
12
11
|
GuardedMutationRunner,
|
|
13
|
-
PersonAssociationApiRecord,
|
|
14
12
|
} from './types'
|
|
15
13
|
|
|
16
|
-
export function normalizePersonAssociationRecord(
|
|
17
|
-
record: PersonAssociationApiRecord,
|
|
18
|
-
fallbackId: string,
|
|
19
|
-
): DealAssociation {
|
|
20
|
-
const displayName =
|
|
21
|
-
typeof record.displayName === 'string' && record.displayName.trim().length
|
|
22
|
-
? record.displayName.trim()
|
|
23
|
-
: typeof record.display_name === 'string' && record.display_name.trim().length
|
|
24
|
-
? record.display_name.trim()
|
|
25
|
-
: null
|
|
26
|
-
const email =
|
|
27
|
-
typeof record.primaryEmail === 'string' && record.primaryEmail.trim().length
|
|
28
|
-
? record.primaryEmail.trim()
|
|
29
|
-
: typeof record.primary_email === 'string' && record.primary_email.trim().length
|
|
30
|
-
? record.primary_email.trim()
|
|
31
|
-
: null
|
|
32
|
-
const phone =
|
|
33
|
-
typeof record.primaryPhone === 'string' && record.primaryPhone.trim().length
|
|
34
|
-
? record.primaryPhone.trim()
|
|
35
|
-
: typeof record.primary_phone === 'string' && record.primary_phone.trim().length
|
|
36
|
-
? record.primary_phone.trim()
|
|
37
|
-
: null
|
|
38
|
-
const profile = record.personProfile ?? record.person_profile ?? null
|
|
39
|
-
const jobTitle =
|
|
40
|
-
profile && typeof profile.jobTitle === 'string' && profile.jobTitle.trim().length
|
|
41
|
-
? profile.jobTitle.trim()
|
|
42
|
-
: null
|
|
43
|
-
return {
|
|
44
|
-
id: typeof record.id === 'string' ? record.id : fallbackId,
|
|
45
|
-
label: displayName ?? email ?? phone ?? fallbackId,
|
|
46
|
-
subtitle: jobTitle ?? email ?? phone ?? null,
|
|
47
|
-
kind: 'person',
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
export function normalizeCompanyAssociationRecord(
|
|
52
|
-
record: CompanyAssociationApiRecord,
|
|
53
|
-
fallbackId: string,
|
|
54
|
-
): DealAssociation {
|
|
55
|
-
const displayName =
|
|
56
|
-
typeof record.displayName === 'string' && record.displayName.trim().length
|
|
57
|
-
? record.displayName.trim()
|
|
58
|
-
: typeof record.display_name === 'string' && record.display_name.trim().length
|
|
59
|
-
? record.display_name.trim()
|
|
60
|
-
: null
|
|
61
|
-
const profile = record.companyProfile ?? record.company_profile ?? null
|
|
62
|
-
const domain =
|
|
63
|
-
typeof record.domain === 'string' && record.domain.trim().length
|
|
64
|
-
? record.domain.trim()
|
|
65
|
-
: profile && typeof profile.domain === 'string' && profile.domain.trim().length
|
|
66
|
-
? profile.domain.trim()
|
|
67
|
-
: null
|
|
68
|
-
const website =
|
|
69
|
-
typeof record.websiteUrl === 'string' && record.websiteUrl.trim().length
|
|
70
|
-
? record.websiteUrl.trim()
|
|
71
|
-
: typeof record.website_url === 'string' && record.website_url.trim().length
|
|
72
|
-
? record.website_url.trim()
|
|
73
|
-
: profile && typeof profile.websiteUrl === 'string' && profile.websiteUrl.trim().length
|
|
74
|
-
? profile.websiteUrl.trim()
|
|
75
|
-
: null
|
|
76
|
-
return {
|
|
77
|
-
id: typeof record.id === 'string' ? record.id : fallbackId,
|
|
78
|
-
label: displayName ?? domain ?? website ?? fallbackId,
|
|
79
|
-
subtitle: domain ?? website ?? null,
|
|
80
|
-
kind: 'company',
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
|
|
84
14
|
function sameIdList(left: string[], right: string[]): boolean {
|
|
85
15
|
if (left.length !== right.length) return false
|
|
86
16
|
return left.every((value, index) => value === right[index])
|
|
@@ -97,8 +27,12 @@ type UseDealAssociationsOptions = {
|
|
|
97
27
|
data: DealDetailPayload | null
|
|
98
28
|
setData: React.Dispatch<React.SetStateAction<DealDetailPayload | null>>
|
|
99
29
|
runMutationWithContext: GuardedMutationRunner
|
|
100
|
-
/**
|
|
101
|
-
|
|
30
|
+
/**
|
|
31
|
+
* Re-fetch the deal detail. Required, not optional: this hook no longer patches the list
|
|
32
|
+
* itself on success, so a caller without it would show a stale list *and* keep a superseded
|
|
33
|
+
* lock token that 409s on the next save. It also backs the conflict bar's refresh on a 409.
|
|
34
|
+
*/
|
|
35
|
+
onRefresh: () => void | Promise<void>
|
|
102
36
|
}
|
|
103
37
|
|
|
104
38
|
type UseDealAssociationsResult = {
|
|
@@ -130,80 +64,6 @@ export function useDealAssociations({
|
|
|
130
64
|
setCompaniesEditorIds(data?.linkedCompanyIds ?? [])
|
|
131
65
|
}, [data?.linkedCompanyIds, data?.linkedPersonIds])
|
|
132
66
|
|
|
133
|
-
const loadPeopleAssociations = React.useCallback(async (ids: string[]): Promise<DealAssociation[]> => {
|
|
134
|
-
const uniqueIds = Array.from(new Set(ids.map((value) => value.trim()).filter(Boolean)))
|
|
135
|
-
if (!uniqueIds.length) return []
|
|
136
|
-
try {
|
|
137
|
-
const params = new URLSearchParams({
|
|
138
|
-
ids: uniqueIds.join(','),
|
|
139
|
-
pageSize: String(Math.max(uniqueIds.length, 1)),
|
|
140
|
-
})
|
|
141
|
-
const payload = await readApiResultOrThrow<{ items?: PersonAssociationApiRecord[] }>(
|
|
142
|
-
`/api/customers/people?${params.toString()}`,
|
|
143
|
-
)
|
|
144
|
-
const items = Array.isArray(payload.items) ? payload.items : []
|
|
145
|
-
const byId = new Map<string, PersonAssociationApiRecord>()
|
|
146
|
-
items.forEach((record) => {
|
|
147
|
-
if (record && typeof record.id === 'string') byId.set(record.id, record)
|
|
148
|
-
})
|
|
149
|
-
return uniqueIds.map((personId) => {
|
|
150
|
-
const record = byId.get(personId)
|
|
151
|
-
return record
|
|
152
|
-
? normalizePersonAssociationRecord(record, personId)
|
|
153
|
-
: {
|
|
154
|
-
id: personId,
|
|
155
|
-
label: personId,
|
|
156
|
-
subtitle: null,
|
|
157
|
-
kind: 'person' as const,
|
|
158
|
-
}
|
|
159
|
-
})
|
|
160
|
-
} catch {
|
|
161
|
-
return uniqueIds.map((personId) => ({
|
|
162
|
-
id: personId,
|
|
163
|
-
label: personId,
|
|
164
|
-
subtitle: null,
|
|
165
|
-
kind: 'person' as const,
|
|
166
|
-
}))
|
|
167
|
-
}
|
|
168
|
-
}, [])
|
|
169
|
-
|
|
170
|
-
const loadCompanyAssociations = React.useCallback(async (ids: string[]): Promise<DealAssociation[]> => {
|
|
171
|
-
const uniqueIds = Array.from(new Set(ids.map((value) => value.trim()).filter(Boolean)))
|
|
172
|
-
if (!uniqueIds.length) return []
|
|
173
|
-
try {
|
|
174
|
-
const params = new URLSearchParams({
|
|
175
|
-
ids: uniqueIds.join(','),
|
|
176
|
-
pageSize: String(Math.max(uniqueIds.length, 1)),
|
|
177
|
-
})
|
|
178
|
-
const payload = await readApiResultOrThrow<{ items?: CompanyAssociationApiRecord[] }>(
|
|
179
|
-
`/api/customers/companies?${params.toString()}`,
|
|
180
|
-
)
|
|
181
|
-
const items = Array.isArray(payload.items) ? payload.items : []
|
|
182
|
-
const byId = new Map<string, CompanyAssociationApiRecord>()
|
|
183
|
-
items.forEach((record) => {
|
|
184
|
-
if (record && typeof record.id === 'string') byId.set(record.id, record)
|
|
185
|
-
})
|
|
186
|
-
return uniqueIds.map((companyId) => {
|
|
187
|
-
const record = byId.get(companyId)
|
|
188
|
-
return record
|
|
189
|
-
? normalizeCompanyAssociationRecord(record, companyId)
|
|
190
|
-
: {
|
|
191
|
-
id: companyId,
|
|
192
|
-
label: companyId,
|
|
193
|
-
subtitle: null,
|
|
194
|
-
kind: 'company' as const,
|
|
195
|
-
}
|
|
196
|
-
})
|
|
197
|
-
} catch {
|
|
198
|
-
return uniqueIds.map((companyId) => ({
|
|
199
|
-
id: companyId,
|
|
200
|
-
label: companyId,
|
|
201
|
-
subtitle: null,
|
|
202
|
-
kind: 'company' as const,
|
|
203
|
-
}))
|
|
204
|
-
}
|
|
205
|
-
}, [])
|
|
206
|
-
|
|
207
67
|
const loadLinkedPeoplePage = React.useCallback(
|
|
208
68
|
async (page: number, query: string): Promise<LinkedPageResult> => {
|
|
209
69
|
if (!currentDealId) {
|
|
@@ -274,17 +134,7 @@ export function useDealAssociations({
|
|
|
274
134
|
),
|
|
275
135
|
{ id: currentDealId, personIds: nextIds, operation: 'updateDealPeople' },
|
|
276
136
|
)
|
|
277
|
-
|
|
278
|
-
setData((prev) =>
|
|
279
|
-
prev
|
|
280
|
-
? {
|
|
281
|
-
...prev,
|
|
282
|
-
people: nextPeople,
|
|
283
|
-
linkedPersonIds: nextIds,
|
|
284
|
-
counts: { ...prev.counts, people: nextIds.length },
|
|
285
|
-
}
|
|
286
|
-
: prev,
|
|
287
|
-
)
|
|
137
|
+
await onRefresh()
|
|
288
138
|
} catch (error) {
|
|
289
139
|
setPeopleEditorIds(previousIds)
|
|
290
140
|
setData((prev) =>
|
|
@@ -299,14 +149,14 @@ export function useDealAssociations({
|
|
|
299
149
|
)
|
|
300
150
|
// runMutationWithContext already surfaces the conflict bar on a 409; only
|
|
301
151
|
// fall back to the generic flash when this is not a record conflict.
|
|
302
|
-
if (!surfaceRecordConflict(error, t, { onRefresh
|
|
152
|
+
if (!surfaceRecordConflict(error, t, { onRefresh })) {
|
|
303
153
|
flash(t('customers.deals.detail.peopleUpdateError', 'Failed to update linked people.'), 'error')
|
|
304
154
|
}
|
|
305
155
|
} finally {
|
|
306
156
|
setPeopleSaving(false)
|
|
307
157
|
}
|
|
308
158
|
},
|
|
309
|
-
[currentDealId, data?.deal.updatedAt, data?.people,
|
|
159
|
+
[currentDealId, data?.deal.updatedAt, data?.people, onRefresh, peopleEditorIds, runMutationWithContext, setData, t],
|
|
310
160
|
)
|
|
311
161
|
|
|
312
162
|
const handleCompaniesAssociationsChange = React.useCallback(
|
|
@@ -325,17 +175,7 @@ export function useDealAssociations({
|
|
|
325
175
|
),
|
|
326
176
|
{ id: currentDealId, companyIds: nextIds, operation: 'updateDealCompanies' },
|
|
327
177
|
)
|
|
328
|
-
|
|
329
|
-
setData((prev) =>
|
|
330
|
-
prev
|
|
331
|
-
? {
|
|
332
|
-
...prev,
|
|
333
|
-
companies: nextCompanies,
|
|
334
|
-
linkedCompanyIds: nextIds,
|
|
335
|
-
counts: { ...prev.counts, companies: nextIds.length },
|
|
336
|
-
}
|
|
337
|
-
: prev,
|
|
338
|
-
)
|
|
178
|
+
await onRefresh()
|
|
339
179
|
} catch (error) {
|
|
340
180
|
setCompaniesEditorIds(previousIds)
|
|
341
181
|
setData((prev) =>
|
|
@@ -350,7 +190,7 @@ export function useDealAssociations({
|
|
|
350
190
|
)
|
|
351
191
|
// runMutationWithContext already surfaces the conflict bar on a 409; only
|
|
352
192
|
// fall back to the generic flash when this is not a record conflict.
|
|
353
|
-
if (!surfaceRecordConflict(error, t, { onRefresh
|
|
193
|
+
if (!surfaceRecordConflict(error, t, { onRefresh })) {
|
|
354
194
|
flash(t('customers.deals.detail.companiesUpdateError', 'Failed to update linked companies.'), 'error')
|
|
355
195
|
}
|
|
356
196
|
} finally {
|
|
@@ -362,7 +202,6 @@ export function useDealAssociations({
|
|
|
362
202
|
currentDealId,
|
|
363
203
|
data?.companies,
|
|
364
204
|
data?.deal.updatedAt,
|
|
365
|
-
loadCompanyAssociations,
|
|
366
205
|
onRefresh,
|
|
367
206
|
runMutationWithContext,
|
|
368
207
|
setData,
|
|
@@ -198,7 +198,7 @@ export default function DealDetailPage({ params }: { params?: { id?: string } })
|
|
|
198
198
|
data,
|
|
199
199
|
setData,
|
|
200
200
|
runMutationWithContext,
|
|
201
|
-
onRefresh: () =>
|
|
201
|
+
onRefresh: () => loadData(),
|
|
202
202
|
})
|
|
203
203
|
|
|
204
204
|
const { isStageSaving, handleStageChange } = useDealPipeline({
|
|
@@ -383,12 +383,49 @@ function toNumericString(value: number | null | undefined): string | null {
|
|
|
383
383
|
return value.toString()
|
|
384
384
|
}
|
|
385
385
|
|
|
386
|
+
function sameLinkIdSet(next: Set<string>, current: Set<string>): boolean {
|
|
387
|
+
if (next.size !== current.size) return false
|
|
388
|
+
for (const id of next) {
|
|
389
|
+
if (!current.has(id)) return false
|
|
390
|
+
}
|
|
391
|
+
return true
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* The deal's optimistic-lock token is `customer_deals.updated_at`, and `CustomerDeal.updatedAt`
|
|
396
|
+
* is declared `onUpdate`-only — it advances only when the deal entity itself enters the change
|
|
397
|
+
* set. A `{ id, personIds }` or `{ id, companyIds }` payload mutates link rows exclusively, so
|
|
398
|
+
* without this explicit touch the token never moves: two clients editing the same deal's links
|
|
399
|
+
* from the same base version both pass the version check and the later stale whole-set payload
|
|
400
|
+
* silently reinstates what the earlier one removed.
|
|
401
|
+
*
|
|
402
|
+
* Assigning the property is what dirties the entity; the `onUpdate` hook then supplies the
|
|
403
|
+
* committed value, so the two do not fight. Same pattern as the profile-only branch in
|
|
404
|
+
* `people.ts`, which touches its parent for exactly this reason.
|
|
405
|
+
*
|
|
406
|
+
* Callers MUST only invoke this when the links actually changed — stamping on a no-op write
|
|
407
|
+
* would invalidate every other session's token on an idle save.
|
|
408
|
+
*
|
|
409
|
+
* ORDERING: this MUST be the last thing a sync helper does. MikroORM v7 discards a pending
|
|
410
|
+
* scalar change on a managed entity when a query runs on the same EntityManager before the
|
|
411
|
+
* flush (SPEC-018) — the same footgun the CRITICAL comment in `updateDealCommand` guards
|
|
412
|
+
* against, and these helpers are exactly the queries it names. Touching before
|
|
413
|
+
* `requireCustomerEntity` runs would silently drop the UPDATE and leave the token frozen.
|
|
414
|
+
*/
|
|
415
|
+
function touchDealLockToken(deal: CustomerDeal): void {
|
|
416
|
+
deal.updatedAt = new Date()
|
|
417
|
+
}
|
|
418
|
+
|
|
386
419
|
async function syncDealPeople(
|
|
387
420
|
em: EntityManager,
|
|
388
421
|
deal: CustomerDeal,
|
|
389
422
|
personIds: string[] | undefined | null,
|
|
390
|
-
primaryPersonEntityId?: string | null
|
|
423
|
+
primaryPersonEntityId?: string | null,
|
|
424
|
+
options?: { stampLockToken?: boolean }
|
|
391
425
|
): Promise<void> {
|
|
426
|
+
// A freshly created deal has no other session holding a token to invalidate, and stamping
|
|
427
|
+
// there would only make `updated_at` overtake `created_at` on every new deal with links.
|
|
428
|
+
const stampLockToken = options?.stampLockToken !== false
|
|
392
429
|
if (personIds === undefined) {
|
|
393
430
|
if (primaryPersonEntityId === undefined) return
|
|
394
431
|
const links = await em.find(CustomerDealPersonLink, { deal })
|
|
@@ -401,6 +438,18 @@ async function syncDealPeople(
|
|
|
401
438
|
),
|
|
402
439
|
})
|
|
403
440
|
}
|
|
441
|
+
const currentPrimaryId = links.find((link) => link.isPrimary)?.person?.id ?? null
|
|
442
|
+
// Nothing changed — same invariant the whole-set branch below enforces. Without this the
|
|
443
|
+
// clear loop and its flush would rewrite every link row just to put the flag back on the
|
|
444
|
+
// row it was already on, and would briefly leave the deal with no primary at all inside
|
|
445
|
+
// the transaction. Only one row can carry the flag (partial unique index on
|
|
446
|
+
// `deal_id where is_primary`), so there is no second stale flag left to clean up here.
|
|
447
|
+
if (currentPrimaryId === primaryPersonEntityId) return
|
|
448
|
+
// Safe here: only assignments and the explicit flush below follow — no query runs
|
|
449
|
+
// between this touch and the flush that persists it.
|
|
450
|
+
if (stampLockToken) {
|
|
451
|
+
touchDealLockToken(deal)
|
|
452
|
+
}
|
|
404
453
|
for (const link of links) {
|
|
405
454
|
link.isPrimary = false
|
|
406
455
|
}
|
|
@@ -421,13 +470,30 @@ async function syncDealPeople(
|
|
|
421
470
|
),
|
|
422
471
|
})
|
|
423
472
|
}
|
|
473
|
+
// Read the current links once: this both resolves the inherited primary (as the previous
|
|
474
|
+
// `findOne(..., { isPrimary: true })` did) and lets us tell a real change from a no-op save,
|
|
475
|
+
// which the lock stamp below depends on.
|
|
476
|
+
const existingLinks = await em.find(CustomerDealPersonLink, { deal })
|
|
477
|
+
const currentPersonIds = new Set(existingLinks.map((link) => link.person.id))
|
|
478
|
+
const currentPrimaryId = existingLinks.find((link) => link.isPrimary)?.person?.id ?? null
|
|
479
|
+
|
|
424
480
|
let effectivePrimaryId = primaryPersonEntityId
|
|
425
481
|
if (effectivePrimaryId === undefined) {
|
|
426
|
-
|
|
427
|
-
effectivePrimaryId = existingPrimary?.person?.id ?? null
|
|
482
|
+
effectivePrimaryId = currentPrimaryId
|
|
428
483
|
}
|
|
484
|
+
const linksChanged =
|
|
485
|
+
!sameLinkIdSet(new Set(unique), currentPersonIds) || effectivePrimaryId !== currentPrimaryId
|
|
486
|
+
// Nothing to do. Returning here also stops a no-op save from deleting and recreating every
|
|
487
|
+
// row, which would otherwise discard `participant_role`, `created_at` and the link ids while
|
|
488
|
+
// this function reports the write as a no-op to every other session.
|
|
489
|
+
//
|
|
490
|
+
// NOTE: this only covers the pure no-op. A genuine change below still deletes and recreates
|
|
491
|
+
// every row, so surviving participants do lose those columns — pre-existing behaviour that
|
|
492
|
+
// the set-diff in PR 3 of the linked-people parity spec removes. It is out of scope here
|
|
493
|
+
// because the linked date it corrupts is not rendered until that PR.
|
|
494
|
+
if (!linksChanged) return
|
|
495
|
+
|
|
429
496
|
await em.nativeDelete(CustomerDealPersonLink, { deal })
|
|
430
|
-
if (!unique.length) return
|
|
431
497
|
for (const personId of unique) {
|
|
432
498
|
const person = await requireCustomerEntity(em, personId, { tenantId: deal.tenantId, organizationId: deal.organizationId }, 'person', 'Person not found')
|
|
433
499
|
ensureSameScope(person, deal.organizationId, deal.tenantId)
|
|
@@ -438,17 +504,24 @@ async function syncDealPeople(
|
|
|
438
504
|
})
|
|
439
505
|
em.persist(link)
|
|
440
506
|
}
|
|
507
|
+
// Last statement on purpose — see the ORDERING note on `touchDealLockToken`.
|
|
508
|
+
if (stampLockToken) touchDealLockToken(deal)
|
|
441
509
|
}
|
|
442
510
|
|
|
443
511
|
async function syncDealCompanies(
|
|
444
512
|
em: EntityManager,
|
|
445
513
|
deal: CustomerDeal,
|
|
446
|
-
companyIds: string[] | undefined | null
|
|
514
|
+
companyIds: string[] | undefined | null,
|
|
515
|
+
options?: { stampLockToken?: boolean }
|
|
447
516
|
): Promise<void> {
|
|
448
517
|
if (companyIds === undefined) return
|
|
518
|
+
const stampLockToken = options?.stampLockToken !== false
|
|
519
|
+
const unique = Array.from(new Set(companyIds ?? []))
|
|
520
|
+
const existingLinks = await em.find(CustomerDealCompanyLink, { deal })
|
|
521
|
+
const currentCompanyIds = new Set(existingLinks.map((link) => link.company.id))
|
|
522
|
+
if (sameLinkIdSet(new Set(unique), currentCompanyIds)) return
|
|
523
|
+
|
|
449
524
|
await em.nativeDelete(CustomerDealCompanyLink, { deal })
|
|
450
|
-
if (!companyIds || !companyIds.length) return
|
|
451
|
-
const unique = Array.from(new Set(companyIds))
|
|
452
525
|
for (const companyId of unique) {
|
|
453
526
|
const company = await requireCustomerEntity(em, companyId, { tenantId: deal.tenantId, organizationId: deal.organizationId }, 'company', 'Company not found')
|
|
454
527
|
ensureSameScope(company, deal.organizationId, deal.tenantId)
|
|
@@ -458,6 +531,8 @@ async function syncDealCompanies(
|
|
|
458
531
|
})
|
|
459
532
|
em.persist(link)
|
|
460
533
|
}
|
|
534
|
+
// Last statement on purpose — see the ORDERING note on `touchDealLockToken`.
|
|
535
|
+
if (stampLockToken) touchDealLockToken(deal)
|
|
461
536
|
}
|
|
462
537
|
|
|
463
538
|
const createDealCommand: CommandHandler<DealCreateInput, { dealId: string }> = {
|
|
@@ -530,8 +605,8 @@ const createDealCommand: CommandHandler<DealCreateInput, { dealId: string }> = {
|
|
|
530
605
|
transitionedByUserId: normalizedTransitionAuthorUserId,
|
|
531
606
|
})
|
|
532
607
|
},
|
|
533
|
-
() => syncDealPeople(em, deal, parsed.personIds ?? [], parsed.primaryPersonEntityId),
|
|
534
|
-
() => syncDealCompanies(em, deal, parsed.companyIds ?? []),
|
|
608
|
+
() => syncDealPeople(em, deal, parsed.personIds ?? [], parsed.primaryPersonEntityId, { stampLockToken: false }),
|
|
609
|
+
() => syncDealCompanies(em, deal, parsed.companyIds ?? [], { stampLockToken: false }),
|
|
535
610
|
], { transaction: true })
|
|
536
611
|
|
|
537
612
|
const de = (ctx.container.resolve('dataEngine') as DataEngine)
|
|
@@ -24,7 +24,7 @@ import {
|
|
|
24
24
|
} from '@open-mercato/ui/primitives/dialog'
|
|
25
25
|
import { buildCountryOptions } from '@open-mercato/shared/lib/location/countries'
|
|
26
26
|
import { buildHrefWithReturnTo } from '@open-mercato/shared/lib/navigation/returnTo'
|
|
27
|
-
import type
|
|
27
|
+
import { resolveTaxIdLabel, type AddressFormatStrategy } from '../utils/addressFormat'
|
|
28
28
|
import { useAddressTypes } from './detail/hooks/useAddressTypes'
|
|
29
29
|
|
|
30
30
|
type Translator = (key: string, fallback?: string, params?: Record<string, string | number>) => string
|
|
@@ -41,6 +41,20 @@ export type AddressEditorDraft = {
|
|
|
41
41
|
region: string
|
|
42
42
|
postalCode: string
|
|
43
43
|
country: string
|
|
44
|
+
/**
|
|
45
|
+
* Contact details that belong to the ADDRESS rather than to the customer: the tax identifier it was
|
|
46
|
+
* invoiced under, and the phone a carrier calls about a delivery. Optional so every existing caller
|
|
47
|
+
* keeps compiling; callers must opt in with the matching `show*Field` props before they render.
|
|
48
|
+
*/
|
|
49
|
+
taxId?: string
|
|
50
|
+
/**
|
|
51
|
+
* Which scheme the identifier belongs to, in Stripe's `{country}_{kind}` vocabulary. Chosen, not
|
|
52
|
+
* inferred: `PL1234567890` and `1234567890` are the same business written two ways, and a rule
|
|
53
|
+
* that reads the form of the value is guessing — the more schemes the vocabulary carries, the more
|
|
54
|
+
* often it guesses wrong, and a wrong type is worse than none because it names the number.
|
|
55
|
+
*/
|
|
56
|
+
taxIdType?: string
|
|
57
|
+
phone?: string
|
|
44
58
|
latitude?: string
|
|
45
59
|
longitude?: string
|
|
46
60
|
isPrimary: boolean
|
|
@@ -58,6 +72,9 @@ export type AddressEditorField =
|
|
|
58
72
|
| 'region'
|
|
59
73
|
| 'postalCode'
|
|
60
74
|
| 'country'
|
|
75
|
+
| 'taxId'
|
|
76
|
+
| 'taxIdType'
|
|
77
|
+
| 'phone'
|
|
61
78
|
| 'latitude'
|
|
62
79
|
| 'longitude'
|
|
63
80
|
| 'isPrimary'
|
|
@@ -72,8 +89,33 @@ type AddressEditorProps = {
|
|
|
72
89
|
hidePrimaryToggle?: boolean
|
|
73
90
|
showFormatHint?: boolean
|
|
74
91
|
showCoordinateFields?: boolean
|
|
92
|
+
/**
|
|
93
|
+
* Render the phone. Off by default, opt-in for the same reason `showCoordinateFields` is: only a
|
|
94
|
+
* caller whose storage can hold a field should offer it.
|
|
95
|
+
*/
|
|
96
|
+
showPhoneField?: boolean
|
|
97
|
+
/**
|
|
98
|
+
* Render the tax identifier and its scheme. Separate from the phone, and not for symmetry — a
|
|
99
|
+
* phone is a contact detail and a tax identifier is not, and the two stop travelling together at
|
|
100
|
+
* the next phase: `CustomerAddress` gains a `phone` column and no tax id, so the address book will
|
|
101
|
+
* offer one and not the other.
|
|
102
|
+
*
|
|
103
|
+
* Both gate the CALLER, not the field. Inside a tile that opts in, the input renders whether or not
|
|
104
|
+
* it carries a value and takes the same `disabled` as every neighbour.
|
|
105
|
+
*/
|
|
106
|
+
showTaxIdField?: boolean
|
|
75
107
|
}
|
|
76
108
|
|
|
109
|
+
/**
|
|
110
|
+
* The schemes offered in the picker, in the order a Polish deployment meets them. The vocabulary is
|
|
111
|
+
* additive under the backward-compatibility contract, so a new scheme is a new entry here rather
|
|
112
|
+
* than a new branch in a rule that has to guess.
|
|
113
|
+
*
|
|
114
|
+
* The labels are the ones `resolveTaxIdLabel` resolves, so the picker and the marker beside the
|
|
115
|
+
* filled field always read the same.
|
|
116
|
+
*/
|
|
117
|
+
const TAX_ID_TYPES = ['pl_nip', 'eu_vat', 'other'] as const
|
|
118
|
+
|
|
77
119
|
export function AddressEditor({
|
|
78
120
|
value,
|
|
79
121
|
onChange,
|
|
@@ -84,6 +126,8 @@ export function AddressEditor({
|
|
|
84
126
|
hidePrimaryToggle = false,
|
|
85
127
|
showFormatHint = true,
|
|
86
128
|
showCoordinateFields = false,
|
|
129
|
+
showPhoneField = false,
|
|
130
|
+
showTaxIdField = false,
|
|
87
131
|
}: AddressEditorProps) {
|
|
88
132
|
const pathname = usePathname()
|
|
89
133
|
const searchParams = useSearchParams()
|
|
@@ -102,6 +146,11 @@ export function AddressEditor({
|
|
|
102
146
|
[t],
|
|
103
147
|
)
|
|
104
148
|
|
|
149
|
+
const taxIdLabels = {
|
|
150
|
+
plNip: t('customers.people.detail.addresses.fields.taxId.plNip', 'Tax ID'),
|
|
151
|
+
euVat: t('customers.people.detail.addresses.fields.taxId.euVat', 'EU VAT'),
|
|
152
|
+
other: t('customers.people.detail.addresses.fields.taxId.other', 'Tax number'),
|
|
153
|
+
}
|
|
105
154
|
const current: AddressEditorDraft = {
|
|
106
155
|
name: value.name ?? '',
|
|
107
156
|
purpose: value.purpose ?? '',
|
|
@@ -114,6 +163,9 @@ export function AddressEditor({
|
|
|
114
163
|
region: value.region ?? '',
|
|
115
164
|
postalCode: value.postalCode ?? '',
|
|
116
165
|
country: value.country ?? '',
|
|
166
|
+
taxId: value.taxId ?? '',
|
|
167
|
+
taxIdType: value.taxIdType ?? '',
|
|
168
|
+
phone: value.phone ?? '',
|
|
117
169
|
...(showCoordinateFields
|
|
118
170
|
? { latitude: value.latitude ?? '', longitude: value.longitude ?? '' }
|
|
119
171
|
: {}),
|
|
@@ -472,6 +524,69 @@ export function AddressEditor({
|
|
|
472
524
|
{errors.longitude ? <p className="text-xs text-destructive">{errors.longitude}</p> : null}
|
|
473
525
|
</>
|
|
474
526
|
) : null}
|
|
527
|
+
{showTaxIdField ? (
|
|
528
|
+
<>
|
|
529
|
+
<Select
|
|
530
|
+
value={current.taxIdType || undefined}
|
|
531
|
+
onValueChange={(next) => update('taxIdType', next ?? '')}
|
|
532
|
+
disabled={disabled}
|
|
533
|
+
>
|
|
534
|
+
<SelectTrigger
|
|
535
|
+
className={errors.taxIdType ? 'border-destructive' : undefined}
|
|
536
|
+
aria-label={t('customers.people.detail.addresses.fields.taxIdType', 'Tax id type')}
|
|
537
|
+
aria-invalid={errors.taxIdType ? 'true' : undefined}
|
|
538
|
+
>
|
|
539
|
+
<SelectValue
|
|
540
|
+
placeholder={t('customers.people.detail.addresses.fields.taxIdType', 'Tax id type')}
|
|
541
|
+
/>
|
|
542
|
+
</SelectTrigger>
|
|
543
|
+
<SelectContent>
|
|
544
|
+
{TAX_ID_TYPES.map((type) => (
|
|
545
|
+
<SelectItem key={type} value={type}>
|
|
546
|
+
{resolveTaxIdLabel(taxIdLabels, type)}
|
|
547
|
+
</SelectItem>
|
|
548
|
+
))}
|
|
549
|
+
</SelectContent>
|
|
550
|
+
</Select>
|
|
551
|
+
<Input
|
|
552
|
+
className={inputClass('taxId')}
|
|
553
|
+
placeholder={t('customers.people.detail.addresses.fields.taxId', 'Tax number')}
|
|
554
|
+
aria-label={t('customers.people.detail.addresses.fields.taxId', 'Tax number')}
|
|
555
|
+
rightIcon={
|
|
556
|
+
current.taxId ? (
|
|
557
|
+
<span className="text-xs">{resolveTaxIdLabel(taxIdLabels, current.taxIdType)}</span>
|
|
558
|
+
) : null
|
|
559
|
+
}
|
|
560
|
+
value={current.taxId ?? ''}
|
|
561
|
+
onChange={(evt) => update('taxId', evt.target.value)}
|
|
562
|
+
disabled={disabled}
|
|
563
|
+
aria-invalid={errors.taxId ? 'true' : undefined}
|
|
564
|
+
/>
|
|
565
|
+
{errors.taxId ? <p className="text-xs text-destructive">{errors.taxId}</p> : null}
|
|
566
|
+
</>
|
|
567
|
+
) : null}
|
|
568
|
+
{showPhoneField ? (
|
|
569
|
+
<>
|
|
570
|
+
<Input
|
|
571
|
+
className={inputClass('phone')}
|
|
572
|
+
placeholder={t('customers.people.detail.addresses.fields.phone', 'Phone')}
|
|
573
|
+
aria-label={t('customers.people.detail.addresses.fields.phone', 'Phone')}
|
|
574
|
+
rightIcon={
|
|
575
|
+
current.phone ? (
|
|
576
|
+
<span className="text-xs">
|
|
577
|
+
{t('customers.people.detail.addresses.fields.phone', 'Phone')}
|
|
578
|
+
</span>
|
|
579
|
+
) : null
|
|
580
|
+
}
|
|
581
|
+
inputMode="tel"
|
|
582
|
+
value={current.phone ?? ''}
|
|
583
|
+
onChange={(evt) => update('phone', evt.target.value)}
|
|
584
|
+
disabled={disabled}
|
|
585
|
+
aria-invalid={errors.phone ? 'true' : undefined}
|
|
586
|
+
/>
|
|
587
|
+
{errors.phone ? <p className="text-xs text-destructive">{errors.phone}</p> : null}
|
|
588
|
+
</>
|
|
589
|
+
) : null}
|
|
475
590
|
</div>
|
|
476
591
|
{!hidePrimaryToggle ? (
|
|
477
592
|
<label className="inline-flex items-center gap-2 text-sm">
|
|
@@ -1867,11 +1867,17 @@
|
|
|
1867
1867
|
"customers.people.detail.addresses.fields.line1": "Adresszeile 1",
|
|
1868
1868
|
"customers.people.detail.addresses.fields.line2": "Adresszeile 2",
|
|
1869
1869
|
"customers.people.detail.addresses.fields.longitude": "Längengrad",
|
|
1870
|
+
"customers.people.detail.addresses.fields.phone": "Telefon",
|
|
1870
1871
|
"customers.people.detail.addresses.fields.postalCode": "Postleitzahl",
|
|
1871
1872
|
"customers.people.detail.addresses.fields.primary": "Als primär markieren",
|
|
1872
1873
|
"customers.people.detail.addresses.fields.region": "Bundesland / Region",
|
|
1873
1874
|
"customers.people.detail.addresses.fields.street": "Straße",
|
|
1874
1875
|
"customers.people.detail.addresses.fields.streetExtra": "Weitere Adressangaben",
|
|
1876
|
+
"customers.people.detail.addresses.fields.taxId": "Steuernummer",
|
|
1877
|
+
"customers.people.detail.addresses.fields.taxId.euVat": "USt-IdNr.",
|
|
1878
|
+
"customers.people.detail.addresses.fields.taxId.other": "Steuerliche Nummer",
|
|
1879
|
+
"customers.people.detail.addresses.fields.taxId.plNip": "Steuernummer",
|
|
1880
|
+
"customers.people.detail.addresses.fields.taxIdType": "Art der Steuernummer",
|
|
1875
1881
|
"customers.people.detail.addresses.fields.type": "Adresstyp",
|
|
1876
1882
|
"customers.people.detail.addresses.formatLoadError": "Adresskonfiguration konnte nicht geladen werden",
|
|
1877
1883
|
"customers.people.detail.addresses.formatLoading": "Adresspräferenzen werden geladen…",
|
|
@@ -1867,11 +1867,17 @@
|
|
|
1867
1867
|
"customers.people.detail.addresses.fields.line1": "Address line 1",
|
|
1868
1868
|
"customers.people.detail.addresses.fields.line2": "Address line 2",
|
|
1869
1869
|
"customers.people.detail.addresses.fields.longitude": "Longitude",
|
|
1870
|
+
"customers.people.detail.addresses.fields.phone": "Phone",
|
|
1870
1871
|
"customers.people.detail.addresses.fields.postalCode": "Postal code",
|
|
1871
1872
|
"customers.people.detail.addresses.fields.primary": "Set as primary",
|
|
1872
1873
|
"customers.people.detail.addresses.fields.region": "Region / State",
|
|
1873
1874
|
"customers.people.detail.addresses.fields.street": "Street",
|
|
1874
1875
|
"customers.people.detail.addresses.fields.streetExtra": "Additional address details",
|
|
1876
|
+
"customers.people.detail.addresses.fields.taxId": "Tax number",
|
|
1877
|
+
"customers.people.detail.addresses.fields.taxId.euVat": "EU VAT",
|
|
1878
|
+
"customers.people.detail.addresses.fields.taxId.other": "Tax number",
|
|
1879
|
+
"customers.people.detail.addresses.fields.taxId.plNip": "Tax ID",
|
|
1880
|
+
"customers.people.detail.addresses.fields.taxIdType": "Tax id type",
|
|
1875
1881
|
"customers.people.detail.addresses.fields.type": "Address type",
|
|
1876
1882
|
"customers.people.detail.addresses.formatLoadError": "Failed to load address configuration",
|
|
1877
1883
|
"customers.people.detail.addresses.formatLoading": "Loading address preferences…",
|
|
@@ -1867,11 +1867,17 @@
|
|
|
1867
1867
|
"customers.people.detail.addresses.fields.line1": "Dirección (línea 1)",
|
|
1868
1868
|
"customers.people.detail.addresses.fields.line2": "Dirección (línea 2)",
|
|
1869
1869
|
"customers.people.detail.addresses.fields.longitude": "Longitud",
|
|
1870
|
+
"customers.people.detail.addresses.fields.phone": "Teléfono",
|
|
1870
1871
|
"customers.people.detail.addresses.fields.postalCode": "Código postal",
|
|
1871
1872
|
"customers.people.detail.addresses.fields.primary": "Marcar como principal",
|
|
1872
1873
|
"customers.people.detail.addresses.fields.region": "Provincia / región",
|
|
1873
1874
|
"customers.people.detail.addresses.fields.street": "Calle",
|
|
1874
1875
|
"customers.people.detail.addresses.fields.streetExtra": "Detalles adicionales de la dirección",
|
|
1876
|
+
"customers.people.detail.addresses.fields.taxId": "Número fiscal",
|
|
1877
|
+
"customers.people.detail.addresses.fields.taxId.euVat": "IVA UE",
|
|
1878
|
+
"customers.people.detail.addresses.fields.taxId.other": "Número fiscal",
|
|
1879
|
+
"customers.people.detail.addresses.fields.taxId.plNip": "NIF",
|
|
1880
|
+
"customers.people.detail.addresses.fields.taxIdType": "Tipo de número fiscal",
|
|
1875
1881
|
"customers.people.detail.addresses.fields.type": "Tipo de dirección",
|
|
1876
1882
|
"customers.people.detail.addresses.formatLoadError": "No se pudo cargar la configuración de la dirección",
|
|
1877
1883
|
"customers.people.detail.addresses.formatLoading": "Cargando preferencias de dirección…",
|