@freighttech/terminal-tracking 0.7.0

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 (236) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +121 -0
  3. package/dist/generated/entities/terminal_config/index.js +53 -0
  4. package/dist/generated/entities/terminal_config/index.js.map +7 -0
  5. package/dist/generated/entities/terminal_event/index.js +59 -0
  6. package/dist/generated/entities/terminal_event/index.js.map +7 -0
  7. package/dist/generated/entities/terminal_tracking_job/index.js +37 -0
  8. package/dist/generated/entities/terminal_tracking_job/index.js.map +7 -0
  9. package/dist/generated/entities/terminal_vessel_visit/index.js +41 -0
  10. package/dist/generated/entities/terminal_vessel_visit/index.js.map +7 -0
  11. package/dist/generated/entities.ids.generated.js +26 -0
  12. package/dist/generated/entities.ids.generated.js.map +7 -0
  13. package/dist/index.js +20 -0
  14. package/dist/index.js.map +7 -0
  15. package/dist/modules/terminal_tracking/acl.js +14 -0
  16. package/dist/modules/terminal_tracking/acl.js.map +7 -0
  17. package/dist/modules/terminal_tracking/api/openapi.js +27 -0
  18. package/dist/modules/terminal_tracking/api/openapi.js.map +7 -0
  19. package/dist/modules/terminal_tracking/api/terminal-configs/route.js +140 -0
  20. package/dist/modules/terminal_tracking/api/terminal-configs/route.js.map +7 -0
  21. package/dist/modules/terminal_tracking/api/terminal-configs/test/route.js +58 -0
  22. package/dist/modules/terminal_tracking/api/terminal-configs/test/route.js.map +7 -0
  23. package/dist/modules/terminal_tracking/api/tracking-jobs/details/route.js +109 -0
  24. package/dist/modules/terminal_tracking/api/tracking-jobs/details/route.js.map +7 -0
  25. package/dist/modules/terminal_tracking/api/tracking-jobs/poll/route.js +58 -0
  26. package/dist/modules/terminal_tracking/api/tracking-jobs/poll/route.js.map +7 -0
  27. package/dist/modules/terminal_tracking/api/tracking-jobs/route.js +189 -0
  28. package/dist/modules/terminal_tracking/api/tracking-jobs/route.js.map +7 -0
  29. package/dist/modules/terminal_tracking/api/utils.js +12 -0
  30. package/dist/modules/terminal_tracking/api/utils.js.map +7 -0
  31. package/dist/modules/terminal_tracking/api/vessel-visits/route.js +83 -0
  32. package/dist/modules/terminal_tracking/api/vessel-visits/route.js.map +7 -0
  33. package/dist/modules/terminal_tracking/backend/terminal-configs/page.js +669 -0
  34. package/dist/modules/terminal_tracking/backend/terminal-configs/page.js.map +7 -0
  35. package/dist/modules/terminal_tracking/backend/terminal-configs/page.meta.js +19 -0
  36. package/dist/modules/terminal_tracking/backend/terminal-configs/page.meta.js.map +7 -0
  37. package/dist/modules/terminal_tracking/backend/terminal-containers/page.js +275 -0
  38. package/dist/modules/terminal_tracking/backend/terminal-containers/page.js.map +7 -0
  39. package/dist/modules/terminal_tracking/backend/terminal-containers/page.meta.js +19 -0
  40. package/dist/modules/terminal_tracking/backend/terminal-containers/page.meta.js.map +7 -0
  41. package/dist/modules/terminal_tracking/ce.js +35 -0
  42. package/dist/modules/terminal_tracking/ce.js.map +7 -0
  43. package/dist/modules/terminal_tracking/commands/index.js +4 -0
  44. package/dist/modules/terminal_tracking/commands/index.js.map +7 -0
  45. package/dist/modules/terminal_tracking/commands/terminal-configs.js +107 -0
  46. package/dist/modules/terminal_tracking/commands/terminal-configs.js.map +7 -0
  47. package/dist/modules/terminal_tracking/commands/terminal-tracking.js +27 -0
  48. package/dist/modules/terminal_tracking/commands/terminal-tracking.js.map +7 -0
  49. package/dist/modules/terminal_tracking/commands/tracking-jobs.js +56 -0
  50. package/dist/modules/terminal_tracking/commands/tracking-jobs.js.map +7 -0
  51. package/dist/modules/terminal_tracking/components/ContainerDetailsDrawer.js +371 -0
  52. package/dist/modules/terminal_tracking/components/ContainerDetailsDrawer.js.map +7 -0
  53. package/dist/modules/terminal_tracking/data/entities.js +337 -0
  54. package/dist/modules/terminal_tracking/data/entities.js.map +7 -0
  55. package/dist/modules/terminal_tracking/data/validators.js +98 -0
  56. package/dist/modules/terminal_tracking/data/validators.js.map +7 -0
  57. package/dist/modules/terminal_tracking/di.js +39 -0
  58. package/dist/modules/terminal_tracking/di.js.map +7 -0
  59. package/dist/modules/terminal_tracking/encryption.js +14 -0
  60. package/dist/modules/terminal_tracking/encryption.js.map +7 -0
  61. package/dist/modules/terminal_tracking/events.js +45 -0
  62. package/dist/modules/terminal_tracking/events.js.map +7 -0
  63. package/dist/modules/terminal_tracking/i18n/de.json +175 -0
  64. package/dist/modules/terminal_tracking/i18n/en.json +178 -0
  65. package/dist/modules/terminal_tracking/i18n/es.json +175 -0
  66. package/dist/modules/terminal_tracking/i18n/pl.json +206 -0
  67. package/dist/modules/terminal_tracking/index.js +15 -0
  68. package/dist/modules/terminal_tracking/index.js.map +7 -0
  69. package/dist/modules/terminal_tracking/lib/adapters/bct/adapter.js +164 -0
  70. package/dist/modules/terminal_tracking/lib/adapters/bct/adapter.js.map +7 -0
  71. package/dist/modules/terminal_tracking/lib/adapters/bct/auth/basic.js +23 -0
  72. package/dist/modules/terminal_tracking/lib/adapters/bct/auth/basic.js.map +7 -0
  73. package/dist/modules/terminal_tracking/lib/adapters/bct/incos-semantics.js +146 -0
  74. package/dist/modules/terminal_tracking/lib/adapters/bct/incos-semantics.js.map +7 -0
  75. package/dist/modules/terminal_tracking/lib/adapters/bct/types.js +1 -0
  76. package/dist/modules/terminal_tracking/lib/adapters/bct/types.js.map +7 -0
  77. package/dist/modules/terminal_tracking/lib/adapters/gct/adapter.js +106 -0
  78. package/dist/modules/terminal_tracking/lib/adapters/gct/adapter.js.map +7 -0
  79. package/dist/modules/terminal_tracking/lib/adapters/gct/auth/token.js +120 -0
  80. package/dist/modules/terminal_tracking/lib/adapters/gct/auth/token.js.map +7 -0
  81. package/dist/modules/terminal_tracking/lib/adapters/gct/gct-semantics.js +77 -0
  82. package/dist/modules/terminal_tracking/lib/adapters/gct/gct-semantics.js.map +7 -0
  83. package/dist/modules/terminal_tracking/lib/adapters/gct/types.js +1 -0
  84. package/dist/modules/terminal_tracking/lib/adapters/gct/types.js.map +7 -0
  85. package/dist/modules/terminal_tracking/lib/adapters/index.js +12 -0
  86. package/dist/modules/terminal_tracking/lib/adapters/index.js.map +7 -0
  87. package/dist/modules/terminal_tracking/lib/adapters/n4/adapter.js +121 -0
  88. package/dist/modules/terminal_tracking/lib/adapters/n4/adapter.js.map +7 -0
  89. package/dist/modules/terminal_tracking/lib/adapters/n4/auth/ropc.js +81 -0
  90. package/dist/modules/terminal_tracking/lib/adapters/n4/auth/ropc.js.map +7 -0
  91. package/dist/modules/terminal_tracking/lib/adapters/n4/http.js +66 -0
  92. package/dist/modules/terminal_tracking/lib/adapters/n4/http.js.map +7 -0
  93. package/dist/modules/terminal_tracking/lib/adapters/n4/n4-semantics.js +95 -0
  94. package/dist/modules/terminal_tracking/lib/adapters/n4/n4-semantics.js.map +7 -0
  95. package/dist/modules/terminal_tracking/lib/adapters/n4/parsers/data-table.js +70 -0
  96. package/dist/modules/terminal_tracking/lib/adapters/n4/parsers/data-table.js.map +7 -0
  97. package/dist/modules/terminal_tracking/lib/adapters/n4/parsers/vessel-data-table.js +44 -0
  98. package/dist/modules/terminal_tracking/lib/adapters/n4/parsers/vessel-data-table.js.map +7 -0
  99. package/dist/modules/terminal_tracking/lib/adapters/n4/proxy.js +15 -0
  100. package/dist/modules/terminal_tracking/lib/adapters/n4/proxy.js.map +7 -0
  101. package/dist/modules/terminal_tracking/lib/adapters/n4/types.js +1 -0
  102. package/dist/modules/terminal_tracking/lib/adapters/n4/types.js.map +7 -0
  103. package/dist/modules/terminal_tracking/lib/adapters/zoned-time.js +27 -0
  104. package/dist/modules/terminal_tracking/lib/adapters/zoned-time.js.map +7 -0
  105. package/dist/modules/terminal_tracking/lib/availability.js +40 -0
  106. package/dist/modules/terminal_tracking/lib/availability.js.map +7 -0
  107. package/dist/modules/terminal_tracking/lib/ensure-schedule.js +51 -0
  108. package/dist/modules/terminal_tracking/lib/ensure-schedule.js.map +7 -0
  109. package/dist/modules/terminal_tracking/lib/holds.js +240 -0
  110. package/dist/modules/terminal_tracking/lib/holds.js.map +7 -0
  111. package/dist/modules/terminal_tracking/lib/logger.js +19 -0
  112. package/dist/modules/terminal_tracking/lib/logger.js.map +7 -0
  113. package/dist/modules/terminal_tracking/lib/rate-limiter.js +30 -0
  114. package/dist/modules/terminal_tracking/lib/rate-limiter.js.map +7 -0
  115. package/dist/modules/terminal_tracking/lib/stops.js +48 -0
  116. package/dist/modules/terminal_tracking/lib/stops.js.map +7 -0
  117. package/dist/modules/terminal_tracking/lib/terminal-adapter.js +1 -0
  118. package/dist/modules/terminal_tracking/lib/terminal-adapter.js.map +7 -0
  119. package/dist/modules/terminal_tracking/lib/totp.js +41 -0
  120. package/dist/modules/terminal_tracking/lib/totp.js.map +7 -0
  121. package/dist/modules/terminal_tracking/migrations/Migration20260609120000_terminal_tracking.js +101 -0
  122. package/dist/modules/terminal_tracking/migrations/Migration20260609120000_terminal_tracking.js.map +7 -0
  123. package/dist/modules/terminal_tracking/migrations/Migration20260610120000_terminal_vessel_visits.js +39 -0
  124. package/dist/modules/terminal_tracking/migrations/Migration20260610120000_terminal_vessel_visits.js.map +7 -0
  125. package/dist/modules/terminal_tracking/migrations/Migration20260611120000_terminal_config_proxy.js +13 -0
  126. package/dist/modules/terminal_tracking/migrations/Migration20260611120000_terminal_config_proxy.js.map +7 -0
  127. package/dist/modules/terminal_tracking/migrations/Migration20260707120000_terminal_job_availability_markers.js +15 -0
  128. package/dist/modules/terminal_tracking/migrations/Migration20260707120000_terminal_job_availability_markers.js.map +7 -0
  129. package/dist/modules/terminal_tracking/migrations/Migration20260805120000_terminal_event_loaded_at.js +13 -0
  130. package/dist/modules/terminal_tracking/migrations/Migration20260805120000_terminal_event_loaded_at.js.map +7 -0
  131. package/dist/modules/terminal_tracking/services/terminalMatcherService.js +61 -0
  132. package/dist/modules/terminal_tracking/services/terminalMatcherService.js.map +7 -0
  133. package/dist/modules/terminal_tracking/services/terminalRegistry.js +24 -0
  134. package/dist/modules/terminal_tracking/services/terminalRegistry.js.map +7 -0
  135. package/dist/modules/terminal_tracking/services/terminalTrackingService.js +687 -0
  136. package/dist/modules/terminal_tracking/services/terminalTrackingService.js.map +7 -0
  137. package/dist/modules/terminal_tracking/setup.js +23 -0
  138. package/dist/modules/terminal_tracking/setup.js.map +7 -0
  139. package/dist/modules/terminal_tracking/subscribers/terminal-tracking-job-initial-poll.js +22 -0
  140. package/dist/modules/terminal_tracking/subscribers/terminal-tracking-job-initial-poll.js.map +7 -0
  141. package/generated/entities/terminal_config/index.ts +36 -0
  142. package/generated/entities/terminal_event/index.ts +39 -0
  143. package/generated/entities/terminal_tracking_job/index.ts +28 -0
  144. package/generated/entities/terminal_vessel_visit/index.ts +30 -0
  145. package/generated/entities.ids.generated.ts +52 -0
  146. package/package.json +80 -0
  147. package/src/index.ts +44 -0
  148. package/src/modules/terminal_tracking/__integration__/TC-TRACK-301-gct-config-test-connection.spec.ts +66 -0
  149. package/src/modules/terminal_tracking/__integration__/TC-TRACK-317-bct-config-test-connection.spec.ts +68 -0
  150. package/src/modules/terminal_tracking/__integration__/helpers.ts +148 -0
  151. package/src/modules/terminal_tracking/__integration__/meta.ts +4 -0
  152. package/src/modules/terminal_tracking/acl.ts +10 -0
  153. package/src/modules/terminal_tracking/api/openapi.ts +28 -0
  154. package/src/modules/terminal_tracking/api/terminal-configs/route.ts +140 -0
  155. package/src/modules/terminal_tracking/api/terminal-configs/test/route.ts +64 -0
  156. package/src/modules/terminal_tracking/api/tracking-jobs/details/route.ts +116 -0
  157. package/src/modules/terminal_tracking/api/tracking-jobs/poll/route.ts +64 -0
  158. package/src/modules/terminal_tracking/api/tracking-jobs/route.ts +243 -0
  159. package/src/modules/terminal_tracking/api/utils.ts +10 -0
  160. package/src/modules/terminal_tracking/api/vessel-visits/route.ts +95 -0
  161. package/src/modules/terminal_tracking/backend/terminal-configs/page.meta.ts +16 -0
  162. package/src/modules/terminal_tracking/backend/terminal-configs/page.tsx +728 -0
  163. package/src/modules/terminal_tracking/backend/terminal-containers/page.meta.ts +16 -0
  164. package/src/modules/terminal_tracking/backend/terminal-containers/page.tsx +328 -0
  165. package/src/modules/terminal_tracking/ce.ts +31 -0
  166. package/src/modules/terminal_tracking/commands/index.ts +3 -0
  167. package/src/modules/terminal_tracking/commands/terminal-configs.ts +132 -0
  168. package/src/modules/terminal_tracking/commands/terminal-tracking.ts +34 -0
  169. package/src/modules/terminal_tracking/commands/tracking-jobs.ts +69 -0
  170. package/src/modules/terminal_tracking/components/ContainerDetailsDrawer.tsx +540 -0
  171. package/src/modules/terminal_tracking/data/entities.ts +386 -0
  172. package/src/modules/terminal_tracking/data/validators.ts +113 -0
  173. package/src/modules/terminal_tracking/di.ts +46 -0
  174. package/src/modules/terminal_tracking/encryption.ts +12 -0
  175. package/src/modules/terminal_tracking/events.ts +48 -0
  176. package/src/modules/terminal_tracking/i18n/de.json +175 -0
  177. package/src/modules/terminal_tracking/i18n/en.json +178 -0
  178. package/src/modules/terminal_tracking/i18n/es.json +175 -0
  179. package/src/modules/terminal_tracking/i18n/pl.json +206 -0
  180. package/src/modules/terminal_tracking/index.ts +13 -0
  181. package/src/modules/terminal_tracking/lib/__tests__/availability.test.ts +136 -0
  182. package/src/modules/terminal_tracking/lib/__tests__/ensure-schedule.test.ts +67 -0
  183. package/src/modules/terminal_tracking/lib/__tests__/holds.test.ts +73 -0
  184. package/src/modules/terminal_tracking/lib/__tests__/stops.test.ts +80 -0
  185. package/src/modules/terminal_tracking/lib/__tests__/totp.test.ts +38 -0
  186. package/src/modules/terminal_tracking/lib/adapters/bct/__tests__/adapter.test.ts +196 -0
  187. package/src/modules/terminal_tracking/lib/adapters/bct/__tests__/incos-semantics.test.ts +303 -0
  188. package/src/modules/terminal_tracking/lib/adapters/bct/adapter.ts +219 -0
  189. package/src/modules/terminal_tracking/lib/adapters/bct/auth/basic.ts +34 -0
  190. package/src/modules/terminal_tracking/lib/adapters/bct/incos-semantics.ts +291 -0
  191. package/src/modules/terminal_tracking/lib/adapters/bct/types.ts +113 -0
  192. package/src/modules/terminal_tracking/lib/adapters/gct/__tests__/adapter.test.ts +238 -0
  193. package/src/modules/terminal_tracking/lib/adapters/gct/__tests__/gct-semantics.test.ts +133 -0
  194. package/src/modules/terminal_tracking/lib/adapters/gct/adapter.ts +152 -0
  195. package/src/modules/terminal_tracking/lib/adapters/gct/auth/token.ts +219 -0
  196. package/src/modules/terminal_tracking/lib/adapters/gct/gct-semantics.ts +136 -0
  197. package/src/modules/terminal_tracking/lib/adapters/gct/types.ts +58 -0
  198. package/src/modules/terminal_tracking/lib/adapters/index.ts +16 -0
  199. package/src/modules/terminal_tracking/lib/adapters/n4/__tests__/adapter-proxy.test.ts +69 -0
  200. package/src/modules/terminal_tracking/lib/adapters/n4/__tests__/data-table.test.ts +179 -0
  201. package/src/modules/terminal_tracking/lib/adapters/n4/__tests__/http.test.ts +98 -0
  202. package/src/modules/terminal_tracking/lib/adapters/n4/__tests__/n4-semantics.test.ts +140 -0
  203. package/src/modules/terminal_tracking/lib/adapters/n4/__tests__/proxy.test.ts +19 -0
  204. package/src/modules/terminal_tracking/lib/adapters/n4/__tests__/vessel-data-table.test.ts +67 -0
  205. package/src/modules/terminal_tracking/lib/adapters/n4/adapter.ts +163 -0
  206. package/src/modules/terminal_tracking/lib/adapters/n4/auth/ropc.ts +104 -0
  207. package/src/modules/terminal_tracking/lib/adapters/n4/http.ts +127 -0
  208. package/src/modules/terminal_tracking/lib/adapters/n4/n4-semantics.ts +156 -0
  209. package/src/modules/terminal_tracking/lib/adapters/n4/parsers/data-table.ts +102 -0
  210. package/src/modules/terminal_tracking/lib/adapters/n4/parsers/vessel-data-table.ts +60 -0
  211. package/src/modules/terminal_tracking/lib/adapters/n4/proxy.ts +24 -0
  212. package/src/modules/terminal_tracking/lib/adapters/n4/types.ts +56 -0
  213. package/src/modules/terminal_tracking/lib/adapters/zoned-time.ts +65 -0
  214. package/src/modules/terminal_tracking/lib/availability.ts +119 -0
  215. package/src/modules/terminal_tracking/lib/ensure-schedule.ts +83 -0
  216. package/src/modules/terminal_tracking/lib/holds.ts +344 -0
  217. package/src/modules/terminal_tracking/lib/logger.ts +21 -0
  218. package/src/modules/terminal_tracking/lib/rate-limiter.ts +52 -0
  219. package/src/modules/terminal_tracking/lib/stops.ts +124 -0
  220. package/src/modules/terminal_tracking/lib/terminal-adapter.ts +136 -0
  221. package/src/modules/terminal_tracking/lib/totp.ts +63 -0
  222. package/src/modules/terminal_tracking/migrations/Migration20260609120000_terminal_tracking.ts +104 -0
  223. package/src/modules/terminal_tracking/migrations/Migration20260610120000_terminal_vessel_visits.ts +40 -0
  224. package/src/modules/terminal_tracking/migrations/Migration20260611120000_terminal_config_proxy.ts +11 -0
  225. package/src/modules/terminal_tracking/migrations/Migration20260707120000_terminal_job_availability_markers.ts +13 -0
  226. package/src/modules/terminal_tracking/migrations/Migration20260805120000_terminal_event_loaded_at.ts +16 -0
  227. package/src/modules/terminal_tracking/services/__tests__/availabilityEmit.test.ts +277 -0
  228. package/src/modules/terminal_tracking/services/__tests__/createJob.test.ts +59 -0
  229. package/src/modules/terminal_tracking/services/__tests__/pollAllBatch.test.ts +147 -0
  230. package/src/modules/terminal_tracking/services/__tests__/terminalMatcher.test.ts +70 -0
  231. package/src/modules/terminal_tracking/services/__tests__/vesselResolution.test.ts +453 -0
  232. package/src/modules/terminal_tracking/services/terminalMatcherService.ts +97 -0
  233. package/src/modules/terminal_tracking/services/terminalRegistry.ts +30 -0
  234. package/src/modules/terminal_tracking/services/terminalTrackingService.ts +912 -0
  235. package/src/modules/terminal_tracking/setup.ts +24 -0
  236. package/src/modules/terminal_tracking/subscribers/terminal-tracking-job-initial-poll.ts +32 -0
