@open-mercato/core 0.9.1-develop.7477.1.64ba865101 → 0.9.1-develop.7497.1.543ea44c40
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/business_rules/data/validators.js +8 -3
- package/dist/modules/business_rules/data/validators.js.map +2 -2
- package/dist/modules/catalog/api/dictionaries/[key]/route.js +35 -7
- package/dist/modules/catalog/api/dictionaries/[key]/route.js.map +2 -2
- package/dist/modules/communication_channels/lib/email-mime.js +1 -2
- package/dist/modules/communication_channels/lib/email-mime.js.map +2 -2
- package/dist/modules/configs/cli.js +8 -2
- package/dist/modules/configs/cli.js.map +2 -2
- package/dist/modules/customers/api/companies/[id]/people/route.js +3 -0
- package/dist/modules/customers/api/companies/[id]/people/route.js.map +2 -2
- package/dist/modules/customers/api/companies/route.js +3 -3
- package/dist/modules/customers/api/companies/route.js.map +2 -2
- package/dist/modules/customers/api/deals/[id]/companies/route.js +3 -0
- package/dist/modules/customers/api/deals/[id]/companies/route.js.map +2 -2
- package/dist/modules/customers/api/deals/[id]/people/route.js +3 -0
- package/dist/modules/customers/api/deals/[id]/people/route.js.map +2 -2
- package/dist/modules/customers/api/dictionaries/kind-settings/route.js +6 -0
- package/dist/modules/customers/api/dictionaries/kind-settings/route.js.map +2 -2
- package/dist/modules/customers/api/labels/route.js +10 -1
- package/dist/modules/customers/api/labels/route.js.map +2 -2
- package/dist/modules/customers/api/people/[id]/companies/[linkId]/route.js +6 -0
- package/dist/modules/customers/api/people/[id]/companies/[linkId]/route.js.map +2 -2
- package/dist/modules/customers/api/people/[id]/companies/enriched/route.js +3 -0
- package/dist/modules/customers/api/people/[id]/companies/enriched/route.js.map +2 -2
- package/dist/modules/customers/api/people/[id]/companies/route.js +6 -0
- package/dist/modules/customers/api/people/[id]/companies/route.js.map +2 -2
- package/dist/modules/customers/api/people/route.js +3 -3
- package/dist/modules/customers/api/people/route.js.map +2 -2
- package/dist/modules/customers/commands/deals.js +2 -0
- package/dist/modules/customers/commands/deals.js.map +2 -2
- package/dist/modules/customers/components/ActivityTypeEditor.js +11 -5
- package/dist/modules/customers/components/ActivityTypeEditor.js.map +2 -2
- package/dist/modules/customers/components/detail/InlineEditors.js +7 -3
- package/dist/modules/customers/components/detail/InlineEditors.js.map +2 -2
- package/dist/modules/customers/components/detail/PersonHighlights.js +1 -1
- package/dist/modules/customers/components/detail/PersonHighlights.js.map +2 -2
- package/dist/modules/customers/components/detail/PlannedActivitiesSection.js +4 -5
- package/dist/modules/customers/components/detail/PlannedActivitiesSection.js.map +2 -2
- package/dist/modules/data_sync/di.js.map +2 -2
- package/dist/modules/data_sync/lib/sync-schedule-service.js +60 -27
- package/dist/modules/data_sync/lib/sync-schedule-service.js.map +2 -2
- package/dist/modules/messages/commands/messages.js +10 -7
- package/dist/modules/messages/commands/messages.js.map +2 -2
- package/dist/modules/messages/lib/constants.js +3 -1
- package/dist/modules/messages/lib/constants.js.map +2 -2
- package/dist/modules/messages/subscribers/message-notification.js +2 -1
- package/dist/modules/messages/subscribers/message-notification.js.map +2 -2
- package/dist/modules/sales/api/documents/factory.js +3 -4
- package/dist/modules/sales/api/documents/factory.js.map +2 -2
- package/dist/modules/sales/commands/payments.js +9 -1
- package/dist/modules/sales/commands/payments.js.map +2 -2
- package/package.json +7 -7
- package/src/modules/business_rules/data/validators.ts +12 -3
- package/src/modules/catalog/api/dictionaries/[key]/route.ts +49 -7
- package/src/modules/communication_channels/lib/email-mime.ts +1 -2
- package/src/modules/configs/cli.ts +10 -1
- package/src/modules/customers/api/companies/[id]/people/route.ts +3 -0
- package/src/modules/customers/api/companies/route.ts +6 -6
- package/src/modules/customers/api/deals/[id]/companies/route.ts +3 -0
- package/src/modules/customers/api/deals/[id]/people/route.ts +3 -0
- package/src/modules/customers/api/dictionaries/kind-settings/route.ts +6 -0
- package/src/modules/customers/api/labels/route.ts +10 -1
- package/src/modules/customers/api/people/[id]/companies/[linkId]/route.ts +6 -0
- package/src/modules/customers/api/people/[id]/companies/enriched/route.ts +3 -0
- package/src/modules/customers/api/people/[id]/companies/route.ts +6 -0
- package/src/modules/customers/api/people/route.ts +6 -6
- package/src/modules/customers/commands/deals.ts +2 -0
- package/src/modules/customers/components/ActivityTypeEditor.tsx +11 -5
- package/src/modules/customers/components/detail/InlineEditors.tsx +8 -4
- package/src/modules/customers/components/detail/PersonHighlights.tsx +4 -1
- package/src/modules/customers/components/detail/PlannedActivitiesSection.tsx +4 -5
- package/src/modules/customers/i18n/de.json +2 -0
- package/src/modules/customers/i18n/en.json +2 -0
- package/src/modules/customers/i18n/es.json +2 -0
- package/src/modules/customers/i18n/ko.json +2 -0
- package/src/modules/customers/i18n/pl.json +2 -0
- package/src/modules/data_sync/di.ts +1 -0
- package/src/modules/data_sync/lib/sync-schedule-service.ts +80 -28
- package/src/modules/messages/commands/messages.ts +10 -7
- package/src/modules/messages/lib/constants.ts +6 -0
- package/src/modules/messages/subscribers/message-notification.ts +2 -0
- package/src/modules/sales/api/documents/factory.ts +6 -7
- package/src/modules/sales/commands/payments.ts +10 -2
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
} from '@open-mercato/shared/lib/crud/custom-fields'
|
|
26
26
|
import { buildIlikeTerm } from '@open-mercato/shared/lib/db/buildIlikeTerm'
|
|
27
27
|
import { parseBooleanToken } from '@open-mercato/shared/lib/boolean'
|
|
28
|
+
import { isUuidListParam, splitIdListParam } from '@open-mercato/shared/lib/crud/ids'
|
|
28
29
|
import { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
|
|
29
30
|
import { consumeAdvancedFilterState, mergeAdvancedFilterTree } from '@open-mercato/shared/lib/crud/advanced-filter-integration'
|
|
30
31
|
import {
|
|
@@ -64,7 +65,10 @@ const listSchema = z
|
|
|
64
65
|
createdFrom: z.string().optional(),
|
|
65
66
|
createdTo: z.string().optional(),
|
|
66
67
|
id: z.string().uuid().optional(),
|
|
67
|
-
tagIds: z
|
|
68
|
+
tagIds: z
|
|
69
|
+
.string()
|
|
70
|
+
.optional()
|
|
71
|
+
.refine(isUuidListParam, { message: 'tagIds must be a comma-separated list of UUIDs' }),
|
|
68
72
|
tagIdsEmpty: z.string().optional(),
|
|
69
73
|
excludeIds: z.string().optional(),
|
|
70
74
|
excludeLinkedPersonId: z.string().uuid().optional(),
|
|
@@ -201,11 +205,7 @@ const crud = makeCrudRoute({
|
|
|
201
205
|
if (query.source) {
|
|
202
206
|
filters.source = { $eq: query.source }
|
|
203
207
|
}
|
|
204
|
-
const
|
|
205
|
-
const tagIds = tagIdsRaw
|
|
206
|
-
.split(',')
|
|
207
|
-
.map((value: string) => value.trim())
|
|
208
|
-
.filter((value: string) => value.length > 0)
|
|
208
|
+
const tagIds = splitIdListParam(query.tagIds)
|
|
209
209
|
const tagIdsEmpty = parseBooleanToken(query.tagIdsEmpty) === true
|
|
210
210
|
if (tagIdsEmpty) {
|
|
211
211
|
filters.id = { $eq: '00000000-0000-0000-0000-000000000000' }
|
|
@@ -171,6 +171,9 @@ export async function GET(req: Request, ctx: { params?: { id?: string } }) {
|
|
|
171
171
|
if (isCrudHttpError(error)) {
|
|
172
172
|
return NextResponse.json(error.body, { status: error.status })
|
|
173
173
|
}
|
|
174
|
+
if (error instanceof z.ZodError) {
|
|
175
|
+
return NextResponse.json({ error: translate('customers.errors.validationFailed', 'Validation failed'), details: error.issues }, { status: 400 })
|
|
176
|
+
}
|
|
174
177
|
logger.error('customers.deals.companies.GET', { err: error })
|
|
175
178
|
return NextResponse.json({ error: translate('customers.errors.deal_companies_load_failed', 'Failed to load linked companies') }, { status: 500 })
|
|
176
179
|
}
|
|
@@ -220,6 +220,9 @@ export async function GET(req: Request, ctx: { params?: { id?: string } }) {
|
|
|
220
220
|
if (isCrudHttpError(error)) {
|
|
221
221
|
return NextResponse.json(error.body, { status: error.status })
|
|
222
222
|
}
|
|
223
|
+
if (error instanceof z.ZodError) {
|
|
224
|
+
return NextResponse.json({ error: translate('customers.errors.validationFailed', 'Validation failed'), details: error.issues }, { status: 400 })
|
|
225
|
+
}
|
|
223
226
|
logger.error('customers.deals.people.GET', { err: error })
|
|
224
227
|
return NextResponse.json({ error: translate('customers.errors.deal_people_load_failed', 'Failed to load linked people') }, { status: 500 })
|
|
225
228
|
}
|
|
@@ -86,6 +86,9 @@ export async function GET(req: Request) {
|
|
|
86
86
|
if (isCrudHttpError(err)) {
|
|
87
87
|
return NextResponse.json(err.body, { status: err.status })
|
|
88
88
|
}
|
|
89
|
+
if (err instanceof z.ZodError) {
|
|
90
|
+
return NextResponse.json({ error: translate('customers.errors.validationFailed', 'Validation failed'), details: err.issues }, { status: 400 })
|
|
91
|
+
}
|
|
89
92
|
logger.error('customers/dictionaries/kind-settings.GET', { err })
|
|
90
93
|
return NextResponse.json({ error: translate('customers.errors.kind_settings_load_failed', 'Failed to load kind settings') }, { status: 500 })
|
|
91
94
|
}
|
|
@@ -187,6 +190,9 @@ export async function PATCH(req: Request) {
|
|
|
187
190
|
if (isCrudHttpError(err)) {
|
|
188
191
|
return NextResponse.json(err.body, { status: err.status })
|
|
189
192
|
}
|
|
193
|
+
if (err instanceof z.ZodError) {
|
|
194
|
+
return NextResponse.json({ error: translate('customers.errors.validationFailed', 'Validation failed'), details: err.issues }, { status: 400 })
|
|
195
|
+
}
|
|
190
196
|
const interceptorRejection = getCommandInterceptorHttpRejection(err)
|
|
191
197
|
if (interceptorRejection) {
|
|
192
198
|
return NextResponse.json(interceptorRejection.body, { status: interceptorRejection.status })
|
|
@@ -172,6 +172,9 @@ export async function GET(req: Request) {
|
|
|
172
172
|
if (isCrudHttpError(err)) {
|
|
173
173
|
return NextResponse.json(err.body, { status: err.status })
|
|
174
174
|
}
|
|
175
|
+
if (err instanceof z.ZodError) {
|
|
176
|
+
return NextResponse.json({ error: translate('customers.errors.validationFailed', 'Validation failed'), details: err.issues }, { status: 400 })
|
|
177
|
+
}
|
|
175
178
|
logger.error('customers/labels.GET', { err })
|
|
176
179
|
return NextResponse.json({ error: translate('customers.errors.labels_load_failed', 'Failed to load labels') }, { status: 500 })
|
|
177
180
|
}
|
|
@@ -186,8 +189,8 @@ function slugifyLabel(value: string): string {
|
|
|
186
189
|
}
|
|
187
190
|
|
|
188
191
|
export async function POST(req: Request) {
|
|
192
|
+
const { translate } = await resolveTranslations()
|
|
189
193
|
try {
|
|
190
|
-
const { translate } = await resolveTranslations()
|
|
191
194
|
const container = await createRequestContainer()
|
|
192
195
|
const auth = await getAuthFromRequest(req)
|
|
193
196
|
const actorUserId = resolveLabelActorUserId(auth)
|
|
@@ -207,6 +210,9 @@ export async function POST(req: Request) {
|
|
|
207
210
|
throw new CrudHttpError(400, { error: translate('customers.errors.organization_required', 'Organization context is required') })
|
|
208
211
|
}
|
|
209
212
|
const slug = body.slug || slugifyLabel(body.label)
|
|
213
|
+
if (!slug) {
|
|
214
|
+
throw new CrudHttpError(400, { error: translate('customers.errors.label_slug_invalid', 'Label must contain at least one letter or number') })
|
|
215
|
+
}
|
|
210
216
|
|
|
211
217
|
const guardResult = await validateCrudMutationGuard(container, {
|
|
212
218
|
tenantId: auth.tenantId,
|
|
@@ -289,6 +295,9 @@ export async function POST(req: Request) {
|
|
|
289
295
|
if (isCrudHttpError(err)) {
|
|
290
296
|
return NextResponse.json(err.body, { status: err.status })
|
|
291
297
|
}
|
|
298
|
+
if (err instanceof z.ZodError) {
|
|
299
|
+
return NextResponse.json({ error: translate('customers.errors.validationFailed', 'Validation failed'), details: err.issues }, { status: 400 })
|
|
300
|
+
}
|
|
292
301
|
const interceptorRejection = getCommandInterceptorHttpRejection(err)
|
|
293
302
|
if (interceptorRejection) {
|
|
294
303
|
return NextResponse.json(interceptorRejection.body, { status: interceptorRejection.status })
|
|
@@ -227,6 +227,9 @@ export async function PATCH(req: Request, ctx: { params?: { id?: string; linkId?
|
|
|
227
227
|
if (isCrudHttpError(err)) {
|
|
228
228
|
return NextResponse.json(err.body, { status: err.status })
|
|
229
229
|
}
|
|
230
|
+
if (err instanceof z.ZodError) {
|
|
231
|
+
return NextResponse.json({ error: translate('customers.errors.validationFailed', 'Validation failed'), details: err.issues }, { status: 400 })
|
|
232
|
+
}
|
|
230
233
|
const interceptorRejection = getCommandInterceptorHttpRejection(err)
|
|
231
234
|
if (interceptorRejection) {
|
|
232
235
|
return NextResponse.json(interceptorRejection.body, { status: interceptorRejection.status })
|
|
@@ -335,6 +338,9 @@ export async function DELETE(req: Request, ctx: { params?: { id?: string; linkId
|
|
|
335
338
|
if (isCrudHttpError(err)) {
|
|
336
339
|
return NextResponse.json(err.body, { status: err.status })
|
|
337
340
|
}
|
|
341
|
+
if (err instanceof z.ZodError) {
|
|
342
|
+
return NextResponse.json({ error: translate('customers.errors.validationFailed', 'Validation failed'), details: err.issues }, { status: 400 })
|
|
343
|
+
}
|
|
338
344
|
const interceptorRejection = getCommandInterceptorHttpRejection(err)
|
|
339
345
|
if (interceptorRejection) {
|
|
340
346
|
return NextResponse.json(interceptorRejection.body, { status: interceptorRejection.status })
|
|
@@ -387,6 +387,9 @@ export async function GET(req: Request, ctx: { params?: { id?: string } }) {
|
|
|
387
387
|
if (isCrudHttpError(err)) {
|
|
388
388
|
return NextResponse.json(err.body, { status: err.status })
|
|
389
389
|
}
|
|
390
|
+
if (err instanceof z.ZodError) {
|
|
391
|
+
return NextResponse.json({ error: translate('customers.errors.validationFailed', 'Validation failed'), details: err.issues }, { status: 400 })
|
|
392
|
+
}
|
|
390
393
|
logger.error('/companies/enriched] GET failed', { component: 'people/[id', err })
|
|
391
394
|
return NextResponse.json({ error: translate('customers.errors.internal', 'Internal server error') }, { status: 500 })
|
|
392
395
|
}
|
|
@@ -95,6 +95,9 @@ export async function GET(req: Request, ctx: { params?: { id?: string } }) {
|
|
|
95
95
|
if (isCrudHttpError(err)) {
|
|
96
96
|
return NextResponse.json(err.body, { status: err.status })
|
|
97
97
|
}
|
|
98
|
+
if (err instanceof z.ZodError) {
|
|
99
|
+
return NextResponse.json({ error: translate('customers.errors.validationFailed', 'Validation failed'), details: err.issues }, { status: 400 })
|
|
100
|
+
}
|
|
98
101
|
return NextResponse.json({ error: translate('customers.errors.internal', 'Internal server error') }, { status: 500 })
|
|
99
102
|
}
|
|
100
103
|
}
|
|
@@ -211,6 +214,9 @@ export async function POST(req: Request, ctx: { params?: { id?: string } }) {
|
|
|
211
214
|
if (isCrudHttpError(err)) {
|
|
212
215
|
return NextResponse.json(err.body, { status: err.status })
|
|
213
216
|
}
|
|
217
|
+
if (err instanceof z.ZodError) {
|
|
218
|
+
return NextResponse.json({ error: translate('customers.errors.validationFailed', 'Validation failed'), details: err.issues }, { status: 400 })
|
|
219
|
+
}
|
|
214
220
|
const interceptorRejection = getCommandInterceptorHttpRejection(err)
|
|
215
221
|
if (interceptorRejection) {
|
|
216
222
|
return NextResponse.json(interceptorRejection.body, { status: interceptorRejection.status })
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
import { buildCustomFieldFiltersFromQuery, extractAllCustomFieldEntries, splitCustomFieldPayload } from '@open-mercato/shared/lib/crud/custom-fields'
|
|
22
22
|
import { buildIlikeTerm } from '@open-mercato/shared/lib/db/buildIlikeTerm'
|
|
23
23
|
import { parseBooleanToken } from '@open-mercato/shared/lib/boolean'
|
|
24
|
+
import { isUuidListParam, splitIdListParam } from '@open-mercato/shared/lib/crud/ids'
|
|
24
25
|
import { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
|
|
25
26
|
import { consumeAdvancedFilterState, mergeAdvancedFilterTree } from '@open-mercato/shared/lib/crud/advanced-filter-integration'
|
|
26
27
|
import {
|
|
@@ -60,7 +61,10 @@ const listSchema = z
|
|
|
60
61
|
sortField: z.string().optional(),
|
|
61
62
|
sortDir: z.enum(['asc', 'desc']).optional(),
|
|
62
63
|
id: z.string().uuid().optional(),
|
|
63
|
-
tagIds: z
|
|
64
|
+
tagIds: z
|
|
65
|
+
.string()
|
|
66
|
+
.optional()
|
|
67
|
+
.refine(isUuidListParam, { message: 'tagIds must be a comma-separated list of UUIDs' }),
|
|
64
68
|
tagIdsEmpty: z.string().optional(),
|
|
65
69
|
excludeIds: z.string().optional(),
|
|
66
70
|
excludeLinkedCompanyId: z.string().uuid().optional(),
|
|
@@ -205,11 +209,7 @@ const crud = makeCrudRoute({
|
|
|
205
209
|
if (query.source) {
|
|
206
210
|
filters.source = { $eq: query.source }
|
|
207
211
|
}
|
|
208
|
-
const
|
|
209
|
-
const tagIds = tagIdsRaw
|
|
210
|
-
.split(',')
|
|
211
|
-
.map((value: string) => value.trim())
|
|
212
|
-
.filter((value: string) => value.length > 0)
|
|
212
|
+
const tagIds = splitIdListParam(query.tagIds)
|
|
213
213
|
const tagIdsEmpty = parseBooleanToken(query.tagIdsEmpty) === true
|
|
214
214
|
if (tagIdsEmpty) {
|
|
215
215
|
filters.id = { $eq: '00000000-0000-0000-0000-000000000000' }
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
CustomerDealStageTransition,
|
|
22
22
|
CustomerPipelineStage,
|
|
23
23
|
} from '../data/entities'
|
|
24
|
+
import { CustomFieldValue } from '@open-mercato/core/modules/entities/data/entities'
|
|
24
25
|
import {
|
|
25
26
|
dealCreateSchema,
|
|
26
27
|
dealUpdateSchema,
|
|
@@ -1119,6 +1120,7 @@ const deleteDealCommand: CommandHandler<{ body?: Record<string, unknown>; query?
|
|
|
1119
1120
|
await deleteDealStageTransitions(em, record)
|
|
1120
1121
|
await em.nativeDelete(CustomerDealPersonLink, { deal: record })
|
|
1121
1122
|
await em.nativeDelete(CustomerDealCompanyLink, { deal: record })
|
|
1123
|
+
await em.nativeDelete(CustomFieldValue, { entityId: DEAL_ENTITY_ID, recordId: record.id })
|
|
1122
1124
|
em.remove(record)
|
|
1123
1125
|
await em.flush()
|
|
1124
1126
|
|
|
@@ -35,12 +35,12 @@ type FormValues = Record<string, unknown>
|
|
|
35
35
|
|
|
36
36
|
const defaults = calendarEventTypes[0]!.behavior
|
|
37
37
|
|
|
38
|
-
function initialValues(item: ScopedCalendarEventType | null): FormValues {
|
|
38
|
+
function initialValues(item: ScopedCalendarEventType | null, version: string | null | undefined): FormValues {
|
|
39
39
|
const behavior = item?.behavior ?? defaults
|
|
40
40
|
return {
|
|
41
41
|
value: item?.key ?? '', label: item?.label ?? '', icon: item?.icon ?? '', color: item?.color ?? '',
|
|
42
42
|
order: behavior.order, baseKind: behavior.baseKind, selectable: behavior.selectable,
|
|
43
|
-
...behavior.fields, customFieldsetIds: [...behavior.customFieldsetIds], updatedAt:
|
|
43
|
+
...behavior.fields, customFieldsetIds: [...behavior.customFieldsetIds], updatedAt: version ?? undefined,
|
|
44
44
|
}
|
|
45
45
|
}
|
|
46
46
|
|
|
@@ -51,6 +51,7 @@ export function ActivityTypeEditor({ title, description }: { title: string; desc
|
|
|
51
51
|
const [items, setItems] = React.useState<ScopedCalendarEventType[]>([])
|
|
52
52
|
const [rows, setRows] = React.useState<DictionaryRow[]>([])
|
|
53
53
|
const [selected, setSelected] = React.useState<ScopedCalendarEventType | null | undefined>(undefined)
|
|
54
|
+
const [selectedVersion, setSelectedVersion] = React.useState<string | null | undefined>(undefined)
|
|
54
55
|
const [fieldsets, setFieldsets] = React.useState<Fieldset[]>([])
|
|
55
56
|
const [fieldsetsStatus, setFieldsetsStatus] = React.useState<'loading' | 'ready' | 'error'>('loading')
|
|
56
57
|
const [fieldsetAttempt, setFieldsetAttempt] = React.useState(0)
|
|
@@ -103,6 +104,10 @@ export function ActivityTypeEditor({ title, description }: { title: string; desc
|
|
|
103
104
|
}, [selected, fieldsetAttempt])
|
|
104
105
|
|
|
105
106
|
const localRow = (key: string) => rows.find((row) => row.value.toLowerCase() === key && !row.isInherited)
|
|
107
|
+
const openEditor = (item: ScopedCalendarEventType | null) => {
|
|
108
|
+
setSelectedVersion(item ? localRow(item.key)?.updatedAt ?? item.updatedAt : undefined)
|
|
109
|
+
setSelected(item)
|
|
110
|
+
}
|
|
106
111
|
const remove = async (item: ScopedCalendarEventType) => {
|
|
107
112
|
const row = localRow(item.key)
|
|
108
113
|
if (!row) return
|
|
@@ -157,6 +162,7 @@ export function ActivityTypeEditor({ title, description }: { title: string; desc
|
|
|
157
162
|
{ id: 'behavior', title: t('customers.config.activityTypes.behavior', 'Form behavior'), fields: ['baseKind', 'selectable', 'endTime', 'allDay', 'recurrence', 'location', 'people', 'priority', 'resources'] },
|
|
158
163
|
...(fieldsets.length || selected?.behavior.customFieldsetIds.length ? [{ id: 'fieldsets', fields: ['customFieldsetIds'] }] : []),
|
|
159
164
|
]
|
|
165
|
+
const formInitialValues = React.useMemo(() => initialValues(selected ?? null, selectedVersion), [selected, selectedVersion])
|
|
160
166
|
const save = async (values: FormValues) => {
|
|
161
167
|
const key = String(values.value ?? '').trim()
|
|
162
168
|
if (!calendarEventTypeKeySchema.safeParse(key).success) throw createCrudFormError(t('customers.config.activityTypes.invalidKey', 'Use a lowercase key with letters, numbers, hyphens, or underscores.'), { value: t('customers.config.activityTypes.invalidKey', 'Use a lowercase key with letters, numbers, hyphens, or underscores.') })
|
|
@@ -206,11 +212,11 @@ export function ActivityTypeEditor({ title, description }: { title: string; desc
|
|
|
206
212
|
{status === 'error' ? <div className="space-y-3"><ErrorMessage label={t('customers.config.activityTypes.loadFailed', 'Failed to load activity types.')} /><Button type="button" onClick={() => { void load() }}>{t('customers.config.activityTypes.retry', 'Retry')}</Button></div> :
|
|
207
213
|
<DataTable<ScopedCalendarEventType> embedded columns={columns} data={items} isLoading={status === 'loading'}
|
|
208
214
|
title={title} titleHeadingLevel={2} emptyState={t('customers.config.activityTypes.empty', 'No activity types available.')}
|
|
209
|
-
actions={<Button type="button" size="sm" onClick={() =>
|
|
215
|
+
actions={<Button type="button" size="sm" onClick={() => openEditor(null)}>{t('customers.config.activityTypes.new', 'New activity type')}</Button>}
|
|
210
216
|
refreshButton={{ label: t('customers.config.activityTypes.refresh', 'Refresh'), onRefresh: load, isRefreshing: status === 'loading' }}
|
|
211
217
|
rowActions={(item) => {
|
|
212
218
|
const actions: RowActionItem[] = [{ id: 'edit', label: item.adminConfigurable
|
|
213
|
-
? t('customers.config.activityTypes.configure', 'Configure') : t('customers.config.activityTypes.view', 'View'), onSelect: () =>
|
|
219
|
+
? t('customers.config.activityTypes.configure', 'Configure') : t('customers.config.activityTypes.view', 'View'), onSelect: () => openEditor(item) }]
|
|
214
220
|
if (localRow(item.key)) actions.push({ id: 'delete', label: item.source === 'dictionary'
|
|
215
221
|
? t('customers.config.activityTypes.delete', 'Delete') : t('customers.config.activityTypes.reset', 'Reset override'),
|
|
216
222
|
destructive: true, onSelect: () => { void remove(item) } })
|
|
@@ -230,7 +236,7 @@ export function ActivityTypeEditor({ title, description }: { title: string; desc
|
|
|
230
236
|
{fieldsetsStatus === 'ready' && fieldsets.length === 0 ? <p className="text-sm text-muted-foreground">{t('customers.config.activityTypes.noFieldsets', 'No custom-field fieldsets are defined yet.')}</p> : null}
|
|
231
237
|
{selected !== undefined && fieldsetsStatus === 'ready' ? <CrudForm<FormValues> key={selected?.key ?? 'new'} embedded
|
|
232
238
|
readOnly={selected?.adminConfigurable === false} fields={fields} groups={groups}
|
|
233
|
-
initialValues={
|
|
239
|
+
initialValues={formInitialValues} onSubmit={save} submitLabel={t('customers.config.activityTypes.save', 'Save')}
|
|
234
240
|
extraActions={<Button type="button" variant="outline" onClick={() => setSelected(undefined)}>{t('customers.config.dictionaries.dialog.cancel', 'Cancel')}</Button>} /> : null}
|
|
235
241
|
</DialogContent>
|
|
236
242
|
</Dialog>
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
type InlineMultilineEditorProps,
|
|
20
20
|
type InlineTextEditorProps,
|
|
21
21
|
InlineSelectEditor as UiInlineSelectEditor,
|
|
22
|
+
INLINE_TRIGGER_HIDDEN_CLASSES,
|
|
22
23
|
type InlineSelectOption,
|
|
23
24
|
} from '@open-mercato/ui/backend/detail/InlineEditors'
|
|
24
25
|
import {
|
|
@@ -372,6 +373,9 @@ export function InlineNextInteractionEditor({
|
|
|
372
373
|
const dateErrorId = React.useId()
|
|
373
374
|
const nameErrorId = React.useId()
|
|
374
375
|
const containerClasses = cn('group relative rounded-lg border p-4', activateOnClick && !editing ? 'cursor-pointer' : null)
|
|
376
|
+
const triggerLabel = editing
|
|
377
|
+
? t('ui.detail.inline.cancel', 'Cancel')
|
|
378
|
+
: t('ui.detail.inline.edit', 'Edit')
|
|
375
379
|
const requiredMessage = React.useMemo(
|
|
376
380
|
() => t('customers.people.detail.inline.required', 'This field is required'),
|
|
377
381
|
[t],
|
|
@@ -535,16 +539,16 @@ export function InlineNextInteractionEditor({
|
|
|
535
539
|
size="sm"
|
|
536
540
|
className={cn(
|
|
537
541
|
'absolute right-3 top-3 transition-opacity duration-150',
|
|
538
|
-
editing
|
|
539
|
-
? 'opacity-100'
|
|
540
|
-
: 'opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 focus-visible:opacity-100',
|
|
542
|
+
editing ? 'opacity-100' : INLINE_TRIGGER_HIDDEN_CLASSES,
|
|
541
543
|
)}
|
|
542
544
|
onClick={(event) => {
|
|
543
545
|
event.stopPropagation()
|
|
544
546
|
setEditing((state) => !state)
|
|
545
547
|
}}
|
|
548
|
+
aria-label={triggerLabel}
|
|
549
|
+
title={triggerLabel}
|
|
546
550
|
>
|
|
547
|
-
{editing ? <X className="h-4 w-4" /> : <Pencil className="h-4 w-4" />}
|
|
551
|
+
{editing ? <X className="h-4 w-4" aria-hidden /> : <Pencil className="h-4 w-4" aria-hidden />}
|
|
548
552
|
</Button>
|
|
549
553
|
<div className="flex items-start gap-2" {...interactiveProps}>
|
|
550
554
|
<div className="flex-1">
|
|
@@ -322,7 +322,10 @@ export function PersonHighlights({
|
|
|
322
322
|
className={
|
|
323
323
|
editingCompany
|
|
324
324
|
? 'opacity-100'
|
|
325
|
-
|
|
325
|
+
// The enclosing panel only activates once a company is assigned, and then it
|
|
326
|
+
// navigates to that company instead of opening this editor — so without the
|
|
327
|
+
// coarse-pointer reveal the Company field cannot be set or changed on touch.
|
|
328
|
+
: 'opacity-0 transition-opacity duration-150 group-hover:opacity-100 hover:opacity-100 focus-visible:opacity-100 [@media(hover:none)]:opacity-100'
|
|
326
329
|
}
|
|
327
330
|
>
|
|
328
331
|
{editingCompany ? <X className="h-4 w-4" /> : <Pencil className="h-4 w-4" />}
|
|
@@ -171,7 +171,7 @@ export function PlannedActivitiesSection({ activities, onComplete, onSchedule, o
|
|
|
171
171
|
{activity.title ?? activity.body ?? activity.interactionType}
|
|
172
172
|
</span>
|
|
173
173
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
|
174
|
-
<span>{activity.scheduledAt ? formatScheduledDate(activity.scheduledAt) : ''}</span>
|
|
174
|
+
<span>{activity.scheduledAt ? formatScheduledDate(activity.scheduledAt, t) : ''}</span>
|
|
175
175
|
{activity.authorName && (
|
|
176
176
|
<>
|
|
177
177
|
<span>·</span>
|
|
@@ -235,11 +235,10 @@ function formatRelativeOverdue(isoString: string, t: TranslateFn): string {
|
|
|
235
235
|
}
|
|
236
236
|
}
|
|
237
237
|
|
|
238
|
-
function formatScheduledDate(isoString: string): string {
|
|
238
|
+
function formatScheduledDate(isoString: string, t: TranslateFn): string {
|
|
239
239
|
try {
|
|
240
240
|
const date = new Date(isoString)
|
|
241
|
-
const
|
|
242
|
-
const tomorrow = new Date(now)
|
|
241
|
+
const tomorrow = new Date()
|
|
243
242
|
tomorrow.setDate(tomorrow.getDate() + 1)
|
|
244
243
|
|
|
245
244
|
const dayName = date.toLocaleDateString(undefined, { weekday: 'short' })
|
|
@@ -247,7 +246,7 @@ function formatScheduledDate(isoString: string): string {
|
|
|
247
246
|
const timeStr = date.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })
|
|
248
247
|
|
|
249
248
|
if (isSameDay(date, tomorrow)) {
|
|
250
|
-
return
|
|
249
|
+
return t('customers.timeline.planned.tomorrow', 'Tomorrow {{time}}', { time: timeStr })
|
|
251
250
|
}
|
|
252
251
|
return `${dayName}, ${dateStr} · ${timeStr}`
|
|
253
252
|
} catch {
|
|
@@ -1728,6 +1728,7 @@
|
|
|
1728
1728
|
"customers.errors.kind_settings_load_failed": "Einstellungen der Kategorie konnten nicht geladen werden",
|
|
1729
1729
|
"customers.errors.label_duplicate": "Ein Label mit diesem Slug existiert bereits",
|
|
1730
1730
|
"customers.errors.label_not_found": "Label nicht gefunden",
|
|
1731
|
+
"customers.errors.label_slug_invalid": "Das Label muss mindestens einen Buchstaben oder eine Ziffer enthalten",
|
|
1731
1732
|
"customers.errors.labels_load_failed": "Labels konnten nicht geladen werden",
|
|
1732
1733
|
"customers.errors.lookup_failed": "Lexikon konnte nicht geladen werden",
|
|
1733
1734
|
"customers.errors.organization_forbidden": "Organisation nicht verfügbar",
|
|
@@ -2935,6 +2936,7 @@
|
|
|
2935
2936
|
"customers.timeline.planned.reschedule": "Reschedule",
|
|
2936
2937
|
"customers.timeline.planned.schedule": "Schedule",
|
|
2937
2938
|
"customers.timeline.planned.title": "Planned activities",
|
|
2939
|
+
"customers.timeline.planned.tomorrow": "Morgen {{time}}",
|
|
2938
2940
|
"customers.timeline.planned.upcoming": "Upcoming",
|
|
2939
2941
|
"customers.timeline.unpin": "Unpin",
|
|
2940
2942
|
"customers.widgets.common.unknown": "Unbekannt",
|
|
@@ -1728,6 +1728,7 @@
|
|
|
1728
1728
|
"customers.errors.kind_settings_load_failed": "Failed to load kind settings",
|
|
1729
1729
|
"customers.errors.label_duplicate": "A label with this slug already exists",
|
|
1730
1730
|
"customers.errors.label_not_found": "Label not found",
|
|
1731
|
+
"customers.errors.label_slug_invalid": "Label must contain at least one letter or number",
|
|
1731
1732
|
"customers.errors.labels_load_failed": "Failed to load labels",
|
|
1732
1733
|
"customers.errors.lookup_failed": "Failed to load dictionary entries",
|
|
1733
1734
|
"customers.errors.organization_forbidden": "Organization not accessible",
|
|
@@ -2935,6 +2936,7 @@
|
|
|
2935
2936
|
"customers.timeline.planned.reschedule": "Reschedule",
|
|
2936
2937
|
"customers.timeline.planned.schedule": "Schedule",
|
|
2937
2938
|
"customers.timeline.planned.title": "Planned activities",
|
|
2939
|
+
"customers.timeline.planned.tomorrow": "Tomorrow {{time}}",
|
|
2938
2940
|
"customers.timeline.planned.upcoming": "Upcoming",
|
|
2939
2941
|
"customers.timeline.unpin": "Unpin",
|
|
2940
2942
|
"customers.widgets.common.unknown": "Unknown",
|
|
@@ -1728,6 +1728,7 @@
|
|
|
1728
1728
|
"customers.errors.kind_settings_load_failed": "No se pudo cargar la configuración de la categoría",
|
|
1729
1729
|
"customers.errors.label_duplicate": "Ya existe una etiqueta con este slug",
|
|
1730
1730
|
"customers.errors.label_not_found": "Etiqueta no encontrada",
|
|
1731
|
+
"customers.errors.label_slug_invalid": "La etiqueta debe contener al menos una letra o un número",
|
|
1731
1732
|
"customers.errors.labels_load_failed": "No se pudieron cargar las etiquetas",
|
|
1732
1733
|
"customers.errors.lookup_failed": "No se pudo cargar el diccionario",
|
|
1733
1734
|
"customers.errors.organization_forbidden": "Organización no accesible",
|
|
@@ -2935,6 +2936,7 @@
|
|
|
2935
2936
|
"customers.timeline.planned.reschedule": "Reschedule",
|
|
2936
2937
|
"customers.timeline.planned.schedule": "Schedule",
|
|
2937
2938
|
"customers.timeline.planned.title": "Planned activities",
|
|
2939
|
+
"customers.timeline.planned.tomorrow": "Mañana {{time}}",
|
|
2938
2940
|
"customers.timeline.planned.upcoming": "Upcoming",
|
|
2939
2941
|
"customers.timeline.unpin": "Unpin",
|
|
2940
2942
|
"customers.widgets.common.unknown": "Desconocido",
|
|
@@ -1728,6 +1728,7 @@
|
|
|
1728
1728
|
"customers.errors.kind_settings_load_failed": "유형 설정을 불러오지 못했습니다",
|
|
1729
1729
|
"customers.errors.label_duplicate": "이 슬러그를 가진 라벨이 이미 존재합니다",
|
|
1730
1730
|
"customers.errors.label_not_found": "라벨을 찾을 수 없습니다",
|
|
1731
|
+
"customers.errors.label_slug_invalid": "라벨에는 문자나 숫자가 하나 이상 포함되어야 합니다",
|
|
1731
1732
|
"customers.errors.labels_load_failed": "라벨을 불러오지 못했습니다",
|
|
1732
1733
|
"customers.errors.lookup_failed": "사전 항목을 불러오지 못했습니다",
|
|
1733
1734
|
"customers.errors.organization_forbidden": "조직에 접근할 수 없습니다",
|
|
@@ -2935,6 +2936,7 @@
|
|
|
2935
2936
|
"customers.timeline.planned.reschedule": "일정 변경",
|
|
2936
2937
|
"customers.timeline.planned.schedule": "예약",
|
|
2937
2938
|
"customers.timeline.planned.title": "예정된 활동",
|
|
2939
|
+
"customers.timeline.planned.tomorrow": "내일 {{time}}",
|
|
2938
2940
|
"customers.timeline.planned.upcoming": "다가오는 일정",
|
|
2939
2941
|
"customers.timeline.unpin": "고정 해제",
|
|
2940
2942
|
"customers.widgets.common.unknown": "알 수 없음",
|
|
@@ -1728,6 +1728,7 @@
|
|
|
1728
1728
|
"customers.errors.kind_settings_load_failed": "Nie udało się załadować ustawień kategorii",
|
|
1729
1729
|
"customers.errors.label_duplicate": "Etykieta o tym identyfikatorze już istnieje",
|
|
1730
1730
|
"customers.errors.label_not_found": "Nie znaleziono etykiety",
|
|
1731
|
+
"customers.errors.label_slug_invalid": "Etykieta musi zawierać co najmniej jedną literę lub cyfrę",
|
|
1731
1732
|
"customers.errors.labels_load_failed": "Nie udało się załadować etykiet",
|
|
1732
1733
|
"customers.errors.lookup_failed": "Nie udało się załadować słownika",
|
|
1733
1734
|
"customers.errors.organization_forbidden": "Organizacja jest niedostępna",
|
|
@@ -2935,6 +2936,7 @@
|
|
|
2935
2936
|
"customers.timeline.planned.reschedule": "Przełóż",
|
|
2936
2937
|
"customers.timeline.planned.schedule": "Zaplanuj",
|
|
2937
2938
|
"customers.timeline.planned.title": "Zaplanowane aktywności",
|
|
2939
|
+
"customers.timeline.planned.tomorrow": "Jutro {{time}}",
|
|
2938
2940
|
"customers.timeline.planned.upcoming": "Nadchodzące",
|
|
2939
2941
|
"customers.timeline.unpin": "Odepnij",
|
|
2940
2942
|
"customers.widgets.common.unknown": "Nieznane",
|
|
@@ -34,6 +34,15 @@ type SchedulerServiceLike = {
|
|
|
34
34
|
isEnabled?: boolean
|
|
35
35
|
}) => Promise<void>
|
|
36
36
|
unregister: (scheduleId: string) => Promise<void>
|
|
37
|
+
exists?: (scheduleId: string) => Promise<boolean>
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function scheduledJobExists(scheduler: SchedulerServiceLike, scheduledJobId: string): Promise<boolean> {
|
|
41
|
+
// A scheduler substitute that cannot answer keeps the pre-existing behaviour:
|
|
42
|
+
// treat the registration as minted, which compensates rather than stranding an
|
|
43
|
+
// orphan on the create path where this is by far the likelier outcome.
|
|
44
|
+
if (!scheduler.exists) return false
|
|
45
|
+
return scheduler.exists(scheduledJobId)
|
|
37
46
|
}
|
|
38
47
|
|
|
39
48
|
export function createSyncScheduleService(em: EntityManager, schedulerService?: SchedulerServiceLike) {
|
|
@@ -162,11 +171,21 @@ export function createSyncScheduleService(em: EntityManager, schedulerService?:
|
|
|
162
171
|
|
|
163
172
|
const id = existing?.id ?? randomUUID()
|
|
164
173
|
const scheduledJobId = existing?.scheduledJobId ?? id
|
|
174
|
+
const scheduler = requireScheduler()
|
|
175
|
+
// Only a registration this call mints may be compensated below. A create
|
|
176
|
+
// mints a fresh id, so no job can already live at it. An update of a row
|
|
177
|
+
// whose scheduledJobId is null is the ambiguous case — deleteSchedule reads
|
|
178
|
+
// that null as "the job lives at row.id", and register() upserts on id — so
|
|
179
|
+
// ask the scheduler instead of assuming, otherwise compensation would
|
|
180
|
+
// delete a job this call merely overwrote.
|
|
181
|
+
const mintsRegistration = existing
|
|
182
|
+
? !existing.scheduledJobId && !(await scheduledJobExists(scheduler, scheduledJobId))
|
|
183
|
+
: true
|
|
165
184
|
|
|
166
185
|
// Validate the schedule (and register it with the scheduler) before writing
|
|
167
186
|
// the SyncSchedule row — an unparseable scheduleValue must not leave a
|
|
168
187
|
// persisted row with no working schedule behind it.
|
|
169
|
-
await
|
|
188
|
+
await scheduler.register({
|
|
170
189
|
id: scheduledJobId,
|
|
171
190
|
name: buildScheduleName(input),
|
|
172
191
|
description: buildScheduleDescription(input),
|
|
@@ -188,37 +207,70 @@ export function createSyncScheduleService(em: EntityManager, schedulerService?:
|
|
|
188
207
|
isEnabled: input.isEnabled,
|
|
189
208
|
})
|
|
190
209
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
210
|
+
try {
|
|
211
|
+
const row = existing ?? em.create(SyncSchedule, {
|
|
212
|
+
id,
|
|
213
|
+
integrationId: input.integrationId,
|
|
214
|
+
entityType: input.entityType,
|
|
215
|
+
direction: input.direction,
|
|
216
|
+
scheduleType: input.scheduleType,
|
|
217
|
+
scheduleValue: input.scheduleValue,
|
|
218
|
+
timezone: input.timezone,
|
|
219
|
+
fullSync: input.fullSync,
|
|
220
|
+
isEnabled: input.isEnabled,
|
|
221
|
+
organizationId: scope.organizationId,
|
|
222
|
+
tenantId: scope.tenantId,
|
|
223
|
+
})
|
|
204
224
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
225
|
+
row.integrationId = input.integrationId
|
|
226
|
+
row.entityType = input.entityType
|
|
227
|
+
row.direction = input.direction
|
|
228
|
+
row.scheduleType = input.scheduleType
|
|
229
|
+
row.scheduleValue = input.scheduleValue
|
|
230
|
+
row.timezone = input.timezone
|
|
231
|
+
row.fullSync = input.fullSync
|
|
232
|
+
row.isEnabled = input.isEnabled
|
|
233
|
+
row.scheduledJobId = scheduledJobId
|
|
214
234
|
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
235
|
+
if (!existing) {
|
|
236
|
+
em.persist(row)
|
|
237
|
+
}
|
|
218
238
|
|
|
219
|
-
|
|
239
|
+
await em.flush()
|
|
220
240
|
|
|
221
|
-
|
|
241
|
+
return row
|
|
242
|
+
} catch (error: unknown) {
|
|
243
|
+
// The registration above is already durable, so a failed write would
|
|
244
|
+
// otherwise strand a live ScheduledJob the data-sync page cannot explain
|
|
245
|
+
// or delete. Only a registration this call minted is compensated: when a
|
|
246
|
+
// job was already there, register() overwrote one we inherited, and
|
|
247
|
+
// SchedulerServiceLike offers no way to restore the previous definition,
|
|
248
|
+
// so unregistering would destroy that job rather than roll anything back.
|
|
249
|
+
if (mintsRegistration) {
|
|
250
|
+
try {
|
|
251
|
+
await scheduler.unregister(scheduledJobId)
|
|
252
|
+
} catch (compensationError: unknown) {
|
|
253
|
+
logger.error('Failed to unregister the scheduled job after a schedule write failure', {
|
|
254
|
+
scheduledJobId,
|
|
255
|
+
scheduleId: id,
|
|
256
|
+
organizationId: scope.organizationId,
|
|
257
|
+
tenantId: scope.tenantId,
|
|
258
|
+
err: compensationError,
|
|
259
|
+
})
|
|
260
|
+
getTelemetryRuntime()?.reportError(compensationError, {
|
|
261
|
+
module: 'data_sync',
|
|
262
|
+
code: 'data_sync.schedule_save_compensation_failed',
|
|
263
|
+
attributes: {
|
|
264
|
+
scheduledJobId,
|
|
265
|
+
scheduleId: id,
|
|
266
|
+
organizationId: scope.organizationId,
|
|
267
|
+
tenantId: scope.tenantId,
|
|
268
|
+
},
|
|
269
|
+
})
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
throw error
|
|
273
|
+
}
|
|
222
274
|
},
|
|
223
275
|
|
|
224
276
|
async deleteSchedule(
|