@open-mercato/core 0.6.5-develop.5382.1.f542de69af → 0.6.6-develop.5412.1.e2a52b14f0

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 (138) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/helpers/integration/crmFixtures.js +4 -0
  3. package/dist/helpers/integration/crmFixtures.js.map +2 -2
  4. package/dist/modules/attachments/api/route.js +2 -0
  5. package/dist/modules/attachments/api/route.js.map +2 -2
  6. package/dist/modules/attachments/lib/access.js +18 -0
  7. package/dist/modules/attachments/lib/access.js.map +2 -2
  8. package/dist/modules/auth/services/rbacService.js +3 -2
  9. package/dist/modules/auth/services/rbacService.js.map +2 -2
  10. package/dist/modules/customer_accounts/backend/customer_accounts/settings/domain/components/Diagnostics.js +0 -3
  11. package/dist/modules/customer_accounts/backend/customer_accounts/settings/domain/components/Diagnostics.js.map +2 -2
  12. package/dist/modules/customers/api/deals/route.js +43 -2
  13. package/dist/modules/customers/api/deals/route.js.map +2 -2
  14. package/dist/modules/customers/api/deals/summary/route.js +402 -0
  15. package/dist/modules/customers/api/deals/summary/route.js.map +7 -0
  16. package/dist/modules/customers/backend/customers/deals/[id]/hooks/useDealActivities.js +16 -5
  17. package/dist/modules/customers/backend/customers/deals/[id]/hooks/useDealActivities.js.map +2 -2
  18. package/dist/modules/customers/backend/customers/deals/[id]/hooks/useDealData.js +22 -5
  19. package/dist/modules/customers/backend/customers/deals/[id]/hooks/useDealData.js.map +2 -2
  20. package/dist/modules/customers/backend/customers/deals/[id]/page.js +12 -2
  21. package/dist/modules/customers/backend/customers/deals/[id]/page.js.map +2 -2
  22. package/dist/modules/customers/backend/customers/deals/page.js +221 -56
  23. package/dist/modules/customers/backend/customers/deals/page.js.map +3 -3
  24. package/dist/modules/customers/backend/customers/deals/pipeline/page.js +1 -1
  25. package/dist/modules/customers/backend/customers/deals/pipeline/page.js.map +2 -2
  26. package/dist/modules/customers/cli.js +15 -9
  27. package/dist/modules/customers/cli.js.map +2 -2
  28. package/dist/modules/customers/components/DealsKpiStrip.js +282 -0
  29. package/dist/modules/customers/components/DealsKpiStrip.js.map +7 -0
  30. package/dist/modules/customers/components/detail/ConfirmDealLostDialog.js +0 -1
  31. package/dist/modules/customers/components/detail/ConfirmDealLostDialog.js.map +2 -2
  32. package/dist/modules/customers/components/detail/DealForm.js +100 -17
  33. package/dist/modules/customers/components/detail/DealForm.js.map +2 -2
  34. package/dist/modules/customers/components/detail/ScheduleActivityDialog.js +1 -2
  35. package/dist/modules/customers/components/detail/ScheduleActivityDialog.js.map +2 -2
  36. package/dist/modules/customers/components/kpi/PipelineStageBar.js +63 -0
  37. package/dist/modules/customers/components/kpi/PipelineStageBar.js.map +7 -0
  38. package/dist/modules/customers/lib/dealsMetrics.js +82 -0
  39. package/dist/modules/customers/lib/dealsMetrics.js.map +7 -0
  40. package/dist/modules/directory/subscribers/invalidateOrgScopeCache.js +2 -1
  41. package/dist/modules/directory/subscribers/invalidateOrgScopeCache.js.map +2 -2
  42. package/dist/modules/directory/utils/organizationScope.js +59 -27
  43. package/dist/modules/directory/utils/organizationScope.js.map +2 -2
  44. package/dist/modules/entities/api/entities.js +7 -0
  45. package/dist/modules/entities/api/entities.js.map +2 -2
  46. package/dist/modules/entities/api/records.js +26 -15
  47. package/dist/modules/entities/api/records.js.map +2 -2
  48. package/dist/modules/entities/backend/entities/user/[entityId]/records/[recordId]/page.js +14 -0
  49. package/dist/modules/entities/backend/entities/user/[entityId]/records/[recordId]/page.js.map +2 -2
  50. package/dist/modules/entities/backend/entities/user/[entityId]/records/create/page.js +14 -0
  51. package/dist/modules/entities/backend/entities/user/[entityId]/records/create/page.js.map +2 -2
  52. package/dist/modules/entities/backend/entities/user/[entityId]/records/page.js +12 -0
  53. package/dist/modules/entities/backend/entities/user/[entityId]/records/page.js.map +2 -2
  54. package/dist/modules/entities/components/useRecordsEntityGuard.js +30 -0
  55. package/dist/modules/entities/components/useRecordsEntityGuard.js.map +7 -0
  56. package/dist/modules/query_index/lib/engine.js +4 -2
  57. package/dist/modules/query_index/lib/engine.js.map +2 -2
  58. package/dist/modules/staff/api/team-members.js +9 -2
  59. package/dist/modules/staff/api/team-members.js.map +2 -2
  60. package/dist/modules/staff/api/timesheets/time-entries/[id]/timer-start/route.js +24 -1
  61. package/dist/modules/staff/api/timesheets/time-entries/[id]/timer-start/route.js.map +2 -2
  62. package/dist/modules/staff/backend/staff/team-members/[id]/page.js +11 -6
  63. package/dist/modules/staff/backend/staff/team-members/[id]/page.js.map +2 -2
  64. package/dist/modules/staff/commands/team-members.js +1 -1
  65. package/dist/modules/staff/commands/team-members.js.map +2 -2
  66. package/dist/modules/staff/components/TeamMemberForm.js +1 -1
  67. package/dist/modules/staff/components/TeamMemberForm.js.map +2 -2
  68. package/dist/modules/staff/lib/scheduleSwitch.js +23 -0
  69. package/dist/modules/staff/lib/scheduleSwitch.js.map +7 -0
  70. package/dist/modules/workflows/backend/definitions/create/page.js +1 -2
  71. package/dist/modules/workflows/backend/definitions/create/page.js.map +2 -2
  72. package/dist/modules/workflows/backend/definitions/visual-editor/page.js +1 -2
  73. package/dist/modules/workflows/backend/definitions/visual-editor/page.js.map +2 -2
  74. package/dist/modules/workflows/components/DefinitionTriggersEditor.js +1 -2
  75. package/dist/modules/workflows/components/DefinitionTriggersEditor.js.map +2 -2
  76. package/dist/modules/workflows/components/NodeEditDialog.js +4 -13
  77. package/dist/modules/workflows/components/NodeEditDialog.js.map +2 -2
  78. package/dist/modules/workflows/components/NodeEditDialogCrudForm.js +4 -13
  79. package/dist/modules/workflows/components/NodeEditDialogCrudForm.js.map +2 -2
  80. package/dist/modules/workflows/components/WorkflowGraphImpl.js +1 -4
  81. package/dist/modules/workflows/components/WorkflowGraphImpl.js.map +2 -2
  82. package/dist/modules/workflows/components/fields/FormFieldArrayEditor.js +2 -5
  83. package/dist/modules/workflows/components/fields/FormFieldArrayEditor.js.map +2 -2
  84. package/package.json +8 -8
  85. package/src/helpers/integration/crmFixtures.ts +21 -1
  86. package/src/modules/attachments/AGENTS.md +79 -0
  87. package/src/modules/attachments/api/route.ts +2 -0
  88. package/src/modules/attachments/lib/access.ts +36 -0
  89. package/src/modules/auth/services/rbacService.ts +11 -2
  90. package/src/modules/customer_accounts/backend/customer_accounts/settings/domain/components/Diagnostics.tsx +0 -3
  91. package/src/modules/customers/api/deals/route.ts +51 -2
  92. package/src/modules/customers/api/deals/summary/route.ts +496 -0
  93. package/src/modules/customers/backend/customers/deals/[id]/hooks/useDealActivities.ts +28 -6
  94. package/src/modules/customers/backend/customers/deals/[id]/hooks/useDealData.ts +33 -6
  95. package/src/modules/customers/backend/customers/deals/[id]/page.tsx +17 -2
  96. package/src/modules/customers/backend/customers/deals/page.tsx +254 -66
  97. package/src/modules/customers/backend/customers/deals/pipeline/page.tsx +1 -2
  98. package/src/modules/customers/cli.ts +15 -15
  99. package/src/modules/customers/components/DealsKpiStrip.tsx +389 -0
  100. package/src/modules/customers/components/detail/ConfirmDealLostDialog.tsx +0 -1
  101. package/src/modules/customers/components/detail/DealForm.tsx +121 -19
  102. package/src/modules/customers/components/detail/ScheduleActivityDialog.tsx +1 -2
  103. package/src/modules/customers/components/kpi/PipelineStageBar.tsx +77 -0
  104. package/src/modules/customers/i18n/de.json +43 -0
  105. package/src/modules/customers/i18n/en.json +43 -0
  106. package/src/modules/customers/i18n/es.json +43 -0
  107. package/src/modules/customers/i18n/pl.json +43 -0
  108. package/src/modules/customers/lib/dealsMetrics.ts +159 -0
  109. package/src/modules/directory/subscribers/invalidateOrgScopeCache.ts +3 -1
  110. package/src/modules/directory/utils/organizationScope.ts +85 -30
  111. package/src/modules/entities/api/entities.ts +11 -0
  112. package/src/modules/entities/api/records.ts +46 -25
  113. package/src/modules/entities/backend/entities/user/[entityId]/records/[recordId]/page.tsx +15 -0
  114. package/src/modules/entities/backend/entities/user/[entityId]/records/create/page.tsx +15 -0
  115. package/src/modules/entities/backend/entities/user/[entityId]/records/page.tsx +23 -0
  116. package/src/modules/entities/components/useRecordsEntityGuard.ts +41 -0
  117. package/src/modules/entities/i18n/de.json +1 -0
  118. package/src/modules/entities/i18n/en.json +1 -0
  119. package/src/modules/entities/i18n/es.json +1 -0
  120. package/src/modules/entities/i18n/pl.json +1 -0
  121. package/src/modules/query_index/lib/engine.ts +11 -5
  122. package/src/modules/staff/api/team-members.ts +9 -2
  123. package/src/modules/staff/api/timesheets/time-entries/[id]/timer-start/route.ts +31 -1
  124. package/src/modules/staff/backend/staff/team-members/[id]/page.tsx +18 -8
  125. package/src/modules/staff/commands/team-members.ts +5 -2
  126. package/src/modules/staff/components/TeamMemberForm.tsx +4 -1
  127. package/src/modules/staff/i18n/de.json +1 -0
  128. package/src/modules/staff/i18n/en.json +1 -0
  129. package/src/modules/staff/i18n/es.json +1 -0
  130. package/src/modules/staff/i18n/pl.json +1 -0
  131. package/src/modules/staff/lib/scheduleSwitch.ts +46 -0
  132. package/src/modules/workflows/backend/definitions/create/page.tsx +1 -2
  133. package/src/modules/workflows/backend/definitions/visual-editor/page.tsx +1 -2
  134. package/src/modules/workflows/components/DefinitionTriggersEditor.tsx +1 -2
  135. package/src/modules/workflows/components/NodeEditDialog.tsx +1 -4
  136. package/src/modules/workflows/components/NodeEditDialogCrudForm.tsx +4 -7
  137. package/src/modules/workflows/components/WorkflowGraphImpl.tsx +1 -2
  138. package/src/modules/workflows/components/fields/FormFieldArrayEditor.tsx +2 -3