@@ -0,0 +1,728 @@
1
+ 'use client'
2
+
3
+ import * as React from 'react'
4
+ import { useCallback, useMemo, useState } from 'react'
5
+ import { Page, PageBody } from '@freighttech/ui/backend/Page'
6
+ import { BooleanIcon } from '@freighttech/ui/backend/ValueIcons'
7
+ import { DynamicTable, TableSkeleton, useDynamicTablePage } from '@freighttech/ui/backend/dynamic-table'
8
+ import type { ColumnDef } from '@freighttech/ui/backend/dynamic-table'
9
+ import { RowActions, type RowActionItem } from '@freighttech/ui/backend/RowActions'
10
+ import { apiCallOrThrow } from '@freighttech/ui/backend/utils/apiCall'
11
+ import { flash } from '@freighttech/ui/backend/FlashMessages'
12
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
13
+ import {
14
+ Dialog,
15
+ DialogContent,
16
+ DialogHeader,
17
+ DialogTitle,
18
+ DialogFooter,
19
+ } from '@freighttech/ui/primitives/dialog'
20
+ import { Button } from '@freighttech/ui/primitives/button'
21
+ import { Input } from '@freighttech/ui/primitives/input'
22
+ import { Label } from '@freighttech/ui/primitives/label'
23
+ import { Checkbox } from '@freighttech/ui/primitives/checkbox'
24
+ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@freighttech/ui/primitives/card'
25
+ import { Loader2, CheckCircle2, XCircle } from 'lucide-react'
26
+
27
+ type AuthType = 'oauth2_password' | 'oauth2_client_credentials' | 'gct_token' | 'basic'
28
+
29
+ type ConfigRow = {
30
+ id: string
31
+ terminalCode: string
32
+ adapterType: string
33
+ displayName: string
34
+ baseUrl: string
35
+ authType: string
36
+ unlocode: string | null
37
+ isActive: boolean
38
+ }
39
+
40
+ function mapItem(item: Record<string, unknown>): ConfigRow | null {
41
+ const id = typeof item.id === 'string' ? item.id : null
42
+ if (!id) return null
43
+ return {
44
+ id,
45
+ terminalCode: (item.terminalCode as string) ?? '',
46
+ adapterType: (item.adapterType as string) ?? '',
47
+ displayName: (item.displayName as string) ?? '',
48
+ baseUrl: (item.baseUrl as string) ?? '',
49
+ authType: (item.authType as string) ?? '',
50
+ unlocode: (item.unlocode as string) ?? null,
51
+ isActive: item.isActive === true,
52
+ }
53
+ }
54
+
55
+ type FormState = {
56
+ terminalCode: string
57
+ adapterType: string
58
+ displayName: string
59
+ baseUrl: string
60
+ proxyUrl: string
61
+ authType: AuthType
62
+ tokenUrl: string
63
+ scope: string
64
+ clientId: string
65
+ credentials: Record<string, string>
66
+ unlocode: string
67
+ bicCodes: string
68
+ smdgCodes: string
69
+ nameAliases: string
70
+ rateLimitRequests: number
71
+ rateLimitWindowSeconds: number
72
+ isActive: boolean
73
+ }
74
+
75
+ // ── Adapter specs ───────────────────────────────────────────────────────────
76
+ // Each adapter maps to one real terminal and one auth type, so selecting the
77
+ // adapter drives the auth type, the credential fields, and the identifier
78
+ // defaults. Values are sourced from the N4 adapter spec + terminal-tracking
79
+ // README (see `.ai/specs/2026-06-08-n4-terminal-tracking-adapter.md` and
80
+ // `packages/terminal-tracking/README.md`). GCT's base URL is intentionally left
81
+ // blank — no real value is published in the repo. Likewise the OAuth account
82
+ // identity (client ID and scope) for each terminal is integrator-specific and
83
+ // left blank here: the operator fills it in when configuring the terminal.
84
+ type CredField = { key: string; label: string; secret?: boolean; placeholder?: string }
85
+
86
+ type AdapterSpec = {
87
+ value: string
88
+ label: string
89
+ authType: AuthType
90
+ /** Show the OAuth block (token URL / client ID / scope). */
91
+ showOAuth: boolean
92
+ defaults: Partial<FormState>
93
+ }
94
+
95
+ const ADAPTER_SPECS: Record<string, AdapterSpec> = {
96
+ n4: {
97
+ value: 'n4',
98
+ label: 'DCT (Gdańsk)',
99
+ authType: 'oauth2_password',
100
+ showOAuth: true,
101
+ defaults: {
102
+ terminalCode: 'dct',
103
+ displayName: 'DCT Gdańsk (Baltic Hub)',
104
+ baseUrl: 'https://api2.baltichub.com/V1/',
105
+ tokenUrl:
106
+ 'https://bhctapicustomers.b2clogin.com/bhctapicustomers.onmicrosoft.com/oauth2/v2.0/token?p=b2c_1_ropc_login',
107
+ // Integrator-specific — configured per deployment, not published here.
108
+ clientId: '',
109
+ scope: '',
110
+ unlocode: 'PLGDN',
111
+ smdgCodes: 'PLGDNDCT',
112
+ bicCodes: 'PLGDNSKZB',
113
+ nameAliases: 'Baltic Hub, DCT Gdańsk',
114
+ rateLimitRequests: 200,
115
+ rateLimitWindowSeconds: 60,
116
+ },
117
+ },
118
+ bct: {
119
+ value: 'bct',
120
+ label: 'BCT / INCOS (Gdynia)',
121
+ authType: 'basic',
122
+ showOAuth: false,
123
+ defaults: {
124
+ terminalCode: 'bct',
125
+ displayName: 'BCT Gdynia (INCOS)',
126
+ baseUrl: 'https://incos.pl',
127
+ tokenUrl: '',
128
+ clientId: '',
129
+ scope: '',
130
+ unlocode: 'PLGDY',
131
+ smdgCodes: 'PLGDYPLBCT',
132
+ bicCodes: 'PLGDYBCTA',
133
+ nameAliases: 'BCT, Bałtycki Terminal Kontenerowy',
134
+ // INCOS caps at 10 000 requests/day.
135
+ rateLimitRequests: 10000,
136
+ rateLimitWindowSeconds: 86400,
137
+ },
138
+ },
139
+ gct: {
140
+ value: 'gct',
141
+ label: 'GCT (Gdynia)',
142
+ authType: 'gct_token',
143
+ showOAuth: false,
144
+ defaults: {
145
+ terminalCode: 'gct',
146
+ displayName: 'GCT Gdynia',
147
+ baseUrl: '',
148
+ tokenUrl: '',
149
+ clientId: '',
150
+ scope: '',
151
+ unlocode: 'PLGDY',
152
+ smdgCodes: 'PLGDYPLGCT',
153
+ bicCodes: 'PLGDYEOOJ',
154
+ nameAliases: 'GCT',
155
+ rateLimitRequests: 200,
156
+ rateLimitWindowSeconds: 60,
157
+ },
158
+ },
159
+ }
160
+
161
+ const ADAPTER_ORDER = ['n4', 'bct', 'gct']
162
+
163
+ /** Credential inputs for a given adapter + auth type (structured, no raw JSON). */
164
+ function credentialFieldsFor(adapterType: string, authType: AuthType): CredField[] {
165
+ if (adapterType === 'gct') {
166
+ return [
167
+ { key: 'companyCode', label: 'Company code', placeholder: 'ACME' },
168
+ { key: 'loginName', label: 'Login' },
169
+ { key: 'totpSecret', label: 'Twój sekret 2FA', secret: true, placeholder: 'JBSWY3DPEHPK3PXP' },
170
+ ]
171
+ }
172
+ if (adapterType === 'n4' && authType === 'oauth2_client_credentials') {
173
+ return [{ key: 'clientSecret', label: 'Client secret', secret: true }]
174
+ }
175
+ // n4 (ROPC) and bct (basic) both use username/password.
176
+ return [
177
+ { key: 'username', label: 'Username' },
178
+ { key: 'password', label: 'Password', secret: true },
179
+ ]
180
+ }
181
+
182
+ const baseForm: FormState = {
183
+ terminalCode: '',
184
+ adapterType: 'n4',
185
+ displayName: '',
186
+ baseUrl: '',
187
+ proxyUrl: '',
188
+ authType: 'oauth2_password',
189
+ tokenUrl: '',
190
+ scope: '',
191
+ clientId: '',
192
+ credentials: {},
193
+ unlocode: '',
194
+ bicCodes: '',
195
+ smdgCodes: '',
196
+ nameAliases: '',
197
+ rateLimitRequests: 200,
198
+ rateLimitWindowSeconds: 60,
199
+ isActive: true,
200
+ }
201
+
202
+ /** Apply an adapter's defaults onto a form (used on create + adapter switch). */
203
+ function applyAdapterDefaults(
204
+ adapterType: string,
205
+ prev: FormState,
206
+ opts: { includeTerminalCode: boolean },
207
+ ): FormState {
208
+ const spec = ADAPTER_SPECS[adapterType] ?? ADAPTER_SPECS.n4
209
+ const d = spec.defaults
210
+ return {
211
+ ...prev,
212
+ adapterType,
213
+ authType: spec.authType,
214
+ displayName: d.displayName ?? '',
215
+ baseUrl: d.baseUrl ?? '',
216
+ tokenUrl: d.tokenUrl ?? '',
217
+ scope: d.scope ?? '',
218
+ clientId: d.clientId ?? '',
219
+ unlocode: d.unlocode ?? '',
220
+ smdgCodes: d.smdgCodes ?? '',
221
+ bicCodes: d.bicCodes ?? '',
222
+ nameAliases: d.nameAliases ?? '',
223
+ rateLimitRequests: d.rateLimitRequests ?? 200,
224
+ rateLimitWindowSeconds: d.rateLimitWindowSeconds ?? 60,
225
+ credentials: {},
226
+ ...(opts.includeTerminalCode ? { terminalCode: d.terminalCode ?? '' } : {}),
227
+ }
228
+ }
229
+
230
+ const emptyForm: FormState = applyAdapterDefaults('n4', baseForm, { includeTerminalCode: true })
231
+
232
+ const API = '/api/terminal_tracking/terminal-configs'
233
+
234
+ function csv(value: string): string[] | undefined {
235
+ const arr = value
236
+ .split(',')
237
+ .map((s) => s.trim())
238
+ .filter(Boolean)
239
+ return arr.length ? arr : undefined
240
+ }
241
+
242
+ export default function TerminalConfigsPage() {
243
+ const t = useT()
244
+ const [dialogOpen, setDialogOpen] = useState(false)
245
+ const [editingId, setEditingId] = useState<string | null>(null)
246
+ const [form, setForm] = useState<FormState>(emptyForm)
247
+ const [submitting, setSubmitting] = useState(false)
248
+ const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null)
249
+ const isEdit = editingId !== null
250
+
251
+ const spec = ADAPTER_SPECS[form.adapterType] ?? ADAPTER_SPECS.n4
252
+ const credFields = credentialFieldsFor(form.adapterType, form.authType)
253
+
254
+ const setField = useCallback(
255
+ <K extends keyof FormState>(key: K, value: FormState[K]) => setForm((p) => ({ ...p, [key]: value })),
256
+ [],
257
+ )
258
+ const setCredential = useCallback(
259
+ (key: string, value: string) => setForm((p) => ({ ...p, credentials: { ...p.credentials, [key]: value } })),
260
+ [],
261
+ )
262
+ const changeAdapter = useCallback(
263
+ (adapterType: string) =>
264
+ setForm((p) => applyAdapterDefaults(adapterType, p, { includeTerminalCode: !isEdit })),
265
+ [isEdit],
266
+ )
267
+
268
+ const columns = useMemo<ColumnDef[]>(
269
+ () => [
270
+ {
271
+ data: 'displayName',
272
+ title: t('terminal_tracking.terminal_config.displayName', 'Name'),
273
+ width: 180,
274
+ readOnly: true,
275
+ renderer: (v: unknown) => <span className="font-medium">{String(v ?? '')}</span>,
276
+ },
277
+ {
278
+ data: 'terminalCode',
279
+ title: t('terminal_tracking.terminal_config.terminalCode', 'Terminal code'),
280
+ width: 120,
281
+ readOnly: true,
282
+ },
283
+ {
284
+ data: 'adapterType',
285
+ title: t('terminal_tracking.terminal_config.adapterType', 'Adapter'),
286
+ width: 130,
287
+ readOnly: true,
288
+ renderer: (v: unknown) => <span className="text-sm">{ADAPTER_SPECS[String(v)]?.label ?? String(v ?? '')}</span>,
289
+ },
290
+ {
291
+ data: 'unlocode',
292
+ title: t('terminal_tracking.terminal_config.unlocode', 'UN/LOCODE'),
293
+ width: 110,
294
+ readOnly: true,
295
+ renderer: (v: unknown) => <span className="text-sm">{String(v || '-')}</span>,
296
+ },
297
+ {
298
+ data: 'isActive',
299
+ title: t('terminal_tracking.terminal_config.isActive', 'Active'),
300
+ width: 80,
301
+ readOnly: true,
302
+ renderer: (v: unknown) => <BooleanIcon value={v === true || v === 'true'} />,
303
+ },
304
+ ],
305
+ [t],
306
+ )
307
+
308
+ const openCreate = useCallback(() => {
309
+ setEditingId(null)
310
+ setForm(emptyForm)
311
+ setTestResult(null)
312
+ setDialogOpen(true)
313
+ }, [])
314
+
315
+ const addButton = useMemo(
316
+ () => (
317
+ <Button size="sm" onClick={openCreate}>
318
+ {t('terminal_tracking.terminal_config.create', 'Add Terminal')}
319
+ </Button>
320
+ ),
321
+ [openCreate, t],
322
+ )
323
+
324
+ const table = useDynamicTablePage<ConfigRow>({
325
+ source: API,
326
+ columns,
327
+ tableName: t('terminal_tracking.terminal_config.title', 'Terminals'),
328
+ defaultPageSize: 20,
329
+ mapApiItem: mapItem,
330
+ cellEdit: false,
331
+ tableProps: {
332
+ height: '100%',
333
+ uiConfig: {
334
+ readOnlyStyle: 'normal',
335
+ hideAddRowButton: true,
336
+ topBarEnd: addButton,
337
+ },
338
+ },
339
+ })
340
+
341
+ const openEdit = useCallback(
342
+ (id: string) => {
343
+ const items = (table.query.data as { items?: Record<string, unknown>[] })?.items ?? []
344
+ const raw = items.find((r) => r.id === id)
345
+ if (!raw) return
346
+ const adapterType = (raw.adapterType as string) ?? 'n4'
347
+ const rowAuthType = (raw.authType as AuthType) ?? ADAPTER_SPECS[adapterType]?.authType ?? 'oauth2_password'
348
+ setEditingId(id)
349
+ setTestResult(null)
350
+ setForm({
351
+ ...emptyForm,
352
+ terminalCode: (raw.terminalCode as string) ?? '',
353
+ adapterType,
354
+ // Preserve the row's stored auth type (e.g. a legacy n4 client-credentials config).
355
+ authType: rowAuthType,
356
+ displayName: (raw.displayName as string) ?? '',
357
+ baseUrl: (raw.baseUrl as string) ?? '',
358
+ proxyUrl: (raw.proxyUrl as string) ?? '',
359
+ tokenUrl: (raw.tokenUrl as string) ?? '',
360
+ scope: (raw.scope as string) ?? '',
361
+ clientId: (raw.clientId as string) ?? '',
362
+ credentials: {}, // secrets are never returned; blank = keep unchanged
363
+ unlocode: (raw.unlocode as string) ?? '',
364
+ bicCodes: Array.isArray(raw.bicCodes) ? (raw.bicCodes as string[]).join(', ') : '',
365
+ smdgCodes: Array.isArray(raw.smdgCodes) ? (raw.smdgCodes as string[]).join(', ') : '',
366
+ nameAliases: Array.isArray(raw.nameAliases) ? (raw.nameAliases as string[]).join(', ') : '',
367
+ rateLimitRequests: typeof raw.rateLimitRequests === 'number' ? raw.rateLimitRequests : 200,
368
+ rateLimitWindowSeconds: typeof raw.rateLimitWindowSeconds === 'number' ? raw.rateLimitWindowSeconds : 60,
369
+ isActive: raw.isActive === true,
370
+ })
371
+ setDialogOpen(true)
372
+ },
373
+ [table.query.data],
374
+ )
375
+
376
+ const buildBody = useCallback((): Record<string, unknown> | null => {
377
+ // Assemble authConfig from the structured credential fields.
378
+ const fields = credentialFieldsFor(form.adapterType, form.authType)
379
+ const filled = fields.filter((f) => (form.credentials[f.key] ?? '').trim() !== '')
380
+ let authConfig: Record<string, string> | undefined
381
+ if (filled.length > 0) {
382
+ if (filled.length !== fields.length) {
383
+ flash(
384
+ t('terminal_tracking.terminal_config.validation.allCredentials', 'Enter all credential fields'),
385
+ 'error',
386
+ )
387
+ return null
388
+ }
389
+ authConfig = Object.fromEntries(fields.map((f) => [f.key, form.credentials[f.key].trim()]))
390
+ } else if (!isEdit) {
391
+ flash(
392
+ t('terminal_tracking.terminal_config.validation.credentialsRequired', 'Credentials are required'),
393
+ 'error',
394
+ )
395
+ return null
396
+ }
397
+ // On edit with no credentials entered, authConfig stays undefined → unchanged.
398
+
399
+ const showOAuth = ADAPTER_SPECS[form.adapterType]?.showOAuth ?? false
400
+ const body: Record<string, unknown> = {
401
+ adapterType: form.adapterType.trim() || 'n4',
402
+ displayName: form.displayName.trim(),
403
+ baseUrl: form.baseUrl.trim(),
404
+ proxyUrl: form.proxyUrl.trim() || null,
405
+ authType: form.authType,
406
+ tokenUrl: showOAuth ? form.tokenUrl.trim() || null : null,
407
+ scope: showOAuth ? form.scope.trim() || null : null,
408
+ clientId: showOAuth ? form.clientId.trim() || null : null,
409
+ unlocode: form.unlocode.trim() || null,
410
+ bicCodes: csv(form.bicCodes) ?? null,
411
+ smdgCodes: csv(form.smdgCodes) ?? null,
412
+ nameAliases: csv(form.nameAliases) ?? null,
413
+ rateLimitRequests: form.rateLimitRequests,
414
+ rateLimitWindowSeconds: form.rateLimitWindowSeconds,
415
+ isActive: form.isActive,
416
+ }
417
+ if (authConfig !== undefined) body.authConfig = authConfig
418
+ if (!isEdit) body.terminalCode = form.terminalCode.trim()
419
+ else body.id = editingId
420
+ return body
421
+ }, [form, isEdit, editingId, t])
422
+
423
+ const handleSubmit = useCallback(async () => {
424
+ if (submitting) return
425
+ if (!isEdit && !form.terminalCode.trim()) {
426
+ flash(t('terminal_tracking.terminal_config.validation.codeRequired', 'Terminal code is required'), 'error')
427
+ return
428
+ }
429
+ if (!form.displayName.trim() || !form.baseUrl.trim()) {
430
+ flash(t('terminal_tracking.terminal_config.validation.required', 'Name and base URL are required'), 'error')
431
+ return
432
+ }
433
+ const body = buildBody()
434
+ if (!body) return
435
+ setSubmitting(true)
436
+ try {
437
+ await apiCallOrThrow(API, { method: isEdit ? 'PUT' : 'POST', body: JSON.stringify(body) })
438
+ flash(
439
+ isEdit
440
+ ? t('terminal_tracking.terminal_config.flash.updated', 'Terminal updated')
441
+ : t('terminal_tracking.terminal_config.flash.created', 'Terminal created'),
442
+ 'success',
443
+ )
444
+ setDialogOpen(false)
445
+ table.refresh()
446
+ } catch (err) {
447
+ flash(err instanceof Error ? err.message : 'Failed to save terminal', 'error')
448
+ } finally {
449
+ setSubmitting(false)
450
+ }
451
+ }, [submitting, isEdit, form, buildBody, table, t])
452
+
453
+ const handleDelete = useCallback(
454
+ async (id: string) => {
455
+ if (!confirm(t('terminal_tracking.terminal_config.confirmDelete', 'Delete this terminal?'))) return
456
+ try {
457
+ await apiCallOrThrow(API, { method: 'DELETE', body: JSON.stringify({ id }) })
458
+ flash(t('terminal_tracking.terminal_config.flash.deleted', 'Terminal deleted'), 'success')
459
+ table.refresh()
460
+ } catch (err) {
461
+ flash(err instanceof Error ? err.message : 'Failed to delete terminal', 'error')
462
+ }
463
+ },
464
+ [table, t],
465
+ )
466
+
467
+ const handleTest = useCallback(
468
+ async (id: string) => {
469
+ setTestResult(null)
470
+ try {
471
+ const { result } = await apiCallOrThrow<{ success: boolean; message: string }>(
472
+ `${API}/test`,
473
+ { method: 'POST', body: JSON.stringify({ id }) },
474
+ )
475
+ flash(
476
+ result?.success ? t('terminal_tracking.terminal_config.flash.testOk', 'Connection OK') : (result?.message ?? 'Connection failed'),
477
+ result?.success ? 'success' : 'error',
478
+ )
479
+ } catch (err) {
480
+ flash(err instanceof Error ? err.message : 'Connection test failed', 'error')
481
+ }
482
+ },
483
+ [t],
484
+ )
485
+
486
+ const handleDialogKeyDown = useCallback(
487
+ (event: React.KeyboardEvent) => {
488
+ if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
489
+ event.preventDefault()
490
+ handleSubmit()
491
+ }
492
+ },
493
+ [handleSubmit],
494
+ )
495
+
496
+ const actionsRenderer = useCallback(
497
+ (rowData: { id: string }) => {
498
+ if (!rowData?.id) return null
499
+ const items: RowActionItem[] = [
500
+ { label: t('terminal_tracking.terminal_config.actions.test', 'Test connection'), onSelect: () => handleTest(rowData.id) },
501
+ { label: t('common.edit', 'Edit'), onSelect: () => openEdit(rowData.id) },
502
+ { label: t('common.delete', 'Delete'), onSelect: () => handleDelete(rowData.id), destructive: true },
503
+ ]
504
+ return <RowActions items={items} />
505
+ },
506
+ [t, handleTest, openEdit, handleDelete],
507
+ )
508
+
509
+ if (table.isLoading) {
510
+ return (
511
+ <Page>
512
+ <PageBody className="space-y-6">
513
+ <Card>
514
+ <CardHeader>
515
+ <CardTitle>{t('terminal_tracking.terminal_config.title', 'Terminals')}</CardTitle>
516
+ </CardHeader>
517
+ <CardContent>
518
+ <TableSkeleton rows={5} columns={5} />
519
+ </CardContent>
520
+ </Card>
521
+ </PageBody>
522
+ </Page>
523
+ )
524
+ }
525
+
526
+ const credPlaceholder = (f: CredField) =>
527
+ isEdit && f.secret ? '•••• ' + t('terminal_tracking.terminal_config.unchanged', 'unchanged') : f.placeholder
528
+
529
+ return (
530
+ <Page>
531
+ <PageBody className="space-y-6">
532
+ <Card>
533
+ <CardHeader>
534
+ <CardTitle>{t('terminal_tracking.terminal_config.title', 'Terminals')}</CardTitle>
535
+ <CardDescription>
536
+ {t(
537
+ 'terminal_tracking.terminal_config.description',
538
+ 'Configure container terminal APIs (auth + URLs) and the identifiers used to match them.',
539
+ )}
540
+ </CardDescription>
541
+ </CardHeader>
542
+ <CardContent>
543
+ <div style={{ height: 420 }}>
544
+ <DynamicTable {...table.props} actionsRenderer={actionsRenderer} />
545
+ </div>
546
+ </CardContent>
547
+ </Card>
548
+
549
+ <Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
550
+ <DialogContent className="sm:max-w-xl max-h-[85vh] overflow-y-auto" onKeyDown={handleDialogKeyDown}>
551
+ <DialogHeader>
552
+ <DialogTitle>
553
+ {isEdit
554
+ ? t('terminal_tracking.terminal_config.edit', 'Edit Terminal')
555
+ : t('terminal_tracking.terminal_config.create', 'Add Terminal')}
556
+ </DialogTitle>
557
+ </DialogHeader>
558
+
559
+ <div className="grid gap-4 py-2">
560
+ {/* ── Terminal ────────────────────────────────── */}
561
+ <SectionLabel>{t('terminal_tracking.terminal_config.section.terminal', 'Terminal')}</SectionLabel>
562
+ <div className="grid grid-cols-2 gap-4">
563
+ <div className="grid gap-2">
564
+ <Label htmlFor="adapterType">{t('terminal_tracking.terminal_config.adapterType', 'Adapter')}</Label>
565
+ <select id="adapterType" className="border rounded h-9 px-2 text-sm" value={form.adapterType}
566
+ onChange={(e) => changeAdapter(e.target.value)}>
567
+ {ADAPTER_ORDER.map((k) => (
568
+ <option key={k} value={k}>{ADAPTER_SPECS[k].label}</option>
569
+ ))}
570
+ </select>
571
+ </div>
572
+ <div className="grid gap-2">
573
+ <Label htmlFor="terminalCode">{t('terminal_tracking.terminal_config.terminalCode', 'Terminal code')}</Label>
574
+ <Input id="terminalCode" value={form.terminalCode} disabled={isEdit}
575
+ onChange={(e) => setField('terminalCode', e.target.value)} placeholder="dct" />
576
+ </div>
577
+ </div>
578
+
579
+ <div className="grid gap-2">
580
+ <Label htmlFor="displayName">{t('terminal_tracking.terminal_config.displayName', 'Name')}</Label>
581
+ <Input id="displayName" value={form.displayName}
582
+ onChange={(e) => setField('displayName', e.target.value)} placeholder="DCT Gdańsk (Baltic Hub)" />
583
+ </div>
584
+
585
+ <div className="grid gap-2">
586
+ <Label htmlFor="baseUrl">{t('terminal_tracking.terminal_config.baseUrl', 'API base URL')}</Label>
587
+ <Input id="baseUrl" value={form.baseUrl}
588
+ onChange={(e) => setField('baseUrl', e.target.value)} placeholder="https://…" />
589
+ </div>
590
+
591
+ {/* ── Authentication ──────────────────────────── */}
592
+ <SectionLabel>{t('terminal_tracking.terminal_config.section.auth', 'Authentication')}</SectionLabel>
593
+
594
+ {spec.showOAuth && (
595
+ <>
596
+ <div className="grid gap-2">
597
+ <Label htmlFor="tokenUrl">{t('terminal_tracking.terminal_config.tokenUrl', 'Token URL')}</Label>
598
+ <Input id="tokenUrl" value={form.tokenUrl}
599
+ onChange={(e) => setField('tokenUrl', e.target.value)} placeholder=".../oauth2/v2.0/token?p=b2c_1_ropc_login" />
600
+ </div>
601
+ <div className="grid grid-cols-2 gap-4">
602
+ <div className="grid gap-2">
603
+ <Label htmlFor="clientId">{t('terminal_tracking.terminal_config.clientId', 'Client ID')}</Label>
604
+ <Input id="clientId" value={form.clientId}
605
+ onChange={(e) => setField('clientId', e.target.value)} placeholder="00000000-0000-0000-0000-000000000000" />
606
+ </div>
607
+ <div className="grid gap-2">
608
+ <Label htmlFor="scope">{t('terminal_tracking.terminal_config.scope', 'Scope')}</Label>
609
+ <Input id="scope" value={form.scope}
610
+ onChange={(e) => setField('scope', e.target.value)} placeholder="https://<tenant>/<app-id>/<scope>" />
611
+ </div>
612
+ </div>
613
+ </>
614
+ )}
615
+
616
+ {credFields.map((f) => (
617
+ <div className="grid gap-2" key={f.key}>
618
+ <Label htmlFor={`cred-${f.key}`}>
619
+ {t(`terminal_tracking.terminal_config.cred.${f.key}`, f.label)}
620
+ </Label>
621
+ <Input
622
+ id={`cred-${f.key}`}
623
+ type={f.secret ? 'password' : 'text'}
624
+ autoComplete="off"
625
+ value={form.credentials[f.key] ?? ''}
626
+ onChange={(e) => setCredential(f.key, e.target.value)}
627
+ placeholder={credPlaceholder(f)}
628
+ />
629
+ </div>
630
+ ))}
631
+ {form.adapterType === 'gct' && (
632
+ <p className="text-xs text-muted-foreground">
633
+ {t(
634
+ 'terminal_tracking.terminal_config.gctHint',
635
+ 'The base32 key shown in the GCT panel next to the QR code. A fresh one-time code is computed from it on each login.',
636
+ )}
637
+ </p>
638
+ )}
639
+ {isEdit && (
640
+ <p className="text-xs text-muted-foreground">
641
+ {t('terminal_tracking.terminal_config.credEditHint', 'Leave credentials blank to keep them unchanged.')}
642
+ </p>
643
+ )}
644
+
645
+ {/* ── Matching ────────────────────────────────── */}
646
+ <SectionLabel>{t('terminal_tracking.terminal_config.section.matching', 'Matching')}</SectionLabel>
647
+ <div className="grid grid-cols-2 gap-4">
648
+ <div className="grid gap-2">
649
+ <Label htmlFor="unlocode">{t('terminal_tracking.terminal_config.unlocode', 'UN/LOCODE')}</Label>
650
+ <Input id="unlocode" value={form.unlocode} maxLength={5}
651
+ onChange={(e) => setField('unlocode', e.target.value.toUpperCase())} placeholder="PLGDN" />
652
+ </div>
653
+ <div className="grid gap-2">
654
+ <Label htmlFor="smdgCodes">{t('terminal_tracking.terminal_config.smdgCodes', 'SMDG codes (csv)')}</Label>
655
+ <Input id="smdgCodes" value={form.smdgCodes}
656
+ onChange={(e) => setField('smdgCodes', e.target.value)} />
657
+ </div>
658
+ </div>
659
+ <div className="grid grid-cols-2 gap-4">
660
+ <div className="grid gap-2">
661
+ <Label htmlFor="bicCodes">{t('terminal_tracking.terminal_config.bicCodes', 'BIC codes (csv)')}</Label>
662
+ <Input id="bicCodes" value={form.bicCodes}
663
+ onChange={(e) => setField('bicCodes', e.target.value)} />
664
+ </div>
665
+ <div className="grid gap-2">
666
+ <Label htmlFor="nameAliases">{t('terminal_tracking.terminal_config.nameAliases', 'Name aliases (csv)')}</Label>
667
+ <Input id="nameAliases" value={form.nameAliases}
668
+ onChange={(e) => setField('nameAliases', e.target.value)} placeholder="Baltic Hub, DCT Gdańsk" />
669
+ </div>
670
+ </div>
671
+
672
+ {/* ── Advanced ────────────────────────────────── */}
673
+ <SectionLabel>{t('terminal_tracking.terminal_config.section.advanced', 'Advanced')}</SectionLabel>
674
+ <div className="grid gap-2">
675
+ <Label htmlFor="proxyUrl">{t('terminal_tracking.terminal_config.proxyUrl', 'API proxy URL (optional)')}</Label>
676
+ <Input id="proxyUrl" value={form.proxyUrl}
677
+ onChange={(e) => setField('proxyUrl', e.target.value)} placeholder="http://proxy.example.com:8888" />
678
+ </div>
679
+ <div className="grid grid-cols-2 gap-4">
680
+ <div className="grid gap-2">
681
+ <Label htmlFor="rlReq">{t('terminal_tracking.terminal_config.rateLimitRequests', 'Rate limit (requests)')}</Label>
682
+ <Input id="rlReq" type="number" min={1} value={form.rateLimitRequests}
683
+ onChange={(e) => setField('rateLimitRequests', Number(e.target.value) || 200)} />
684
+ </div>
685
+ <div className="grid gap-2">
686
+ <Label htmlFor="rlWin">{t('terminal_tracking.terminal_config.rateLimitWindowSeconds', 'Window (seconds)')}</Label>
687
+ <Input id="rlWin" type="number" min={1} value={form.rateLimitWindowSeconds}
688
+ onChange={(e) => setField('rateLimitWindowSeconds', Number(e.target.value) || 60)} />
689
+ </div>
690
+ </div>
691
+
692
+ <div className="flex items-center gap-2">
693
+ <Checkbox id="isActive" checked={form.isActive}
694
+ onCheckedChange={(c) => setField('isActive', c === true)} />
695
+ <Label htmlFor="isActive" className="cursor-pointer">{t('terminal_tracking.terminal_config.isActive', 'Active')}</Label>
696
+ </div>
697
+
698
+ {testResult && (
699
+ <div className={`flex items-center gap-2 rounded-md p-3 text-sm ${testResult.success ? 'bg-green-50 text-green-700' : 'bg-red-50 text-red-700'}`}>
700
+ {testResult.success ? <CheckCircle2 className="h-4 w-4" /> : <XCircle className="h-4 w-4" />}
701
+ <span>{testResult.message}</span>
702
+ </div>
703
+ )}
704
+ </div>
705
+
706
+ <DialogFooter>
707
+ <Button variant="outline" onClick={() => setDialogOpen(false)} disabled={submitting}>
708
+ {t('common.cancel', 'Cancel')}
709
+ </Button>
710
+ <Button onClick={handleSubmit} disabled={submitting}>
711
+ {submitting ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
712
+ {isEdit ? t('common.save', 'Save') : t('common.create', 'Create')}
713
+ </Button>
714
+ </DialogFooter>
715
+ </DialogContent>
716
+ </Dialog>
717
+ </PageBody>
718
+ </Page>
719
+ )
720
+ }
721
+
722
+ function SectionLabel({ children }: { children: React.ReactNode }) {
723
+ return (
724
+ <div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground pt-1">
725
+ {children}
726
+ </div>
727
+ )
728
+ }