@open-mercato/core 0.6.4-develop.4000.1.450e315cec → 0.6.4-develop.4015.1.efaafadf79

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 (62) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/auth/backend/users/[id]/edit/page.js +70 -57
  3. package/dist/modules/auth/backend/users/[id]/edit/page.js.map +2 -2
  4. package/dist/modules/catalog/acl.js +30 -5
  5. package/dist/modules/catalog/acl.js.map +2 -2
  6. package/dist/modules/catalog/backend/catalog/products/[id]/page.js +17 -5
  7. package/dist/modules/catalog/backend/catalog/products/[id]/page.js.map +2 -2
  8. package/dist/modules/catalog/commands/offers.js +26 -7
  9. package/dist/modules/catalog/commands/offers.js.map +2 -2
  10. package/dist/modules/catalog/commands/prices.js +41 -26
  11. package/dist/modules/catalog/commands/prices.js.map +2 -2
  12. package/dist/modules/catalog/commands/productUnitConversions.js +7 -1
  13. package/dist/modules/catalog/commands/productUnitConversions.js.map +2 -2
  14. package/dist/modules/catalog/commands/products.js +2 -0
  15. package/dist/modules/catalog/commands/products.js.map +2 -2
  16. package/dist/modules/catalog/commands/shared.js +58 -11
  17. package/dist/modules/catalog/commands/shared.js.map +2 -2
  18. package/dist/modules/catalog/commands/variants.js +18 -5
  19. package/dist/modules/catalog/commands/variants.js.map +2 -2
  20. package/dist/modules/customers/api/companies/route.js +6 -0
  21. package/dist/modules/customers/api/companies/route.js.map +2 -2
  22. package/dist/modules/customers/api/people/route.js +6 -0
  23. package/dist/modules/customers/api/people/route.js.map +2 -2
  24. package/dist/modules/customers/backend/customers/companies/page.js +10 -9
  25. package/dist/modules/customers/backend/customers/companies/page.js.map +2 -2
  26. package/dist/modules/customers/backend/customers/listSorting.js +28 -0
  27. package/dist/modules/customers/backend/customers/listSorting.js.map +7 -0
  28. package/dist/modules/customers/backend/customers/people/page.js +10 -9
  29. package/dist/modules/customers/backend/customers/people/page.js.map +2 -2
  30. package/dist/modules/resources/backend/resources/resources/[id]/page.js +17 -2
  31. package/dist/modules/resources/backend/resources/resources/[id]/page.js.map +2 -2
  32. package/dist/modules/sales/backend/sales/documents/[id]/page.js +20 -1
  33. package/dist/modules/sales/backend/sales/documents/[id]/page.js.map +2 -2
  34. package/package.json +7 -7
  35. package/src/modules/auth/backend/users/[id]/edit/page.tsx +28 -6
  36. package/src/modules/auth/i18n/de.json +1 -0
  37. package/src/modules/auth/i18n/en.json +1 -0
  38. package/src/modules/auth/i18n/es.json +1 -0
  39. package/src/modules/auth/i18n/pl.json +1 -0
  40. package/src/modules/catalog/acl.ts +30 -5
  41. package/src/modules/catalog/backend/catalog/products/[id]/page.tsx +21 -5
  42. package/src/modules/catalog/commands/offers.ts +26 -7
  43. package/src/modules/catalog/commands/prices.ts +41 -26
  44. package/src/modules/catalog/commands/productUnitConversions.ts +7 -1
  45. package/src/modules/catalog/commands/products.ts +2 -0
  46. package/src/modules/catalog/commands/shared.ts +70 -6
  47. package/src/modules/catalog/commands/variants.ts +18 -5
  48. package/src/modules/catalog/i18n/de.json +1 -0
  49. package/src/modules/catalog/i18n/en.json +1 -0
  50. package/src/modules/catalog/i18n/es.json +1 -0
  51. package/src/modules/catalog/i18n/pl.json +1 -0
  52. package/src/modules/customers/api/companies/route.ts +6 -0
  53. package/src/modules/customers/api/people/route.ts +6 -0
  54. package/src/modules/customers/backend/customers/companies/page.tsx +12 -11
  55. package/src/modules/customers/backend/customers/listSorting.ts +27 -0
  56. package/src/modules/customers/backend/customers/people/page.tsx +12 -11
  57. package/src/modules/resources/backend/resources/resources/[id]/page.tsx +21 -2
  58. package/src/modules/sales/backend/sales/documents/[id]/page.tsx +28 -1
  59. package/src/modules/sales/i18n/de.json +3 -0
  60. package/src/modules/sales/i18n/en.json +3 -0
  61. package/src/modules/sales/i18n/es.json +3 -0
  62. package/src/modules/sales/i18n/pl.json +3 -0
@@ -5,7 +5,7 @@ import {
5
5
  CatalogOptionSchemaTemplate,
6
6
  CatalogPriceKind,
7
7
  } from '../data/entities'
8
- import type { EntityManager } from '@mikro-orm/postgresql'
8
+ import type { EntityManager, FilterQuery } from '@mikro-orm/postgresql'
9
9
  import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'
10
10
  import type { CommandRuntimeContext } from '@open-mercato/shared/lib/commands'
