@open-mercato/core 0.6.8-develop.7082.1.f2a845b020 → 0.6.8-develop.7090.1.4dbca7b350

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/customers/components/DealsKpiStrip.js +7 -6
  3. package/dist/modules/customers/components/DealsKpiStrip.js.map +2 -2
  4. package/dist/modules/customers/components/detail/CompanyKpiBar.js +5 -4
  5. package/dist/modules/customers/components/detail/CompanyKpiBar.js.map +2 -2
  6. package/dist/modules/customers/components/detail/DealsSection.js +6 -5
  7. package/dist/modules/customers/components/detail/DealsSection.js.map +2 -2
  8. package/dist/modules/customers/components/detail/utils.js +5 -5
  9. package/dist/modules/customers/components/detail/utils.js.map +2 -2
  10. package/dist/modules/sales/backend/sales/documents/[id]/page.js +10 -8
  11. package/dist/modules/sales/backend/sales/documents/[id]/page.js.map +2 -2
  12. package/dist/modules/sales/components/PriceWithCurrency.js +7 -5
  13. package/dist/modules/sales/components/PriceWithCurrency.js.map +2 -2
  14. package/dist/modules/sales/components/documents/AdjustmentsSection.js +8 -6
  15. package/dist/modules/sales/components/documents/AdjustmentsSection.js.map +2 -2
  16. package/dist/modules/sales/components/documents/ItemsSection.js +14 -7
  17. package/dist/modules/sales/components/documents/ItemsSection.js.map +2 -2
  18. package/dist/modules/sales/components/documents/LineItemDialog.js +8 -7
  19. package/dist/modules/sales/components/documents/LineItemDialog.js.map +2 -2
  20. package/dist/modules/sales/components/documents/PaymentsSection.js +9 -8
  21. package/dist/modules/sales/components/documents/PaymentsSection.js.map +2 -2
  22. package/dist/modules/sales/components/documents/ReturnsSection.js +8 -6
  23. package/dist/modules/sales/components/documents/ReturnsSection.js.map +2 -2
  24. package/dist/modules/sales/components/documents/SalesOrderDraftLines.js +5 -4
  25. package/dist/modules/sales/components/documents/SalesOrderDraftLines.js.map +2 -2
  26. package/dist/modules/sales/components/documents/ShipmentDialog.js +5 -4
  27. package/dist/modules/sales/components/documents/ShipmentDialog.js.map +2 -2
  28. package/dist/modules/sales/components/documents/ShipmentsSection.js +8 -6
  29. package/dist/modules/sales/components/documents/ShipmentsSection.js.map +2 -2
  30. package/dist/modules/sales/components/documents/lineItemUtils.js +2 -2
  31. package/dist/modules/sales/components/documents/lineItemUtils.js.map +2 -2
  32. package/dist/modules/sales/data/entities.js +8 -2
  33. package/dist/modules/sales/data/entities.js.map +2 -2
  34. package/dist/modules/sales/migrations/Migration20260821120000_sales_notes_addresses_context_idx.js +32 -0
  35. package/dist/modules/sales/migrations/Migration20260821120000_sales_notes_addresses_context_idx.js.map +7 -0
  36. package/dist/modules/staff/backend/staff/timesheets/page.js +29 -18
  37. package/dist/modules/staff/backend/staff/timesheets/page.js.map +2 -2
  38. package/package.json +7 -7
  39. package/src/modules/auth/i18n/de.json +3 -0
  40. package/src/modules/auth/i18n/en.json +3 -0
  41. package/src/modules/auth/i18n/es.json +3 -0
  42. package/src/modules/auth/i18n/ko.json +3 -0
  43. package/src/modules/auth/i18n/pl.json +3 -0
  44. package/src/modules/customers/components/DealsKpiStrip.tsx +7 -7
  45. package/src/modules/customers/components/detail/CompanyKpiBar.tsx +5 -4
  46. package/src/modules/customers/components/detail/DealsSection.tsx +11 -5
  47. package/src/modules/customers/components/detail/utils.ts +15 -5
  48. package/src/modules/sales/backend/sales/documents/[id]/page.tsx +14 -8
  49. package/src/modules/sales/components/PriceWithCurrency.tsx +17 -5
  50. package/src/modules/sales/components/documents/AdjustmentsSection.tsx +6 -5
  51. package/src/modules/sales/components/documents/ItemsSection.tsx +8 -1
  52. package/src/modules/sales/components/documents/LineItemDialog.tsx +9 -8
  53. package/src/modules/sales/components/documents/PaymentsSection.tsx +9 -8
  54. package/src/modules/sales/components/documents/ReturnsSection.tsx +6 -5
  55. package/src/modules/sales/components/documents/SalesOrderDraftLines.tsx +5 -4
  56. package/src/modules/sales/components/documents/ShipmentDialog.tsx +5 -4
  57. package/src/modules/sales/components/documents/ShipmentsSection.tsx +6 -5
  58. package/src/modules/sales/components/documents/lineItemUtils.ts +7 -2
  59. package/src/modules/sales/data/entities.ts +6 -0
  60. package/src/modules/sales/migrations/.snapshot-open-mercato.json +60 -0
  61. package/src/modules/sales/migrations/Migration20260821120000_sales_notes_addresses_context_idx.ts +87 -0
  62. package/src/modules/staff/backend/staff/timesheets/page.tsx +12 -1
