From c32639a045989d5eed0a9f75ffa186a9568194a8 Mon Sep 17 00:00:00 2001 From: Sennay Date: Tue, 21 Jul 2026 15:39:37 +0300 Subject: [PATCH 1/7] Delete .github/workflows/malware-scan.yaml --- .github/workflows/malware-scan.yaml | 256 ---------------------------- 1 file changed, 256 deletions(-) delete mode 100644 .github/workflows/malware-scan.yaml diff --git a/.github/workflows/malware-scan.yaml b/.github/workflows/malware-scan.yaml deleted file mode 100644 index 01dbef1f2..000000000 --- a/.github/workflows/malware-scan.yaml +++ /dev/null @@ -1,256 +0,0 @@ -name: Malware & Obfuscation Scan - -on: - push: - branches: ["**"] - pull_request: - branches: ["**"] - # Allow manual triggering for ad-hoc full scans - workflow_dispatch: - inputs: - scan_path: - description: "Sub-directory to scan (leave blank for full repo)" - required: false - default: "." - -# Prevent concurrent scans on the same ref from stepping on each other -concurrency: - group: malware-scan-${{ github.ref }} - cancel-in-progress: true - -permissions: - contents: read - # Needed if you later add GitHub Code Scanning / SARIF upload - security-events: write - -jobs: - malware-scan: - name: Scan for malicious / obfuscated code - runs-on: self-hosted - timeout-minutes: 15 - - steps: - # ── 1. Checkout ──────────────────────────────────────────────────────── - - name: Checkout repository - uses: actions/checkout@v4 - with: - # Full history lets the scanner see every file, not just the diff. - # For very large repos you can set fetch-depth: 1 to speed things up, - # but you may miss injected files in unchanged paths. - fetch-depth: 0 - - # ── 2. Setup Node ────────────────────────────────────────────────────── - - name: Set up Node.js - uses: actions/setup-node@v4 - with: - node-version: "20" - - # ── 3. Install scanner ───────────────────────────────────────────────── - # The scanner is pure Node.js stdlib — no npm install needed. - # We just copy the script into a known location inside the runner. - - name: Install scanner script - run: | - mkdir -p "$RUNNER_TOOL_CACHE/malware-scanner" - cp .github/scripts/scan-malware.js \ - "$RUNNER_TOOL_CACHE/malware-scanner/scan-malware.js" - chmod +x "$RUNNER_TOOL_CACHE/malware-scanner/scan-malware.js" - - # ── 4. Run the scanner ───────────────────────────────────────────────── - - name: Run malware scanner - id: scan - env: - SCAN_JSON_OUT: ${{ runner.temp }}/scan-results.json - run: | - SCAN_PATH="${{ github.event.inputs.scan_path || '.' }}" - echo "Scanning path: $SCAN_PATH" - echo "────────────────────────────────────────" - - node "$RUNNER_TOOL_CACHE/malware-scanner/scan-malware.js" "$SCAN_PATH" - # The script exits 0 (clean), 1 (threats found), or 2 (internal error). - # We want the step to "succeed" so the upload-artifact step always runs, - # but we'll fail the job in the gate step below. - continue-on-error: true - - # ── 5. Upload JSON report as artifact (always, even on failure) ──────── - # Retained so the full per-file, per-rule detail is always downloadable. - # The Telegram message below links directly to the Actions run where - # this artifact appears. - - name: Upload scan report artifact - if: always() - uses: actions/upload-artifact@v4 - with: - name: malware-scan-report-${{ github.sha }} - path: ${{ runner.temp }}/scan-results.json - retention-days: 90 - if-no-files-found: ignore - - # ── 6. Check whether any critical findings were reported ──────────────── - - name: Check for critical findings - id: critical - if: always() - env: - SCAN_JSON: ${{ runner.temp }}/scan-results.json - run: | - node - << 'EOF' - const fs = require('fs'); - - let findings = []; - try { - findings = JSON.parse(fs.readFileSync(process.env.SCAN_JSON, 'utf8')); - } catch { /* missing file = clean run or scanner error */ } - - const criticalCount = findings.filter(f => f.severity === 'CRITICAL').length; - fs.appendFileSync(process.env.GITHUB_OUTPUT, `count=${criticalCount}\n`); - fs.appendFileSync(process.env.GITHUB_OUTPUT, `has_critical=${criticalCount > 0}\n`); - console.log(`Critical findings: ${criticalCount}`); - EOF - - # ── 7. Send Telegram notification for critical findings only ───────────── - # Requires two repository secrets: - # TELEGRAM_BOT_TOKEN — from @BotFather (format: 123456:ABC-xxx) - # TELEGRAM_CHAT_ID — target chat/channel ID (format: -100xxxxxxxxxx) - # - # Intentionally short — plain HTML mode, no code spans, no snippets. - # All special characters that would break MarkdownV2 are avoided entirely. - # Full details are in the artifact linked via the Actions run URL. - - name: Send Telegram notification - if: always() && steps.critical.outputs.has_critical == 'true' - env: - TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }} - TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }} - SCAN_JSON: ${{ runner.temp }}/scan-results.json - GH_REPO: ${{ github.repository }} - GH_SHA: ${{ github.sha }} - GH_REF: ${{ github.ref_name }} - GH_ACTOR: ${{ github.actor }} - GH_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} - run: | - node - << 'EOF' - const fs = require('fs'); - const https = require('https'); - - const token = process.env.TELEGRAM_BOT_TOKEN; - const chatId = process.env.TELEGRAM_CHAT_ID; - const repo = process.env.GH_REPO; - const sha = process.env.GH_SHA.slice(0, 7); - const ref = process.env.GH_REF; - const actor = process.env.GH_ACTOR; - const runUrl = process.env.GH_RUN_URL; - - // HTML-escape only the four characters HTML cares about. - // Using HTML parse_mode means code snippets, file paths, and rule IDs - // with special characters can never break the parser. - const h = s => String(s) - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"'); - - let findings = []; - try { - findings = JSON.parse(fs.readFileSync(process.env.SCAN_JSON, 'utf8')); - } catch { /* missing file = clean run or scanner error */ } - - const counts = { CRITICAL: 0, HIGH: 0, MEDIUM: 0, LOW: 0 }; - for (const f of findings) counts[f.severity] = (counts[f.severity] || 0) + 1; - - const criticalFindings = findings.filter(f => f.severity === 'CRITICAL'); - - // ── Unique affected files ────────────────────────────────────────── - const affectedFiles = [...new Set(criticalFindings.map(f => f.file))]; - - // ── Build a short, fixed-size message ───────────────────────────── - // No snippets, no descriptions — just counts, affected files, and a - // direct link to the artifact. Stays well under 500 chars. - let lines = []; - - lines.push('🚨 Malware Scan — CRITICAL THREATS DETECTED'); - - lines.push(''); - lines.push(`Repo: ${h(repo)}`); - lines.push(`Branch: ${h(ref)} Commit: ${h(sha)}`); - lines.push(`Actor: ${h(actor)}`); - - lines.push(''); - lines.push( - `Findings: ` + - `🔴 ${counts.CRITICAL} CRITICAL ` + - `🟠 ${counts.HIGH} HIGH ` + - `🟡 ${counts.MEDIUM} MEDIUM ` + - `⚪ ${counts.LOW} LOW` - ); - lines.push(''); - lines.push(`Critical affected files (${affectedFiles.length}):`); - // Cap at 10 files to keep the message short - const shown = affectedFiles.slice(0, 10); - for (const f of shown) lines.push(` • ${h(f)}`); - if (affectedFiles.length > 10) { - lines.push(` • … and ${affectedFiles.length - 10} more`); - } - - lines.push(''); - lines.push(`📋 View full run & download report artifact`); - - const text = lines.join('\n'); - - // ── Send via Bot API (HTML parse mode) ──────────────────────────── - const body = JSON.stringify({ - chat_id: chatId, - text, - parse_mode: 'HTML', - disable_web_page_preview: true, - }); - - const options = { - hostname: 'api.telegram.org', - path: `/bot${token}/sendMessage`, - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Content-Length': Buffer.byteLength(body), - }, - }; - - const req = https.request(options, res => { - let data = ''; - res.on('data', chunk => data += chunk); - res.on('end', () => { - const parsed = JSON.parse(data); - if (!parsed.ok) { - console.error('Telegram API error:', JSON.stringify(parsed)); - process.exit(1); - } - console.log('Telegram notification sent successfully.'); - }); - }); - req.on('error', err => { - console.error('Request failed:', err.message); - process.exit(1); - }); - req.write(body); - req.end(); - EOF - - # ── 8. Gate — fail the workflow if critical findings were found ───────── - # Runs after the Telegram step so the alert always fires first. - - name: Fail workflow if critical threats detected - if: always() && steps.critical.outputs.has_critical == 'true' - run: | - echo "::error::⛔ Critical malicious or highly-suspicious code patterns were detected." - echo "::error::Check your Telegram channel for the summary." - echo "::error::Download the 'malware-scan-report' artifact for full details." - echo "::error::Do NOT merge or deploy this branch until findings are reviewed." - exit 1 - - # ── 9. (Optional) Diff-only scan on PRs for faster feedback ─────────── - # Uncomment this block if you want a second, faster pass that only - # checks the files changed in the PR diff. - # - # - name: Diff-only scan (PR only) - # if: github.event_name == 'pull_request' - # env: - # SCAN_JSON_OUT: ${{ runner.temp }}/scan-results-diff.json - # run: | - # git diff --name-only origin/${{ github.base_ref }}...HEAD \ - # | grep -E '\.(js|cjs|mjs|ts|tsx|jsx)$' \ - # | xargs -I{} node "$RUNNER_TOOL_CACHE/malware-scanner/scan-malware.js" {} From 43cf8e7ce8f81594c3a7dc76068edec6296d29ac Mon Sep 17 00:00:00 2001 From: Sennay Date: Tue, 21 Jul 2026 15:40:34 +0300 Subject: [PATCH 2/7] Update .gitignore to remove temporary files Removed temporary files from .gitignore. --- .gitignore | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index 20b771e89..b833848bb 100644 --- a/.gitignore +++ b/.gitignore @@ -23,9 +23,7 @@ coverage/ .vscode/ .claude/ .npmrc -branch_structure.json -temp_auto_push.bat -temp_interactive_push.bat + .nx apps/backoffice/public/_um/ From fef001d86e704f4752ddd28563e164ecc2bdeb43 Mon Sep 17 00:00:00 2001 From: Yonas Tewabe Date: Tue, 28 Jul 2026 11:03:48 +0300 Subject: [PATCH 3/7] Update Dockerfile --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index d54102eb3..34879644d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ FROM node:24-alpine AS deps -WORKDIR /app +WORKDIR / COPY package.json package-lock.json* ./ RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \ npm install --legacy-peer-deps From 296abdf8ae858bc26f62cd5354cfd2114578ef9c Mon Sep 17 00:00:00 2001 From: Yonas Tewabe Date: Tue, 28 Jul 2026 11:07:38 +0300 Subject: [PATCH 4/7] Update Dockerfile --- Dockerfile | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 34879644d..dbab371f4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,7 @@ FROM node:24-alpine AS deps -WORKDIR / +WORKDIR /app COPY package.json package-lock.json* ./ +COPY local-packages/ ./local-packages/ RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \ npm install --legacy-peer-deps From c4af36005c953a4061febacdce576ea71acf7c27 Mon Sep 17 00:00:00 2001 From: fitse-yotor Date: Tue, 28 Jul 2026 12:58:03 +0300 Subject: [PATCH 5/7] feat: add vessel registration feature with dashboard and ownership transfer - Implemented VesselRegistrationPage for managing vessel registrations. - Added localization for vessel registration and ownership transfer in Amharic and English. - Updated PortalLayout to include navigation for vessel registration and ownership transfer. - Created VesselOwnerLayout to restrict access to vessel owner-specific routes. - Integrated routing for vessel registration, application, and ownership transfer pages. --- .../VesselRegistrationHeadDashboardPage.tsx | 165 +++++ .../VesselOwnershipTransferQueuePage.tsx | 415 +++++++++++ .../VesselOwnershipTransferReviewPage.tsx | 334 +++++++++ .../VesselRegistrationFormBuilderPage.tsx | 527 ++++++++++++++ .../pages/VesselRegistrationQueuePage.tsx | 535 ++++++++++++++ .../pages/VesselRegistrationReportPage.tsx | 235 ++++++ .../pages/VesselRegistrationReviewPage.tsx | 393 ++++++++++ apps/backoffice/src/app/i18n/locales/am.ts | 5 + apps/backoffice/src/app/i18n/locales/en.ts | 5 + .../src/app/layouts/BackofficeLayout.tsx | 8 + apps/backoffice/src/app/router/index.tsx | 14 + .../pages/VesselOwnerDashboardPage.tsx | 114 +++ .../pages/VesselOwnerLoginPage.tsx | 128 ++++ .../pages/VesselOwnerRegisterPage.tsx | 199 +++++ .../pages/VesselRegistrationDashboardPage.tsx | 174 +++++ .../pages/OwnershipTransferPage.tsx | 440 ++++++++++++ .../VesselRegistrationApplicationPage.tsx | 678 ++++++++++++++++++ .../pages/VesselRegistrationPage.tsx | 339 +++++++++ apps/portal/src/app/i18n/locales/am.ts | 3 + apps/portal/src/app/i18n/locales/en.ts | 3 + apps/portal/src/app/layouts/PortalLayout.tsx | 9 + .../src/app/layouts/VesselOwnerLayout.tsx | 79 ++ apps/portal/src/app/router.tsx | 30 + 23 files changed, 4832 insertions(+) create mode 100644 apps/backoffice/src/app/features/vessel-registration-head/pages/VesselRegistrationHeadDashboardPage.tsx create mode 100644 apps/backoffice/src/app/features/vessel-registration/pages/VesselOwnershipTransferQueuePage.tsx create mode 100644 apps/backoffice/src/app/features/vessel-registration/pages/VesselOwnershipTransferReviewPage.tsx create mode 100644 apps/backoffice/src/app/features/vessel-registration/pages/VesselRegistrationFormBuilderPage.tsx create mode 100644 apps/backoffice/src/app/features/vessel-registration/pages/VesselRegistrationQueuePage.tsx create mode 100644 apps/backoffice/src/app/features/vessel-registration/pages/VesselRegistrationReportPage.tsx create mode 100644 apps/backoffice/src/app/features/vessel-registration/pages/VesselRegistrationReviewPage.tsx create mode 100644 apps/portal/src/app/features/vessel-owner/pages/VesselOwnerDashboardPage.tsx create mode 100644 apps/portal/src/app/features/vessel-owner/pages/VesselOwnerLoginPage.tsx create mode 100644 apps/portal/src/app/features/vessel-owner/pages/VesselOwnerRegisterPage.tsx create mode 100644 apps/portal/src/app/features/vessel-registration-dashboard/pages/VesselRegistrationDashboardPage.tsx create mode 100644 apps/portal/src/app/features/vessel-registration/pages/OwnershipTransferPage.tsx create mode 100644 apps/portal/src/app/features/vessel-registration/pages/VesselRegistrationApplicationPage.tsx create mode 100644 apps/portal/src/app/features/vessel-registration/pages/VesselRegistrationPage.tsx create mode 100644 apps/portal/src/app/layouts/VesselOwnerLayout.tsx diff --git a/apps/backoffice/src/app/features/vessel-registration-head/pages/VesselRegistrationHeadDashboardPage.tsx b/apps/backoffice/src/app/features/vessel-registration-head/pages/VesselRegistrationHeadDashboardPage.tsx new file mode 100644 index 000000000..fd2dbda78 --- /dev/null +++ b/apps/backoffice/src/app/features/vessel-registration-head/pages/VesselRegistrationHeadDashboardPage.tsx @@ -0,0 +1,165 @@ +import { useNavigate } from 'react-router-dom'; +import { + Anchor, + Badge, + Button, + Grid, + Group, + Paper, + SimpleGrid, + Stack, + Text, + ThemeIcon, + Title, +} from '@mantine/core'; +import { + IconAnchor, + IconArrowRight, + IconChartBar, + IconCircleCheck, + IconClockHour4, + IconFileDescription, + IconTransferIn, +} from '@tabler/icons-react'; +import { MOCK_VESSEL_REGISTRATIONS } from '../../vessel-registration/pages/VesselRegistrationQueuePage'; + +const STATUS_COLOR: Record = { + Pending: 'gray', + 'Under Review': 'yellow', + Approved: 'teal', + Rejected: 'red', + 'Correction Required': 'orange', +}; + +function StatCard({ + label, + value, + icon: Icon, + color, +}: { + label: string; + value: number; + icon: typeof IconAnchor; + color: string; +}) { + return ( + + + + + + + + {value} + + + {label} + + + ); +} + +export function VesselRegistrationHeadDashboardPage() { + const navigate = useNavigate(); + const regs = MOCK_VESSEL_REGISTRATIONS; + + const stats = { + total: regs.length, + underReview: regs.filter((r) => r.status === 'Under Review' || r.status === 'Pending').length, + approved: regs.filter((r) => r.status === 'Approved').length, + renewalsDue: regs.filter((r) => r.renewalStatus === 'Due Soon' || r.renewalStatus === 'Overdue').length, + }; + + const recent = [...regs] + .sort((a, b) => (a.submittedDate < b.submittedDate ? 1 : -1)) + .slice(0, 5); + + return ( + + + + + + + + Vessel Registration Department + + Overview of vessel registration and ownership transfer activity + + + + + + + + + + + + + + + + + + + + + Recent Applications + navigate('/vessel-registration-queue')} style={{ cursor: 'pointer' }}> + + View queue + + + + + + {recent.map((r, i) => ( + navigate(`/vessel-registration-queue/${r.id}`)} + > + + {r.vesselName} + {r.id} — {r.ownerName} + + + {r.status} + + + ))} + + + + + + + Quick Links + + + + + + + + + + ); +} diff --git a/apps/backoffice/src/app/features/vessel-registration/pages/VesselOwnershipTransferQueuePage.tsx b/apps/backoffice/src/app/features/vessel-registration/pages/VesselOwnershipTransferQueuePage.tsx new file mode 100644 index 000000000..3760ec73f --- /dev/null +++ b/apps/backoffice/src/app/features/vessel-registration/pages/VesselOwnershipTransferQueuePage.tsx @@ -0,0 +1,415 @@ +import { useEffect, useRef, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useApiMutation } from '@ema-platform/api'; +import { + Badge, + Button, + Card, + Divider, + Drawer, + Group, + Modal, + Paper, + Select, + SimpleGrid, + Stack, + Table, + Text, + Textarea, + TextInput, + ThemeIcon, + Title, +} from '@mantine/core'; +import { + IconCheck, + IconCircleCheck, + IconFileDescription, + IconSearch, + IconTransferIn, + IconX, +} from '@tabler/icons-react'; +import { notify } from '@ema-platform/ui'; +// --------------------------------------------------------------------------- +// Types & mock data (self-contained — portal page has its own copy) +// --------------------------------------------------------------------------- +export type TransferStatus = 'Pending' | 'Under Review' | 'Approved' | 'Rejected'; + +export interface OwnershipTransferRequest { + id: string; + vesselId: string; + vesselName: string; + category: string; + vesselType: string; + currentOwnerName: string; + currentOwnerIdOrTin: string; + currentOwnerPhone: string; + newOwnerName: string; + newOwnerIdOrTin: string; + newOwnerPhone: string; + newOwnerEmail: string; + newOwnerAddress: string; + transferReason: string; + remarks: string; + status: TransferStatus; + submittedDate: string; + approvalDate: string | null; +} + +export const MOCK_TRANSFER_REQUESTS: OwnershipTransferRequest[] = [ + { + id: 'OT-2024-001', + vesselId: 'VR-2024-001', + vesselName: 'Lake Tana Star', + category: 'Inland Waterway Vessel', + vesselType: 'Passenger Ferry', + currentOwnerName: 'Abebe Girma', + currentOwnerIdOrTin: 'ET-9812345', + currentOwnerPhone: '+251 911 234 567', + newOwnerName: 'Tigist Haile', + newOwnerIdOrTin: 'ET-7743210', + newOwnerPhone: '+251 922 876 543', + newOwnerEmail: 'tigist.haile@email.com', + newOwnerAddress: 'Bahir Dar, Amhara Region', + transferReason: 'Sale / Purchase', + remarks: 'Vessel sold to new owner. Bill of sale attached.', + status: 'Pending', + submittedDate: '2024-06-01', + approvalDate: null, + }, + { + id: 'OT-2024-002', + vesselId: 'VR-2024-002', + vesselName: 'Red Sea Voyager', + category: 'Sea-going Vessel (International)', + vesselType: 'General Cargo', + currentOwnerName: 'Ethio Shipping Lines PLC', + currentOwnerIdOrTin: 'TIN-0045678', + currentOwnerPhone: '+251 115 501 010', + newOwnerName: 'Ethiopian Maritime Transport S.C.', + newOwnerIdOrTin: 'TIN-0078910', + newOwnerPhone: '+251 115 502 020', + newOwnerEmail: 'info@emtsc.et', + newOwnerAddress: 'Addis Ababa, Bole Sub-city', + transferReason: 'Corporate Restructuring', + remarks: 'Merger-related transfer. Court order attached.', + status: 'Under Review', + submittedDate: '2024-05-20', + approvalDate: null, + }, +]; + +const STATUS_COLOR: Record = { + Pending: 'gray', + 'Under Review': 'yellow', + Approved: 'teal', + Rejected: 'red', +}; + +const STATUS_OPTIONS = [ + { value: '', label: 'All Statuses' }, + { value: 'Pending', label: 'Pending' }, + { value: 'Under Review', label: 'Under Review' }, + { value: 'Approved', label: 'Approved' }, + { value: 'Rejected', label: 'Rejected' }, +]; + +const ACTION_STATUSES = ['Under Review', 'Approved', 'Rejected'] as const; + +// Certificates generated on approval +const INLAND_CERTS = ['Inland Vessel Registration Certificate']; +const SEAGOING_CERTS = [ + 'Certificate of Nationality', + 'Certificate of Ownership', + 'Certificate of Registration', + 'Minimum Safe Manning Certificate', +]; + +export function VesselOwnershipTransferQueuePage() { + const navigate = useNavigate(); + const [records, setRecords] = useState([]); + const [filtered, setFiltered] = useState([]); + const [search, setSearch] = useState(''); + const [statusFilter, setStatusFilter] = useState(''); + const [drawerOpen, setDrawerOpen] = useState(false); + const [selected, setSelected] = useState(null); + const [modalOpen, setModalOpen] = useState(false); + const [newStatus, setNewStatus] = useState(''); + const [remarks, setRemarks] = useState(''); + const [saving, setSaving] = useState(false); + const [fetchTrigger] = useApiMutation(); + const [actionTrigger] = useApiMutation<{ success: boolean }>(); + const fetched = useRef(false); + + useEffect(() => { + if (fetched.current) return; + fetched.current = true; + fetchTrigger({ url: '/vessel-ownership-transfers', method: 'GET' }) + .unwrap() + .then((data) => setRecords(Array.isArray(data) ? data : [data])) + .catch(() => setRecords(MOCK_TRANSFER_REQUESTS)); + }, [fetchTrigger]); + + useEffect(() => { + let result = records; + if (search.trim()) { + const q = search.toLowerCase(); + result = result.filter((r) => + r.vesselName.toLowerCase().includes(q) || + r.id.toLowerCase().includes(q) || + r.currentOwnerName.toLowerCase().includes(q) || + r.newOwnerName.toLowerCase().includes(q) + ); + } + if (statusFilter) result = result.filter((r) => r.status === statusFilter); + setFiltered(result); + }, [records, search, statusFilter]); + + const openDrawer = (req: OwnershipTransferRequest) => { setSelected(req); setDrawerOpen(true); }; + const closeDrawer = () => { setDrawerOpen(false); setSelected(null); }; + + const handleOpenModal = () => { + if (!selected) return; + setNewStatus(selected.status); + setRemarks(selected.remarks ?? ''); + setModalOpen(true); + }; + + const handleAction = async () => { + if (!selected) return; + setSaving(true); + const isApproval = newStatus === 'Approved'; + try { + await actionTrigger({ + url: `/vessel-ownership-transfers/${selected.id}/status`, + method: 'PATCH', + body: { status: newStatus, remarks, generateCertificates: isApproval }, + }).unwrap(); + } catch { /* mock mode */ } + + const today = new Date().toISOString().split('T')[0]; + setRecords((prev) => + prev.map((r) => + r.id === selected.id + ? { ...r, status: newStatus as TransferStatus, remarks, approvalDate: isApproval ? today : r.approvalDate } + : r + ) + ); + setSelected((prev) => prev ? { ...prev, status: newStatus as TransferStatus, remarks, approvalDate: isApproval ? today : prev.approvalDate } : null); + setModalOpen(false); + setSaving(false); + + if (isApproval) { + const certs = selected.category === 'Inland Waterway Vessel' ? INLAND_CERTS : SEAGOING_CERTS; + notify.success(`Ownership transfer approved. ${certs.length} certificate(s) generated for ${selected.newOwnerName}.`); + } else { + notify.success('Status updated.'); + } + }; + + // Stats + const total = records.length; + const pending = records.filter((r) => r.status === 'Pending').length; + const underReview = records.filter((r) => r.status === 'Under Review').length; + const approved = records.filter((r) => r.status === 'Approved').length; + + const isTerminal = selected?.status === 'Approved' || selected?.status === 'Rejected'; + + return ( + + {/* Header */} + + + + +
+ Ownership Transfer Queue + Review and process vessel ownership transfer requests +
+
+ + {/* Stats */} + + {[ + { label: 'Total Requests', value: total, color: 'blue' }, + { label: 'Pending', value: pending, color: 'gray' }, + { label: 'Under Review', value: underReview, color: 'yellow' }, + { label: 'Approved', value: approved, color: 'teal' }, + ].map((s) => ( + + {s.value} + {s.label} + + ))} + + + {/* Filters */} + + } + value={search} + onChange={(e) => setSearch(e.currentTarget.value)} + style={{ flex: 1 }} + /> + ({ value: s, label: s }))} + value={newStatus} + onChange={(v) => setNewStatus(v ?? '')} + /> + {newStatus === 'Approved' && ( + + + + + Approving will transfer ownership to {selected?.newOwnerName} and auto-generate{' '} + {selected?.category === 'Inland Waterway Vessel' ? '1 certificate' : '4 certificates'}. + + + + )} +