11
11
  import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
@@ -73,12 +73,42 @@ export function toNumericString(value: number | null | undefined): string | null
73
73
  return value.toString()
74
74
  }
75
75
 
76
+ export type RequireScope = {
77
+ tenantId: string | null
78
+ organizationId: string | null
79
+ }
80
+
81
+ // Derives the actor's effective tenant/org scope for entry-point lookups, mirroring
82
+ // the bypass semantics of ensureTenantScope/ensureOrganizationScope: tenant is always
83
+ // strict, organization is left unrestricted for super-admins and global-org actors.
84
+ export function commandActorScope(ctx: CommandRuntimeContext): RequireScope {
85
+ const orgUnrestricted = ctx.auth?.isSuperAdmin === true || ctx.organizationScope?.allowedIds === null
86
+ return {
87
+ tenantId: ctx.auth?.tenantId ?? null,
88
+ organizationId: orgUnrestricted ? null : (ctx.selectedOrganizationId ?? ctx.auth?.orgId ?? null),
89
+ }
90
+ }
91
+
92
+ function applyScopeToWhere(where: Record<string, unknown>, scope: RequireScope): void {
93
+ if (scope.tenantId != null) where.tenantId = scope.tenantId
94
+ if (scope.organizationId != null) where.organizationId = scope.organizationId
95
+ }
96
+
76
97
  export async function requireProduct(
77
98
  em: EntityManager,
78
99
  id: string,
100
+ scope: RequireScope,
79
101
  message = 'Catalog product not found'
80
102
  ): Promise<CatalogProduct> {
81
- const product = await findOneWithDecryption(em, CatalogProduct, { id, deletedAt: null })
103
+ const where: Record<string, unknown> = { id, deletedAt: null }
104
+ applyScopeToWhere(where, scope)
105
+ const product = await findOneWithDecryption(
106
+ em,
107
+ CatalogProduct,
108
+ where as FilterQuery<CatalogProduct>,
109
+ undefined,
110
+ { tenantId: scope.tenantId, organizationId: scope.organizationId },
111
+ )
82
112
  if (!product) throw new CrudHttpError(404, { error: message })
83
113
  return product
84
114
  }