@@ -10,7 +10,7 @@ import { apiCall, withScopedApiRequestHeaders } from '@open-mercato/ui/backend/u
10
10
  import { buildOptimisticLockHeader } from '@open-mercato/ui/backend/utils/optimisticLock'
11
11
  import { deleteCrud } from '@open-mercato/ui/backend/utils/crud'
12
12
  import { useOrganizationScopeDetail } from '@open-mercato/shared/lib/frontend/useOrganizationScope'
13
- import { useT } from '@open-mercato/shared/lib/i18n/context'
13
+ import { useT, useLocale } from '@open-mercato/shared/lib/i18n/context'
14
14
  import { useConfirmDialog } from '@open-mercato/ui/backend/confirm-dialog'
15
15
  import {
16
16
  emitSalesDocumentTotalsRefresh,
@@ -41,11 +41,11 @@ type SalesShipmentsSectionProps = {
41
41
  onAddComment?: (body: string) => Promise<void>
42
42
  }
43
43
 
44
- function formatDisplayDate(value: string | null | undefined): string | null {
44
+ export function formatDisplayDate(value: string | null | undefined, locale?: string): string | null {
45
45
  if (!value) return null
46
46
  const date = new Date(value)
47
47
  if (Number.isNaN(date.getTime())) return null
48
- return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium' }).format(date)
48
+ return new Intl.DateTimeFormat(locale, { dateStyle: 'medium' }).format(date)
49
49
  }
50
50
 
51
51
  const formatShipmentAddress = (metadata?: Record<string, unknown> | null): string | null => {
@@ -123,6 +123,7 @@ export function SalesShipmentsSection({
123
123
  onAddComment,
124
124
  }: SalesShipmentsSectionProps) {
125
125
  const t = useT()
126
+ const locale = useLocale()
126
127
  const { organizationId, tenantId } = useOrganizationScopeDetail()
127
128
  const { confirm, ConfirmDialogElement } = useConfirmDialog()
128
129
  const resolvedOrganizationId = organizationIdProp ?? organizationId ?? null
@@ -486,8 +487,8 @@ export function SalesShipmentsSection({
486
487
  ) : (
487
488
  <div className="grid gap-3 md:grid-cols-2">
488
489
  {shipments.map((shipment) => {
489
- const shippedAt = formatDisplayDate(shipment.shippedAt)
490
- const deliveredAt = formatDisplayDate(shipment.deliveredAt)
490
+ const shippedAt = formatDisplayDate(shipment.shippedAt, locale)
491
+ const deliveredAt = formatDisplayDate(shipment.deliveredAt, locale)
491
492
  const addressSummary = formatShipmentAddress(shipment.metadata)
492
493
  const statusLabel =
493
494
  shipment.statusLabel ??
@@ -9,9 +9,14 @@ export function normalizeNumber(value: unknown, fallback = 0): number {
9
9
  return fallback
10
10
  }
11
11
 
12
- export function formatMoney(value: number, currency: string | null | undefined): string {
12
+ /**
13
+ * Pass `locale` to format in the application locale (`useLocale()` in client components).
14
+ * Omitting it keeps the runtime default, which varies per machine and is therefore not
15
+ * assertable in tests.
16
+ */
17
+ export function formatMoney(value: number, currency: string | null | undefined, locale?: string): string {
13
18
  if (!currency) return value.toFixed(2)
14
- return new Intl.NumberFormat(undefined, { style: 'currency', currency }).format(value)
19
+ return new Intl.NumberFormat(locale, { style: 'currency', currency }).format(value)
15
20
  }
16
21
 
17
22
  export type LineDiscountDisplay = {
@@ -1790,6 +1790,9 @@ export class SalesPaymentAllocation {
1790
1790
 
1791
1791
  @Entity({ tableName: 'sales_notes' })
1792
1792
  @Index({ name: 'sales_notes_scope_idx', properties: ['organizationId', 'tenantId'] })
1793
+ @Index({ name: 'sales_notes_context_idx', properties: ['contextId'] })
1794
+ @Index({ name: 'sales_notes_order_idx', properties: ['order'] })
1795
+ @Index({ name: 'sales_notes_quote_idx', properties: ['quote'] })
1793
1796
  export class SalesNote {
1794
1797
  @PrimaryKey({ type: 'uuid', defaultRaw: 'gen_random_uuid()' })
1795
1798
  id!: string
@@ -1836,6 +1839,9 @@ export class SalesNote {
1836
1839
 
1837
1840
  @Entity({ tableName: 'sales_document_addresses' })
1838
1841
  @Index({ name: 'sales_document_addresses_scope_idx', properties: ['organizationId', 'tenantId'] })
1842
+ @Index({ name: 'sales_document_addresses_document_idx', properties: ['documentId'] })
1843
+ @Index({ name: 'sales_document_addresses_order_idx', properties: ['order'] })
1844
+ @Index({ name: 'sales_document_addresses_quote_idx', properties: ['quote'] })
1839
1845
  export class SalesDocumentAddress {
1840
1846
  @PrimaryKey({ type: 'uuid', defaultRaw: 'gen_random_uuid()' })
1841
1847
  id!: string
@@ -1962,6 +1962,36 @@
1962
1962
  "keyName": "sales_document_addresses_scope_idx",
1963
1963
  "primary": false,
1964
1964
  "unique": false
1965
+ },
1966
+ {
1967
+ "columnNames": [
1968
+ "document_id"
1969
+ ],
1970
+ "composite": false,
1971
+ "constraint": false,
1972
+ "keyName": "sales_document_addresses_document_idx",
1973
+ "primary": false,
1974
+ "unique": false
1975
+ },
1976
+ {
1977
+ "columnNames": [
1978
+ "order_id"
1979
+ ],
1980
+ "composite": false,
1981
+ "constraint": false,
1982
+ "keyName": "sales_document_addresses_order_idx",
1983
+ "primary": false,
1984
+ "unique": false
1985
+ },
1986
+ {
1987
+ "columnNames": [
1988
+ "quote_id"
1989
+ ],
1990
+ "composite": false,
1991
+ "constraint": false,
1992
+ "keyName": "sales_document_addresses_quote_idx",
1993
+ "primary": false,
1994
+ "unique": false
1965
1995
  }
1966
1996
  ],
1967
1997
  "checks": [],
@@ -3750,6 +3780,36 @@
3750
3780
  "keyName": "sales_notes_scope_idx",
3751
3781
  "primary": false,
3752
3782
  "unique": false
3783
+ },
3784
+ {
3785
+ "columnNames": [
3786
+ "context_id"
3787
+ ],
3788
+ "composite": false,
3789
+ "constraint": false,
3790
+ "keyName": "sales_notes_context_idx",
3791
+ "primary": false,
3792
+ "unique": false
3793
+ },
3794
+ {
3795
+ "columnNames": [
3796
+ "order_id"
3797
+ ],
3798
+ "composite": false,
3799
+ "constraint": false,
3800
+ "keyName": "sales_notes_order_idx",
3801
+ "primary": false,
3802
+ "unique": false
3803
+ },
3804
+ {
3805
+ "columnNames": [
3806
+ "quote_id"
3807
+ ],
3808
+ "composite": false,
3809
+ "constraint": false,
3810
+ "keyName": "sales_notes_quote_idx",
3811
+ "primary": false,
3812
+ "unique": false
3753
3813
  }
3754
3814
  ],
3755
3815
  "checks": [],
@@ -0,0 +1,87 @@
1
+ import { Migration } from '@mikro-orm/migrations';
2
+
3
+ // sales_notes and sales_document_addresses each carried a single index —
4
+ // (organization_id, tenant_id) — and are never read by that predicate alone.
5
+ // loadOrderSnapshot / loadQuoteSnapshot (commands/documents.ts) read them by
6
+ // context in the same Promise.all:
7
+ //
8
+ // findWithDecryption(em, SalesNote, { contextType, contextId, ...scope })
9
+ // findWithDecryption(em, SalesDocumentAddress, { documentId, documentKind, ...scope })
10
+ //
11
+ // On a single-organization deployment the scope index matches every row, so the
12
+ // planner discards it and scans. The cost is CPU filtering, so a warm cache does
13
+ // not reduce it. Measured on a table of ~331k rows / 110 MB: a parallel
14
+ // sequential scan removing 110,457 rows per worker, 82 ms per read to return 0
15
+ // rows. The command bus runs prepare() and captureAfter() around every command
16
+ // and both load a document snapshot, so one written document — itself several
17
+ // commands (the document, one per changed line, the payment reconcile) —
18
+ // performs roughly 20 of these reads. Counters over one bulk-import batch of 100
19
+ // records showed 1,971 sequential scans reading 217,670,528 rows, 2.18M rows per
20
+ // imported record; repeatable across consecutive batches (1,971 / 1,974 / 1,911).
21
+ //
22
+ // context_type and document_kind are deliberately omitted, as they were for
23
+ // sales_document_tag_assignments_document_idx (Migration20260806120000), and the
24
+ // scope columns with them. sales_orders.id and sales_quotes.id are both
25
+ // gen_random_uuid() from their own tables, so a context_id already determines its
26
+ // context_type and its tenant; the seek returns the handful of rows belonging to
27
+ // one document and the remaining terms are rechecked over those. Folding three
28
+ // more columns in — one text and two uuids — roughly triples the index entry for
29
+ // no selectivity, on tables that take a write per document save and a note per
30
+ // order status change. The sibling scope indexes that do fold scope in
31
+ // (sales_order_lines_scope_idx, sales_payments_scope_idx) predate that reasoning.
32
+ //
33
+ // order_id and quote_id are separate indexes rather than a widening of the above:
34
+ // both tables are the child side of `on delete set null` constraints, which
35
+ // PostgreSQL does not index automatically, and the constraint check probes
36
+ // order_id / quote_id — columns the context index cannot serve even though they
37
+ // hold the same uuid. Without them every delete of a parent order or quote scans
38
+ // the child table in full.
39
+ //
40
+ // Both tables are high-churn, so the indexes are built CONCURRENTLY to avoid
41
+ // blocking writes during the build. CREATE INDEX CONCURRENTLY cannot run inside a
42
+ // transaction, hence isTransactional() => false; the migration runner applies
43
+ // migrations one-by-one, so this opt-out is safe. Drop first so retrying a failed
44
+ // concurrent build removes PostgreSQL's invalid index stub instead of letting IF
45
+ // NOT EXISTS silently accept it: a half-built concurrent index is left INVALID,
46
+ // the planner ignores it, and IF NOT EXISTS would report success while the table
47
+ // stays unindexed. Same shape as Migration20260806120000 and
48
+ // Migration20260731105052_query_index.
49
+ //
50
+ // On a database that does not have these indexes yet — every fresh install — the
51
+ // drops are no-ops and there is no window without them. An operator who already
52
+ // created one out of band should expect it to be rebuilt here, and the reads that
53
+ // depend on it to fall back to their pre-index cost until the concurrent build
54
+ // finishes; on a large table, run this while the writers that depend on it are
55
+ // paused.
56
+ export class Migration20260821120000_sales_notes_addresses_context_idx extends Migration {
57
+
58
+ override isTransactional(): boolean {
59
+ return false;
60
+ }
61
+
62
+ override up(): void | Promise<void> {
63
+ this.addSql(`drop index concurrently if exists "sales_notes_context_idx";`);
64
+ this.addSql(`create index concurrently "sales_notes_context_idx" on "sales_notes" ("context_id");`);
65
+ this.addSql(`drop index concurrently if exists "sales_notes_order_idx";`);
66
+ this.addSql(`create index concurrently "sales_notes_order_idx" on "sales_notes" ("order_id");`);
67
+ this.addSql(`drop index concurrently if exists "sales_notes_quote_idx";`);
68
+ this.addSql(`create index concurrently "sales_notes_quote_idx" on "sales_notes" ("quote_id");`);
69
+
70
+ this.addSql(`drop index concurrently if exists "sales_document_addresses_document_idx";`);
71
+ this.addSql(`create index concurrently "sales_document_addresses_document_idx" on "sales_document_addresses" ("document_id");`);
72
+ this.addSql(`drop index concurrently if exists "sales_document_addresses_order_idx";`);
73
+ this.addSql(`create index concurrently "sales_document_addresses_order_idx" on "sales_document_addresses" ("order_id");`);
74
+ this.addSql(`drop index concurrently if exists "sales_document_addresses_quote_idx";`);
75
+ this.addSql(`create index concurrently "sales_document_addresses_quote_idx" on "sales_document_addresses" ("quote_id");`);
76
+ }
77
+
78
+ override down(): void | Promise<void> {
79
+ this.addSql(`drop index concurrently if exists "sales_document_addresses_quote_idx";`);
80
+ this.addSql(`drop index concurrently if exists "sales_document_addresses_order_idx";`);
81
+ this.addSql(`drop index concurrently if exists "sales_document_addresses_document_idx";`);
82
+ this.addSql(`drop index concurrently if exists "sales_notes_quote_idx";`);
83
+ this.addSql(`drop index concurrently if exists "sales_notes_order_idx";`);
84
+ this.addSql(`drop index concurrently if exists "sales_notes_context_idx";`);
85
+ }
86
+
87
+ }
@@ -178,6 +178,12 @@ export default function MyTimesheetsPage() {
178
178
  blockedMessage: t('ui.forms.flash.saveBlocked', 'Save blocked by validation'),
179
179
  })
180
180
 
181
+ const gridInstanceId = React.useId()
182
+ const durationHintId = `${gridInstanceId}-duration-hint`
183
+ const buildDurationErrorId = React.useCallback(
184
+ (projectId: string, dateKey: string): string => `${gridInstanceId}-duration-error-${projectId}-${dateKey}`,
185
+ [gridInstanceId],
186
+ )
181
187
  const durationFormatHint = t(
182
188
  'staff.timesheets.my.duration.hint',
183
189
  'Enter hours (8 or 1.5), h:mm (1:30) or minutes (90m). Max 24h per day.',
@@ -919,6 +925,7 @@ export default function MyTimesheetsPage() {
919
925
  const isDirty = dirty[project.id]?.[dateKey] !== undefined
920
926
  const cellError = cellErrors[project.id]?.[dateKey]
921
927
  const cellErrorMessage = cellError ? describeDurationError(cellError) : undefined
928
+ const cellErrorId = cellErrorMessage ? buildDurationErrorId(project.id, dateKey) : undefined
922
929
  return (
923
930
  <td key={dateKey} className={`px-0.5 py-0.5 ${weekend ? 'bg-muted/40' : ''}`}>
924
931
  {weekend ? (
@@ -943,9 +950,13 @@ export default function MyTimesheetsPage() {
943
950
  placeholder={t('staff.timesheets.my.durationPlaceholder', '0')}
944
951
  aria-invalid={cellError !== undefined}
945
952
  aria-label={describeDurationCell(project.name, date)}
953
+ aria-describedby={[durationHintId, cellErrorId].filter(Boolean).join(' ')}
946
954
  title={cellErrorMessage ?? durationFormatHint}
947
955
  />
948
956
  )}
957
+ {cellErrorMessage ? (
958
+ <span id={cellErrorId} role="alert" className="sr-only">{cellErrorMessage}</span>
959
+ ) : null}
949
960
  </td>
950
961
  )
951
962
  })}
@@ -984,7 +995,7 @@ export default function MyTimesheetsPage() {
984
995
  </tr>
985
996
  </tfoot>
986
997
  </table>
987
- <p className="px-3 py-2 text-xs text-muted-foreground">{durationFormatHint}</p>
998
+ <p id={durationHintId} className="px-3 py-2 text-xs text-muted-foreground">{durationFormatHint}</p>
988
999
  </div>
989
1000
  )}
990
1001
  </div>