@@ -82,6 +82,7 @@
82
82
  "entities.userEntities.records.errors.deleteFailed": "Nie udało się usunąć rekordu",
83
83
  "entities.userEntities.records.errors.entityIdRequired": "Wymagany jest identyfikator encji",
84
84
  "entities.userEntities.records.errors.recordIdRequired": "Wymagany jest identyfikator rekordu",
85
+ "entities.userEntities.records.errors.systemEntity": "Ta encja jest zarządzana systemowo. Rekordy są dostępne wyłącznie dla encji niestandardowych.",
85
86
  "entities.userEntities.records.form.createTitle": "Utwórz rekord",
86
87
  "entities.userEntities.records.form.editTitle": "Edytuj rekord",
87
88
  "entities.userEntities.records.form.submitCreate": "Utwórz",
@@ -2,7 +2,7 @@ import type { QueryEngine, QueryOptions, QueryResult, FilterOp, Filter, QueryCus
2
2
  import { SortDir } from '@open-mercato/shared/lib/query/types'
3
3
  import type { EntityId } from '@open-mercato/shared/modules/entities'
4
4
  import type { EntityManager } from '@mikro-orm/postgresql'
5
- import { BasicQueryEngine, resolveEntityTableName } from '@open-mercato/shared/lib/query/engine'
5
+ import { BasicQueryEngine, resolveEntityTableName, resolveRegisteredEntityTableName } from '@open-mercato/shared/lib/query/engine'
6
6
  import { type Kysely, sql, type RawBuilder } from 'kysely'
7
7
  import type { EventBus } from '@open-mercato/events'
8
8
  import { readCoverageSnapshot, refreshCoverageSnapshot } from './coverage'
@@ -219,7 +219,7 @@ export class HybridQueryEngine implements QueryEngine {
219
219
  const debugEnabled = this.isDebugVerbosity()
220
220
  if (debugEnabled) this.debug('query:start', { entity })
221
221
 
222
- const isCustom = await this.isCustomEntity(entity)
222
+ const isCustom = opts.forceCustomEntityStorage === true || await this.isCustomEntity(entity)
223
223
  if (isCustom) {
224
224
  if (debugEnabled) this.debug('query:custom-entity', { entity })
225
225
  const section = profiler.section('custom_entity')
@@ -1005,6 +1005,14 @@ export class HybridQueryEngine implements QueryEngine {
1005
1005
  .executeTakeFirst()
1006
1006
  if (row) {
1007
1007
  result = true
1008
+ } else if (resolveRegisteredEntityTableName(this.em, entity) !== null) {
1009
+ // An id backed by a registered ORM table is never doc-storage-backed by
1010
+ // inference: stray `custom_entities_storage` rows for such an id (e.g. written
1011
+ // through the generic entities data engine) must not hijack every list/detail
1012
+ // read for the whole entity type away from its base table (#2939). Surfaces
1013
+ // that intentionally read doc records for a dual-declared id pass
1014
+ // `forceCustomEntityStorage` in QueryOptions instead.
1015
+ result = false
1008
1016
  } else {
1009
1017
  // Read/write symmetry. Records written through the entities data engine
1010
1018
  // (`de.createCustomEntityRecord`) always land in `custom_entities_storage`,
@@ -1012,9 +1020,7 @@ export class HybridQueryEngine implements QueryEngine {
1012
1020
  // id — those are NEVER registered in `custom_entities` (install treats a
1013
1021
  // system id as non-registrable). Without this fallback the query routes to
1014
1022
  // the empty ORM/index path and those records are write-only (created with
1015
- // 200 but unreadable on the edit form). A real ORM entity never writes rows
1016
- // to `custom_entities_storage`, so this can only ever re-classify genuine
1017
- // doc-storage entities — it cannot misroute table-backed entities.
1023
+ // 200 but unreadable on the edit form).
1018
1024
  result = await this.hasCustomEntityStorageRows(entity)
1019
1025
  }
1020
1026
  } catch {
@@ -225,7 +225,12 @@ const crud = makeCrudRoute({
225
225
  const { translate } = await resolveTranslations()
226
226
  return parseScopedCommandInput(staffTeamMemberUpdateSchema, raw ?? {}, ctx, translate)
227
227
  },
228
- response: () => ({ ok: true }),
228
+ // Surface the freshly-bumped updatedAt so inline (non-CrudForm) callers can
229
+ // refresh their optimistic-lock token between sequential edits (#2848).
230
+ response: (arg: { result?: { updatedAt?: string | null } | null }) => ({
231
+ ok: true,
232
+ updatedAt: arg?.result?.updatedAt ?? null,
233
+ }),
229
234
  },
230
235
  delete: {
231
236
  commandId: 'staff.team-members.delete',
@@ -287,7 +292,9 @@ export const openApi = createStaffCrudOpenApi({
287
292
  },
288
293
  update: {
289
294
  schema: staffTeamMemberUpdateSchema,
290
- responseSchema: defaultOkResponseSchema,
295
+ responseSchema: defaultOkResponseSchema.extend({
296
+ updatedAt: z.string().nullable().optional(),
297
+ }),
291
298
  description: 'Updates a team member by id.',
292
299
  },
293
300
  del: {
@@ -120,6 +120,36 @@ export async function POST(req: Request) {
120
120
  })
121
121
  }
122
122
 
123
+ // Single-active-timer invariant (issue #2855): reject the start when the
124
+ // staff member already has another running entry (started_at set,
125
+ // ended_at null). Without this guard a second surface (dashboard widget,
126
+ // another tab) could create and start a parallel timer, leaving two
127
+ // concurrent running entries and the "stopped timer comes back running"
128
+ // symptom reported in #2456.
129
+ const otherRunningEntry = await findOneWithDecryption(
130
+ trx,
131
+ StaffTimeEntry,
132
+ {
133
+ tenantId,
134
+ organizationId,
135
+ staffMemberId: lockedEntry.staffMemberId,
136
+ id: { $ne: lockedEntry.id },
137
+ startedAt: { $ne: null },
138
+ endedAt: null,
139
+ deletedAt: null,
140
+ },
141
+ {},
142
+ scopeCtx,
143
+ )
144
+ if (otherRunningEntry) {
145
+ throw new CrudHttpError(409, {
146
+ error: translate(
147
+ 'staff.timesheets.errors.timerAlreadyRunning',
148
+ 'Another timer is already running. Stop it before starting a new one.',
149
+ ),
150
+ })
151
+ }
152
+
123
153
  const startedAt = new Date()
124
154
  lockedEntry.startedAt = startedAt
125
155
  lockedEntry.source = 'timer'
@@ -184,7 +214,7 @@ export const openApi: OpenApiRouteDoc = {
184
214
  responses: [
185
215
  { status: 200, description: 'Timer started', schema: z.object({ ok: z.literal(true) }) },
186
216
  { status: 404, description: 'Time entry not found', schema: z.object({ error: z.string() }) },
187
- { status: 409, description: 'Timer already started', schema: z.object({ error: z.string() }) },
217
+ { status: 409, description: 'Timer already started, or another timer is already running for this staff member', schema: z.object({ error: z.string() }) },
188
218
  { status: 401, description: 'Unauthorized', schema: z.object({ error: z.string() }) },
189
219
  ],
190
220
  },
@@ -5,10 +5,10 @@ import Link from 'next/link'
5
5
  import { useRouter, useSearchParams } from 'next/navigation'
6
6
  import { Page, PageBody } from '@open-mercato/ui/backend/Page'
7
7
  import { Button } from '@open-mercato/ui/primitives/button'
8
- import { readApiResultOrThrow, withScopedApiRequestHeaders } from '@open-mercato/ui/backend/utils/apiCall'
8
+ import { readApiResultOrThrow } from '@open-mercato/ui/backend/utils/apiCall'
9
9
  import { extractCustomFieldEntries } from '@open-mercato/shared/lib/crud/custom-fields-client'
10
10
  import { updateCrud, deleteCrud } from '@open-mercato/ui/backend/utils/crud'
11
- import { buildOptimisticLockHeader } from '@open-mercato/ui/backend/utils/optimisticLock'
11
+ import { switchTeamMemberSchedule } from '@open-mercato/core/modules/staff/lib/scheduleSwitch'
12
12
  import { flash } from '@open-mercato/ui/backend/FlashMessages'
13
13
  import { useT } from '@open-mercato/shared/lib/i18n/context'
14
14
  import { createTranslatorWithFallback } from '@open-mercato/shared/lib/i18n/translate'
@@ -276,6 +276,11 @@ export default function StaffTeamMemberDetailPage({ params }: { params?: { id?:
276
276
  }
277
277
  }, [memberId, router, searchParams, t])
278
278
 
279
+ // optimistic-lock-exempt: the inline updateCrud/deleteCrud below run inside the
280
+ // TeamMemberForm <CrudForm> host, which auto-derives the version header from
281
+ // initialValues.updatedAt and surfaces 409 conflicts for both submit and delete.
282
+ // The availability-schedule switch is version-locked separately via
283
+ // switchTeamMemberSchedule (see ../../../../lib/scheduleSwitch).
279
284
  const handleSubmit = React.useCallback(async (values: TeamMemberFormValues) => {
280
285
  if (!memberId) return
281
286
  const payload = buildTeamMemberPayload(values, { id: memberId })
@@ -297,12 +302,17 @@ export default function StaffTeamMemberDetailPage({ params }: { params?: { id?:
297
302
 
298
303
  const handleRulesetChange = React.useCallback(async (nextId: string | null) => {
299
304
  if (!memberId) return
300
- const headers = buildOptimisticLockHeader(initialValues?.updatedAt)
301
- await withScopedApiRequestHeaders(headers, () => (
302
- updateCrud('staff/team-members', { id: memberId, availabilityRuleSetId: nextId }, {
303
- errorMessage: t('staff.teamMembers.availability.ruleset.updateError', 'Failed to update schedule.'),
304
- })
305
- ))
305
+ const { updatedAt: nextUpdatedAt } = await switchTeamMemberSchedule({
306
+ memberId,
307
+ nextRuleSetId: nextId,
308
+ expectedUpdatedAt: initialValues?.updatedAt,
309
+ t,
310
+ })
311
+ // Advance the optimistic-lock token so the next sequential switch sends the
312
+ // fresh updatedAt instead of the stale one and does not falsely 409 (#2848).
313
+ if (nextUpdatedAt) {
314
+ setInitialValues((prev) => (prev ? { ...prev, updatedAt: nextUpdatedAt } : prev))
315
+ }
306
316
  setAvailabilityRuleSetId(nextId)
307
317
  flash(t('staff.teamMembers.availability.ruleset.updateSuccess', 'Schedule updated.'), 'success')
308
318
  }, [initialValues?.updatedAt, memberId, t])
@@ -292,7 +292,7 @@ const createTeamMemberCommand: CommandHandler<StaffTeamMemberCreateInput, { memb
292
292
  },
293
293
  }
294
294
 
295
- const updateTeamMemberCommand: CommandHandler<StaffTeamMemberUpdateInput, { memberId: string }> = {
295
+ const updateTeamMemberCommand: CommandHandler<StaffTeamMemberUpdateInput, { memberId: string; updatedAt: string | null }> = {
296
296
  id: 'staff.team-members.update',
297
297
  async prepare(rawInput, ctx) {
298
298
  const { parsed } = parseWithCustomFields(staffTeamMemberUpdateSchema, rawInput)
@@ -368,7 +368,10 @@ const updateTeamMemberCommand: CommandHandler<StaffTeamMemberUpdateInput, { memb
368
368
  indexer: teamMemberCrudIndexer,
369
369
  })
370
370
 
371
- return { memberId: member.id }
371
+ // Return the freshly-bumped updatedAt so non-CrudForm callers (e.g. the
372
+ // availability schedule switcher) can refresh their optimistic-lock token
373
+ // and not falsely 409 on the next sequential edit (#2848).
374
+ return { memberId: member.id, updatedAt: member.updatedAt ? member.updatedAt.toISOString() : null }
372
375
  },
373
376
  buildLog: async ({ snapshots, ctx }) => {
374
377
  const before = snapshots.before as TeamMemberSnapshot | undefined
@@ -454,9 +454,12 @@ export function TeamMemberForm(props: TeamMemberFormProps) {
454
454
  ]
455
455
 
456
456
  if (!tagsSection) {
457
+ // The tags field lives in its own card whose group title already reads
458
+ // "Tags" (see groups below). Leave the field label empty so the heading
459
+ // is not rendered twice in the team member edit view.
457
460
  baseFields.splice(5, 0, {
458
461
  id: 'tags',
459
- label: translate('staff.teamMembers.form.fields.tags', 'Tags'),
462
+ label: '',
460
463
  type: 'tags',
461
464
  placeholder: translate('staff.teamMembers.form.fields.tags.placeholder', 'Add tags'),
462
465
  })
@@ -950,6 +950,7 @@
950
950
  "staff.timesheets.errors.projectsKpis": "Failed to load project KPIs.",
951
951
  "staff.timesheets.errors.segmentCreate": "Zeiteintragssegment konnte nicht erstellt werden.",
952
952
  "staff.timesheets.errors.staffMemberNotFound": "Mitarbeiter nicht gefunden oder nicht zugänglich.",
953
+ "staff.timesheets.errors.timerAlreadyRunning": "Es läuft bereits ein anderer Timer. Stoppen Sie ihn, bevor Sie einen neuen starten.",
953
954
  "staff.timesheets.errors.timerAlreadyStarted": "Der Timer ist für diesen Eintrag bereits gestartet.",
954
955
  "staff.timesheets.errors.timerStart": "Timer konnte nicht gestartet werden.",
955
956
  "staff.timesheets.errors.timerStop": "Timer konnte nicht gestoppt werden.",
@@ -950,6 +950,7 @@
950
950
  "staff.timesheets.errors.projectsKpis": "Failed to load project KPIs.",
951
951
  "staff.timesheets.errors.segmentCreate": "Failed to create time entry segment.",
952
952
  "staff.timesheets.errors.staffMemberNotFound": "Staff member not found or not accessible.",
953
+ "staff.timesheets.errors.timerAlreadyRunning": "Another timer is already running. Stop it before starting a new one.",
953
954
  "staff.timesheets.errors.timerAlreadyStarted": "Timer is already started for this entry.",
954
955
  "staff.timesheets.errors.timerStart": "Failed to start timer.",
955
956
  "staff.timesheets.errors.timerStop": "Failed to stop timer.",
@@ -950,6 +950,7 @@
950
950
  "staff.timesheets.errors.projectsKpis": "Failed to load project KPIs.",
951
951
  "staff.timesheets.errors.segmentCreate": "No se pudo crear el segmento del registro de tiempo.",
952
952
  "staff.timesheets.errors.staffMemberNotFound": "Miembro del personal no encontrado o no accesible.",
953
+ "staff.timesheets.errors.timerAlreadyRunning": "Ya hay otro temporizador en marcha. Detenlo antes de iniciar uno nuevo.",
953
954
  "staff.timesheets.errors.timerAlreadyStarted": "El temporizador ya está iniciado para este registro.",
954
955
  "staff.timesheets.errors.timerStart": "No se pudo iniciar el temporizador.",
955
956
  "staff.timesheets.errors.timerStop": "No se pudo detener el temporizador.",
@@ -950,6 +950,7 @@
950
950
  "staff.timesheets.errors.projectsKpis": "Failed to load project KPIs.",
951
951
  "staff.timesheets.errors.segmentCreate": "Nie udało się utworzyć segmentu wpisu czasu.",
952
952
  "staff.timesheets.errors.staffMemberNotFound": "Pracownik nie został znaleziony lub jest niedostępny.",
953
+ "staff.timesheets.errors.timerAlreadyRunning": "Inny licznik czasu jest już uruchomiony. Zatrzymaj go przed rozpoczęciem nowego.",
953
954
  "staff.timesheets.errors.timerAlreadyStarted": "Czasomierz jest już uruchomiony dla tego wpisu.",
954
955
  "staff.timesheets.errors.timerStart": "Nie udało się uruchomić czasomierza.",
955
956
  "staff.timesheets.errors.timerStop": "Nie udało się zatrzymać czasomierza.",
@@ -0,0 +1,46 @@
1
+ import { withScopedApiRequestHeaders } from '@open-mercato/ui/backend/utils/apiCall'
2
+ import { buildOptimisticLockHeader } from '@open-mercato/ui/backend/utils/optimisticLock'
3
+ import { surfaceRecordConflict } from '@open-mercato/ui/backend/conflicts'
4
+ import { updateCrud } from '@open-mercato/ui/backend/utils/crud'
5
+
6
+ type Translate = (key: string, fallback?: string) => string
7
+
8
+ export type TeamMemberScheduleSwitchResult = {
9
+ /** The team member's freshly-bumped updatedAt, used to refresh the optimistic-lock token. */
10
+ updatedAt: string | null
11
+ }
12
+
13
+ /**
14
+ * Persist a team member's selected availability schedule.
15
+ *
16
+ * Sends the optimistic-lock header derived from the caller's current
17
+ * `expectedUpdatedAt` and returns the server's freshly-bumped `updatedAt` so the
18
+ * caller can advance its token before the next sequential switch — otherwise the
19
+ * second switch reuses a stale version and falsely 409s (#2848).
20
+ *
21
+ * On an optimistic-lock conflict the shared conflict bar is surfaced before the
22
+ * error is re-thrown, so the selection reverts AND the user sees visible feedback
23
+ * instead of a silent revert.
24
+ */
25
+ export async function switchTeamMemberSchedule(args: {
26
+ memberId: string
27
+ nextRuleSetId: string | null
28
+ expectedUpdatedAt: string | null | undefined
29
+ t: Translate
30
+ }): Promise<TeamMemberScheduleSwitchResult> {
31
+ const { memberId, nextRuleSetId, expectedUpdatedAt, t } = args
32
+ const headers = buildOptimisticLockHeader(expectedUpdatedAt)
33
+ try {
34
+ const call = await withScopedApiRequestHeaders(headers, () => (
35
+ updateCrud<{ ok?: boolean; updatedAt?: string | null }>(
36
+ 'staff/team-members',
37
+ { id: memberId, availabilityRuleSetId: nextRuleSetId },
38
+ { errorMessage: t('staff.teamMembers.availability.ruleset.updateError', 'Failed to update schedule.') },
39
+ )
40
+ ))
41
+ return { updatedAt: call?.result?.updatedAt ?? null }
42
+ } catch (error) {
43
+ surfaceRecordConflict(error, t)
44
+ throw error
45
+ }
46
+ }
@@ -53,8 +53,7 @@ export default function CreateWorkflowDefinitionPage() {
53
53
  return (
54
54
  <Page>
55
55
  <PageBody>
56
- <Alert variant="info" className="mb-6">
57
- <Zap className="w-4 h-4" />
56
+ <Alert variant="info" icon={<Zap aria-hidden="true" />} className="mb-6">
58
57
  <AlertTitle>{t('workflows.create.eventTriggersTitle')}</AlertTitle>
59
58
  <AlertDescription>
60
59
  {t('workflows.create.eventTriggersDescription')}
@@ -36,7 +36,7 @@ import { apiCall, withScopedApiRequestHeaders } from '@open-mercato/ui/backend/u
36
36
  import { buildOptimisticLockHeader } from '@open-mercato/ui/backend/utils/optimisticLock'
37
37
  import { surfaceRecordConflict } from '@open-mercato/ui/backend/conflicts'
38
38
  import { flash } from '@open-mercato/ui/backend/FlashMessages'
39
- import { CircleQuestionMark, Info, PanelTopClose, PanelTopOpen, Play, Save, Trash2 } from 'lucide-react'
39
+ import { CircleQuestionMark, PanelTopClose, PanelTopOpen, Play, Save, Trash2 } from 'lucide-react'
40
40
  import { NODE_TYPE_ICONS, NODE_TYPE_COLORS, NODE_TYPE_LABELS } from '../../../lib/node-type-icons'
41
41
  import { DefinitionTriggersEditor } from '../../../components/DefinitionTriggersEditor'
42
42
  import { MobileVisualEditor } from '../../../components/mobile/MobileVisualEditor'
@@ -1139,7 +1139,6 @@ export default function VisualEditorPage() {
1139
1139
 
1140
1140
  {/* Instructions */}
1141
1141
  <Alert variant="info" className="mt-6">
1142
- <Info className="size-4" />
1143
1142
  <AlertTitle className="text-xs">{t('workflows.visualEditor.howToUse', 'How to use:')}</AlertTitle>
1144
1143
  <div className="mt-2">
1145
1144
  <ul className="list-inside list-disc space-y-1 text-xs">
@@ -26,7 +26,7 @@ import {
26
26
  import { Alert, AlertDescription, AlertTitle } from '@open-mercato/ui/primitives/alert'
27
27
  import { EventPatternInput } from '@open-mercato/ui/backend/inputs/EventPatternInput'
28
28
  import { useT } from '@open-mercato/shared/lib/i18n/context'
29
- import { Plus, Trash2, Edit2, Zap, Info, X } from 'lucide-react'
29
+ import { Plus, Trash2, Edit2, Zap, X } from 'lucide-react'
30
30
  import type { WorkflowDefinitionTrigger } from '../data/entities'
31
31
 
32
32
  interface DefinitionTriggersEditorProps {
@@ -302,7 +302,6 @@ export function DefinitionTriggersEditor({
302
302
 
303
303
  {value.length === 0 ? (
304
304
  <Alert variant="info">
305
- <Info className="w-4 h-4" />
306
305
  <AlertTitle>{t('workflows.triggers.empty.title', 'No triggers configured')}</AlertTitle>
307
306
  <AlertDescription>
308
307
  {t('workflows.triggers.empty.description', 'Click "Add Trigger" to create an event trigger that automatically starts this workflow.')}
@@ -14,7 +14,7 @@ import {
14
14
  SelectValue,
15
15
  } from '@open-mercato/ui/primitives/select'
16
16
  import {Alert, AlertDescription} from '@open-mercato/ui/primitives/alert'
17
- import {ChevronDown, Info, Plus, Trash2} from 'lucide-react'
17
+ import {ChevronDown, Plus, Trash2} from 'lucide-react'
18
18
  import {sanitizeId} from '../lib/graph-utils'
19
19
  import {WorkflowDefinition, WorkflowSelector} from './WorkflowSelector'
20
20
  import {JsonBuilder} from '@open-mercato/ui/backend/JsonBuilder'
@@ -539,7 +539,6 @@ export function NodeEditDialog({ node, isOpen, onClose, onSave, onDelete }: Node
539
539
  <div className="space-y-4">
540
540
  {!isEditable ? (
541
541
  <Alert variant="info">
542
- <Info className="size-4" />
543
542
  <AlertDescription>
544
543
  {t('workflows.nodeEditor.endStepsNotEditable')}
545
544
  </AlertDescription>
@@ -548,7 +547,6 @@ export function NodeEditDialog({ node, isOpen, onClose, onSave, onDelete }: Node
548
547
  <div className="space-y-4">
549
548
  {/* Info Alert for START nodes */}
550
549
  <Alert variant="info">
551
- <Info className="size-4" />
552
550
  <AlertDescription>
553
551
  {t('workflows.nodeEditor.startStepsInfo')}
554
552
  </AlertDescription>
@@ -693,7 +691,6 @@ export function NodeEditDialog({ node, isOpen, onClose, onSave, onDelete }: Node
693
691
  {/* JSON Schema Format Notice */}
694
692
  {isJsonSchemaFormat && (
695
693
  <Alert variant="info" className="mb-3">
696
- <Info className="size-4" />
697
694
  <AlertDescription>
698
695
  {t('workflows.nodeEditor.jsonSchemaFormat')}
699
696
  </AlertDescription>
@@ -6,7 +6,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } f
6
6
  import { Badge } from '@open-mercato/ui/primitives/badge'
7
7
  import { Alert, AlertDescription } from '@open-mercato/ui/primitives/alert'
8
8
  import { Button } from '@open-mercato/ui/primitives/button'
9
- import { Info, Trash2 } from 'lucide-react'
9
+ import { Trash2 } from 'lucide-react'
10
10
  import { CrudForm, type CrudFormGroup, type CrudField, type CrudCustomFieldRenderProps } from '@open-mercato/ui/backend/CrudForm'
11
11
  import { JsonBuilder } from '@open-mercato/ui/backend/JsonBuilder'
12
12
  import { FormFieldArrayEditor } from './fields/FormFieldArrayEditor'
@@ -115,8 +115,7 @@ export function NodeEditDialogCrudForm({ node, isOpen, onClose, onSave, onDelete
115
115
  column: 1,
116
116
  bare: true,
117
117
  component: () => (
118
- <Alert variant="default" className="border-blue-200 bg-blue-50">
119
- <Info className="size-4" />
118
+ <Alert variant="info">
120
119
  <AlertDescription>
121
120
  End nodes cannot be edited. They mark the completion of the workflow.
122
121
  </AlertDescription>
@@ -134,8 +133,7 @@ export function NodeEditDialogCrudForm({ node, isOpen, onClose, onSave, onDelete
134
133
  column: 1,
135
134
  bare: true,
136
135
  component: () => (
137
- <Alert variant="default" className="border-blue-200 bg-blue-50 mb-4">
138
- <Info className="size-4" />
136
+ <Alert variant="info" className="mb-4">
139
137
  <AlertDescription>
140
138
  Start nodes mark the beginning of the workflow. You can define pre-conditions that must pass before the workflow can be started.
141
139
  </AlertDescription>
@@ -476,8 +474,7 @@ export function NodeEditDialogCrudForm({ node, isOpen, onClose, onSave, onDelete
476
474
  <div className="flex-1 overflow-y-auto min-h-0 px-6">
477
475
  {/* JSON Schema Conversion Warning */}
478
476
  {showJsonSchemaWarning && (
479
- <Alert variant="default" className="border-blue-200 bg-blue-50 mb-4">
480
- <Info className="size-4" />
477
+ <Alert variant="info" className="mb-4">
481
478
  <AlertDescription className="text-xs">
482
479
  This form uses JSON Schema format. Fields have been converted for visual editing.
483
480
  When you save, it will be converted to the simplified format. To preserve the original JSON Schema,
@@ -221,8 +221,7 @@ export default function WorkflowGraphImpl({
221
221
 
222
222
  {editable && !isCompactViewport && (
223
223
  <Panel position="top-left" style={{ margin: 10 }}>
224
- <Alert variant="info" className="max-w-sm">
225
- <Edit3 className="size-4" />
224
+ <Alert variant="info" icon={<Edit3 aria-hidden="true" />} className="max-w-sm">
226
225
  <AlertDescription className="font-medium">
227
226
  {t('workflows.graph.editModeInfo')}
228
227
  </AlertDescription>
@@ -15,7 +15,7 @@ import {
15
15
  SelectValue,
16
16
  } from '@open-mercato/ui/primitives/select'
17
17
  import { Alert, AlertDescription } from '@open-mercato/ui/primitives/alert'
18
- import { ChevronDown, Plus, Trash2, Info } from 'lucide-react'
18
+ import { ChevronDown, Plus, Trash2 } from 'lucide-react'
19
19
  import type { CrudCustomFieldRenderProps } from '@open-mercato/ui/backend/CrudForm'
20
20
 
21
21
  /**
@@ -134,8 +134,7 @@ export function FormFieldArrayEditor({
134
134
 
135
135
  {/* JSON Schema Format Notice */}
136
136
  {isJsonSchemaFormat && (
137
- <Alert variant="default" className="border-blue-200 bg-blue-50">
138
- <Info className="size-4" />
137
+ <Alert variant="info">
139
138
  <AlertDescription className="text-xs">
140
139
  {t('workflows.fieldEditors.formFields.jsonSchemaNotice')}
141
140
  </AlertDescription>