@@ -86,13 +116,17 @@ export async function requireProduct(
86
116
  export async function requireVariant(
87
117
  em: EntityManager,
88
118
  id: string,
119
+ scope: RequireScope,
89
120
  message = 'Catalog variant not found'
90
121
  ): Promise<CatalogProductVariant> {
122
+ const where: Record<string, unknown> = { id, deletedAt: null }
123
+ applyScopeToWhere(where, scope)
91
124
  const variant = await findOneWithDecryption(
92
125
  em,
93
126
  CatalogProductVariant,
94
- { id, deletedAt: null },
127
+ where as FilterQuery<CatalogProductVariant>,
95
128
  { populate: ['product'] },
129
+ { tenantId: scope.tenantId, organizationId: scope.organizationId },
96
130
  )
97
131
  if (!variant) throw new CrudHttpError(404, { error: message })
98
132
  return variant
@@ -101,9 +135,18 @@ export async function requireVariant(
101
135
  export async function requireOffer(
102
136
  em: EntityManager,
103
137
  id: string,
138
+ scope: RequireScope,
104
139
  message = 'Catalog offer not found'
105
140
  ): Promise<CatalogOffer> {
106
- const offer = await findOneWithDecryption(em, CatalogOffer, { id })
141
+ const where: Record<string, unknown> = { id }
142
+ applyScopeToWhere(where, scope)
143
+ const offer = await findOneWithDecryption(
144
+ em,
145
+ CatalogOffer,
146
+ where as FilterQuery<CatalogOffer>,
147
+ undefined,
148
+ { tenantId: scope.tenantId, organizationId: scope.organizationId },
149
+ )
107
150
  if (!offer) throw new CrudHttpError(404, { error: message })
108
151
  return offer
109
152
  }
@@ -111,9 +154,21 @@ export async function requireOffer(
111
154
  export async function requirePriceKind(
112
155
  em: EntityManager,
113
156
  id: string,
157
+ scope: RequireScope,
114
158
  message = 'Catalog price kind not found'
115
159
  ): Promise<CatalogPriceKind> {
116
- const priceKind = await findOneWithDecryption(em, CatalogPriceKind, { id, deletedAt: null })
160
+ // Price kinds are tenant-global: organization_id is always null and the unique key is
161
+ // (tenant_id, code). Scope by tenant only — applying a concrete org would never match the
162
+ // null row. Tenant scoping still closes the cross-tenant read hole this helper guards.
163
+ const where: Record<string, unknown> = { id, deletedAt: null }
164
+ applyScopeToWhere(where, { tenantId: scope.tenantId, organizationId: null })
165
+ const priceKind = await findOneWithDecryption(
166
+ em,
167
+ CatalogPriceKind,
168
+ where as FilterQuery<CatalogPriceKind>,
169
+ undefined,
170
+ { tenantId: scope.tenantId, organizationId: null },
171
+ )
117
172
  if (!priceKind) throw new CrudHttpError(404, { error: message })
118
173
  return priceKind
119
174
  }
@@ -121,9 +176,18 @@ export async function requirePriceKind(
121
176
  export async function requireOptionSchemaTemplate(
122
177
  em: EntityManager,
123
178
  id: string,
179
+ scope: RequireScope,
124
180
  message = 'Option schema not found'
125
181
  ): Promise<CatalogOptionSchemaTemplate> {
126
- const schema = await findOneWithDecryption(em, CatalogOptionSchemaTemplate, { id, deletedAt: null })
182
+ const where: Record<string, unknown> = { id, deletedAt: null }
183
+ applyScopeToWhere(where, scope)
184
+ const schema = await findOneWithDecryption(
185
+ em,
186
+ CatalogOptionSchemaTemplate,
187
+ where as FilterQuery<CatalogOptionSchemaTemplate>,
188
+ undefined,
189
+ { tenantId: scope.tenantId, organizationId: scope.organizationId },
190
+ )
127
191
  if (!schema) throw new CrudHttpError(404, { error: message })
128
192
  return schema
129
193
  }
@@ -25,6 +25,7 @@ import {
25
25
  } from '../data/validators'
26
26
  import {
27
27
  cloneJson,
28
+ commandActorScope,
28
29
  ensureOrganizationScope,
29
30
  ensureTenantScope,
30
31
  emitCatalogQueryIndexEvent,
@@ -318,7 +319,10 @@ async function restoreVariantPricesFromSnapshots(
318
319
  if (!snapshots.length) return
319
320
  const productRef =
320
321
  typeof variant.product === 'string'
321
- ? await requireProduct(em, variant.product)
322
+ ? await requireProduct(em, variant.product, {
323
+ tenantId: variant.tenantId,
324
+ organizationId: variant.organizationId,
325
+ })
322
326
  : variant.product
323
327
  for (const snapshot of snapshots) {
324
328
  const product =
@@ -547,7 +551,7 @@ const createVariantCommand: CommandHandler<VariantCreateInput, { variantId: stri
547
551
  async execute(rawInput, ctx) {
548
552
  const { parsed, custom } = parseWithCustomFields(variantCreateSchema, rawInput)
549
553
  const em = (ctx.container.resolve('em') as EntityManager).fork()
550
- const product = await requireProduct(em, parsed.productId)
554
+ const product = await requireProduct(em, parsed.productId, commandActorScope(ctx))
551
555
  ensureTenantScope(ctx, product.tenantId)
552
556
  ensureOrganizationScope(ctx, product.organizationId)
553
557
  const { taxRateId, taxRate } = await resolveVariantTaxRate(
@@ -705,7 +709,10 @@ const updateVariantCommand: CommandHandler<VariantUpdateInput, { variantId: stri
705
709
  if (!record) throw new CrudHttpError(404, { error: 'Catalog variant not found' })
706
710
  ensureTenantScope(ctx, record.tenantId)
707
711
  ensureOrganizationScope(ctx, record.organizationId)
708
- const product = await requireProduct(em, record.product.id)
712
+ const product = await requireProduct(em, record.product.id, {
713
+ tenantId: record.tenantId,
714
+ organizationId: record.organizationId,
715
+ })
709
716
 
710
717
  if (!product) throw new CrudHttpError(400, { error: 'Variant product missing' })
711
718
 
@@ -827,7 +834,10 @@ const updateVariantCommand: CommandHandler<VariantUpdateInput, { variantId: stri
827
834
  const em = (ctx.container.resolve('em') as EntityManager).fork()
828
835
  let record = await em.findOne(CatalogProductVariant, { id: before.id })
829
836
  if (!record) {
830
- const product = await requireProduct(em, before.productId)
837
+ const product = await requireProduct(em, before.productId, {
838
+ tenantId: before.tenantId,
839
+ organizationId: before.organizationId,
840
+ })
831
841
  record = em.create(CatalogProductVariant, {
832
842
  id: before.id,
833
843
  product,
@@ -994,7 +1004,10 @@ const deleteVariantCommand: CommandHandler<
994
1004
  const em = (ctx.container.resolve('em') as EntityManager).fork()
995
1005
  let record = await em.findOne(CatalogProductVariant, { id: before.id })
996
1006
  if (!record) {
997
- const product = await requireProduct(em, before.productId)
1007
+ const product = await requireProduct(em, before.productId, {
1008
+ tenantId: before.tenantId,
1009
+ organizationId: before.organizationId,
1010
+ })
998
1011
  record = em.create(CatalogProductVariant, {
999
1012
  id: before.id,
1000
1013
  product,
@@ -361,6 +361,7 @@
361
361
  "catalog.products.create.variantsBuilder.vatColumn": "Steuerklasse",
362
362
  "catalog.products.create.variantsBuilder.vatOptionDefault": "Produkt-Steuerklasse verwenden ({{label}})",
363
363
  "catalog.products.create.variantsBuilder.vatOptionNone": "Keine Steuerklasse",
364
+ "catalog.products.edit.actions.backToList": "Zurück zu Produkten",
364
365
  "catalog.products.edit.custom.title": "Benutzerdefinierte Attribute",
365
366
  "catalog.products.edit.dimensions": "Maße & Gewicht",
366
367
  "catalog.products.edit.dimensions.depth": "Tiefe",
@@ -361,6 +361,7 @@
361
361
  "catalog.products.create.variantsBuilder.vatColumn": "Tax class",
362
362
  "catalog.products.create.variantsBuilder.vatOptionDefault": "Use product tax class ({{label}})",
363
363
  "catalog.products.create.variantsBuilder.vatOptionNone": "No tax class",
364
+ "catalog.products.edit.actions.backToList": "Back to products",
364
365
  "catalog.products.edit.custom.title": "Custom attributes",
365
366
  "catalog.products.edit.dimensions": "Dimensions & weight",
366
367
  "catalog.products.edit.dimensions.depth": "Depth",
@@ -361,6 +361,7 @@
361
361
  "catalog.products.create.variantsBuilder.vatColumn": "Clase de impuesto",
362
362
  "catalog.products.create.variantsBuilder.vatOptionDefault": "Usar clase de impuesto del producto ({{label}})",
363
363
  "catalog.products.create.variantsBuilder.vatOptionNone": "Sin clase de impuesto",
364
+ "catalog.products.edit.actions.backToList": "Volver a productos",
364
365
  "catalog.products.edit.custom.title": "Atributos personalizados",
365
366
  "catalog.products.edit.dimensions": "Dimensiones y peso",
366
367
  "catalog.products.edit.dimensions.depth": "Profundidad",
@@ -361,6 +361,7 @@
361
361
  "catalog.products.create.variantsBuilder.vatColumn": "Klasa podatkowa",
362
362
  "catalog.products.create.variantsBuilder.vatOptionDefault": "Użyj klasy podatkowej produktu ({{label}})",
363
363
  "catalog.products.create.variantsBuilder.vatOptionNone": "Brak klasy podatkowej",
364
+ "catalog.products.edit.actions.backToList": "Powrót do listy produktów",
364
365
  "catalog.products.edit.custom.title": "Atrybuty niestandardowe",
365
366
  "catalog.products.edit.dimensions": "Wymiary i waga",
366
367
  "catalog.products.edit.dimensions.depth": "Głębokość",
@@ -114,6 +114,12 @@ const crud = makeCrudRoute({
114
114
  ],
115
115
  sortFieldMap: {
116
116
  name: 'display_name',
117
+ email: 'primary_email',
118
+ primaryEmail: 'primary_email',
119
+ status: 'status',
120
+ lifecycleStage: 'lifecycle_stage',
121
+ source: 'source',
122
+ nextInteractionAt: 'next_interaction_at',
117
123
  createdAt: 'created_at',
118
124
  updatedAt: 'updated_at',
119
125
  },
@@ -108,6 +108,12 @@ const crud = makeCrudRoute({
108
108
  ],
109
109
  sortFieldMap: {
110
110
  name: 'display_name',
111
+ email: 'primary_email',
112
+ primaryEmail: 'primary_email',
113
+ status: 'status',
114
+ lifecycleStage: 'lifecycle_stage',
115
+ source: 'source',
116
+ nextInteractionAt: 'next_interaction_at',
111
117
  createdAt: 'created_at',
112
118
  updatedAt: 'updated_at',
113
119
  },
@@ -5,7 +5,7 @@ import Link from 'next/link'
5
5
  import { usePathname, useRouter, useSearchParams } from 'next/navigation'
6
6
  import { Page, PageBody } from '@open-mercato/ui/backend/Page'
7
7
  import { DataTable, type DataTableExportFormat, withDataTableNamespaces } from '@open-mercato/ui/backend/DataTable'
8
- import type { ColumnDef } from '@tanstack/react-table'
8
+ import type { ColumnDef, SortingState } from '@tanstack/react-table'
9
9
  import { Button } from '@open-mercato/ui/primitives/button'
10
10
  import { RowActions } from '@open-mercato/ui/backend/RowActions'
11
11
  import { apiCall, apiCallOrThrow } from '@open-mercato/ui/backend/utils/apiCall'
@@ -53,6 +53,7 @@ import {
53
53
  mapAssignableStaffToFilterOptions,
54
54
  } from '../../../components/detail/assignableStaff'
55
55
  import { CollectionPreviewCell, normalizeCollectionLabels } from '../../../components/list/CollectionPreviewCell'
56
+ import { appendCustomerListSortParams } from '../listSorting'
56
57
 
57
58
  type DictionaryOptionWithTone = AdvancedFilterOption & FilterOption
58
59
 
@@ -191,7 +192,7 @@ export default function CustomersCompaniesPage() {
191
192
  const [rows, setRows] = React.useState<CompanyRow[]>([])
192
193
  const [page, setPage] = React.useState(1)
193
194
  const [pageSize, setPageSize] = React.useState(20)
194
- const [sorting, setSorting] = React.useState<import('@tanstack/react-table').SortingState>([])
195
+ const [sorting, setSorting] = React.useState<SortingState>([])
195
196
  const [total, setTotal] = React.useState(0)
196
197
  const [totalPages, setTotalPages] = React.useState(1)
197
198
  const [search, setSearch] = React.useState('')
@@ -246,6 +247,10 @@ export default function CustomersCompaniesPage() {
246
247
  setPageSize(newSize)
247
248
  setPage(1)
248
249
  }, [])
250
+ const handleSortingChange = React.useCallback((nextSorting: SortingState) => {
251
+ setSorting(nextSorting)
252
+ setPage(1)
253
+ }, [])
249
254
 
250
255
  const bulkMutationContextId = 'customers-companies-list:bulk-delete'
251
256
  const { runMutation: runBulkMutation, retryLastMutation: retryBulkMutation } = useGuardedMutation<{
@@ -350,10 +355,7 @@ export default function CustomersCompaniesPage() {
350
355
  const params = new URLSearchParams()
351
356
  params.set('page', String(page))
352
357
  params.set('pageSize', String(pageSize))
353
- if (sorting.length > 0) {
354
- params.set('sort', sorting[0].id)
355
- params.set('order', sorting[0].desc ? 'desc' : 'asc')
356
- }
358
+ appendCustomerListSortParams(params, sorting)
357
359
  if (search.trim()) params.set('search', search.trim())
358
360
  const advancedParams = serializeTree(advancedFilterState)
359
361
  for (const [key, val] of Object.entries(advancedParams)) {
@@ -374,10 +376,7 @@ export default function CustomersCompaniesPage() {
374
376
  const params = new URLSearchParams()
375
377
  if (search.trim().length) params.set('search', search.trim())
376
378
  if (page > 1) params.set('page', String(page))
377
- if (sorting.length > 0) {
378
- params.set('sort', sorting[0].id)
379
- params.set('order', sorting[0].desc ? 'desc' : 'asc')
380
- }
379
+ appendCustomerListSortParams(params, sorting)
381
380
  const advancedParams = serializeTree(advancedFilterState)
382
381
  for (const [key, val] of Object.entries(advancedParams)) {
383
382
  params.set(key, val)
@@ -820,6 +819,7 @@ export default function CustomersCompaniesPage() {
820
819
  .map<ColumnDef<CompanyRow>>((def) => ({
821
820
  accessorKey: `cf_${def.key}`,
822
821
  header: def.label || def.key,
822
+ enableSorting: true,
823
823
  meta: {
824
824
  columnChooserGroup: def.group?.title ?? 'Custom Fields',
825
825
  filterGroup: def.group?.title ?? 'Custom Fields',
@@ -887,8 +887,9 @@ export default function CustomersCompaniesPage() {
887
887
  onRowClick={(row) => router.push(`/backend/customers/companies-v2/${row.id}`)}
888
888
  perspective={{ tableId: 'customers.companies.list' }}
889
889
  sortable
890
+ manualSorting
890
891
  sorting={sorting}
891
- onSortingChange={setSorting}
892
+ onSortingChange={handleSortingChange}
892
893
  bulkActions={[
893
894
  {
894
895
  id: 'delete',
@@ -0,0 +1,27 @@
1
+ import type { SortingState } from '@tanstack/react-table'
2
+
3
+ const CUSTOMER_LIST_SORT_FIELDS: Record<string, string> = {
4
+ name: 'name',
5
+ email: 'primaryEmail',
6
+ status: 'status',
7
+ lifecycleStage: 'lifecycleStage',
8
+ source: 'source',
9
+ nextInteractionAt: 'nextInteractionAt',
10
+ }
11
+
12
+ export function resolveCustomerListSortField(columnId: string): string | null {
13
+ const normalized = columnId.trim()
14
+ if (!normalized) return null
15
+ if (normalized.startsWith('cf:')) return normalized
16
+ if (normalized.startsWith('cf_')) return `cf:${normalized.slice(3)}`
17
+ return CUSTOMER_LIST_SORT_FIELDS[normalized] ?? null
18
+ }
19
+
20
+ export function appendCustomerListSortParams(params: URLSearchParams, sorting: SortingState): void {
21
+ const activeSort = sorting[0]
22
+ if (!activeSort) return
23
+ const sortField = resolveCustomerListSortField(activeSort.id)
24
+ if (!sortField) return
25
+ params.set('sortField', sortField)
26
+ params.set('sortDir', activeSort.desc ? 'desc' : 'asc')
27
+ }
@@ -5,7 +5,7 @@ import Link from 'next/link'
5
5
  import { usePathname, useRouter, useSearchParams } from 'next/navigation'
6
6
  import { Page, PageBody } from '@open-mercato/ui/backend/Page'
7
7
  import { DataTable, type DataTableExportFormat, withDataTableNamespaces } from '@open-mercato/ui/backend/DataTable'
8
- import type { ColumnDef } from '@tanstack/react-table'
8
+ import type { ColumnDef, SortingState } from '@tanstack/react-table'
9
9
  import { Button } from '@open-mercato/ui/primitives/button'
10
10
  import { RowActions } from '@open-mercato/ui/backend/RowActions'
11
11
  import { apiCall, apiCallOrThrow } from '@open-mercato/ui/backend/utils/apiCall'
@@ -53,6 +53,7 @@ import {
53
53
  mapAssignableStaffToFilterOptions,
54
54
  } from '../../../components/detail/assignableStaff'
55
55
  import { CollectionPreviewCell, normalizeCollectionLabels } from '../../../components/list/CollectionPreviewCell'
56
+ import { appendCustomerListSortParams } from '../listSorting'
56
57
 
57
58
  type DictionaryOptionWithTone = AdvancedFilterOption & FilterOption
58
59
 
@@ -199,7 +200,7 @@ export default function CustomersPeoplePage() {
199
200
  const [rows, setRows] = React.useState<PersonRow[]>([])
200
201
  const [page, setPage] = React.useState(1)
201
202
  const [pageSize, setPageSize] = React.useState(20)
202
- const [sorting, setSorting] = React.useState<import('@tanstack/react-table').SortingState>([])
203
+ const [sorting, setSorting] = React.useState<SortingState>([])
203
204
  const [total, setTotal] = React.useState(0)
204
205
  const [totalPages, setTotalPages] = React.useState(1)
205
206
  const [search, setSearch] = React.useState('')
@@ -256,6 +257,10 @@ export default function CustomersPeoplePage() {
256
257
  setPageSize(newSize)
257
258
  setPage(1)
258
259
  }, [])
260
+ const handleSortingChange = React.useCallback((nextSorting: SortingState) => {
261
+ setSorting(nextSorting)
262
+ setPage(1)
263
+ }, [])
259
264
 
260
265
  const bulkMutationContextId = 'customers-people-list:bulk-delete'
261
266
  const { runMutation: runBulkMutation, retryLastMutation: retryBulkMutation } = useGuardedMutation<{
@@ -361,10 +366,7 @@ export default function CustomersPeoplePage() {
361
366
  const params = new URLSearchParams()
362
367
  params.set('page', String(page))
363
368
  params.set('pageSize', String(pageSize))
364
- if (sorting.length > 0) {
365
- params.set('sort', sorting[0].id)
366
- params.set('order', sorting[0].desc ? 'desc' : 'asc')
367
- }
369
+ appendCustomerListSortParams(params, sorting)
368
370
  if (search.trim()) params.set('search', search.trim())
369
371
  const advancedParams = serializeTree(advancedFilterState)
370
372
  for (const [key, val] of Object.entries(advancedParams)) {
@@ -386,10 +388,7 @@ export default function CustomersPeoplePage() {
386
388
  const params = new URLSearchParams()
387
389
  if (search.trim().length) params.set('search', search.trim())
388
390
  if (page > 1) params.set('page', String(page))
389
- if (sorting.length > 0) {
390
- params.set('sort', sorting[0].id)
391
- params.set('order', sorting[0].desc ? 'desc' : 'asc')
392
- }
391
+ appendCustomerListSortParams(params, sorting)
393
392
  const advancedParams = serializeTree(advancedFilterState)
394
393
  for (const [key, val] of Object.entries(advancedParams)) {
395
394
  params.set(key, val)
@@ -847,6 +846,7 @@ export default function CustomersPeoplePage() {
847
846
  .map<ColumnDef<PersonRow>>((def) => ({
848
847
  accessorKey: `cf_${def.key}`,
849
848
  header: def.label || def.key,
849
+ enableSorting: true,
850
850
  meta: {
851
851
  columnChooserGroup: def.group?.title ?? 'Custom Fields',
852
852
  filterGroup: def.group?.title ?? 'Custom Fields',
@@ -914,8 +914,9 @@ export default function CustomersPeoplePage() {
914
914
  perspective={{ tableId: 'customers.people.list' }}
915
915
  onRowClick={(row) => router.push(`/backend/customers/people-v2/${row.id}`)}
916
916
  sortable
917
+ manualSorting
917
918
  sorting={sorting}
918
- onSortingChange={setSorting}
919
+ onSortingChange={handleSortingChange}
919
920
  bulkActions={[
920
921
  {
921
922
  id: 'delete',
@@ -10,7 +10,7 @@ import { collectCustomFieldValues } from '@open-mercato/ui/backend/utils/customF
10
10
  import { createCrudFormError } from '@open-mercato/ui/backend/utils/serverErrors'
11
11
  import { updateCrud, deleteCrud } from '@open-mercato/ui/backend/utils/crud'
12
12
  import { flash } from '@open-mercato/ui/backend/FlashMessages'
13
- import { ActivitiesSection, NotesSection, type SectionAction, type TagOption } from '@open-mercato/ui/backend/detail'
13
+ import { ActivitiesSection, NotesSection, RecordNotFoundState, type SectionAction, type TagOption } from '@open-mercato/ui/backend/detail'
14
14
  import { VersionHistoryAction } from '@open-mercato/ui/backend/version-history'
15
15
  import { SendObjectMessageDialog } from '@open-mercato/ui/backend/messages'
16
16
  import { useT } from '@open-mercato/shared/lib/i18n/context'
@@ -83,6 +83,7 @@ export default function ResourcesResourceDetailPage({ params }: { params?: { id?
83
83
  const router = useRouter()
84
84
  const searchParams = useSearchParams()
85
85
  const [initialValues, setInitialValues] = React.useState<Record<string, unknown> | null>(null)
86
+ const [isNotFound, setIsNotFound] = React.useState(false)
86
87
  const [tags, setTags] = React.useState<TagOption[]>([])
87
88
  const [activeTab, setActiveTab] = React.useState<'details' | 'availability'>('details')
88
89
  const [activeDetailTab, setActiveDetailTab] = React.useState<'notes' | 'activities'>('notes')
@@ -411,6 +412,7 @@ export default function ResourcesResourceDetailPage({ params }: { params?: { id?
411
412
 
412
413
  React.useEffect(() => {
413
414
  if (!resourceId || !resourceTypesLoaded) return
415
+ setIsNotFound(false)
414
416
  let cancelled = false
415
417
  async function loadResource() {
416
418
  try {
@@ -421,7 +423,10 @@ export default function ResourcesResourceDetailPage({ params }: { params?: { id?
421
423
  const record = await readApiResultOrThrow<ResourceResponse>(`/api/resources/resources?${params.toString()}`)
422
424
  const resourceRaw = Array.isArray(record?.items) ? record.items[0] : null
423
425
  const resource = resourceRaw ? normalizeResourceRecord(resourceRaw) : null
424
- if (!resource) throw new Error(t('resources.resources.form.errors.notFound', 'Resource not found.'))
426
+ if (!resource) {
427
+ if (!cancelled) setIsNotFound(true)
428
+ return
429
+ }
425
430
  if (!cancelled) {
426
431
  const customValues = extractCustomFieldEntries(resource)
427
432
  setTags(Array.isArray(resource.tags) ? resource.tags : [])
@@ -479,6 +484,20 @@ export default function ResourcesResourceDetailPage({ params }: { params?: { id?
479
484
  ? initialValues.name.trim()
480
485
  : t('resources.resources.detail.untitled', 'Unnamed resource')
481
486
 
487
+ if (isNotFound) {
488
+ return (
489
+ <Page>
490
+ <PageBody>
491
+ <RecordNotFoundState
492
+ label={t('resources.resources.form.errors.notFound', 'Resource not found.')}
493
+ backHref="/backend/resources/resources"
494
+ backLabel={t('resources.resources.detail.back', 'Back to resources')}
495
+ />
496
+ </PageBody>
497
+ </Page>
498
+ )
499
+ }
500
+
482
501
  return (
483
502
  <Page>
484
503
  <PageBody>
@@ -11,6 +11,7 @@ import {
11
11
  ErrorMessage,
12
12
  InlineTextEditor,
13
13
  LoadingMessage,
14
+ RecordNotFoundState,
14
15
  TabEmptyState,
15
16
  TagsSection,
16
17
  type TagOption,
@@ -1876,6 +1877,7 @@ export default function SalesDocumentDetailPage({
1876
1877
  const searchParams = useSearchParams()
1877
1878
  const { confirm, ConfirmDialogElement } = useConfirmDialog()
1878
1879
  const [loading, setLoading] = React.useState(true)
1880
+ const [isNotFound, setIsNotFound] = React.useState(false)
1879
1881
  const [record, setRecord] = React.useState<DocumentRecord | null>(null)
1880
1882
  const [tags, setTags] = React.useState<TagOption[]>([])
1881
1883
  const [kind, setKind] = React.useState<'order' | 'quote'>('quote')
@@ -2483,6 +2485,7 @@ export default function SalesDocumentDetailPage({
2483
2485
  async function load() {
2484
2486
  setLoading(true)
2485
2487
  setError(null)
2488
+ setIsNotFound(false)
2486
2489
  const requestedKind = searchParams.get('kind')
2487
2490
  const preferredKind = requestedKind === 'order' ? 'order' : requestedKind === 'quote' ? 'quote' : initialKind ?? null
2488
2491
  const kindsToTry: Array<'order' | 'quote'> = preferredKind
@@ -2505,7 +2508,11 @@ export default function SalesDocumentDetailPage({
2505
2508
  }
2506
2509
  if (!cancelled) {
2507
2510
  setLoading(false)
2508
- setError(lastError ?? loadErrorMessage)
2511
+ if (lastError) {
2512
+ setError(lastError)
2513
+ } else {
2514
+ setIsNotFound(true)
2515
+ }
2509
2516
  }
2510
2517
  }
2511
2518
  load().catch((err) => {
@@ -4435,6 +4442,26 @@ export default function SalesDocumentDetailPage({
4435
4442
  )
4436
4443
  }
4437
4444
 
4445
+ if (isNotFound) {
4446
+ const backHref = (searchParams.get('kind') === 'order' || initialKind === 'order')
4447
+ ? '/backend/sales/orders'
4448
+ : '/backend/sales/quotes'
4449
+ const backLabel = (searchParams.get('kind') === 'order' || initialKind === 'order')
4450
+ ? t('sales.documents.detail.backToOrders', 'Back to orders')
4451
+ : t('sales.documents.detail.backToQuotes', 'Back to quotes')
4452
+ return (
4453
+ <Page>
4454
+ <PageBody>
4455
+ <RecordNotFoundState
4456
+ label={t('sales.documents.detail.notFound', 'Document not found.')}
4457
+ backHref={backHref}
4458
+ backLabel={backLabel}
4459
+ />
4460
+ </PageBody>
4461
+ </Page>
4462
+ )
4463
+ }
4464
+
4438
4465
  if (error) {
4439
4466
  return (
4440
4467
  <Page>
@@ -552,6 +552,8 @@
552
552
  "sales.documents.detail.adjustments.updated": "Anpassung aktualisiert.",
553
553
  "sales.documents.detail.back": "Zurück zu Sales",
554
554
  "sales.documents.detail.backToCreate": "Neues Dokument erstellen",
555
+ "sales.documents.detail.backToOrders": "Zurück zu Bestellungen",
556
+ "sales.documents.detail.backToQuotes": "Zurück zu Angeboten",
555
557
  "sales.documents.detail.billing": "Rechnungsadresse",
556
558
  "sales.documents.detail.channel": "Kanal",
557
559
  "sales.documents.detail.channelInvalid": "Ausgewählter Kanal wurde nicht gefunden.",
@@ -679,6 +681,7 @@
679
681
  "sales.documents.detail.items.variant": "Variante",
680
682
  "sales.documents.detail.items.variantSearch": "Variante suchen",
681
683
  "sales.documents.detail.loading": "Dokument wird geladen…",
684
+ "sales.documents.detail.notFound": "Dokument nicht gefunden.",
682
685
  "sales.documents.detail.number": "Dokumentnummer",
683
686
  "sales.documents.detail.numberEditForbidden": "Dokumentennummer kann nicht bearbeitet werden.",
684
687
  "sales.documents.detail.numberEmpty": "Noch keine Nummer",
@@ -552,6 +552,8 @@
552
552
  "sales.documents.detail.adjustments.updated": "Adjustment updated.",
553
553
  "sales.documents.detail.back": "Back to Sales",
554
554
  "sales.documents.detail.backToCreate": "Create a new document",
555
+ "sales.documents.detail.backToOrders": "Back to orders",
556
+ "sales.documents.detail.backToQuotes": "Back to quotes",
555
557
  "sales.documents.detail.billing": "Billing address",
556
558
  "sales.documents.detail.channel": "Channel",
557
559
  "sales.documents.detail.channelInvalid": "Selected channel could not be found.",
@@ -679,6 +681,7 @@
679
681
  "sales.documents.detail.items.variant": "Variant",
680
682
  "sales.documents.detail.items.variantSearch": "Search variant",
681
683
  "sales.documents.detail.loading": "Loading document…",
684
+ "sales.documents.detail.notFound": "Document not found.",
682
685
  "sales.documents.detail.number": "Document number",
683
686
  "sales.documents.detail.numberEditForbidden": "Document number cannot be edited.",
684
687
  "sales.documents.detail.numberEmpty": "No number yet",
@@ -552,6 +552,8 @@
552
552
  "sales.documents.detail.adjustments.updated": "Ajuste actualizado.",
553
553
  "sales.documents.detail.back": "Volver a Ventas",
554
554
  "sales.documents.detail.backToCreate": "Crear un nuevo documento",
555
+ "sales.documents.detail.backToOrders": "Volver a pedidos",
556
+ "sales.documents.detail.backToQuotes": "Volver a cotizaciones",
555
557
  "sales.documents.detail.billing": "Dirección de facturación",
556
558
  "sales.documents.detail.channel": "Canal",
557
559
  "sales.documents.detail.channelInvalid": "No se pudo encontrar el canal seleccionado.",
@@ -679,6 +681,7 @@
679
681
  "sales.documents.detail.items.variant": "Variante",
680
682
  "sales.documents.detail.items.variantSearch": "Buscar variante",
681
683
  "sales.documents.detail.loading": "Cargando documento…",
684
+ "sales.documents.detail.notFound": "Documento no encontrado.",
682
685
  "sales.documents.detail.number": "Número de documento",
683
686
  "sales.documents.detail.numberEditForbidden": "El número de documento no se puede editar.",
684
687
  "sales.documents.detail.numberEmpty": "Sin número aún",
@@ -552,6 +552,8 @@
552
552
  "sales.documents.detail.adjustments.updated": "Korekta zaktualizowana.",
553
553
  "sales.documents.detail.back": "Wróć do sprzedaży",
554
554
  "sales.documents.detail.backToCreate": "Utwórz nowy dokument",
555
+ "sales.documents.detail.backToOrders": "Powrót do zamówień",
556
+ "sales.documents.detail.backToQuotes": "Powrót do ofert",
555
557
  "sales.documents.detail.billing": "Adres rozliczeniowy",
556
558
  "sales.documents.detail.channel": "Kanał",
557
559
  "sales.documents.detail.channelInvalid": "Nie znaleziono wybranego kanału.",
@@ -679,6 +681,7 @@
679
681
  "sales.documents.detail.items.variant": "Wariant",
680
682
  "sales.documents.detail.items.variantSearch": "Szukaj wariantu",
681
683
  "sales.documents.detail.loading": "Ładowanie dokumentu…",
684
+ "sales.documents.detail.notFound": "Nie znaleziono dokumentu.",
682
685
  "sales.documents.detail.number": "Numer dokumentu",
683
686
  "sales.documents.detail.numberEditForbidden": "Numer dokumentu nie może być edytowany.",
684
687
  "sales.documents.detail.numberEmpty": "Brak numeru",