'use client' import * as React from 'react' import { useCallback, useMemo, useState } from 'react' import { Page, PageBody } from '@freighttech/ui/backend/Page' import { BooleanIcon } from '@freighttech/ui/backend/ValueIcons' import { DynamicTable, TableSkeleton, useDynamicTablePage } from '@freighttech/ui/backend/dynamic-table' import type { ColumnDef } from '@freighttech/ui/backend/dynamic-table' import { RowActions, type RowActionItem } from '@freighttech/ui/backend/RowActions' import { apiCallOrThrow } from '@freighttech/ui/backend/utils/apiCall' import { flash } from '@freighttech/ui/backend/FlashMessages' import { useT } from '@open-mercato/shared/lib/i18n/context' import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, } from '@freighttech/ui/primitives/dialog' import { Button } from '@freighttech/ui/primitives/button' import { Input } from '@freighttech/ui/primitives/input' import { Label } from '@freighttech/ui/primitives/label' import { Checkbox } from '@freighttech/ui/primitives/checkbox' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@freighttech/ui/primitives/card' import { Loader2, CheckCircle2, XCircle } from 'lucide-react' type AuthType = 'oauth2_password' | 'oauth2_client_credentials' | 'gct_token' | 'basic' type ConfigRow = { id: string terminalCode: string adapterType: string displayName: string baseUrl: string authType: string unlocode: string | null isActive: boolean } function mapItem(item: Record): ConfigRow | null { const id = typeof item.id === 'string' ? item.id : null if (!id) return null return { id, terminalCode: (item.terminalCode as string) ?? '', adapterType: (item.adapterType as string) ?? '', displayName: (item.displayName as string) ?? '', baseUrl: (item.baseUrl as string) ?? '', authType: (item.authType as string) ?? '', unlocode: (item.unlocode as string) ?? null, isActive: item.isActive === true, } } type FormState = { terminalCode: string adapterType: string displayName: string baseUrl: string proxyUrl: string authType: AuthType tokenUrl: string scope: string clientId: string credentials: Record unlocode: string bicCodes: string smdgCodes: string nameAliases: string rateLimitRequests: number rateLimitWindowSeconds: number isActive: boolean } // ── Adapter specs ─────────────────────────────────────────────────────────── // Each adapter maps to one real terminal and one auth type, so selecting the // adapter drives the auth type, the credential fields, and the identifier // defaults. Values are sourced from the N4 adapter spec + terminal-tracking // README (see `.ai/specs/2026-06-08-n4-terminal-tracking-adapter.md` and // `packages/terminal-tracking/README.md`). GCT's base URL is intentionally left // blank — no real value is published in the repo. Likewise the OAuth account // identity (client ID and scope) for each terminal is integrator-specific and // left blank here: the operator fills it in when configuring the terminal. type CredField = { key: string; label: string; secret?: boolean; placeholder?: string } type AdapterSpec = { value: string label: string authType: AuthType /** Show the OAuth block (token URL / client ID / scope). */ showOAuth: boolean defaults: Partial } const ADAPTER_SPECS: Record = { n4: { value: 'n4', label: 'DCT (Gdańsk)', authType: 'oauth2_password', showOAuth: true, defaults: { terminalCode: 'dct', displayName: 'DCT Gdańsk (Baltic Hub)', baseUrl: 'https://api2.baltichub.com/V1/', tokenUrl: 'https://bhctapicustomers.b2clogin.com/bhctapicustomers.onmicrosoft.com/oauth2/v2.0/token?p=b2c_1_ropc_login', // Integrator-specific — configured per deployment, not published here. clientId: '', scope: '', unlocode: 'PLGDN', smdgCodes: 'PLGDNDCT', bicCodes: 'PLGDNSKZB', nameAliases: 'Baltic Hub, DCT Gdańsk', rateLimitRequests: 200, rateLimitWindowSeconds: 60, }, }, bct: { value: 'bct', label: 'BCT / INCOS (Gdynia)', authType: 'basic', showOAuth: false, defaults: { terminalCode: 'bct', displayName: 'BCT Gdynia (INCOS)', baseUrl: 'https://incos.pl', tokenUrl: '', clientId: '', scope: '', unlocode: 'PLGDY', smdgCodes: 'PLGDYPLBCT', bicCodes: 'PLGDYBCTA', nameAliases: 'BCT, Bałtycki Terminal Kontenerowy', // INCOS caps at 10 000 requests/day. rateLimitRequests: 10000, rateLimitWindowSeconds: 86400, }, }, gct: { value: 'gct', label: 'GCT (Gdynia)', authType: 'gct_token', showOAuth: false, defaults: { terminalCode: 'gct', displayName: 'GCT Gdynia', baseUrl: '', tokenUrl: '', clientId: '', scope: '', unlocode: 'PLGDY', smdgCodes: 'PLGDYPLGCT', bicCodes: 'PLGDYEOOJ', nameAliases: 'GCT', rateLimitRequests: 200, rateLimitWindowSeconds: 60, }, }, } const ADAPTER_ORDER = ['n4', 'bct', 'gct'] /** Credential inputs for a given adapter + auth type (structured, no raw JSON). */ function credentialFieldsFor(adapterType: string, authType: AuthType): CredField[] { if (adapterType === 'gct') { return [ { key: 'companyCode', label: 'Company code', placeholder: 'ACME' }, { key: 'loginName', label: 'Login' }, { key: 'totpSecret', label: 'Twój sekret 2FA', secret: true, placeholder: 'JBSWY3DPEHPK3PXP' }, ] } if (adapterType === 'n4' && authType === 'oauth2_client_credentials') { return [{ key: 'clientSecret', label: 'Client secret', secret: true }] } // n4 (ROPC) and bct (basic) both use username/password. return [ { key: 'username', label: 'Username' }, { key: 'password', label: 'Password', secret: true }, ] } const baseForm: FormState = { terminalCode: '', adapterType: 'n4', displayName: '', baseUrl: '', proxyUrl: '', authType: 'oauth2_password', tokenUrl: '', scope: '', clientId: '', credentials: {}, unlocode: '', bicCodes: '', smdgCodes: '', nameAliases: '', rateLimitRequests: 200, rateLimitWindowSeconds: 60, isActive: true, } /** Apply an adapter's defaults onto a form (used on create + adapter switch). */ function applyAdapterDefaults( adapterType: string, prev: FormState, opts: { includeTerminalCode: boolean }, ): FormState { const spec = ADAPTER_SPECS[adapterType] ?? ADAPTER_SPECS.n4 const d = spec.defaults return { ...prev, adapterType, authType: spec.authType, displayName: d.displayName ?? '', baseUrl: d.baseUrl ?? '', tokenUrl: d.tokenUrl ?? '', scope: d.scope ?? '', clientId: d.clientId ?? '', unlocode: d.unlocode ?? '', smdgCodes: d.smdgCodes ?? '', bicCodes: d.bicCodes ?? '', nameAliases: d.nameAliases ?? '', rateLimitRequests: d.rateLimitRequests ?? 200, rateLimitWindowSeconds: d.rateLimitWindowSeconds ?? 60, credentials: {}, ...(opts.includeTerminalCode ? { terminalCode: d.terminalCode ?? '' } : {}), } } const emptyForm: FormState = applyAdapterDefaults('n4', baseForm, { includeTerminalCode: true }) const API = '/api/terminal_tracking/terminal-configs' function csv(value: string): string[] | undefined { const arr = value .split(',') .map((s) => s.trim()) .filter(Boolean) return arr.length ? arr : undefined } export default function TerminalConfigsPage() { const t = useT() const [dialogOpen, setDialogOpen] = useState(false) const [editingId, setEditingId] = useState(null) const [form, setForm] = useState(emptyForm) const [submitting, setSubmitting] = useState(false) const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null) const isEdit = editingId !== null const spec = ADAPTER_SPECS[form.adapterType] ?? ADAPTER_SPECS.n4 const credFields = credentialFieldsFor(form.adapterType, form.authType) const setField = useCallback( (key: K, value: FormState[K]) => setForm((p) => ({ ...p, [key]: value })), [], ) const setCredential = useCallback( (key: string, value: string) => setForm((p) => ({ ...p, credentials: { ...p.credentials, [key]: value } })), [], ) const changeAdapter = useCallback( (adapterType: string) => setForm((p) => applyAdapterDefaults(adapterType, p, { includeTerminalCode: !isEdit })), [isEdit], ) const columns = useMemo( () => [ { data: 'displayName', title: t('terminal_tracking.terminal_config.displayName', 'Name'), width: 180, readOnly: true, renderer: (v: unknown) => {String(v ?? '')}, }, { data: 'terminalCode', title: t('terminal_tracking.terminal_config.terminalCode', 'Terminal code'), width: 120, readOnly: true, }, { data: 'adapterType', title: t('terminal_tracking.terminal_config.adapterType', 'Adapter'), width: 130, readOnly: true, renderer: (v: unknown) => {ADAPTER_SPECS[String(v)]?.label ?? String(v ?? '')}, }, { data: 'unlocode', title: t('terminal_tracking.terminal_config.unlocode', 'UN/LOCODE'), width: 110, readOnly: true, renderer: (v: unknown) => {String(v || '-')}, }, { data: 'isActive', title: t('terminal_tracking.terminal_config.isActive', 'Active'), width: 80, readOnly: true, renderer: (v: unknown) => , }, ], [t], ) const openCreate = useCallback(() => { setEditingId(null) setForm(emptyForm) setTestResult(null) setDialogOpen(true) }, []) const addButton = useMemo( () => ( ), [openCreate, t], ) const table = useDynamicTablePage({ source: API, columns, tableName: t('terminal_tracking.terminal_config.title', 'Terminals'), defaultPageSize: 20, mapApiItem: mapItem, cellEdit: false, tableProps: { height: '100%', uiConfig: { readOnlyStyle: 'normal', hideAddRowButton: true, topBarEnd: addButton, }, }, }) const openEdit = useCallback( (id: string) => { const items = (table.query.data as { items?: Record[] })?.items ?? [] const raw = items.find((r) => r.id === id) if (!raw) return const adapterType = (raw.adapterType as string) ?? 'n4' const rowAuthType = (raw.authType as AuthType) ?? ADAPTER_SPECS[adapterType]?.authType ?? 'oauth2_password' setEditingId(id) setTestResult(null) setForm({ ...emptyForm, terminalCode: (raw.terminalCode as string) ?? '', adapterType, // Preserve the row's stored auth type (e.g. a legacy n4 client-credentials config). authType: rowAuthType, displayName: (raw.displayName as string) ?? '', baseUrl: (raw.baseUrl as string) ?? '', proxyUrl: (raw.proxyUrl as string) ?? '', tokenUrl: (raw.tokenUrl as string) ?? '', scope: (raw.scope as string) ?? '', clientId: (raw.clientId as string) ?? '', credentials: {}, // secrets are never returned; blank = keep unchanged unlocode: (raw.unlocode as string) ?? '', bicCodes: Array.isArray(raw.bicCodes) ? (raw.bicCodes as string[]).join(', ') : '', smdgCodes: Array.isArray(raw.smdgCodes) ? (raw.smdgCodes as string[]).join(', ') : '', nameAliases: Array.isArray(raw.nameAliases) ? (raw.nameAliases as string[]).join(', ') : '', rateLimitRequests: typeof raw.rateLimitRequests === 'number' ? raw.rateLimitRequests : 200, rateLimitWindowSeconds: typeof raw.rateLimitWindowSeconds === 'number' ? raw.rateLimitWindowSeconds : 60, isActive: raw.isActive === true, }) setDialogOpen(true) }, [table.query.data], ) const buildBody = useCallback((): Record | null => { // Assemble authConfig from the structured credential fields. const fields = credentialFieldsFor(form.adapterType, form.authType) const filled = fields.filter((f) => (form.credentials[f.key] ?? '').trim() !== '') let authConfig: Record | undefined if (filled.length > 0) { if (filled.length !== fields.length) { flash( t('terminal_tracking.terminal_config.validation.allCredentials', 'Enter all credential fields'), 'error', ) return null } authConfig = Object.fromEntries(fields.map((f) => [f.key, form.credentials[f.key].trim()])) } else if (!isEdit) { flash( t('terminal_tracking.terminal_config.validation.credentialsRequired', 'Credentials are required'), 'error', ) return null } // On edit with no credentials entered, authConfig stays undefined → unchanged. const showOAuth = ADAPTER_SPECS[form.adapterType]?.showOAuth ?? false const body: Record = { adapterType: form.adapterType.trim() || 'n4', displayName: form.displayName.trim(), baseUrl: form.baseUrl.trim(), proxyUrl: form.proxyUrl.trim() || null, authType: form.authType, tokenUrl: showOAuth ? form.tokenUrl.trim() || null : null, scope: showOAuth ? form.scope.trim() || null : null, clientId: showOAuth ? form.clientId.trim() || null : null, unlocode: form.unlocode.trim() || null, bicCodes: csv(form.bicCodes) ?? null, smdgCodes: csv(form.smdgCodes) ?? null, nameAliases: csv(form.nameAliases) ?? null, rateLimitRequests: form.rateLimitRequests, rateLimitWindowSeconds: form.rateLimitWindowSeconds, isActive: form.isActive, } if (authConfig !== undefined) body.authConfig = authConfig if (!isEdit) body.terminalCode = form.terminalCode.trim() else body.id = editingId return body }, [form, isEdit, editingId, t]) const handleSubmit = useCallback(async () => { if (submitting) return if (!isEdit && !form.terminalCode.trim()) { flash(t('terminal_tracking.terminal_config.validation.codeRequired', 'Terminal code is required'), 'error') return } if (!form.displayName.trim() || !form.baseUrl.trim()) { flash(t('terminal_tracking.terminal_config.validation.required', 'Name and base URL are required'), 'error') return } const body = buildBody() if (!body) return setSubmitting(true) try { await apiCallOrThrow(API, { method: isEdit ? 'PUT' : 'POST', body: JSON.stringify(body) }) flash( isEdit ? t('terminal_tracking.terminal_config.flash.updated', 'Terminal updated') : t('terminal_tracking.terminal_config.flash.created', 'Terminal created'), 'success', ) setDialogOpen(false) table.refresh() } catch (err) { flash(err instanceof Error ? err.message : 'Failed to save terminal', 'error') } finally { setSubmitting(false) } }, [submitting, isEdit, form, buildBody, table, t]) const handleDelete = useCallback( async (id: string) => { if (!confirm(t('terminal_tracking.terminal_config.confirmDelete', 'Delete this terminal?'))) return try { await apiCallOrThrow(API, { method: 'DELETE', body: JSON.stringify({ id }) }) flash(t('terminal_tracking.terminal_config.flash.deleted', 'Terminal deleted'), 'success') table.refresh() } catch (err) { flash(err instanceof Error ? err.message : 'Failed to delete terminal', 'error') } }, [table, t], ) const handleTest = useCallback( async (id: string) => { setTestResult(null) try { const { result } = await apiCallOrThrow<{ success: boolean; message: string }>( `${API}/test`, { method: 'POST', body: JSON.stringify({ id }) }, ) flash( result?.success ? t('terminal_tracking.terminal_config.flash.testOk', 'Connection OK') : (result?.message ?? 'Connection failed'), result?.success ? 'success' : 'error', ) } catch (err) { flash(err instanceof Error ? err.message : 'Connection test failed', 'error') } }, [t], ) const handleDialogKeyDown = useCallback( (event: React.KeyboardEvent) => { if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') { event.preventDefault() handleSubmit() } }, [handleSubmit], ) const actionsRenderer = useCallback( (rowData: { id: string }) => { if (!rowData?.id) return null const items: RowActionItem[] = [ { label: t('terminal_tracking.terminal_config.actions.test', 'Test connection'), onSelect: () => handleTest(rowData.id) }, { label: t('common.edit', 'Edit'), onSelect: () => openEdit(rowData.id) }, { label: t('common.delete', 'Delete'), onSelect: () => handleDelete(rowData.id), destructive: true }, ] return }, [t, handleTest, openEdit, handleDelete], ) if (table.isLoading) { return ( {t('terminal_tracking.terminal_config.title', 'Terminals')} ) } const credPlaceholder = (f: CredField) => isEdit && f.secret ? '•••• ' + t('terminal_tracking.terminal_config.unchanged', 'unchanged') : f.placeholder return ( {t('terminal_tracking.terminal_config.title', 'Terminals')} {t( 'terminal_tracking.terminal_config.description', 'Configure container terminal APIs (auth + URLs) and the identifiers used to match them.', )}
{isEdit ? t('terminal_tracking.terminal_config.edit', 'Edit Terminal') : t('terminal_tracking.terminal_config.create', 'Add Terminal')}
{/* ── Terminal ────────────────────────────────── */} {t('terminal_tracking.terminal_config.section.terminal', 'Terminal')}
setField('terminalCode', e.target.value)} placeholder="dct" />
setField('displayName', e.target.value)} placeholder="DCT Gdańsk (Baltic Hub)" />
setField('baseUrl', e.target.value)} placeholder="https://…" />
{/* ── Authentication ──────────────────────────── */} {t('terminal_tracking.terminal_config.section.auth', 'Authentication')} {spec.showOAuth && ( <>
setField('tokenUrl', e.target.value)} placeholder=".../oauth2/v2.0/token?p=b2c_1_ropc_login" />
setField('clientId', e.target.value)} placeholder="00000000-0000-0000-0000-000000000000" />
setField('scope', e.target.value)} placeholder="https:////" />
)} {credFields.map((f) => (
setCredential(f.key, e.target.value)} placeholder={credPlaceholder(f)} />
))} {form.adapterType === 'gct' && (

{t( 'terminal_tracking.terminal_config.gctHint', '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.', )}

)} {isEdit && (

{t('terminal_tracking.terminal_config.credEditHint', 'Leave credentials blank to keep them unchanged.')}

)} {/* ── Matching ────────────────────────────────── */} {t('terminal_tracking.terminal_config.section.matching', 'Matching')}
setField('unlocode', e.target.value.toUpperCase())} placeholder="PLGDN" />
setField('smdgCodes', e.target.value)} />
setField('bicCodes', e.target.value)} />
setField('nameAliases', e.target.value)} placeholder="Baltic Hub, DCT Gdańsk" />
{/* ── Advanced ────────────────────────────────── */} {t('terminal_tracking.terminal_config.section.advanced', 'Advanced')}
setField('proxyUrl', e.target.value)} placeholder="http://proxy.example.com:8888" />
setField('rateLimitRequests', Number(e.target.value) || 200)} />
setField('rateLimitWindowSeconds', Number(e.target.value) || 60)} />
setField('isActive', c === true)} />
{testResult && (
{testResult.success ? : } {testResult.message}
)}
) } function SectionLabel({ children }: { children: React.ReactNode }) { return (
{children}
) }