diff --git a/.github/scripts/scan.js b/.github/scripts/scan.js new file mode 100644 index 000000000..7b1924b3f --- /dev/null +++ b/.github/scripts/scan.js @@ -0,0 +1,583 @@ +/** + * PolinRider / Famous Chollima Supply-Chain Malware Scanner + * + * Detects the specific injection pattern used in the PolinRider campaign + * attributed to North Korean APT (Void Dokkaebi / Famous Chollima / UNC5342). + * + * IOCs sourced from: + * - Direct analysis of the injected tailwind.config.js sample + * - Socket Security report (May 2026) on roberts/leads compromise + * - PolinRider technical analysis report (Trend Micro / safedep.io) + * + * Zero external dependencies — runs on any Node.js >= 14. + */ + +"use strict"; + +const fs = require("fs"); +const path = require("path"); +const crypto = require("crypto"); + +// ─── Configuration ──────────────────────────────────────────────────────────── + +const CONFIG = { + // Maximum legitimate size for JS config files. + // Real tailwind/postcss/babel configs are rarely > 3 KB. + // Injected files jump to 5–8 KB instantly. + maxLegitConfigBytes: 3072, + + // Minimum whitespace run on a single line that signals hidden payload. + // The campaign uses ~280–510 spaces to push payload off-screen. + minSuspiciousInlineSpaces: 100, + + // Files that are high-value injection targets for this campaign. + targetedFilenames: [ + "tailwind.config.js", + "tailwind.config.ts", + "tailwind.config.cjs", + "tailwind.js", + "postcss.config.js", + "postcss.config.mjs", + "postcss.config.cjs", + "babel.config.js", + "babel.config.cjs", + "next.config.js", + "next.config.mjs", + "next.config.cjs", + "astro.config.mjs", + "astro.config.js", + "vite.config.js", + "vite.config.ts", + "webpack.config.js", + "webpack.mix.js", + ], + + // Files intentionally containing malware indicators for scanner logic/tests. + // These filenames are skipped before malware rules are evaluated. + ignoredFilenames: ["scan.js"], + + // Filesystem paths that indicate active persistence mechanisms + persistenceArtifacts: [ + "temp_auto_push.bat", + "temp_interactive_push.bat", + "branch_structure.json", + // Note: queue.bat and .plist are OS-level; checked separately + ], +}; + +// ─── IOC Definitions ────────────────────────────────────────────────────────── + +/** + * Each rule has: + * id – unique rule identifier for reporting + * severity – CRITICAL | HIGH | MEDIUM + * description – human-readable explanation + * test(content, filePath, lines) – returns array of match details or [] + */ +const RULES = [ + // ── Tier 1: Definitive campaign signatures ───────────────────────────────── + + { + id: "POLINRIDER-001", + severity: "CRITICAL", + description: + "PolinRider string-shuffler variable _$_1e42 — present in every known sample of this campaign", + test(content) { + const matches = []; + const re = /_\$_1e42/g; + let m; + while ((m = re.exec(content)) !== null) { + matches.push(`offset ${m.index}`); + } + return matches; + }, + }, + + { + id: "POLINRIDER-002", + severity: "CRITICAL", + description: + "PolinRider campaign marker global['!'] assignment — used to route C2 traffic", + test(content) { + const matches = []; + const re = /global\s*\[\s*['"]!\s*['"]\s*\]\s*=/g; + let m; + while ((m = re.exec(content)) !== null) { + const snippet = content + .slice(m.index, m.index + 40) + .replace(/\n/g, "\\n"); + matches.push(`"${snippet}"`); + } + return matches; + }, + }, + + { + id: "POLINRIDER-003", + severity: "CRITICAL", + description: + 'PolinRider shuffler seed string "rmcej%otb%" — embedded in the string-decryption bootstrap of the specific variant targeting this repo', + test(content) { + return content.includes("rmcej%otb%") ? ["seed string found"] : []; + }, + }, + + { + id: "POLINRIDER-004", + severity: "CRITICAL", + description: + "Known C2 IP addresses associated with PolinRider infrastructure", + test(content) { + const knownC2 = ["198.105.127.210", "166.88.54.158", "23.27.202.27"]; + return knownC2.filter((ip) => content.includes(ip)); + }, + }, + + { + id: "POLINRIDER-005", + severity: "CRITICAL", + description: + "Known TRON blockchain wallet addresses used as dead-drop C2 resolvers", + test(content) { + const wallets = [ + "TMfKQEd7TJJa5xNZJZ2Lep838vrzrs7mAP", + "TXfxHUet9pJVU1BgVkBAbrES4YUc1nGzcG", + ]; + return wallets.filter((w) => content.includes(w)); + }, + }, + + { + id: "POLINRIDER-006", + severity: "CRITICAL", + description: + "Known Aptos blockchain addresses used as fallback dead-drop resolvers", + test(content) { + const addrs = [ + "0xbe037400670fbf1c32364f762975908dc43eeb38759263e7dfcdabc76380811e", + "0x3f0e5781d0855fb460661ac63257376db1941b2bb522499e4757ecb3ebd5dce3", + ]; + return addrs.filter((a) => content.includes(a)); + }, + }, + + { + id: "POLINRIDER-007", + severity: "CRITICAL", + description: + "Known XOR decryption keys used to decrypt the second-stage payload from BSC transactions", + test(content) { + const keys = ["2[gWfGj;<:-93Z^C", "m6:tTh^D)cBz?NM]"]; + return keys.filter((k) => content.includes(k)); + }, + }, + + { + id: "POLINRIDER-008", + severity: "CRITICAL", + description: + "Known SHA-256 hash of compromised tailwind.js file (Socket Security, 2026-05-31)", + test(content) { + const knownHashes = new Set([ + "96afdba882046385242cbed46871e41147c8055c5d9eff7460847b2c01a77dc3", + "522b28a2f78771715497ba53729d4ab9a50e982322c391379f3bddf7c8cb363f", + ]); + const hash = crypto.createHash("sha256").update(content).digest("hex"); + return knownHashes.has(hash) ? [`SHA-256: ${hash}`] : []; + }, + }, + + // ── Tier 2: Behavioral / structural indicators ───────────────────────────── + + { + id: "POLINRIDER-009", + severity: "HIGH", + description: + "Blockchain RPC infrastructure contact — campaign uses TRON, Aptos, and BSC as dead-drop C2 resolvers", + test(content) { + const endpoints = [ + "trongrid.io", + "aptoslabs.com", + "bsc-dataseed.binance.org", + "bsc-rpc.publicnode.com", + "eth_getTransactionByHash", + ]; + return endpoints.filter((e) => content.includes(e)); + }, + }, + + { + id: "POLINRIDER-010", + severity: "HIGH", + description: + "Hidden process spawn with windowsHide:true — used by InvisibleFerret / BeaverTail stager to launch detached Node.js child processes invisibly", + test(content) { + return /windowsHide\s*:\s*true/.test(content) + ? ["windowsHide:true found"] + : []; + }, + }, + + { + id: "POLINRIDER-011", + severity: "HIGH", + description: + "Duplicate createRequire injection at file top — campaign restores require() for ES module environments by prepending two identical import statements", + test(content) { + const matches = + content.match(/import\s*\{\s*createRequire\s*\}\s*from/g) || []; + return matches.length >= 2 + ? [`Found ${matches.length} duplicate createRequire imports`] + : []; + }, + }, + + { + id: "POLINRIDER-012", + severity: "HIGH", + description: + "Payload hidden after large horizontal whitespace (>100 spaces on one line) — evasion technique to hide code off-screen in editors and GitHub diff views", + test(content, _filePath, lines) { + const hits = []; + lines.forEach((line, i) => { + const spaceRun = line.match(/\s{100,}/); + if (spaceRun) { + hits.push(`line ${i + 1}: ${spaceRun[0].length} consecutive spaces`); + } + }); + return hits; + }, + }, + + { + id: "POLINRIDER-013", + severity: "HIGH", + description: + "Config file size anomaly — legitimate tailwind/postcss/babel configs are < 3 KB; injected files jump to 5–8 KB", + test(content, filePath) { + const bytes = Buffer.byteLength(content, "utf8"); + const base = path.basename(filePath).toLowerCase(); + const isTargeted = CONFIG.targetedFilenames.some( + (f) => f.toLowerCase() === base, + ); + if (isTargeted && bytes > CONFIG.maxLegitConfigBytes) { + return [ + `${bytes} bytes (threshold: ${CONFIG.maxLegitConfigBytes} bytes)`, + ]; + } + return []; + }, + }, + + { + id: "POLINRIDER-014", + severity: "HIGH", + description: + "Persistence artifact detected — files used by temp_auto_push.bat to rewrite git history and propagate infection to all branches", + test(_content, filePath) { + const base = path.basename(filePath); + return CONFIG.persistenceArtifacts.includes(base) ? [base] : []; + }, + }, + + // ── Tier 3: Supporting behavioral indicators ─────────────────────────────── + + { + id: "POLINRIDER-015", + severity: "MEDIUM", + description: + "Campaign marker pattern — numeric string assigned to global['!'], used to select C2 tier (alpha/beta/fallback)", + test(content) { + const matches = []; + // Matches patterns like '8-3317', '9-0264-2', '8-3946-1', 'A4-1928' + const re = + /global\s*\[\s*['"]!\s*['"]\s*\]\s*=\s*['"]([A-Z]?\d[\d-]+)['"]/g; + let m; + while ((m = re.exec(content)) !== null) { + matches.push(`marker value: "${m[1]}"`); + } + return matches; + }, + }, + + { + id: "POLINRIDER-016", + severity: "MEDIUM", + description: + "sfL obfuscation function — secondary string-shuffler present in multi-stage loader variant", + test(content) { + // sfL appears as a named function used to decode the larger payload blob + const occurrences = (content.match(/\bsfL\b/g) || []).length; + return occurrences >= 3 ? [`sfL referenced ${occurrences} times`] : []; + }, + }, + + { + id: "POLINRIDER-017", + severity: "MEDIUM", + description: + "global require/module injection — bootloader dynamically restores Node.js internals to bypass ES module restrictions", + test(content) { + const hits = []; + if (/global\s*\[.*\]\s*=\s*require/.test(content)) + hits.push("global[x] = require"); + if (/global\s*\[.*module.*\]\s*=\s*module/.test(content)) + hits.push("global[x] = module"); + return hits; + }, + }, +]; + +// ─── Scanner Engine ──────────────────────────────────────────────────────────── + +function scanFile(filePath) { + if (shouldIgnoreFile(filePath)) { + return { filePath, findings: [], skipped: true }; + } + + let content; + try { + content = fs.readFileSync(filePath, "utf8"); + } catch (err) { + return { filePath, error: err.message, findings: [] }; + } + + const lines = content.split("\n"); + const findings = []; + + for (const rule of RULES) { + let matches; + try { + matches = rule.test(content, filePath, lines); + } catch (err) { + matches = [`[rule error: ${err.message}]`]; + } + + if (matches && matches.length > 0) { + findings.push({ + id: rule.id, + severity: rule.severity, + description: rule.description, + matches, + }); + } + } + + return { filePath, findings }; +} + +function shouldIgnoreFile(filePath) { + const base = path.basename(filePath).toLowerCase(); + return CONFIG.ignoredFilenames.some((f) => f.toLowerCase() === base); +} + +function walkDir(dir, results = []) { + let entries; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return results; + } + + for (const entry of entries) { + if (entry.name === "node_modules" || entry.name === ".git") continue; + + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + walkDir(full, results); + } else if (entry.isFile() && !shouldIgnoreFile(full)) { + const ext = path.extname(entry.name).toLowerCase(); + const base = entry.name.toLowerCase(); + + // Scan all JS/TS config files + any file matching a targeted name + const isTargetedName = CONFIG.targetedFilenames.some( + (f) => f.toLowerCase() === base, + ); + const isPersistenceArtifact = CONFIG.persistenceArtifacts.some( + (f) => f.toLowerCase() === base, + ); + const isJsLike = [".js", ".mjs", ".cjs", ".ts", ".tsx", ".jsx"].includes( + ext, + ); + + if (isTargetedName || isPersistenceArtifact || isJsLike) { + results.push(full); + } + } + } + + return results; +} + +// ─── Reporting ───────────────────────────────────────────────────────────────── + +const SEVERITY_RANK = { CRITICAL: 3, HIGH: 2, MEDIUM: 1 }; +const ANSI = { + reset: "\x1b[0m", + bold: "\x1b[1m", + red: "\x1b[31m", + yellow: "\x1b[33m", + cyan: "\x1b[36m", + green: "\x1b[32m", + dim: "\x1b[2m", +}; + +function colorSeverity(sev) { + if (sev === "CRITICAL") return `${ANSI.bold}${ANSI.red}${sev}${ANSI.reset}`; + if (sev === "HIGH") return `${ANSI.yellow}${sev}${ANSI.reset}`; + return `${ANSI.cyan}${sev}${ANSI.reset}`; +} + +function printReport(allResults, { json = false, outputFile = null } = {}) { + const infected = allResults.filter( + (r) => r.findings && r.findings.length > 0, + ); + const errors = allResults.filter((r) => r.error); + + if (json) { + const report = { + scannedAt: new Date().toISOString(), + totalFilesScanned: allResults.length, + infectedFiles: infected.length, + results: infected, + errors, + }; + const out = JSON.stringify(report, null, 2); + if (outputFile) { + fs.writeFileSync(outputFile, out); + console.log(`JSON report written to: ${outputFile}`); + } else { + console.log(out); + } + return infected.length > 0; + } + + // Human-readable output + console.log( + `\n${ANSI.bold}╔══════════════════════════════════════════════════════════╗`, + ); + console.log(`║ PolinRider / Famous Chollima Malware Scanner ║`); + console.log( + `╚══════════════════════════════════════════════════════════╝${ANSI.reset}`, + ); + console.log( + `${ANSI.dim}Scanned ${allResults.length} files · ${new Date().toISOString()}${ANSI.reset}\n`, + ); + + if (infected.length === 0) { + console.log( + `${ANSI.green}${ANSI.bold}✓ No infections detected.${ANSI.reset}\n`, + ); + } else { + console.log( + `${ANSI.red}${ANSI.bold}✗ INFECTION DETECTED in ${infected.length} file(s)${ANSI.reset}\n`, + ); + + for (const result of infected) { + // Sort findings by severity descending + const sorted = [...result.findings].sort( + (a, b) => SEVERITY_RANK[b.severity] - SEVERITY_RANK[a.severity], + ); + const topSev = sorted[0].severity; + + console.log( + ` ${colorSeverity(topSev)} ${ANSI.bold}${result.filePath}${ANSI.reset}`, + ); + for (const f of sorted) { + console.log( + ` ${ANSI.dim}[${f.id}]${ANSI.reset} ${colorSeverity(f.severity)} — ${f.description}`, + ); + for (const m of f.matches) { + console.log(` → ${m}`); + } + } + console.log(); + } + + console.log(`${ANSI.bold}Remediation steps:${ANSI.reset}`); + console.log( + ` 1. Immediately isolate the affected machine from the network.`, + ); + console.log( + ` 2. Do NOT run npm install, npm build, or any script on this repo.`, + ); + console.log( + ` 3. Check for running node.exe / node processes with obfuscated args.`, + ); + console.log( + ` 4. Remove all code after the legitimate config closing block.`, + ); + console.log( + ` 5. Remove duplicate 'import { createRequire }' lines at file top.`, + ); + console.log( + ` 6. Recover the git repository from a clean local clone (see docs).`, + ); + console.log( + ` 7. Revoke ALL secrets, tokens, and credentials in .env and CI.`, + ); + console.log( + ` 8. See full remediation guide in the attached incident report.\n`, + ); + } + + if (errors.length > 0) { + console.log(`${ANSI.yellow}Scan errors (${errors.length}):${ANSI.reset}`); + for (const e of errors) { + console.log(` ${e.filePath}: ${e.error}`); + } + console.log(); + } + + return infected.length > 0; +} + +// ─── CLI Entry Point ─────────────────────────────────────────────────────────── + +function main() { + const args = process.argv.slice(2); + const jsonFlag = args.includes("--json"); + const outputFileIdx = args.indexOf("--output"); + const outputFile = outputFileIdx !== -1 ? args[outputFileIdx + 1] : null; + + // Positional args after flags are scan targets + const targets = args.filter( + (a) => !a.startsWith("--") && args[args.indexOf(a) - 1] !== "--output", + ); + + if (targets.length === 0) { + console.error( + "Usage: scan.js [--json] [--output report.json] [path...]", + ); + console.error( + " path can be a file or directory (directories are walked recursively)", + ); + process.exit(1); + } + + const filesToScan = []; + for (const target of targets) { + if (!fs.existsSync(target)) { + console.error(`Path not found: ${target}`); + process.exit(1); + } + const stat = fs.statSync(target); + if (stat.isDirectory()) { + const found = walkDir(target); + filesToScan.push(...found); + } else { + filesToScan.push(target); + } + } + + // Deduplicate + const unique = [...new Set(filesToScan)]; + const allResults = unique.map(scanFile); + + const infected = printReport(allResults, { json: jsonFlag, outputFile }); + + // Exit code 1 if any infection found — used by CI to block deployments + process.exit(infected ? 1 : 0); +} + +main(); diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index dbf44509b..d8413bf71 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1,66 +1,131 @@ name: Deploy Stacks - on: push: branches: - main - dev - staging - paths: - - "apps/edr-freight-api/**" - - "apps/edr-freight-web/**" - - "apps/edr-passenger-api/**" - - "apps/edr-passenger-web/**" - - "packages/**" - - "infrastructure/docker/Dockerfile.web" - - "infrastructure/nginx/**" - - "docker-compose.yaml" - - "pnpm-lock.yaml" - - "scripts/deploy/**" - - ".github/workflows/deploy.yml" workflow_dispatch: -concurrency: - group: deploy-${{ github.ref_name }} - cancel-in-progress: true +permissions: + contents: read jobs: + detect-changes: + name: Detect changed services + runs-on: self-hosted + outputs: + matrix: ${{ steps.filter.outputs.matrix }} + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 2 + + - name: Determine changed services + id: filter + run: | + set -euo pipefail + + ALL_SERVICES=( + "freight-api" + "freight-portal" + "freight-backoffice" + "passenger-api" + "passenger-portal" + "passenger-backoffice" + "payment-api" + ) + + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + JSON=$(printf '%s\n' "${ALL_SERVICES[@]}" | jq -R . | jq -sc .) + echo "matrix=${JSON}" >> "$GITHUB_OUTPUT" + exit 0 + fi + + CHANGED=$(git diff --name-only HEAD~1 HEAD) + echo "=== Changed files ===" + echo "$CHANGED" + echo "=====================" + + SERVICES=() + + NON_DEPLOYABLE_PATTERN="^docs/|^README[.]md$|^DEPLOYMENT[.]md$|^CLAUDE[.]md$|^checkpoint[.]md$|^orgstructure[.]md$|^ITMLS_DB_Design[.]md$|.*[.]md$|^[.]eslintrc|^[.]prettierrc|^[.]editorconfig|^[.]gitignore|^[.]gitattributes|^commitlint[.]config[.]js$" + + GLOBAL_PATTERN="^[.]github/|^docker-compose[.]yaml$|^turbo[.]json$|^tsconfig[.]json$|^tsconfig[.]base[.]json$|^pnpm-workspace[.]yaml$|^pnpm-lock[.]yaml$|^package[.]json$|^[.]env([.][a-z]+)?$|^packages/|^infrastructure/|^scripts/deploy/|^wagon[.][^/]*[.]ts$|^cargo[.][^/]*[.]ts$|^container[.][^/]*[.]ts$|^use-[^/]*[.]ts$|^[^/]*[.]service[.]ts$|^[^/]*[.]entity[.]ts$|^[^/]*-types[.]ts$" + + DEPLOYABLE=$(echo "$CHANGED" | grep -vE "$NON_DEPLOYABLE_PATTERN" || true) + if [ -z "$DEPLOYABLE" ]; then + echo "Only non-deployable files changed. Skipping deploy." + echo "matrix=[]" >> "$GITHUB_OUTPUT" + exit 0 + fi + + if echo "$CHANGED" | grep -qE "$GLOBAL_PATTERN"; then + echo "Global file(s) changed — deploying all services." + JSON=$(printf '%s\n' "${ALL_SERVICES[@]}" | jq -R . | jq -sc .) + echo "matrix=${JSON}" >> "$GITHUB_OUTPUT" + exit 0 + fi + + echo "$CHANGED" | grep -q "^apps/edr-freight-api/" && SERVICES+=("freight-api") + echo "$CHANGED" | grep -q "^apps/edr-freight-web/portal/" && SERVICES+=("freight-portal") + echo "$CHANGED" | grep -q "^apps/edr-freight-web/backoffice/" && SERVICES+=("freight-backoffice") + echo "$CHANGED" | grep -q "^apps/edr-passenger-api/" && SERVICES+=("passenger-api") + echo "$CHANGED" | grep -q "^apps/edr-passenger-web/portal/" && SERVICES+=("passenger-portal") + echo "$CHANGED" | grep -q "^apps/edr-passenger-web/backoffice/" && SERVICES+=("passenger-backoffice") + echo "$CHANGED" | grep -q "^apps/edr-payment-api/" && SERVICES+=("payment-api") + + SERVICES=($(printf '%s\n' "${SERVICES[@]}" | sort -u)) + + if [ ${#SERVICES[@]} -eq 0 ]; then + echo "No deployable service changes detected." + echo "matrix=[]" >> "$GITHUB_OUTPUT" + else + echo "Services to deploy: ${SERVICES[*]}" + JSON=$(printf '%s\n' "${SERVICES[@]}" | jq -R . | jq -sc .) + echo "matrix=${JSON}" >> "$GITHUB_OUTPUT" + fi + deploy: name: Deploy ${{ matrix.service }} + needs: detect-changes + if: ${{ needs.detect-changes.outputs.matrix != '[]' }} runs-on: self-hosted strategy: fail-fast: false matrix: - include: - - project: edr-freight - build_env_file: freight-web.build.env - service: freight-api - # - project: edr-freight - # build_env_file: freight-web.build.env - # service: freight-portal - # - project: edr-freight - # build_env_file: freight-web.build.env - # service: freight-backoffice - - project: edr-passenger - build_env_file: passenger-web.build.env - service: passenger-api - - project: edr-passenger - build_env_file: passenger-web.build.env - service: passenger-portal - - project: edr-passenger - build_env_file: passenger-web.build.env - service: passenger-backoffice + service: ${{ fromJson(needs.detect-changes.outputs.matrix) }} env: - PROJECT: ${{ matrix.project }} BRANCH: ${{ github.ref_name }} DEPLOY_USER: tria - BUILD_ENV_FILE: ${{ matrix.build_env_file }} DOCKER_BUILDKIT: "1" COMPOSE_DOCKER_CLI_BUILD: "1" + steps: - name: Checkout uses: actions/checkout@v4 + - name: Resolve project and build env file + run: | + case "${{ matrix.service }}" in + freight-api|freight-portal|freight-backoffice) + echo "PROJECT=edr-freight" >> "$GITHUB_ENV" + echo "BUILD_ENV_FILE=freight-web.build.env" >> "$GITHUB_ENV" + ;; + passenger-api|passenger-portal|passenger-backoffice) + echo "PROJECT=edr-passenger" >> "$GITHUB_ENV" + echo "BUILD_ENV_FILE=passenger-web.build.env" >> "$GITHUB_ENV" + ;; + payment-api) + echo "PROJECT=edr-payment" >> "$GITHUB_ENV" + echo "BUILD_ENV_FILE=payment-web.build.env" >> "$GITHUB_ENV" + ;; + *) + echo "Unknown service: ${{ matrix.service }}" && exit 1 + ;; + esac + - name: Sync environment from server run: | chmod +x scripts/deploy/*.sh @@ -80,12 +145,12 @@ jobs: - name: Build ${{ matrix.service }} run: | set -euo pipefail - docker compose --project-name "${COMPOSE_PROJECT_NAME}" build --no-cache "${{ matrix.service }}" + docker compose --project-name "${COMPOSE_PROJECT_NAME}" build "${{ matrix.service }}" - name: Deploy ${{ matrix.service }} run: | set -euo pipefail - docker compose --project-name "${COMPOSE_PROJECT_NAME}" up -d "${{ matrix.service }}" + docker compose --project-name "${COMPOSE_PROJECT_NAME}" up -d "${{ matrix.service }}" --force-recreate - name: Remove npm credentials from workspace if: always() diff --git a/.gitignore b/.gitignore index 8eea260e2..ba46f7fd7 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ node_modules/ coverage/ *.tsbuildinfo **/*.tsbuildinfo +**/vite.config.ts.timestamp-*.mjs # env .env @@ -23,6 +24,8 @@ coverage/ .idea/ .vscode/ .npmrc -branch_structure.json -temp_auto_push.bat -temp_interactive_push.bat + +# emacs cache files +*~ +\#*\# +.\#* diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 000000000..80eabfb57 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,6 @@ +[submodule "user-management"] + path = user-management + url = git@github.com:Tria-plc/iamui.git +[submodule "apps/edr-freight-web/backoffice/user-management"] + path = apps/edr-freight-web/backoffice/user-management + url = git@github.com:Tria-plc/iamui.git diff --git a/.npmrc b/.npmrc deleted file mode 100644 index 164947d90..000000000 --- a/.npmrc +++ /dev/null @@ -1,11 +0,0 @@ -# Increase fetch timeouts for network resilience -fetch-timeout=60000 -fetch-retry-mintimeout=20000 -fetch-retry-maxtimeout=120000 - -# GitHub Packages configuration for @tria-plc scope -@tria-plc:registry=https://npm.pkg.github.com -//npm.pkg.github.com/:_authToken=${GITHUB_PACKAGE_TOKEN} - -# Default registry for other packages -registry=https://registry.npmjs.org/ diff --git a/CLAUDE.md b/CLAUDE.md index c06000dac..d67e6c3d5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,6 +12,7 @@ Monorepo for the Ethio Djibouti Railway (EDR) digital platform. Contains the Fre | `edr-freight-web/portal` | `@edr/freight-portal` | React frontend for freight customer/portal users | 5173 | | `edr-freight-web/backoffice` | `@edr/freight-backoffice` | React frontend for freight backoffice employees | 5183 | | `edr-passenger-api` | `@edr/passenger-api` | NestJS API for passenger management | 3002 | +| `edr-payment-api` | `@edr/payment-api` | NestJS payment microservice (intents, webhooks) | 3003 | | `edr-passenger-web/portal` | `@edr/passenger-portal` | React frontend for passenger customer/portal users | 5174 | | `edr-passenger-web/backoffice` | `@edr/passenger-backoffice` | React frontend for passenger backoffice employees | 5184 | @@ -73,6 +74,7 @@ The `@CurrentUser`, `@Roles`, and `@Public` decorators in `@edr/api-common` are - `edr-freight-web/portal`: 5173 - `edr-freight-web/backoffice`: 5183 - `edr-passenger-api`: 3002 +- `edr-payment-api`: 3003 - `edr-passenger-web/portal`: 5174 - `edr-passenger-web/backoffice`: 5184 @@ -80,6 +82,7 @@ The `@CurrentUser`, `@Roles`, and `@Public` decorators in `@edr/api-common` are - `postgres-freight` (port 5433): database `edr_freight` — freight API only. - `postgres-passenger` (port 5434): database `edr_passenger` — passenger API only. +- `edr_payment` schema — lives in the same Postgres database as the domain system (whatever the passenger `DATABASE_URL` points at) but is owned exclusively by `apps/edr-payment-api`. Dedicated DB user, no cross-schema FKs, domain apps have no grants on it (see `docs/payment-service/`). - Each app owns its own DB. No cross-database joins; cross-domain data flows through API calls or message queues. ## Adding a new module to a NestJS app diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index 00ca421d1..2818dcd97 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -61,15 +61,16 @@ The sync script validates this and fails if missing. ### Build env files (optional) -Used for build-time variables (example: Vite API URLs), with `export` syntax: +Used for additional build-time variables (example: Vite API URL for freight web), with `export` syntax: ```bash export FREIGHT_VITE_API_URL=https://freight-api.example.com/api -export PASSENGER_VITE_API_URL=https://passenger-api.example.com ``` These are injected into `GITHUB_ENV` during workflow execution. +> **Passenger web:** `NEXT_PUBLIC_API_URL` does **not** need a separate build env file. Place it directly in the service runtime env file (`passenger-portal.env` / `passenger-backoffice.env`) and the sync script will forward it to the build automatically. + ## Docker Compose Port Mapping `docker-compose.yaml` uses per-service env variables for host/container port mappings: @@ -83,6 +84,51 @@ These are injected into `GITHUB_ENV` during workflow execution. `scripts/deploy/sync-env-from-server.sh` extracts `PORT` from each synced `.env` and exports the corresponding `*_PORT` variable to `GITHUB_ENV`. +## Passenger Web Docker Configuration + +The passenger web apps (portal and backoffice) are deployed as **Next.js applications** using a dedicated Dockerfile: + +- Dockerfile: `infrastructure/docker/Dockerfile.passenger-web` +- Apps: `apps/edr-passenger-web/portal` and `apps/edr-passenger-web/backoffice` + +### Key differences from freight-web + +| Aspect | Freight Web | Passenger Web | +| --- | --- | --- | +| Framework | Vite (SPA) | Next.js (SSR/SSG) | +| Deployment | Static export + nginx | Node.js server | +| Dockerfile | `Dockerfile.web` | `Dockerfile.passenger-web` | +| Final port (container) | 80 (nginx) | driven by `PORT` in service `.env` | +| Build arg | `TURBO_FILTER` | `APP_PACKAGE` + `APP_PATH` + `NEXT_PUBLIC_API_URL` | + +### Build arguments + +The Dockerfile accepts the following build args: + +- `APP_PACKAGE`: Turbo package filter (e.g., `@edr/passenger-portal`) +- `APP_PATH`: App directory path (e.g., `apps/edr-passenger-web/portal`) +- `NEXT_PUBLIC_API_URL`: API URL visible to browser — sourced from `NEXT_PUBLIC_API_URL` in the service `.env` file + +### Port mapping + +Both host and container ports are driven by `PORT` in the service env file. The sync script reads `PORT`, exports `PASSENGER_PORTAL_PORT` / `PASSENGER_BACKOFFICE_PORT` to `GITHUB_ENV`, and `docker-compose.yaml` uses those variables for both sides of the mapping: + +``` +${PASSENGER_PORTAL_PORT:-5174}:${PASSENGER_PORTAL_PORT:-5174} +``` + +This ensures `docker ps` shows `0.0.0.0:->/tcp` with matching ports. + +### Runtime + +The final image runs: + +```bash +npx next start +``` + +Next.js reads `PORT` from the runtime environment (supplied via `env_file` in docker-compose) to determine which port to listen on. + ## GitHub Actions Deployment Flow Workflow file: `.github/workflows/deploy.yml` diff --git a/ITMLS_DB_Design.md b/ITMLS_DB_Design.md new file mode 100644 index 000000000..d4757b7de --- /dev/null +++ b/ITMLS_DB_Design.md @@ -0,0 +1,1002 @@ +# ITMLS — Full Database Design +## Booking & Configuration Domain + +> **Schema:** `freight` +> **Base columns on every table (via `BaseEntity`):** `id UUID PK`, `created_at TIMESTAMPTZ`, `updated_at TIMESTAMPTZ` +> **Principle:** Every business rule lives as a config-table row — not hard-coded in application logic. + +--- + +## Table of Contents + +1. [Design Philosophy](#1-design-philosophy) +2. [Entity Relationship Diagram](#2-entity-relationship-diagram) +3. [Configuration Tables](#3-configuration-tables) + - 3.1 [`service_types`](#31-service_types) + - 3.2 [`container_types`](#32-container_types) + - 3.3 [`cargo_types`](#33-cargo_types) + - 3.4 [`yards`](#34-yards) + - 3.5 [`shipping_lines`](#35-shipping_lines) + - 3.6 [`weight_limit_rules`](#36-weight_limit_rules) + - 3.7 [`rates`](#37-rates) + - 3.8 [`surcharge_types`](#38-surcharge_types) + - 3.9 [`priority_rules`](#39-priority_rules) + - 3.10 [`approval_rules`](#310-approval_rules) +4. [Core Booking Tables](#4-core-booking-tables) + - 4.1 [`booking`](#41-booking) + - 4.2 [`booking_container`](#42-booking_container) + - 4.3 [`booking_cargo_modifier`](#43-booking_cargo_modifier) +5. [Supporting Tables](#5-supporting-tables) + - 5.1 [`booking_approval_step`](#51-booking_approval_step) + - 5.2 [`booking_rate_snapshot`](#52-booking_rate_snapshot) +6. [Business Rule Traceability](#6-business-rule-traceability) +7. [Key Formulas](#7-key-formulas) +8. [Index Strategy](#8-index-strategy) + +--- + +## 1. Design Philosophy + +| Principle | Application | +|---|---| +| **Config-Driven** | Every rule is a row in a config table. Changing a business rule = updating a row, not deploying code. | +| **No Redundancy** | If a flag already exists on a config table it is not duplicated on `booking`. `first_mile_enabled` is removed — `service_types.includes_first_mile` already encodes it. | +| **No Magic Strings** | All cross-table references use UUID FKs. No string-matched lookups. | +| **Immutable Snapshots** | Rates are frozen into `booking_rate_snapshot` at quote time. Rate changes never mutate historical bookings. | +| **Normalized Containers** | Containers are a child table `booking_container`, not a JSONB array, so wagon math and weight checks are proper SQL aggregates. | + +--- + +## 2. Entity Relationship Diagram + +``` +┌──────────────────────────────────────────────────────────────────────────┐ +│ CONFIG LAYER │ +│ │ +│ service_types container_types cargo_types yards shipping_lines │ +│ │ │ │ │ │ │ +└───────┼────────────────┼────────────────┼───────────┼──────────┼─────────┘ + │ │ │ │ │ + ▼ ▼ ▼ ▼ ▼ +┌──────────────────────────────────────────────────────────────────────────┐ +│ BOOKING LAYER │ +│ │ +│ booking ◄──────────────────────────────────────┤ +│ (service_type_id FK) │ +│ (cargo_type_id FK) │ +│ (origin_yard_id FK) │ +│ (destination_yard_id FK) │ +│ (shipping_line_id FK) │ +│ │ │ +│ ┌───────────────┼──────────────────────┐ │ +│ ▼ ▼ ▼ │ +│ booking_container booking_cargo_modifier booking_approval_step │ +│ (container_type_id FK) (surcharge_type_id FK) (approval_rule_id FK) │ +│ (weight_limit_rule_id FK) (rate_snapshot_id FK) │ +│ │ +│ booking_rate_snapshot │ +│ (booking_id FK, rate_id FK) │ +└──────────────────────────────────────────────────────────────────────────┘ + │ │ + ▼ ▼ +┌──────────────────────────────┐ +│ RATE / SURCHARGE LAYER │ +│ │ +│ rates ◄─── surcharge_types │ +│ (rate_id FK) │ +│ │ +│ weight_limit_rules │ +│ (container_type_id FK) │ +│ │ +│ priority_rules │ +│ approval_rules │ +└──────────────────────────────┘ +``` + +**Relationship summary:** + +| From | To | Cardinality | +|---|---|---| +| `booking` | `service_types` | M:1 | +| `booking` | `cargo_types` | M:1 | +| `booking` | `yards` (origin) | M:1 | +| `booking` | `yards` (destination) | M:1 | +| `booking` | `shipping_lines` | M:1 (nullable) | +| `booking` | `booking` (self — renewal) | M:1 (nullable) | +| `booking` | `booking` (self — consolidation) | M:1 (nullable) | +| `booking_container` | `booking` | M:1 | +| `booking_container` | `container_types` | M:1 | +| `booking_container` | `weight_limit_rules` | M:1 (nullable) | +| `booking_cargo_modifier` | `booking` | M:1 | +| `booking_cargo_modifier` | `surcharge_types` | M:1 | +| `booking_cargo_modifier` | `booking_rate_snapshot` | M:1 | +| `booking_approval_step` | `booking` | M:1 | +| `booking_approval_step` | `approval_rules` | M:1 | +| `booking_rate_snapshot` | `booking` | M:1 | +| `booking_rate_snapshot` | `rates` | M:1 | +| `surcharge_types` | `rates` | M:1 | +| `weight_limit_rules` | `container_types` | M:1 | +| `cargo_types` | `cargo_types` (self — parent) | M:1 (nullable) | + +--- + +## 3. Configuration Tables + +> All config tables share `id UUID PK`, `created_at TIMESTAMPTZ`, `updated_at TIMESTAMPTZ` from `BaseEntity`. + +--- + +### 3.1 `service_types` + +**Purpose:** Defines every bookable service combination. A single row fully describes what a service includes — no need for the `booking` table to carry redundant boolean flags. + +| Column | Type | Constraints | Notes | +|---|---|---|---| +| `id` | UUID | PK | | +| `code` | VARCHAR(50) | UNIQUE NOT NULL | `RAIL`, `RAIL_CUSTOMS`, `RAIL_FIRST_MILE`, `RAIL_LAST_MILE`, `RAIL_FULL` | +| `service_name` | VARCHAR(255) | NOT NULL | Customer-facing display name | +| `description` | TEXT | NULL | Optional description | +| `can_be_booked_alone` | BOOLEAN | NOT NULL DEFAULT true | `false` for Customs — cannot be selected without Rail | +| `includes_first_mile` | BOOLEAN | NOT NULL DEFAULT false | If true, `booking.first_mile_pickup_address` is mandatory | +| `includes_last_mile` | BOOLEAN | NOT NULL DEFAULT false | If true, `booking.last_mile_delivery_address` is mandatory | +| `includes_customs` | BOOLEAN | NOT NULL DEFAULT false | If true, customs clearing is bundled | +| `priority_bonus_points` | INT | NOT NULL DEFAULT 0 | Added to `booking.priority_score` when this service is selected | +| `is_active` | BOOLEAN | NOT NULL DEFAULT true | | +| `display_order` | INT | NOT NULL DEFAULT 1 | UI sort order | +| `created_at` | TIMESTAMPTZ | NOT NULL | | +| `updated_at` | TIMESTAMPTZ | NOT NULL | | + +**Indexes:** `code`, `is_active`, `display_order` + +**Seed data:** + +| code | service_name | can_be_booked_alone | includes_first_mile | includes_last_mile | includes_customs | priority_bonus_points | +|---|---|---|---|---|---|---| +| `RAIL` | Rail Transport Only | true | false | false | false | 0 | +| `RAIL_CUSTOMS` | Rail + Customs Clearing | false | false | false | true | 0 | +| `RAIL_FIRST_MILE` | Rail + First-Mile | true | true | false | false | 0 | +| `RAIL_LAST_MILE` | Rail + Last-Mile | true | false | true | false | 0 | +| `RAIL_FULL` | Rail + First-Mile + Last-Mile + Customs | true | true | true | true | 100 | + +**Business rules owned by this table:** +- `can_be_booked_alone = false` → system blocks standalone selection of that service (US-02 Step 1.1) +- `includes_first_mile = true` → renders mandatory Pick-Up Address field on booking form +- `includes_last_mile = true` → renders mandatory Delivery Address field + prompts Container Return selection +- `priority_bonus_points` → feeds directly into `booking.priority_score` formula + +> ✅ **Current code status:** `service-type.entity.ts` matches this design exactly. No changes needed. + +--- + +### 3.2 `container_types` + +**Purpose:** Defines each physical container variant. Wagon math and reefer surcharge logic flow entirely from this table. + +| Column | Type | Constraints | Notes | +|---|---|---|---| +| `id` | UUID | PK | | +| `code` | VARCHAR(20) | UNIQUE NOT NULL | e.g. `20DV`, `40HC`, `40FR`, `20RF`, `40OT`, `TK20`, `OS20` | +| `label` | VARCHAR(100) | NOT NULL | e.g. `"20ft Dry Container"`, `"40ft High Cube"` | +| `size_ft` | SMALLINT | NOT NULL | `20` or `40` | +| `wagons_per_unit` | NUMERIC(4,2) | NOT NULL | `40ft = 1.00`; `20ft = 0.50` | +| `is_reefer` | BOOLEAN | NOT NULL DEFAULT false | Triggers reefer surcharge automatically | +| `is_open_top` | BOOLEAN | NOT NULL DEFAULT false | | +| `is_active` | BOOLEAN | NOT NULL DEFAULT true | | +| `display_order` | INT | NOT NULL DEFAULT 1 | UI sort order | +| `created_at` | TIMESTAMPTZ | NOT NULL | | +| `updated_at` | TIMESTAMPTZ | NOT NULL | | + +**Indexes:** `code`, `is_active` + +**Wagon formula (pure SQL, no application logic):** +```sql +CEILING( SUM(booking_container.quantity * container_types.wagons_per_unit) ) +``` + +**Seed data:** + +| code | label | size_ft | wagons_per_unit | is_reefer | is_open_top | +|---|---|---|---|---|---| +| `20DV` | 20ft Dry Container | 20 | 0.50 | false | false | +| `40HC` | 40ft High Cube Container | 40 | 1.00 | false | false | +| `20RF` | 20ft Reefer Container | 20 | 0.50 | true | false | +| `40OT` | 40ft Open Top Container | 40 | 1.00 | false | true | +| `40FR` | 40ft Flat Rack Container | 40 | 1.00 | false | false | +| `TK20` | 20ft Tank Container | 20 | 0.50 | false | false | +| `OS20` | 20ft Open Side Container | 20 | 0.50 | false | false | + +**Business rules owned by this table:** +- `wagons_per_unit` → drives all wagon allocation math (2 × 20ft = 1 wagon; 1 × 40ft = 1 wagon) +- `is_reefer = true` → auto-triggers `REEFER` surcharge via `surcharge_types` +- `is_open_top = true` → can drive `LASHING` surcharge (via `surcharge_types` config) + +> ⚠️ **Gap — current `container-type.entity.ts`:** +> | Current field | Issue | +> |---|---| +> | `size_code` VARCHAR(20) | Rename to `code` | +> | `description` VARCHAR(100) | Rename to `label`; upgrade to VARCHAR(100) ✓ | +> | `containers_per_wagon` INT | Replace with `wagons_per_unit NUMERIC(4,2)` (inverted logic: current stores containers-per-wagon; target stores wagon fraction per container) | +> | — | **Add:** `size_ft SMALLINT` | +> | — | **Add:** `is_reefer BOOLEAN DEFAULT false` | +> | — | **Add:** `is_open_top BOOLEAN DEFAULT false` | +> | — | **Add:** `display_order INT DEFAULT 1` | + +--- + +### 3.3 `cargo_types` + +**Purpose:** Two-level cargo taxonomy. Self-referencing via `parent_group_id`. The `requires_director_approval` boolean drives which approval chain is instantiated — no magic string matching. + +| Column | Type | Constraints | Notes | +|---|---|---|---| +| `id` | UUID | PK | | +| `code` | VARCHAR(50) | UNIQUE NOT NULL | e.g. `GENERAL`, `BULK`, `BULK_COFFEE`, `BREAK_BULK` | +| `cargo_type_name` | VARCHAR(255) | NOT NULL | Display name | +| `parent_group_id` | UUID | NULL, FK → `cargo_types.id` | NULL = top-level group | +| `show_free_text_box` | BOOLEAN | NOT NULL DEFAULT false | `true` for `BULK_OTHERS`, `BREAK_BULK_OTHERS` — renders free-text input | +| `requires_director_approval` | BOOLEAN | NOT NULL DEFAULT false | `true` = Director + CEO chain; `false` = Line Staff + Director chain | +| `is_active` | BOOLEAN | NOT NULL DEFAULT true | | +| `display_order` | INT | NOT NULL DEFAULT 1 | UI sort order | +| `created_at` | TIMESTAMPTZ | NOT NULL | | +| `updated_at` | TIMESTAMPTZ | NOT NULL | | + +**Indexes:** `code`, `parent_group_id`, `is_active`, `display_order` + +**Seed data:** + +| code | cargo_type_name | parent_group_id | requires_director_approval | show_free_text_box | +|---|---|---|---|---| +| `GENERAL` | General Cargo | NULL | false | false | +| `BULK` | Bulk Cargo | NULL | true | false | +| `BULK_COFFEE` | Coffee | uuid-BULK | true | false | +| `BULK_FERTILIZER` | Fertilizer | uuid-BULK | true | false | +| `BULK_SUGAR` | Sugar | uuid-BULK | true | false | +| `BULK_OIL` | Oil | uuid-BULK | true | false | +| `BULK_LIVESTOCK` | Livestock | uuid-BULK | true | false | +| `BULK_STEEL` | Steel | uuid-BULK | true | false | +| `BULK_OTHERS` | Others (Bulk) | uuid-BULK | true | true | +| `BREAK_BULK` | Break-Bulk | NULL | true | false | +| `BREAK_BULK_MACHINERY` | Machinery | uuid-BREAK_BULK | true | false | +| `BREAK_BULK_RORO` | Ro-Ro | uuid-BREAK_BULK | true | false | +| `BREAK_BULK_OTHERS` | Others (Break-Bulk) | uuid-BREAK_BULK | true | true | + +**Business rules owned by this table:** +- `requires_director_approval` → join key to `approval_rules` to determine which approval chain to instantiate +- `show_free_text_box = true` → renders `cargo_free_text` input on booking form (only for "Others" variants) +- `parent_group_id IS NULL` → top-level group shown as category header in UI + +> ✅ **Current code status:** `cargo-type.entity.ts` matches this design exactly. No changes needed. + +--- + +### 3.4 `yards` + +**Purpose:** All rail terminal locations selectable as booking origin or destination. Trade direction (`IMPORT`/`EXPORT`) is inferred by comparing `origin_yard.country` with `destination_yard.country` — no hard-coded corridor strings. + +| Column | Type | Constraints | Notes | +|---|---|---|---| +| `id` | UUID | PK | | +| `code` | VARCHAR(20) | UNIQUE NOT NULL | e.g. `KALITY`, `DIRE_DAWA`, `DJIB_PORT`, `MOJO` | +| `label` | VARCHAR(100) | NOT NULL | Customer-facing display name | +| `country` | VARCHAR(50) | NOT NULL | `Ethiopia` or `Djibouti` | +| `is_active` | BOOLEAN | NOT NULL DEFAULT true | | +| `display_order` | INT | NOT NULL DEFAULT 1 | UI sort order | +| `created_at` | TIMESTAMPTZ | NOT NULL | | +| `updated_at` | TIMESTAMPTZ | NOT NULL | | + +**Indexes:** `code`, `country`, `is_active` + +**Seed data:** + +| code | label | country | +|---|---|---| +| `KALITY` | Kality Rail Terminal | Ethiopia | +| `MOJO` | Mojo Dry Port | Ethiopia | +| `DIRE_DAWA` | Dire Dawa Yard | Ethiopia | +| `DJIB_PORT` | Djibouti Port Terminal | Djibouti | +| `NAGAD` | Nagad Terminal, Djibouti | Djibouti | + +**Business rules owned by this table:** +- `origin.country ≠ destination.country` → `trade_direction = IMPORT` (origin Djibouti, destination Ethiopia) or `EXPORT` (origin Ethiopia, destination Djibouti) +- `origin.country = destination.country` → intercity/domestic corridor — drives `INTERCITY_*` rate type selection + +> ⚠️ **Gap — no `yards` entity exists in the current codebase.** +> The current `booking` entity stores `origin_station VARCHAR(255)` and `destination_station VARCHAR(255)` as free strings. +> **Action required:** Create `Yard` entity + migration; replace both booking columns with `origin_yard_id UUID FK` and `destination_yard_id UUID FK`. + +--- + +### 3.5 `shipping_lines` + +**Purpose:** Shipping line catalogue including the PIL→Maersk silent mapping rule and the extra-fee notice flag. + +| Column | Type | Constraints | Notes | +|---|---|---|---| +| `id` | UUID | PK | | +| `code` | VARCHAR(20) | UNIQUE NOT NULL | e.g. `MSC`, `CMA_CGM`, `PIL`, `MAERSK`, `ESLSE` | +| `label` | VARCHAR(100) | NOT NULL | Customer-facing name | +| `mapped_to_code` | VARCHAR(20) | NULL | `PIL → MAERSK`; backend uses this code for pricing tier lookup | +| `show_extra_fee_notice` | BOOLEAN | NOT NULL DEFAULT false | If true, quotation renders additional fee notice to customer | +| `is_active` | BOOLEAN | NOT NULL DEFAULT true | | +| `created_at` | TIMESTAMPTZ | NOT NULL | | +| `updated_at` | TIMESTAMPTZ | NOT NULL | | + +**Indexes:** `code`, `is_active` + +**Seed data:** + +| code | label | mapped_to_code | show_extra_fee_notice | +|---|---|---|---| +| `MSC` | MSC | NULL | false | +| `CMA_CGM` | CMA CGM | NULL | false | +| `EVERGREEN` | Evergreen | NULL | false | +| `COSCO` | COSCO | NULL | false | +| `HAPAG_LLOYD` | Hapag-Lloyd | NULL | false | +| `ONE` | ONE | NULL | false | +| `YANG_MING` | Yang Ming | NULL | false | +| `ZIM` | ZIM | NULL | false | +| `MESSINA` | Messina Line | NULL | false | +| `SAFMARINE` | Safmarine | NULL | false | +| `WAN_HAI` | Wan Hai | NULL | false | +| `ESLSE` | Ethiopian Shipping Lines | NULL | false | +| `PIL` | Pacific International Lines | `MAERSK` | true | +| `MAERSK` | Maersk | NULL | false | + +**Business rules owned by this table:** +- `mapped_to_code IS NOT NULL` → backend silently uses the mapped code for all pricing tier lookups +- `show_extra_fee_notice = true` → quotation document explicitly states additional fee to customer (PIL rule, US-02 Step 4B-ii) + +> ⚠️ **Gap — no `shipping_lines` entity exists in the current codebase.** +> **Action required:** Create `ShippingLine` entity + migration; add `shipping_line_id UUID NULL FK → shipping_lines` to `booking`. + +--- + +### 3.6 `weight_limit_rules` + +**Purpose:** Maximum VGM per container type per trade direction. Rules are per exact container variant FK — not just by size — so different container codes can have different limits. + +| Column | Type | Constraints | Notes | +|---|---|---|---| +| `id` | UUID | PK | | +| `container_type_id` | UUID | NOT NULL, FK → `container_types.id` | | +| `trade_direction` | VARCHAR(10) | NOT NULL | `IMPORT`, `EXPORT`, or `ANY` | +| `max_vgm_tons` | NUMERIC(8,3) | NOT NULL | Structural maximum VGM | +| `effective_from` | DATE | NOT NULL | Allows pre-loading future rule changes | +| `effective_to` | DATE | NULL | NULL = currently active rule | +| `created_at` | TIMESTAMPTZ | NOT NULL | | +| `updated_at` | TIMESTAMPTZ | NOT NULL | | + +**Indexes:** `container_type_id`, `trade_direction`, `effective_from` + +**Unique constraint:** `(container_type_id, trade_direction, effective_from)` — prevents duplicate active rules per container/direction. + +**Seed data:** + +| container_type (code) | trade_direction | max_vgm_tons | effective_from | effective_to | +|---|---|---|---|---| +| `40HC` | ANY | 32.500 | 2024-01-01 | NULL | +| `40OT` | ANY | 32.500 | 2024-01-01 | NULL | +| `40FR` | ANY | 32.500 | 2024-01-01 | NULL | +| `20DV` | IMPORT | 20.000 | 2024-01-01 | NULL | +| `20DV` | EXPORT | 25.000 | 2024-01-01 | NULL | +| `20RF` | IMPORT | 20.000 | 2024-01-01 | NULL | +| `20RF` | EXPORT | 25.000 | 2024-01-01 | NULL | +| `TK20` | IMPORT | 20.000 | 2024-01-01 | NULL | +| `TK20` | EXPORT | 25.000 | 2024-01-01 | NULL | +| `OS20` | IMPORT | 20.000 | 2024-01-01 | NULL | +| `OS20` | EXPORT | 25.000 | 2024-01-01 | NULL | + +**Business rules owned by this table:** +- `max_vgm_tons` → compared against `booking_container.total_vgm_tons`; excess drives overweight surcharge calculation +- `effective_from` / `effective_to` → allows rule rotation without deleting historical data; query with `WHERE effective_from <= NOW() AND (effective_to IS NULL OR effective_to > NOW())` +- `trade_direction = ANY` → single rule covers both import and export for that container type + +> ⚠️ **Gap — current `weight-limit-rule.entity.ts` diverges:** +> | Current field | Issue | +> |---|---| +> | `max_weight_tons NUMERIC(10,2)` | Rename to `max_vgm_tons NUMERIC(8,3)` | +> | `warning_threshold_tons NUMERIC(10,2)` | **Remove** — not in design; VGM check is binary (exceeded or not) | +> | `exceeded_action ENUM` | **Remove** — exceeded action is always "apply surcharge via `surcharge_types`" | +> | `surcharge_id UUID FK → surcharges` | **Remove** — surcharge linking goes through `surcharge_types`, not directly from weight rule | +> | — | **Add:** `effective_from DATE NOT NULL` | +> | — | **Add:** `effective_to DATE NULL` | +> | `is_active BOOLEAN` | **Remove** — replaced by `effective_to IS NULL` logic | + +--- + +### 3.7 `rates` + +**Purpose:** Master rate matrix. All rate types live as rows. Status lifecycle enforces Director-proposes / CEO-approves workflow. Rate changes never mutate historical bookings (see `booking_rate_snapshot`). + +| Column | Type | Constraints | Notes | +|---|---|---|---| +| `id` | UUID | PK | | +| `rate_type` | VARCHAR(50) | NOT NULL | See enumeration below | +| `container_type_id` | UUID | NULL, FK → `container_types.id` | NULL for non-container rates (e.g. bulk, flat fees) | +| `trade_direction` | VARCHAR(10) | NULL | `IMPORT`, `EXPORT`, `ANY`, or NULL for direction-agnostic rates | +| `currency` | VARCHAR(5) | NOT NULL | `ETB` or `USD` | +| `rate_value` | NUMERIC(14,4) | NOT NULL | | +| `rate_unit` | VARCHAR(30) | NOT NULL | `PER_WAGON`, `PER_TON`, `PER_CONTAINER`, `PER_KM`, `FLAT` | +| `status` | VARCHAR(20) | NOT NULL DEFAULT 'DRAFT' | `DRAFT` → `PENDING_APPROVAL` → `LIVE` → `SUPERSEDED` | +| `proposed_by_staff_id` | UUID | NOT NULL | Director who submitted the rate | +| `approved_by_ceo_id` | UUID | NULL | CEO who authorized | +| `approved_at` | TIMESTAMPTZ | NULL | | +| `effective_from` | DATE | NOT NULL | | +| `effective_to` | DATE | NULL | NULL = currently active | +| `created_at` | TIMESTAMPTZ | NOT NULL | | +| `updated_at` | TIMESTAMPTZ | NOT NULL | | + +**Indexes:** `rate_type`, `status`, `effective_from`, `container_type_id` + +**`rate_type` enumeration:** + +| Value | Description | +|---|---| +| `CONTAINER_IMPORT` | Rail rate per container, import direction | +| `CONTAINER_EXPORT` | Rail rate per container, export direction | +| `BULK_IMPORT` | Bulk cargo rail rate, import | +| `BULK_EXPORT` | Bulk cargo rail rate, export | +| `INTERCITY_BULK` | Domestic bulk corridor rate | +| `INTERCITY_CONTAINER` | Domestic container corridor rate | +| `FIRST_MILE` | Truck pick-up from warehouse to origin yard | +| `LAST_MILE` | Truck delivery from destination yard to final address | +| `DEMURRAGE` | Container detention fee | +| `LASHING` | Cargo securing/lashing fee | +| `DOUBLE_HANDLING` | Extra handling surcharge | +| `CONTAINER_WITH_RETURN` | Equipment return cost | +| `CANCELLATION_FEE` | Booking cancellation penalty | +| `OVERWEIGHT_PER_TON` | Per-ton fee for VGM exceeding limit | +| `HAZARD_SURCHARGE` | Flat surcharge for hazardous goods | +| `REEFER_SURCHARGE` | Flat surcharge for reefer containers | +| `PIL_EXTRA_FEE` | Additional fee for PIL-mapped shipping line | + +**Rate approval lifecycle:** +``` +Director inputs values → status = DRAFT +Director clicks "Submit for Approval" → status = PENDING_APPROVAL (locked, no edits) +CEO reviews & digitally approves → status = LIVE (applied to all new quotations) +When a new rate supersedes: old row → status = SUPERSEDED; new row → LIVE +``` + +**Business rules owned by this table:** +- Rate matrix covers all 17 rate types; Director proposes, CEO approves (US-07) +- `status = LIVE AND effective_from <= NOW() AND (effective_to IS NULL OR effective_to > NOW())` → the active rate query +- `SUPERSEDED` rows are never deleted — they back `booking_rate_snapshot` for historical accuracy + +> ⚠️ **Gap — no `rates` entity exists in the current codebase.** +> The current `surcharge.entity.ts` stores a `rate` NUMERIC field directly on the surcharge — this is a partial, non-scalable substitute. +> **Action required:** Create `Rate` entity; separate rate management from surcharge configuration entirely. + +--- + +### 3.8 `surcharge_types` + +**Purpose:** Each row is one auto-trigger rule. Defines the condition that fires a surcharge and points to the `rates` row used to price it via a real FK — not a string match. + +| Column | Type | Constraints | Notes | +|---|---|---|---| +| `id` | UUID | PK | | +| `code` | VARCHAR(40) | UNIQUE NOT NULL | e.g. `HAZARD`, `REEFER`, `OVERWEIGHT`, `PIL_FEE`, `CONSOLIDATION` | +| `label` | VARCHAR(100) | NOT NULL | | +| `trigger_condition` | VARCHAR(50) | NOT NULL | See enumeration below | +| `rate_id` | UUID | NOT NULL, FK → `rates.id` | The `LIVE` rate used to price this surcharge | +| `is_active` | BOOLEAN | NOT NULL DEFAULT true | Toggle to globally disable a surcharge without code deploy | +| `created_at` | TIMESTAMPTZ | NOT NULL | | +| `updated_at` | TIMESTAMPTZ | NOT NULL | | + +**Indexes:** `code`, `is_active`, `rate_id` + +**`trigger_condition` enumeration:** + +| Value | Fires When | +|---|---| +| `CARGO_FLAG_HAZARDOUS` | `booking.is_hazardous = true` | +| `CARGO_FLAG_REEFER` | Any `booking_container.container_type.is_reefer = true` | +| `VGM_EXCEEDS_LIMIT` | `booking_container.is_overweight = true` | +| `SHIPPING_LINE_MAPPED` | `booking.shipping_line.mapped_to_code IS NOT NULL` | +| `CONSOLIDATION_ENABLED` | `booking.allow_consolidation = true` | + +**Seed data:** + +| code | label | trigger_condition | rate_id | +|---|---|---|---| +| `HAZARD` | Hazardous Goods Surcharge | `CARGO_FLAG_HAZARDOUS` | FK→ rates(HAZARD_SURCHARGE, LIVE) | +| `REEFER` | Reefer Container Surcharge | `CARGO_FLAG_REEFER` | FK→ rates(REEFER_SURCHARGE, LIVE) | +| `OVERWEIGHT` | Overweight Per-Ton Surcharge | `VGM_EXCEEDS_LIMIT` | FK→ rates(OVERWEIGHT_PER_TON, LIVE) | +| `PIL_FEE` | PIL Extra Fee | `SHIPPING_LINE_MAPPED` | FK→ rates(PIL_EXTRA_FEE, LIVE) | + +> ⚠️ **Gap — current `surcharge-type.entity.ts` and `surcharge.entity.ts` diverge significantly:** +> - Current design has a two-level hierarchy: `surcharge_types` (category) → `surcharges` (instance with rate). The target collapses this into a single `surcharge_types` table with a direct `rate_id FK → rates`. +> - Current `SurchargeType` is missing: `trigger_condition`, `rate_id`. +> - Current `Surcharge` entity (`fee_name`, `calculation_method`, `rate`, `currency`, `apply_to_rail`, `apply_to_first_mile`, `apply_to_last_mile`) is **replaced entirely** by the `rates` table + `surcharge_types.rate_id FK`. +> - **Action required:** Refactor `surcharge_types` to add `trigger_condition` and `rate_id FK`; remove `surcharges` table; migrate rate data to `rates` table. + +--- + +### 3.9 `priority_rules` + +**Purpose:** Scoring rules for the queue engine. Each active row contributes points to `booking.priority_score` when its condition matches. Feature flags control which rules are live without code deployment. + +| Column | Type | Constraints | Notes | +|---|---|---|---| +| `id` | UUID | PK | | +| `code` | VARCHAR(40) | UNIQUE NOT NULL | e.g. `USD_PAYER`, `GOV_REQUEST`, `FREQUENT_USER_GOLD` | +| `label` | VARCHAR(100) | NOT NULL | | +| `score` | INT | NOT NULL DEFAULT 0 | Points added to `booking.priority_score` when condition matches | +| `condition_currency` | VARCHAR(5) | NULL | `USD` = matches only USD-paying bookings; NULL = matches all | +| `is_active` | BOOLEAN | NOT NULL DEFAULT false | Feature flag — toggle per sprint without code deploy | +| `created_at` | TIMESTAMPTZ | NOT NULL | | +| `updated_at` | TIMESTAMPTZ | NOT NULL | | + +**Indexes:** `code`, `is_active` + +**Seed data:** + +| code | label | score | condition_currency | is_active | +|---|---|---|---|---| +| `USD_PAYER` | USD Currency Payer | 100 | `USD` | **true** (ACTIVE) | +| `GOV_REQUEST` | Government Freight Request | 300 | NULL | false (DEACTIVATED) | +| `FREQUENT_USER_GOLD` | Gold-Tier Frequent User | 150 | NULL | false (DEACTIVATED) | + +**Priority score formula:** +``` +booking.priority_score = + SUM(priority_rules.score WHERE is_active = true AND condition matches booking) + + service_types.priority_bonus_points +``` + +**Queue sort order (current active rules):** +``` +1. USD payers with RAIL_FULL service (score = 100 + 100 = 200) ← HIGHEST +2. USD payers with other Rail service (score = 100 + 0 = 100) +3. ETB payers with RAIL_FULL (score = 0 + 100 = 100) +4. All other ETB payers (score = 0) ← LOWEST +Within each tier: sorted by created_at ASC (oldest first) +``` + +> ⚠️ **Gap — current `priority-rule.entity.ts` diverges:** +> | Current field | Issue | +> |---|---| +> | `priority_type ENUM` | Replace with `code VARCHAR(40) UNIQUE` — free text code is more flexible and matches the design | +> | `rule_name VARCHAR(255)` | Rename to `label VARCHAR(100)` | +> | `bonus_points INT` | Rename to `score INT` | +> | `activation_condition TEXT` | **Remove** — replaced by `condition_currency VARCHAR(5) NULL` (structured, queryable) | +> | `description TEXT` | **Remove** — not in design | +> | — | **Add:** `condition_currency VARCHAR(5) NULL` | + +--- + +### 3.10 `approval_rules` + +**Purpose:** Two approval chains stored as ordered step rows. Linked to `cargo_types` via the shared boolean `requires_director_approval` — no string matching required. + +| Column | Type | Constraints | Notes | +|---|---|---|---| +| `id` | UUID | PK | | +| `requires_director_approval` | BOOLEAN | NOT NULL | Join key: matches `cargo_types.requires_director_approval` | +| `step_order` | SMALLINT | NOT NULL | `1` or `2` — sequence of approval steps | +| `required_role` | VARCHAR(30) | NOT NULL | `LINE_STAFF`, `DIRECTOR`, `CEO` | +| `action_label` | VARCHAR(50) | NOT NULL | e.g. `"Review & Approve"`, `"Final Signature"` | +| `blocks_role` | VARCHAR(30) | NULL | Role explicitly blocked from acting at this step | +| `created_at` | TIMESTAMPTZ | NOT NULL | | +| `updated_at` | TIMESTAMPTZ | NOT NULL | | + +**Indexes:** `requires_director_approval`, `step_order` + +**Unique constraint:** `(requires_director_approval, step_order)` — each chain has exactly one row per step. + +**Seed data:** + +| requires_director_approval | step_order | required_role | action_label | blocks_role | +|---|---|---|---|---| +| false | 1 | `LINE_STAFF` | Review & Approve | NULL | +| false | 2 | `DIRECTOR` | Final Signature | `LINE_STAFF` | +| true | 1 | `DIRECTOR` | Review & Approve | `LINE_STAFF` | +| true | 2 | `CEO` | Final Signature | NULL | + +**Approval chain lookup query:** +```sql +SELECT * FROM approval_rules +WHERE requires_director_approval = :cargo_type_requires_director_approval +ORDER BY step_order ASC; +``` + +> ⚠️ **Gap — no `approval_rules` entity exists in the current codebase.** +> **Action required:** Create `ApprovalRule` entity + migration. + +--- + +## 4. Core Booking Tables + +--- + +### 4.1 `booking` + +**Purpose:** The central booking record. References all config tables via FKs. Contains no denormalized copies of config data. + +| Column | Type | Constraints | Notes | +|---|---|---|---| +| `id` | UUID | PK | | +| `reference` | VARCHAR(64) | UNIQUE NOT NULL | System-generated human-readable reference | +| `customer_id` | UUID | NOT NULL | FK → `customers.id` | +| `train_id` | UUID | NULL | FK → `trains.id` | +| `origin_yard_id` | UUID | NOT NULL, FK → `yards.id` | ► Replaces `origin_station VARCHAR` | +| `destination_yard_id` | UUID | NOT NULL, FK → `yards.id` | ► Replaces `destination_station VARCHAR` | +| `trade_direction` | VARCHAR(10) | NOT NULL | `IMPORT` or `EXPORT` — derived from yards, stored for fast query | +| `status` | VARCHAR(40) | NOT NULL DEFAULT 'DRAFT' | Full lifecycle — see below | +| `contract_type` | VARCHAR(20) | NOT NULL | `NEW` or `RENEWAL` | +| `previous_contract_id` | UUID | NULL, FK → `booking.id` | Self-ref for contract renewals | +| `version_number` | INT | NOT NULL DEFAULT 1 | Increments on renewal | +| `service_type_id` | UUID | NOT NULL, FK → `service_types.id` | ► Replaces `service_type VARCHAR` | +| `first_mile_pickup_address` | TEXT | NULL | Required when `service_types.includes_first_mile = true` | +| `last_mile_delivery_address` | TEXT | NULL | Required when `service_types.includes_last_mile = true` | +| `equipment_return` | VARCHAR(20) | NOT NULL DEFAULT 'NA' | `WITH_RETURN`, `WITHOUT_RETURN`, `NA` | +| `cargo_type_id` | UUID | NOT NULL, FK → `cargo_types.id` | ► Replaces `freight_type + freight_subtype VARCHAR` | +| `cargo_free_text` | VARCHAR(200) | NULL | Only populated when `cargo_types.show_free_text_box = true` | +| `shipping_line_id` | UUID | NULL, FK → `shipping_lines.id` | ► New field (US-02 Step 4B-ii) | +| `is_hazardous` | BOOLEAN | NOT NULL DEFAULT false | | +| `cargo_total_weight_vgm` | NUMERIC(12,3) | NOT NULL DEFAULT 0 | Sum of all container VGMs | +| `allow_consolidation` | BOOLEAN | NOT NULL DEFAULT false | Customer opted into wagon sharing | +| `consolidation_partner_id` | UUID | NULL, FK → `booking.id` | Matched consolidation partner booking | +| `payment_currency` | VARCHAR(5) | NOT NULL | `ETB` or `USD` | +| `payment_status` | VARCHAR(20) | NOT NULL DEFAULT 'PENDING' | `PENDING`, `PNR_GENERATED`, `PAID`, `FAILED` | +| `pnr_code` | VARCHAR(50) | NULL | ► New: system-generated PNR for ETB bank payment (US-09) | +| `total_amount` | NUMERIC(14,2) | NOT NULL DEFAULT 0 | | +| `financial_terms` | TEXT | NULL | | +| `scheduled_date` | TIMESTAMPTZ | NOT NULL | | +| `start_date` | DATE | NULL | | +| `end_date` | DATE | NULL | | +| `priority_score` | INT | NOT NULL DEFAULT 0 | Computed at booking creation from priority_rules + service_types.priority_bonus_points | +| `approved_by_staff_id` | UUID | NULL | | +| `approved_by_staff_at` | TIMESTAMPTZ | NULL | | +| `signed_by_director_id` | UUID | NULL | | +| `signed_by_director_at` | TIMESTAMPTZ | NULL | | +| `signed_by_ceo_id` | UUID | NULL | | +| `signed_by_ceo_at` | TIMESTAMPTZ | NULL | | +| `customer_signed_at` | TIMESTAMPTZ | NULL | ► New: when customer applied digital signature (US-11) | +| `fully_executed_at` | TIMESTAMPTZ | NULL | ► New: when contract reached "Fully Executed" state (US-11) | +| `created_at` | TIMESTAMPTZ | NOT NULL | | +| `updated_at` | TIMESTAMPTZ | NOT NULL | | + +**Indexes:** `reference`, `customer_id`, `status`, `trade_direction`, `priority_score DESC`, `service_type_id`, `cargo_type_id`, `origin_yard_id`, `destination_yard_id`, `payment_status` + +**Booking status lifecycle:** +``` +DRAFT + └→ RFQ_SUBMITTED + └→ QUOTATION_SENT + ├→ QUOTATION_APPROVED + │ └→ PENDING_APPROVAL + │ └→ APPROVED + │ └→ SIGNED_CUSTOMER + │ └→ FULLY_EXECUTED + │ └→ PAID + │ └→ IN_TRANSIT + │ └→ COMPLETED + └→ QUOTATION_REJECTED → (archived) + └→ CANCELLED (from any active state) +``` + +**Removed fields vs original entity:** + +| Removed | Reason | +|---|---| +| `origin_station VARCHAR(255)` | Replaced by `origin_yard_id FK → yards` | +| `destination_station VARCHAR(255)` | Replaced by `destination_yard_id FK → yards` | +| `service_type VARCHAR(30)` | Replaced by `service_type_id FK → service_types` | +| `freight_type VARCHAR(20)` | Replaced by `cargo_type_id FK → cargo_types` | +| `freight_subtype VARCHAR(100)` | Replaced by `cargo_free_text VARCHAR(200)` (only for "Others") | +| `containers JSONB` | Replaced by normalized `booking_container` table | +| `first_mile_enabled BOOLEAN` | Redundant — read from `service_types.includes_first_mile` | +| `last_mile_enabled BOOLEAN` | Redundant — read from `service_types.includes_last_mile` | +| `is_refrigerated BOOLEAN` | Redundant — read from `container_types.is_reefer` | + +> ⚠️ **Gap — current `booking.entity.ts` has 9 fields to remove, 7 fields to add, and 5 FKs to introduce:** +> +> **Remove:** +> - `origin_station VARCHAR` +> - `destination_station VARCHAR` +> - `service_type VARCHAR` +> - `freight_type VARCHAR` +> - `freight_subtype VARCHAR` +> - `containers JSONB` +> - `first_mile_enabled BOOLEAN` +> - `last_mile_enabled BOOLEAN` +> - `is_refrigerated BOOLEAN` +> +> **Add:** +> - `origin_yard_id UUID FK → yards` +> - `destination_yard_id UUID FK → yards` +> - `service_type_id UUID FK → service_types` +> - `cargo_type_id UUID FK → cargo_types` +> - `cargo_free_text VARCHAR(200) NULL` +> - `shipping_line_id UUID NULL FK → shipping_lines` +> - `pnr_code VARCHAR(50) NULL` +> - `customer_signed_at TIMESTAMPTZ NULL` +> - `fully_executed_at TIMESTAMPTZ NULL` + +--- + +### 4.2 `booking_container` + +**Purpose:** One row per container line item on a booking. Replaces the `containers JSONB` array. Enables SQL-level wagon math and per-container weight enforcement. + +| Column | Type | Constraints | Notes | +|---|---|---|---| +| `id` | UUID | PK | | +| `booking_id` | UUID | NOT NULL, FK → `booking.id` ON DELETE CASCADE | | +| `container_type_id` | UUID | NOT NULL, FK → `container_types.id` | | +| `quantity` | SMALLINT | NOT NULL | Number of containers of this type | +| `vgm_per_unit_tons` | NUMERIC(10,3) | NOT NULL | VGM for one container | +| `total_vgm_tons` | NUMERIC(12,3) | NOT NULL GENERATED | `quantity × vgm_per_unit_tons` (computed or app-maintained) | +| `wagons_required` | NUMERIC(6,2) | NOT NULL GENERATED | `CEILING(quantity × container_types.wagons_per_unit)` | +| `weight_limit_rule_id` | UUID | NULL, FK → `weight_limit_rules.id` | Rule applied at time of entry — immutable snapshot reference | +| `is_overweight` | BOOLEAN | NOT NULL DEFAULT false | `true` when `total_vgm_tons > weight_limit_rules.max_vgm_tons × quantity` | +| `overweight_excess_tons` | NUMERIC(10,3) | NULL | `MAX(0, total_vgm_tons − max_vgm_tons × quantity)` | +| `created_at` | TIMESTAMPTZ | NOT NULL | | + +**Indexes:** `booking_id`, `container_type_id`, `is_overweight` + +**Wagon math (SQL aggregate across all lines):** +```sql +SELECT CEILING(SUM(bc.quantity * ct.wagons_per_unit)) AS total_wagons +FROM booking_container bc +JOIN container_types ct ON ct.id = bc.container_type_id +WHERE bc.booking_id = :booking_id; +``` + +**Overweight check (per line):** +```sql +UPDATE booking_container +SET + total_vgm_tons = quantity * vgm_per_unit_tons, + is_overweight = (quantity * vgm_per_unit_tons) > (wlr.max_vgm_tons * quantity), + overweight_excess_tons = GREATEST(0, (quantity * vgm_per_unit_tons) - (wlr.max_vgm_tons * quantity)) +FROM weight_limit_rules wlr +WHERE booking_container.weight_limit_rule_id = wlr.id; +``` + +> ⚠️ **Gap — `booking_container` table does not exist in the current codebase.** +> The current `booking` entity stores `containers JSONB` — a non-queryable, non-validated array. +> **Action required:** Create `BookingContainer` entity + migration; remove `containers` from `booking`. + +--- + +### 4.3 `booking_cargo_modifier` + +**Purpose:** One row per surcharge applied to a booking. Created automatically by the rule engine when a `surcharge_types.trigger_condition` is matched. Links to the frozen rate snapshot for immutable billing. + +| Column | Type | Constraints | Notes | +|---|---|---|---| +| `id` | UUID | PK | | +| `booking_id` | UUID | NOT NULL, FK → `booking.id` ON DELETE CASCADE | | +| `surcharge_type_id` | UUID | NOT NULL, FK → `surcharge_types.id` | The rule that was triggered | +| `trigger_value` | NUMERIC(14,4) | NULL | Contextual value — e.g. excess tons for overweight surcharge | +| `calculated_amount` | NUMERIC(14,2) | NOT NULL | Charge in booking currency | +| `rate_snapshot_id` | UUID | NOT NULL, FK → `booking_rate_snapshot.id` | The frozen rate used to compute this charge | +| `created_at` | TIMESTAMPTZ | NOT NULL | | + +**Indexes:** `booking_id`, `surcharge_type_id` + +**Trigger logic (application layer):** +``` +For each active surcharge_type WHERE is_active = true: + Evaluate trigger_condition against booking state: + CARGO_FLAG_HAZARDOUS → booking.is_hazardous = true + CARGO_FLAG_REEFER → any booking_container.container_type.is_reefer = true + VGM_EXCEEDS_LIMIT → any booking_container.is_overweight = true + SHIPPING_LINE_MAPPED → booking.shipping_line.mapped_to_code IS NOT NULL + CONSOLIDATION_ENABLED → booking.allow_consolidation = true + + If triggered: + calculated_amount = trigger_value × rate_snapshot.rate_value + Insert row into booking_cargo_modifier +``` + +> ⚠️ **Gap — `booking_cargo_modifier` table does not exist in the current codebase.** +> **Action required:** Create `BookingCargoModifier` entity + migration. + +--- + +## 5. Supporting Tables + +--- + +### 5.1 `booking_approval_step` + +**Purpose:** One row per approval step per booking. Instantiated from `approval_rules` when a booking is submitted. Columns are partially copied from `approval_rules` to create an immutable audit trail — the audit record is immune to future changes in `approval_rules`. + +| Column | Type | Constraints | Notes | +|---|---|---|---| +| `id` | UUID | PK | | +| `booking_id` | UUID | NOT NULL, FK → `booking.id` ON DELETE CASCADE | | +| `approval_rule_id` | UUID | NOT NULL, FK → `approval_rules.id` | Source rule — for traceability | +| `step_order` | SMALLINT | NOT NULL | Copied from `approval_rules` at creation — immutable | +| `required_role` | VARCHAR(30) | NOT NULL | Copied from `approval_rules` at creation — immutable | +| `status` | VARCHAR(20) | NOT NULL DEFAULT 'PENDING' | `PENDING`, `APPROVED`, `REJECTED`, `SKIPPED` | +| `actioned_by_staff_id` | UUID | NULL | | +| `actioned_at` | TIMESTAMPTZ | NULL | | +| `remarks` | TEXT | NULL | Optional reviewer notes | +| `created_at` | TIMESTAMPTZ | NOT NULL | | + +**Indexes:** `booking_id`, `status`, `step_order` + +**Instantiation logic (on RFQ submission):** +```sql +INSERT INTO booking_approval_step (booking_id, approval_rule_id, step_order, required_role) +SELECT :booking_id, ar.id, ar.step_order, ar.required_role +FROM approval_rules ar +WHERE ar.requires_director_approval = ( + SELECT ct.requires_director_approval + FROM cargo_types ct + WHERE ct.id = :cargo_type_id +) +ORDER BY ar.step_order; +``` + +> ⚠️ **Gap — `booking_approval_step` table does not exist in the current codebase.** +> **Action required:** Create `BookingApprovalStep` entity + migration. + +--- + +### 5.2 `booking_rate_snapshot` + +**Purpose:** Immutable copy of every `LIVE` rate at the moment a quotation is sent. Protects historical billing accuracy — subsequent rate changes never retroactively alter past bookings. + +| Column | Type | Constraints | Notes | +|---|---|---|---| +| `id` | UUID | PK | | +| `booking_id` | UUID | NOT NULL, FK → `booking.id` ON DELETE CASCADE | | +| `rate_id` | UUID | NOT NULL, FK → `rates.id` | Source rate row — for audit traceability | +| `rate_type` | VARCHAR(50) | NOT NULL | Copied from `rates` at snapshot time | +| `rate_value` | NUMERIC(14,4) | NOT NULL | Copied from `rates` at snapshot time | +| `rate_unit` | VARCHAR(30) | NOT NULL | Copied from `rates` at snapshot time | +| `currency` | VARCHAR(5) | NOT NULL | Copied from `rates` at snapshot time | +| `snapshotted_at` | TIMESTAMPTZ | NOT NULL | When this copy was created | +| `created_at` | TIMESTAMPTZ | NOT NULL | | + +**Indexes:** `booking_id`, `rate_id`, `rate_type` + +**Snapshot creation (on quotation send):** +```sql +INSERT INTO booking_rate_snapshot + (booking_id, rate_id, rate_type, rate_value, rate_unit, currency, snapshotted_at) +SELECT + :booking_id, r.id, r.rate_type, r.rate_value, r.rate_unit, r.currency, NOW() +FROM rates r +WHERE r.status = 'LIVE' + AND r.effective_from <= NOW() + AND (r.effective_to IS NULL OR r.effective_to > NOW()); +``` + +> ⚠️ **Gap — `booking_rate_snapshot` table does not exist in the current codebase.** +> **Action required:** Create `BookingRateSnapshot` entity + migration. + +--- + +## 6. Business Rule Traceability + +| Business Rule | User Story | Config Table(s) That Own It | +|---|---|---| +| Customs cannot be selected without Rail | US-02 | `service_types.can_be_booked_alone = false` | +| First-Mile address field is mandatory when selected | US-02 | `service_types.includes_first_mile = true` | +| Last-Mile address field + return prompt is mandatory when selected | US-02 | `service_types.includes_last_mile = true` | +| 1 × 40ft = 1 wagon; 2 × 20ft = 1 wagon | US-02, US-07 | `container_types.wagons_per_unit` | +| 20ft Import max VGM = 20T; Export max = 25T; 40ft max = 32.5T | US-07 | `weight_limit_rules` (FK → `container_types`) | +| Overweight surcharge per excess ton | US-07 | `surcharge_types(OVERWEIGHT)` + `rates(OVERWEIGHT_PER_TON)` via FK | +| Hazardous goods surcharge | US-02, US-07 | `surcharge_types(HAZARD)` + `rates(HAZARD_SURCHARGE)` via FK | +| Reefer container surcharge | US-02, US-07 | `surcharge_types(REEFER)` triggered by `container_types.is_reefer` | +| PIL → Maersk silent mapping + fee notice | US-02 | `shipping_lines.mapped_to_code` + `show_extra_fee_notice` | +| Consolidation surcharge | US-08 | `surcharge_types(CONSOLIDATION)` | +| USD payers ranked higher in queue | US-06 | `priority_rules(USD_PAYER, is_active=true)` | +| RAIL_FULL service boosts queue score | US-06 | `service_types.priority_bonus_points = 100` | +| Bulk cargo → Director + CEO approval chain | US-06 | `cargo_types.requires_director_approval = true` + `approval_rules` | +| Standard cargo → Line Staff + Director chain | US-06 | `cargo_types.requires_director_approval = false` + `approval_rules` | +| Line Staff blocked from Bulk approval Step 1 | US-06 | `approval_rules.blocks_role = 'LINE_STAFF'` | +| Director proposes rates; CEO approves | US-07 | `rates.status` lifecycle (`DRAFT → PENDING_APPROVAL → LIVE`) | +| Old booking rates never change | US-07, US-10 | `booking_rate_snapshot` (frozen copy at quote time) | +| "Others" free-text cargo input | US-02 | `cargo_types.show_free_text_box = true` | +| Trade direction inferred from yard countries | US-02 | `yards.country` comparison | +| ETB payment via PNR code | US-09 | `booking.pnr_code` (system-generated) | +| Contract locked after both signatures | US-11 | `booking.fully_executed_at IS NOT NULL` | + +--- + +## 7. Key Formulas + +### Wagon Allocation + +``` +For 40ft containers: + wagons = quantity × 1.00 → CEILING(sum) + +For 20ft containers: + wagons = quantity × 0.50 → CEILING(sum) + +Mixed example: 3 × 40ft + 5 × 20ft + wagons = CEILING(3 × 1.00 + 5 × 0.50) = CEILING(3.0 + 2.5) = CEILING(5.5) = 6 wagons + +SQL: + SELECT CEILING(SUM(bc.quantity * ct.wagons_per_unit)) + FROM booking_container bc + JOIN container_types ct ON ct.id = bc.container_type_id + WHERE bc.booking_id = :id +``` + +### Overweight Surcharge + +``` +excess_tons = MAX(0, total_vgm_tons − (max_vgm_tons × quantity)) +surcharge = excess_tons × rates(OVERWEIGHT_PER_TON).rate_value + +Example: + 1 × 20ft on IMPORT, VGM = 23T, max = 20T + excess = 23 − 20 = 3T + surcharge = 3 × overweight_rate +``` + +### Priority Score + +``` +priority_score = + SUM(pr.score FROM priority_rules pr + WHERE pr.is_active = true + AND (pr.condition_currency IS NULL OR pr.condition_currency = booking.payment_currency)) + + service_types.priority_bonus_points + +Active examples: + USD payer + RAIL only: 100 + 0 = 100 + USD payer + RAIL_FULL: 100 + 100 = 200 + ETB payer + RAIL_FULL: 0 + 100 = 100 + ETB payer + RAIL: 0 + 0 = 0 + +Queue sort: priority_score DESC, created_at ASC +``` + +### Trade Direction Inference + +``` +IF origin_yard.country = 'Djibouti' AND destination_yard.country = 'Ethiopia' + THEN trade_direction = 'IMPORT' +ELSE IF origin_yard.country = 'Ethiopia' AND destination_yard.country = 'Djibouti' + THEN trade_direction = 'EXPORT' +ELSE + THEN trade_direction = 'DOMESTIC' -- intercity corridor +``` + +--- + +## 8. Index Strategy + +| Table | Index Columns | Type | Rationale | +|---|---|---|---| +| `service_types` | `code` | UNIQUE | Config lookup | +| `service_types` | `is_active`, `display_order` | COMPOSITE | UI list query | +| `container_types` | `code` | UNIQUE | Config lookup | +| `cargo_types` | `code` | UNIQUE | Config lookup | +| `cargo_types` | `parent_group_id` | BTREE | Hierarchy traversal | +| `cargo_types` | `requires_director_approval` | BTREE | Approval chain join | +| `yards` | `code` | UNIQUE | Config lookup | +| `yards` | `country` | BTREE | Trade direction inference | +| `shipping_lines` | `code` | UNIQUE | Config lookup | +| `weight_limit_rules` | `(container_type_id, trade_direction)` | COMPOSITE | Rate lookup per container/direction | +| `rates` | `(rate_type, status, effective_from)` | COMPOSITE | Active rate query | +| `surcharge_types` | `code` | UNIQUE | Trigger lookup | +| `surcharge_types` | `trigger_condition`, `is_active` | COMPOSITE | Rule engine scan | +| `priority_rules` | `is_active` | BTREE | Score computation filter | +| `approval_rules` | `(requires_director_approval, step_order)` | COMPOSITE | Chain instantiation | +| `booking` | `reference` | UNIQUE | Human-readable lookup | +| `booking` | `customer_id`, `status` | COMPOSITE | Customer dashboard | +| `booking` | `priority_score DESC`, `created_at ASC` | COMPOSITE | Queue sort | +| `booking` | `origin_yard_id`, `destination_yard_id` | BTREE | Route filtering | +| `booking_container` | `booking_id` | BTREE | Child lookup | +| `booking_container` | `is_overweight` | BTREE | Overweight report | +| `booking_cargo_modifier` | `booking_id` | BTREE | Surcharge aggregation | +| `booking_approval_step` | `booking_id`, `status` | COMPOSITE | Pending step lookup | +| `booking_rate_snapshot` | `booking_id` | BTREE | Rate reconstruction | + +--- + +*End of Document — ITMLS Booking & Configuration Domain DB Design* +*Generated from: `ITMLS_Entity_Design.md` + `ITMLS_User_Stories_Full_Updated.md` + codebase analysis* +*Schema: `freight` | ORM: TypeORM (NestJS) | DB: PostgreSQL* diff --git a/README.md b/README.md index 7ff862555..125561b9d 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,189 @@ cd edr-platform ``` ### 2. Install Dependencies +# EDR Platform + +Monorepo for the **Ethio-Djibouti Railway** digital platform. Hosts two product lines — **Freight Management** and **Passenger Management** — each with a NestJS API plus React portal and back-office web apps, sharing TypeScript types, NestJS utilities, and a React component library. + +--- + +## Tech Stack + +### Backend + +| Layer | Tech | +| ---------------- | ----------------------------------------------- | +| Runtime | Node.js ≥ 20 | +| Framework | NestJS 11 (modular architecture) | +| Language | TypeScript 5 (strict mode, project-wide) | +| ORM | TypeORM 0.3 (UUID PKs, soft deletes, `snake_case` columns) | +| Database | PostgreSQL 16 (one DB per domain) | +| Validation | class-validator + class-transformer | +| API docs | Swagger via `@nestjs/swagger` | +| Messaging | `@nestjs/microservices` (inter-service ready) | +| Testing | Jest + Supertest | + +### Frontend + +| Layer | Tech | +| ---------------- | ----------------------------------------------- | +| Framework | React 18 + Vite 5 | +| Language | TypeScript 5 (strict) | +| Routing | React Router v6 | +| Styling | Tailwind CSS v4 (`@tailwindcss/vite`) + `tailwind-merge` + `class-variance-authority` | +| UI primitives | Radix UI (meta `radix-ui` package, shadcn-style components) | +| Icons | lucide-react | +| State / Data | Zustand (client state) · TanStack Query (server state) | +| HTTP | Axios | +| Auth UI | `@tria-plc/iamui-common` (external IAM) | + +### Shared Packages + +| Package | Purpose | +| ---------------------- | -------------------------------------------------------------------- | +| `@edr/types` | Shared TypeScript interfaces and enums | +| `@edr/api-common` | NestJS decorators, filters, interceptors, pipes, `BaseEntity`, `BaseRepository` | +| `@edr/ui-common` | Shared React components (`DashboardLayout`, `Sidebar`, `Button`, `Modal`, etc.) and theme tokens | +| `@edr/eslint-config` | Shared ESLint configs (base / nestjs / react) | +| `@edr/tsconfig` | Shared TypeScript configs | +| `@edr/prettier-config` | Shared Prettier configuration | + +### Tooling + +- **pnpm 9** — workspace package manager (sole supported PM) +- **Turborepo 2** — task orchestrator with caching +- **Husky + lint-staged + commitlint** — pre-commit ESLint/Prettier and conventional-commit enforcement +- **Docker Compose** — local Postgres instances + production stack (see `infrastructure/`) +- **Nginx** — reverse proxy / static asset server in production + +### Planned Integrations + +- **MinIO** — S3-compatible object storage for documents (object keys already shaped under `edr-freight/{linkedType}/{ref}/{filename}`) + +--- + +## Architecture + +### High-level layout + +``` +┌──────────────────────────────────────────────────────────────────┐ +│ EDR Platform (monorepo) │ +├──────────────────────┬───────────────────────────────────────────┤ +│ Freight domain │ Passenger domain │ +│ ┌────────────────┐ │ ┌────────────────┐ │ +│ │ freight-portal │ │ │ passenger- │ │ +│ │ (React) │ │ │ portal (React)│ │ +│ ├────────────────┤ │ ├────────────────┤ │ +│ │ freight- │ │ │ passenger- │ │ +│ │ backoffice │ │ │ backoffice │ │ +│ └───────┬────────┘ │ └───────┬────────┘ │ +│ │ │ │ │ +│ ▼ │ ▼ │ +│ ┌────────────────┐ │ ┌────────────────┐ │ +│ │ freight-api │ │ │ passenger-api │ │ +│ │ (NestJS) │ │ │ (NestJS) │ │ +│ └───────┬────────┘ │ └───────┬────────┘ │ +│ │ │ │ │ +│ ▼ │ ▼ │ +│ ┌────────────────┐ │ ┌────────────────┐ │ +│ │ postgres- │ │ │ postgres- │ │ +│ │ freight │ │ │ passenger │ │ +│ └────────────────┘ │ └────────────────┘ │ +└──────────────────────┴───────────────────────────────────────────┘ + Shared (workspace) packages + @edr/types · @edr/api-common · @edr/ui-common · @edr/{eslint,tsconfig,prettier}-config +``` + +### Domain isolation + +- **One database per domain.** `postgres-freight` (port 5433, db `edr_freight`) and `postgres-passenger` (port 5434, db `edr_passenger`). No cross-database joins. Cross-domain data flows only through API calls or message queues. +- **Each domain owns its data model.** Freight bookings/consignments/shipments/trains/invoices/documents live only in the freight DB; passenger journeys/tickets live only in the passenger DB. + +### NestJS module pattern (per feature) + +``` +modules// + entities/.entity.ts // extends BaseEntity (UUID, timestamps, soft delete) + dto/-.dto.ts // class-validator DTOs + .module.ts // wires controller + service + repository + .controller.ts // HTTP layer only — no business logic + .service.ts // business logic + .repository.ts // extends BaseRepository; services inject this, NEVER `Repository` directly +``` + +Conventions enforced across the codebase: + +- All entities have UUID primary keys (`@PrimaryGeneratedColumn('uuid')`). +- All entities inherit `createdAt` / `updatedAt` / `deletedAt` from `BaseEntity` (soft delete). +- DB columns use `snake_case` via `@Column({ name: '...' })`; TS properties stay `camelCase`. +- **No `synchronize: true`** in production — schema changes go through TypeORM migrations. +- ESLint + Prettier run on pre-commit via Husky + lint-staged. +- Conventional-commits enforced via commitlint. + +### Frontend application structure + +``` +apps/edr-freight-web/portal/src/ + App.tsx // routes + sidebar definition + main.tsx // React Router + QueryClient providers + components/ + Breadcrumbs.tsx // shared local UI + ui/ // shadcn-style primitives (Button, Input, Dialog, Label, Textarea) + pages/ + customers/ // CustomersPage + CustomerDetailPage + NewCustomerPage (dialog) + bookings/ // ... + multimodal Transport Legs editor + consignments/ + tracking/ // Shipment grid + table view toggle + trains/ // Fleet roster + billing/ // Invoices + documents/ // MinIO-shaped document library + hooks/ // TanStack Query hooks (per feature) + services/ // Axios clients (per feature) + store/ // Zustand stores + lib/ // utilities (`cn` helper, formatters) +``` + +Each feature folder typically contains: `*Page.tsx` (list), `*DetailPage.tsx`, `New*Page.tsx` (create/edit dialog with `mode: "create" | "edit"`), `Delete*Dialog.tsx`, and a `*.mock.ts` seed file used by the current mock UI. + +### Shared layout (`@edr/ui-common`) + +`DashboardLayout` provides the sidebar + header shell shared across all freight and passenger web apps: + +- **Sidebar** — brand-tinted, icon-led navigation. Main brand color: `#10B981` (`rgb(16, 185, 129)` — emerald-500). Icon containers use the filled style: brand-color background with a white icon. +- **Header** — language picker, notifications, user dropdown (click-driven, click-outside / Escape close); host apps opt into a light/dark theme toggle via `enableThemeToggle` (Tailwind class-based, persisted in `localStorage`). + +### Auth integration (planned) + +Authentication is provided by an external `@edr/iamui-common` / `@tria-plc/iamapi-common` package. **Do not** implement login/JWT/password logic in this repo. Use placeholder TODO comments next to controllers and `@CurrentUser` decorators (in `@edr/api-common`) until the integration ships. + +--- + +## Apps & Ports + +| App | Package name | Purpose | Port | +| ------------------------------ | --------------------------- | ---------------------------------------- | ---- | +| `edr-freight-api` | `@edr/freight-api` | NestJS API for freight | 3001 | +| `edr-freight-web/portal` | `@edr/freight-portal` | React frontend for freight customers | 5173 | +| `edr-freight-web/backoffice` | `@edr/freight-backoffice` | React frontend for freight employees | 5183 | +| `edr-passenger-api` | `@edr/passenger-api` | NestJS API for passengers | 3002 | +| `edr-passenger-web/portal` | `@edr/passenger-portal` | React frontend for passenger customers | 5174 | +| `edr-passenger-web/backoffice` | `@edr/passenger-backoffice` | React frontend for passenger employees | 5184 | + +`edr-freight-web` and `edr-passenger-web` are grouping folders, not workspace packages. Each holds independent `portal/` and `backoffice/` workspace packages declared in `pnpm-workspace.yaml`. + +--- + +## Getting Started + +### Prerequisites + +- Node.js ≥ 20 +- pnpm 9 (`corepack enable && corepack prepare pnpm@9.12.0 --activate`) +- Docker (for local Postgres) + +### Install + ```bash pnpm install ``` @@ -255,7 +438,7 @@ The API uses two authentication schemes: | **Routes** | `/routes` | JWT/IAM | Reusable route templates with ordered stops | | **Schedules** | `/schedules` | JWT/IAM | Trip schedules, fare rules, status updates | | **Fleet** | `/fleet` | JWT/IAM | Train services, coaches, seat configurations | -| **Seat Classes** | `/seat-classes` | Public/JWT/IAM | Seat class management and configuration | +| **Seat Classes** | `/seat-classes` or `/classes` | Public/JWT/IAM | Seat class management and configuration | | **Segment Seats** | `/segments/seats` | Public/JWT | Segment-based seat availability and booking | | **Agents** | `/agents` | IAM | Agent booking, shifts, commissions, reconciliation | | **Fraud Detection** | `/fraud` | IAM | Fraud alerts, rules management, user blocking | @@ -765,3 +948,75 @@ For technical support or questions: --- **Built with ❤️ for Ethio-Djibouti Railway** +### Start local databases + +```bash +docker compose -f infrastructure/docker/docker-compose.db.dev.yml up -d +``` + +### Run apps + +```bash +pnpm dev # every app +pnpm dev:freight # freight API + portal + backoffice +pnpm dev:passenger # passenger API + portal + backoffice +``` + +### Common scripts + +| Script | Description | +| ------------------- | ------------------------------------ | +| `pnpm install` | Install workspace dependencies | +| `pnpm dev` | Run every app in watch mode | +| `pnpm build` | Build every package and app | +| `pnpm test` | Run all tests | +| `pnpm lint` | Lint everything | +| `pnpm type-check` | Type-check every package | +| `pnpm format` | Format with Prettier | + +--- + +## Repository Layout + +``` +. +├── apps/ +│ ├── edr-freight-api/ NestJS — freight backend +│ ├── edr-freight-web/ +│ │ ├── portal/ React — freight customer portal +│ │ └── backoffice/ React — freight back-office +│ ├── edr-passenger-api/ NestJS — passenger backend +│ └── edr-passenger-web/ +│ ├── portal/ React — passenger customer portal +│ └── backoffice/ React — passenger back-office +├── packages/ +│ ├── api-common/ Shared NestJS utilities + BaseEntity/Repository +│ ├── types/ Shared TS types/enums +│ ├── ui-common/ Shared React components + theme +│ └── config/ +│ ├── eslint/ @edr/eslint-config +│ ├── tsconfig/ @edr/tsconfig +│ └── prettier/ @edr/prettier-config +├── infrastructure/ +│ ├── docker/ docker-compose files (db.dev / dev / prod) +│ └── nginx/ Production nginx config +├── CLAUDE.md Developer guide for AI-assisted work +├── turbo.json Turborepo task pipeline +├── pnpm-workspace.yaml Workspace manifest +└── README.md +``` + +--- + +## Standards (recap) + +- **TypeScript strict mode** is enabled in every package. +- **pnpm only** — never run `npm install` or `yarn`. +- **Conventional commits** — enforced via commitlint on every commit. +- **NestJS 4-layer pattern** — `module → controller → service → repository`. +- **Repository injection** — services inject the custom `*Repository` class, not `Repository`. +- **Controllers are thin** — no business logic; delegate to services. +- **Migrations only** — never enable TypeORM `synchronize` in production. +- **One DB per domain** — no cross-database joins. + +See [`CLAUDE.md`](./CLAUDE.md) for the deeper developer guide used during AI-assisted contributions. diff --git a/WagonForm.tsx b/WagonForm.tsx new file mode 100644 index 000000000..e69de29bb diff --git a/apps/edr-freight-api/.env.example b/apps/edr-freight-api/.env.example index a3ddffc40..73312aae3 100644 --- a/apps/edr-freight-api/.env.example +++ b/apps/edr-freight-api/.env.example @@ -5,3 +5,50 @@ DB_PORT=5433 DB_USER=postgres DB_PASSWORD= DB_NAME=edr_freight + +# Telebirr payment gateway (freight merchant credentials) +TELEBIRR_BASE_URL= +TELEBIRR_WEB_BASE_URL= +TELEBIRR_FABRIC_APP_ID= +TELEBIRR_APP_SECRET= +TELEBIRR_MERCHANT_APP_ID= +TELEBIRR_MERCHANT_CODE= +TELEBIRR_NOTIFY_URL=https://freight-api.edr.et/payments/webhooks/telebirr +TELEBIRR_RETURN_URL= +TELEBIRR_TIMEOUT_EXPRESS=15m +TELEBIRR_PRIVATE_KEY= +TELEBIRR_PUBLIC_KEY= +TELEBIRR_INSECURE_TLS=false + +# Portal pages the payment provider redirects the browser to after payment. +# Point these at the freight portal's public payment result routes. +PAYMENT_RETURN_URL=http://localhost:5173/payment/success +PAYMENT_FAILURE_URL=http://localhost:5173/payment/failure +# JWT (used by @tria-plc/api-common SharedAuthModule) +JWT_SECRET= +JWT_ACCESS_TOKEN_SECRET= +JWT_REFRESH_TOKEN_SECRET= +JWT_EXPIRES_IN=3600 +# JWT expiry for @tria-plc/api-common token utils (jsonwebtoken timespan format) +JWT_ACCESS_TOKEN_EXPIRES=1h +JWT_REFRESH_TOKEN_EXPIRES=7d + +# IAM seed defaults (used by @tria-plc/iamapi-common on first boot) +SUPER_ADMIN_EMAIL=superadmin@tria.com +SUPER_ADMIN_PHONE= +DEFAULT_PASSWORD=password@tria + +# Freight org + staff (bookings / rule-engine IAM) +SEED_EDR_ORG=true +SEED_FREIGHT_STAFF=true + +# MinIO (used by @tria-plc/iamapi-common for file storage) +MINIO_ENDPOINT=localhost +MINIO_PORT=9000 +MINIO_USE_SSL=false +MINIO_ACCESS_KEY= +MINIO_SECRET_KEY= + +# Redis +REDIS_HOST=localhost +REDIS_PORT=6379 diff --git a/apps/edr-freight-api/nest-cli.json b/apps/edr-freight-api/nest-cli.json index f9aa683b1..4df6a9aef 100644 --- a/apps/edr-freight-api/nest-cli.json +++ b/apps/edr-freight-api/nest-cli.json @@ -3,6 +3,21 @@ "collection": "@nestjs/schematics", "sourceRoot": "src", "compilerOptions": { - "deleteOutDir": true + "deleteOutDir": false, + "assets": [ + { + "include": "migrations/**/*", + "outDir": "dist" + }, + { + "include": "contracts/templates/**/*", + "watchAssets": true + }, + { + "include": "modules/payment/templates/**/*", + "watchAssets": true + } + ], + "watchAssets": true } } diff --git a/apps/edr-freight-api/package.json b/apps/edr-freight-api/package.json index 09a312ad6..3fcde133e 100644 --- a/apps/edr-freight-api/package.json +++ b/apps/edr-freight-api/package.json @@ -4,44 +4,68 @@ "private": true, "description": "EDR Freight Management API", "scripts": { + "clean": "node -e \"const fs=require('fs'); fs.rmSync('dist',{recursive:true,force:true}); fs.rmSync('.tsbuildinfo',{force:true});\"", + "predev": "pnpm run clean", "dev": "nest start --watch", + "prebuild": "pnpm run clean", "build": "nest build", "start": "node dist/main.js", "lint": "eslint src", "test": "jest", "test:e2e": "jest --config ./test/jest-e2e.json", - "type-check": "tsc --noEmit" + "seed:wagons": "ts-node -r tsconfig-paths/register src/scripts/seed-edr-wagons.ts", + "type-check": "tsc --noEmit", + "seed:demo-scheduling": "ts-node -r tsconfig-paths/register src/scripts/seed-demo-scheduling.ts", + "seed:freight-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-freight-demo.ts", + "seed:fleet-wagons": "bash ../../../docs/new/seeds/seed-fleet-wagons.sh" }, "dependencies": { - "@tria-plc/api-common": "^0.1.0", - "@tria-plc/iamapi-common": "^0.1.0", "@edr/api-common": "workspace:*", + "@edr/payment-providers": "workspace:*", "@edr/types": "workspace:*", + "@golevelup/nestjs-rabbitmq": "^5.5.0", + "@nestjs/axios": "^4.0.1", "@nestjs/common": "^11.0.0", "@nestjs/config": "^4.0.0", "@nestjs/core": "^11.0.0", + "@nestjs/event-emitter": "^2.0.4", + "@nestjs/mapped-types": "^2.1.1", "@nestjs/microservices": "^11.0.0", "@nestjs/platform-express": "^11.0.0", + "@nestjs/schedule": "^6.1.3", "@nestjs/swagger": "^11.4.2", "@nestjs/typeorm": "^11.0.1", + "@tria-plc/api-common": "^1.4.3", + "@tria-plc/iamapi-common": "^0.6.6", + "amqp-connection-manager": "^5.0.0", + "amqplib": "^2.0.1", + "axios": "^1.16.1", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", + "dotenv": "^17.4.2", + "handlebars": "^4.7.9", + "minio": "7.1.3", "pg": "^8.13.0", + "puppeteer": "^24.2.0", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", - "typeorm": "^0.3.20" + "typeorm": "^0.3.30" + }, "devDependencies": { "@edr/api-common": "workspace:*", - "@edr/types": "workspace:*", "@edr/eslint-config": "workspace:*", "@edr/tsconfig": "workspace:*", + "@edr/types": "workspace:*", "@nestjs/cli": "^11.0.0", "@nestjs/schematics": "^11.0.0", "@nestjs/testing": "^11.0.0", + "@types/amqplib": "^0.10.8", "@types/express": "^5.0.0", "@types/jest": "^29.5.13", + "@types/multer": "^2.1.0", "@types/node": "^20.14.0", + "@types/pg": "^8.6.7", "@types/supertest": "^6.0.2", "jest": "^29.7.0", "supertest": "^7.0.0", diff --git a/apps/edr-freight-api/pnpm-lock.yaml b/apps/edr-freight-api/pnpm-lock.yaml new file mode 100644 index 000000000..7a7a3beb2 --- /dev/null +++ b/apps/edr-freight-api/pnpm-lock.yaml @@ -0,0 +1,206 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@edr/api-common': + specifier: workspace:* + version: link:../../packages/api-common + '@edr/types': + specifier: workspace:* + version: link:../../packages/types + '@nestjs/common': + specifier: ^11.0.0 + version: 11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/mapped-types': + specifier: ^2.1.1 + version: 2.1.1(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2) + reflect-metadata: + specifier: ^0.2.2 + version: 0.2.2 + rxjs: + specifier: ^7.8.1 + version: 7.8.2 + devDependencies: + '@edr/eslint-config': + specifier: workspace:* + version: link:../../packages/config/eslint-config + '@edr/tsconfig': + specifier: workspace:* + version: link:../../packages/config/tsconfig + +packages: + + '@borewit/text-codec@0.2.2': + resolution: {integrity: sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==} + + '@lukeed/csprng@1.1.0': + resolution: {integrity: sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==} + engines: {node: '>=8'} + + '@nestjs/common@11.1.24': + resolution: {integrity: sha512-9zHxaDDM+oXW9As6UsP5yYB+UqczBmpeSCIFWdPEtEukMnZhxODG1BBjaUcdBB8Sc1uzojSJSJlp3yFp853t1g==} + peerDependencies: + class-transformer: '>=0.4.1' + class-validator: '>=0.13.2' + reflect-metadata: ^0.1.12 || ^0.2.0 + rxjs: ^7.1.0 + peerDependenciesMeta: + class-transformer: + optional: true + class-validator: + optional: true + + '@nestjs/mapped-types@2.1.1': + resolution: {integrity: sha512-SCCoMEJ6jdeI5h/N+KCVF1+pmg/hmEkNA5nHTS8Gvww7T/LCl4o1gFLinw2iQ60w7slFkszHcGLKGdazVI4F8A==} + peerDependencies: + '@nestjs/common': ^10.0.0 || ^11.0.0 + class-transformer: ^0.4.0 || ^0.5.0 + class-validator: ^0.13.0 || ^0.14.0 || ^0.15.0 + reflect-metadata: ^0.1.12 || ^0.2.0 + peerDependenciesMeta: + class-transformer: + optional: true + class-validator: + optional: true + + '@tokenizer/inflate@0.4.1': + resolution: {integrity: sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==} + engines: {node: '>=18'} + + '@tokenizer/token@0.3.0': + resolution: {integrity: sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + file-type@21.3.4: + resolution: {integrity: sha512-Ievi/yy8DS3ygGvT47PjSfdFoX+2isQueoYP1cntFW1JLYAuS4GD7NUPGg4zv2iZfV52uDyk5w5Z0TdpRS6Q1g==} + engines: {node: '>=20'} + + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + + iterare@1.2.1: + resolution: {integrity: sha512-RKYVTCjAnRthyJes037NX/IiqeidgN1xc3j1RjFfECFp28A1GVwK9nA+i0rJPaHqSZwygLzRnFlzUuHFoWWy+Q==} + engines: {node: '>=6'} + + load-esm@1.0.3: + resolution: {integrity: sha512-v5xlu8eHD1+6r8EHTg6hfmO97LN8ugKtiXcy5e6oN72iD2r6u0RPfLl6fxM+7Wnh2ZRq15o0russMst44WauPA==} + engines: {node: '>=13.2.0'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + reflect-metadata@0.2.2: + resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} + + rxjs@7.8.2: + resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} + + strtok3@10.3.5: + resolution: {integrity: sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==} + engines: {node: '>=18'} + + token-types@6.1.2: + resolution: {integrity: sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==} + engines: {node: '>=14.16'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + uid@2.0.2: + resolution: {integrity: sha512-u3xV3X7uzvi5b1MncmZo3i2Aw222Zk1keqLA1YkHldREkAhAqi65wuPfe7lHx8H/Wzy+8CE7S7uS3jekIM5s8g==} + engines: {node: '>=8'} + + uint8array-extras@1.5.0: + resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==} + engines: {node: '>=18'} + +snapshots: + + '@borewit/text-codec@0.2.2': {} + + '@lukeed/csprng@1.1.0': {} + + '@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2)': + dependencies: + file-type: 21.3.4 + iterare: 1.2.1 + load-esm: 1.0.3 + reflect-metadata: 0.2.2 + rxjs: 7.8.2 + tslib: 2.8.1 + uid: 2.0.2 + transitivePeerDependencies: + - supports-color + + '@nestjs/mapped-types@2.1.1(@nestjs/common@11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)': + dependencies: + '@nestjs/common': 11.1.24(reflect-metadata@0.2.2)(rxjs@7.8.2) + reflect-metadata: 0.2.2 + + '@tokenizer/inflate@0.4.1': + dependencies: + debug: 4.4.3 + token-types: 6.1.2 + transitivePeerDependencies: + - supports-color + + '@tokenizer/token@0.3.0': {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + file-type@21.3.4: + dependencies: + '@tokenizer/inflate': 0.4.1 + strtok3: 10.3.5 + token-types: 6.1.2 + uint8array-extras: 1.5.0 + transitivePeerDependencies: + - supports-color + + ieee754@1.2.1: {} + + iterare@1.2.1: {} + + load-esm@1.0.3: {} + + ms@2.1.3: {} + + reflect-metadata@0.2.2: {} + + rxjs@7.8.2: + dependencies: + tslib: 2.8.1 + + strtok3@10.3.5: + dependencies: + '@tokenizer/token': 0.3.0 + + token-types@6.1.2: + dependencies: + '@borewit/text-codec': 0.2.2 + '@tokenizer/token': 0.3.0 + ieee754: 1.2.1 + + tslib@2.8.1: {} + + uid@2.0.2: + dependencies: + '@lukeed/csprng': 1.1.0 + + uint8array-extras@1.5.0: {} diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index 3eb7d30ec..7857d7c7e 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -1,36 +1,157 @@ -import { Module } from "@nestjs/common"; +import { Module, OnApplicationBootstrap } from "@nestjs/common"; import { ConfigModule, ConfigService } from "@nestjs/config"; import { TypeOrmModule, TypeOrmModuleOptions } from "@nestjs/typeorm"; +import { ScheduleModule } from "@nestjs/schedule"; +import { DataSource, DataSourceOptions } from "typeorm"; +import { ensurePostgresSchemas } from "./config/ensure-postgres-schemas"; +import { IamModule, DataSeeder } from "@tria-plc/iamapi-common"; +import { SharedAuthModule } from "@tria-plc/api-common/modules/auth/shared-auth.module"; import appConfig from "./config/app.config"; import databaseConfig from "./config/database.config"; +import telebirrConfig from "./config/telebirr.config"; +import rabbitmqConfig from "./config/rabbitmq.config"; import { BookingsModule } from "./modules/bookings/bookings.module"; +import { SignaturesModule } from "./modules/signatures/signatures.module"; +import { FilesModule } from "./modules/files/files.module"; import { ConsignmentsModule } from "./modules/consignments/consignments.module"; -import { TrainsModule } from "./modules/trains/trains.module"; -import { CustomersModule } from "./modules/customers/customers.module"; + +// import { TrainsModule } from "./modules/trains/trains.module"; +import { LocomotivesModule } from "./modules/locomotives/locomotives.module"; +import { WagonTypesModule } from "./modules/wagon-types/wagon-types.module"; +import { TrainSetsModule } from "./modules/train-sets/train-sets.module"; +import { TrainSchedulesModule } from "./modules/train-schedules/train-schedules.module"; +import { TrainSchedulingModule } from "./modules/train-scheduling/train-scheduling.module"; +import { SchedulingRescheduleModule } from "./modules/scheduling-reschedule/scheduling-reschedule.module"; +import { CompaniesModule } from "./modules/companies/companies.module"; import { TrackingModule } from "./modules/tracking/tracking.module"; import { BillingModule } from "./modules/billing/billing.module"; import { NotificationsModule } from "./modules/notifications/notifications.module"; +import { FileUploadSettingsModule } from "./modules/file-upload-settings/file-upload-settings.module"; +import { DropdownSettingsModule } from "./modules/dropdown-settings/dropdown-settings.module"; +import { OtpModule } from "./modules/otp/otp.module"; +import { RuleEngineModule } from "./modules/rule-engine/rule-engine.module"; +import { BackofficeModule } from "./modules/backoffice/backoffice.module"; +import { DemoPermissionsModule } from "./modules/demo-permissions/demo-permissions.module"; +import { FreightAuthModule } from "./modules/auth/freight-auth.module"; +import { + EDR_FREIGHT_APPLICATION, + EDR_FREIGHT_PERMISSIONS, +} from "./seed/edr-freight.seed"; +import { EdrOrgSeeder } from "./seed/edr-org.seeder"; +import { DemoUsersSeeder } from "./seed/demo-users.seeder"; +import { FreightStaffUsersSeeder } from "./seed/freight-staff-users.seeder"; +import { PaymentModule } from "./modules/payment/payment.module"; +import { PricingDataSeeder } from "./seed/pricing-data.seeder"; +import { FileUploadSettingsSeeder } from "./seed/file-upload-settings.seeder"; +import { IndodeFacilitySeeder } from "./seed/indode-facility.seeder"; +import { Batch14TestDataSeeder } from "./seed/batch1-4-test-data.seeder"; +import { FreightPermissionKeyMigrationSeeder } from "./seed/freight-permission-key-migration.seeder"; +import { DemoFreightDataSeeder } from "./seed/demo-freight-data.seeder"; +//New Trains, Wagons, Container and Cargo management modules +import { TrainsModule } from "./modules/trains/trains.module"; +import { WagonsModule } from './modules/wagons/wagons.module'; +import { ContainersModule } from './modules/container-management/containers.module'; +import { CargoesModule } from './modules/cargoes/cargoes.module'; +import { RoutesModule } from './modules/routes/routes.module'; +import { WarehousesModule } from './modules/warehouses/warehouses.module'; +import { FacilitiesModule } from './modules/facilities/facilities.module'; +import { OverviewModule } from './modules/overview/overview.module'; +import { VehiclesModule } from './modules/vehicles/vehicles.module'; @Module({ imports: [ ConfigModule.forRoot({ isGlobal: true, - load: [appConfig, databaseConfig], + load: [appConfig, databaseConfig, telebirrConfig, rabbitmqConfig], }), + ScheduleModule.forRoot(), + // EventEmitterModule.forRoot(), TypeOrmModule.forRootAsync({ inject: [ConfigService], useFactory: (config: ConfigService): TypeOrmModuleOptions => config.get("database")!, + dataSourceFactory: async (options) => { + if (!options) { + throw new Error("Missing TypeORM DataSource options"); + } + await ensurePostgresSchemas(options as DataSourceOptions); + const dataSource = new DataSource(options as DataSourceOptions); + return dataSource.initialize(); + }, + }), + SharedAuthModule, + IamModule.forRoot({ + applications: [EDR_FREIGHT_APPLICATION], + permissions: EDR_FREIGHT_PERMISSIONS, }), BookingsModule, + SignaturesModule, + FilesModule, ConsignmentsModule, - TrainsModule, - CustomersModule, + LocomotivesModule, + WagonTypesModule, + TrainSetsModule, + TrainSchedulesModule, + TrainSchedulingModule, + SchedulingRescheduleModule, + CompaniesModule, TrackingModule, BillingModule, NotificationsModule, + FileUploadSettingsModule, + DropdownSettingsModule, + OtpModule, + RuleEngineModule, + BackofficeModule, + DemoPermissionsModule, + FreightAuthModule, + PaymentModule, + //New Modules + TrainsModule, + WagonsModule, + ContainersModule, + CargoesModule, + RoutesModule, + FacilitiesModule, + WarehousesModule, + OverviewModule, + VehiclesModule, + ], + providers: [ + EdrOrgSeeder, + DemoUsersSeeder, + FreightStaffUsersSeeder, + PricingDataSeeder, + FileUploadSettingsSeeder, + FreightPermissionKeyMigrationSeeder, + DemoFreightDataSeeder, + IndodeFacilitySeeder, + Batch14TestDataSeeder, ], }) -export class AppModule {} +export class AppModule implements OnApplicationBootstrap { + constructor( + private readonly seeder: DataSeeder, + private readonly edrOrgSeeder: EdrOrgSeeder, + private readonly demoUsersSeeder: DemoUsersSeeder, + private readonly freightStaffUsersSeeder: FreightStaffUsersSeeder, + private readonly freightPermissionKeyMigrationSeeder: FreightPermissionKeyMigrationSeeder, + private readonly demoFreightDataSeeder: DemoFreightDataSeeder, + ) { } + + async onApplicationBootstrap() { + await this.freightPermissionKeyMigrationSeeder.run(); + await this.seeder.run(); + await this.edrOrgSeeder.run(); + await this.demoUsersSeeder.run(); + await this.freightStaffUsersSeeder.run(); + // Demo data seeds (DemoBookingsSeeder, PricingDataSeeder, + // FileUploadSettingsSeeder) are intentionally disabled — they stay + // registered as providers but are not run. Re-inject + call .run() to enable. + // demoFreightDataSeeder now seeds ONLY the 4 staff users (wagons + approval + // rules are disabled inside the seeder). Kept running for the staff users. + await this.demoFreightDataSeeder.run(); + } +} diff --git a/apps/edr-freight-api/src/common/booking-guards.ts b/apps/edr-freight-api/src/common/booking-guards.ts new file mode 100644 index 000000000..8d55f1dc4 --- /dev/null +++ b/apps/edr-freight-api/src/common/booking-guards.ts @@ -0,0 +1,30 @@ +import { applyDecorators, UseGuards } from '@nestjs/common'; +import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard'; + +import { FreightPermissionGuard } from './freight-permission.guard'; +import { FREIGHT_PERMS } from '../seed/freight-permissions.registry'; + +export const BookingStaff = (permission: string | string[]) => + applyDecorators( + UseGuards( + JwtGuard, + FreightPermissionGuard( + Array.isArray(permission) ? permission : [permission], + ), + ), + ); + +export const BookingView = () => BookingStaff(FREIGHT_PERMS.bookings.view); + +export const TrainSchedulingView = () => + BookingStaff(FREIGHT_PERMS.trainScheduling.view); + +export const TrainSchedulingManage = () => + BookingStaff(FREIGHT_PERMS.trainScheduling.manage); + +export const FleetView = () => BookingStaff(FREIGHT_PERMS.fleet.view); + +export const FleetManage = () => BookingStaff(FREIGHT_PERMS.fleet.manage); + +/** Org-administration endpoints (user mgmt, billing config, company CRUD, settings). */ +export const FreightAdmin = () => BookingStaff(FREIGHT_PERMS.admin); diff --git a/apps/edr-freight-api/src/common/derive-trade-direction.util.spec.ts b/apps/edr-freight-api/src/common/derive-trade-direction.util.spec.ts new file mode 100644 index 000000000..f245bca83 --- /dev/null +++ b/apps/edr-freight-api/src/common/derive-trade-direction.util.spec.ts @@ -0,0 +1,21 @@ +import { deriveTradeDirection } from './derive-trade-direction.util'; + +describe('deriveTradeDirection', () => { + it('returns IMPORT when origin is Djibouti', () => { + expect(deriveTradeDirection({ country: 'Djibouti' }, { country: 'Ethiopia' })).toBe( + 'IMPORT', + ); + }); + + it('returns EXPORT when destination is Djibouti and origin is not', () => { + expect(deriveTradeDirection({ country: 'Ethiopia' }, { country: 'Djibouti' })).toBe( + 'EXPORT', + ); + }); + + it('returns DOMESTIC for intra-Ethiopia routes', () => { + expect(deriveTradeDirection({ country: 'Ethiopia' }, { country: 'Ethiopia' })).toBe( + 'DOMESTIC', + ); + }); +}); diff --git a/apps/edr-freight-api/src/common/derive-trade-direction.util.ts b/apps/edr-freight-api/src/common/derive-trade-direction.util.ts new file mode 100644 index 000000000..e9e183b25 --- /dev/null +++ b/apps/edr-freight-api/src/common/derive-trade-direction.util.ts @@ -0,0 +1,20 @@ +import type { ScheduleTradeDirection } from '@edr/types'; + +type YardLike = { country?: string | null }; + +/** Derive booking/schedule trade direction from origin and destination yard countries. */ +export function deriveTradeDirection( + originYard: YardLike, + destinationYard: YardLike, +): ScheduleTradeDirection { + const originCountry = originYard.country?.trim(); + const destinationCountry = destinationYard.country?.trim(); + + if (originCountry === 'Djibouti') { + return 'IMPORT'; + } + if (destinationCountry === 'Djibouti' && originCountry !== 'Djibouti') { + return 'EXPORT'; + } + return 'DOMESTIC'; +} diff --git a/apps/edr-freight-api/src/common/freight-permission.guard.ts b/apps/edr-freight-api/src/common/freight-permission.guard.ts new file mode 100644 index 000000000..68def6440 --- /dev/null +++ b/apps/edr-freight-api/src/common/freight-permission.guard.ts @@ -0,0 +1,38 @@ +import { + CanActivate, + ExecutionContext, + ForbiddenException, + Injectable, + Type, + UnauthorizedException, +} from '@nestjs/common'; +import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; + +import { hasFreightPermission } from './freight-permission.util'; + +export function FreightPermissionGuard( + permissions: string[], +): Type { + @Injectable() + class FreightPermissionsGuard implements CanActivate { + canActivate(context: ExecutionContext): boolean { + const request = context.switchToHttp().getRequest<{ user?: TCurrentUser }>(); + const user = request.user; + + if (!permissions?.length) return true; + if (!user) { + throw new UnauthorizedException('Authentication required'); + } + + if (permissions.some((p) => hasFreightPermission(user, p))) { + return true; + } + + throw new ForbiddenException( + `Missing permission. Required one of: ${permissions.join(', ')}`, + ); + } + } + + return FreightPermissionsGuard; +} diff --git a/apps/edr-freight-api/src/common/freight-permission.util.ts b/apps/edr-freight-api/src/common/freight-permission.util.ts new file mode 100644 index 000000000..69596c21d --- /dev/null +++ b/apps/edr-freight-api/src/common/freight-permission.util.ts @@ -0,0 +1,109 @@ +import { ForbiddenException } from '@nestjs/common'; +import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; + +import { FREIGHT_PERMS } from '../seed/freight-permissions.registry'; + +const SUPER_ADMIN_ROLE = 'super_admin'; +const ORGANIZATION_ADMIN_ROLE = 'organization_admin'; + +type PermissionLike = { key?: string }; +type MeLikeUser = { + roles?: { key?: string }[]; + permissions?: PermissionLike[]; + employee?: + | { + position?: { permissions?: PermissionLike[] }; + delegatedPositions?: { permissions?: PermissionLike[] }[]; + } + | { + positions?: { permissions?: PermissionLike[] }[]; + }[] + | null; +}; + +export function isSuperAdmin(user: MeLikeUser | null | undefined): boolean { + if (!user?.roles?.length) return false; + return user.roles.some((r) => r.key === SUPER_ADMIN_ROLE); +} + +export function isOrganizationAdmin(user: MeLikeUser | null | undefined): boolean { + if (!user?.roles?.length) return false; + return user.roles.some((r) => r.key === ORGANIZATION_ADMIN_ROLE); +} + +export function isFreightApprovalAdmin(user: MeLikeUser | null | undefined): boolean { + return isSuperAdmin(user) || isOrganizationAdmin(user); +} + +/** Flat permission keys from JWT / session user (roles + position permissions). */ +export function collectPermissionKeys(user: MeLikeUser | null | undefined): string[] { + if (!user) return []; + + const keys = new Set(); + + for (const p of user.permissions ?? []) { + if (p.key) keys.add(p.key); + } + + const employee = user.employee; + if (!employee) { + return [...keys]; + } + + if (Array.isArray(employee)) { + for (const emp of employee) { + for (const pos of emp.positions ?? []) { + for (const p of pos.permissions ?? []) { + if (p.key) keys.add(p.key); + } + } + } + return [...keys]; + } + + for (const p of employee.position?.permissions ?? []) { + if (p.key) keys.add(p.key); + } + for (const delegated of employee.delegatedPositions ?? []) { + for (const p of delegated.permissions ?? []) { + if (p.key) keys.add(p.key); + } + } + + return [...keys]; +} + +export function hasFreightPermission( + user: MeLikeUser | null | undefined, + permissionKey: string, +): boolean { + if (!user) return false; + if (isSuperAdmin(user)) return true; + return collectPermissionKeys(user).includes(permissionKey); +} + +export function assertFreightPermission( + user: TCurrentUser | MeLikeUser | null | undefined, + permissionKey: string, +): void { + if (hasFreightPermission(user, permissionKey)) return; + throw new ForbiddenException(`Missing permission: ${permissionKey}`); +} + +const APPROVE_ROLE_PERMISSION: Record = { + LINE_STAFF: FREIGHT_PERMS.bookings.approveLineStaff, + DIRECTOR: FREIGHT_PERMS.bookings.approveDirector, + CEO: FREIGHT_PERMS.bookings.approveCeo, +}; + +export function assertCanApproveBookingStep( + user: TCurrentUser | MeLikeUser | null | undefined, + requiredRole: string, +): void { + if (isFreightApprovalAdmin(user)) return; + const perm = APPROVE_ROLE_PERMISSION[requiredRole]; + if (!perm) { + throw new ForbiddenException(`Unknown approval role: ${requiredRole}`); + } + assertFreightPermission(user, perm); +} diff --git a/apps/edr-freight-api/src/common/guards/service-auth.guard.ts b/apps/edr-freight-api/src/common/guards/service-auth.guard.ts new file mode 100644 index 000000000..9165e54d5 --- /dev/null +++ b/apps/edr-freight-api/src/common/guards/service-auth.guard.ts @@ -0,0 +1,51 @@ +import { + CanActivate, + ExecutionContext, + Injectable, + Logger, + UnauthorizedException, +} from "@nestjs/common"; +import { timingSafeEqual } from "node:crypto"; +import { Request } from "express"; + +/** + * Shared-secret guard for endpoints only the payment microservice may call + * (e.g. /internal/payments/mark-paid). The secret is the same SERVICE_AUTH_TOKEN + * the payment service uses on its own internal surface. + */ +@Injectable() +export class ServiceAuthGuard implements CanActivate { + private readonly logger = new Logger(ServiceAuthGuard.name); + private readonly token = process.env.SERVICE_AUTH_TOKEN ?? ""; + private warned = false; + + constructor() { + if (!this.token && process.env.NODE_ENV === "production") { + throw new Error("SERVICE_AUTH_TOKEN must be set in production"); + } + } + + canActivate(context: ExecutionContext): boolean { + if (!this.token) { + if (!this.warned) { + this.logger.warn( + "SERVICE_AUTH_TOKEN unset — internal endpoints are UNGUARDED (dev only)", + ); + this.warned = true; + } + return true; + } + + const request = context.switchToHttp().getRequest(); + const header = request.headers["x-service-token"]; + const bearer = request.headers.authorization?.replace(/^Bearer\s+/i, ""); + const presented = (Array.isArray(header) ? header[0] : header) ?? bearer ?? ""; + + const expected = Buffer.from(this.token); + const actual = Buffer.from(presented); + const valid = + expected.length === actual.length && timingSafeEqual(expected, actual); + if (!valid) throw new UnauthorizedException("Invalid service token"); + return true; + } +} diff --git a/apps/edr-freight-api/src/common/resolve-auth-user-id.ts b/apps/edr-freight-api/src/common/resolve-auth-user-id.ts new file mode 100644 index 000000000..cab29b671 --- /dev/null +++ b/apps/edr-freight-api/src/common/resolve-auth-user-id.ts @@ -0,0 +1,12 @@ +import { UnauthorizedException } from '@nestjs/common'; + +export type AuthUserPayload = { id?: string; sub?: string } | null | undefined; + +/** Resolve IAM user id from JWT payload attached by JwtGuard. */ +export function resolveAuthUserId(user: AuthUserPayload): string { + const id = user?.id ?? user?.sub; + if (!id) { + throw new UnauthorizedException('Authentication required'); + } + return id; +} diff --git a/apps/edr-freight-api/src/common/rule-engine-guards.ts b/apps/edr-freight-api/src/common/rule-engine-guards.ts new file mode 100644 index 000000000..12ba30e11 --- /dev/null +++ b/apps/edr-freight-api/src/common/rule-engine-guards.ts @@ -0,0 +1,18 @@ +import { applyDecorators, UseGuards } from '@nestjs/common'; +import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard'; + +import { FreightPermissionGuard } from './freight-permission.guard'; +import { + FREIGHT_PERMS, + type RuleEngineResourceSlug, +} from '../seed/freight-permissions.registry'; + +export const RuleEngineView = (slug: RuleEngineResourceSlug) => + applyDecorators( + UseGuards(JwtGuard, FreightPermissionGuard([FREIGHT_PERMS.ruleEngine.view(slug)])), + ); + +export const RuleEngineManage = (slug: RuleEngineResourceSlug) => + applyDecorators( + UseGuards(JwtGuard, FreightPermissionGuard([FREIGHT_PERMS.ruleEngine.manage(slug)])), + ); diff --git a/apps/edr-freight-api/src/common/utils/generate-code.util.ts b/apps/edr-freight-api/src/common/utils/generate-code.util.ts new file mode 100644 index 000000000..26f978f02 --- /dev/null +++ b/apps/edr-freight-api/src/common/utils/generate-code.util.ts @@ -0,0 +1,15 @@ +/** + * Derives a stable, uppercase, underscore-separated code from a human-readable name. + * + * Examples: + * "Hazard Surcharge" → "HAZARD_SURCHARGE" + * "20ft Dry Container" → "20FT_DRY_CONTAINER" + * "Kality Yard (ET)" → "KALITY_YARD_ET" + */ +export function generateCode(name: string): string { + return name + .trim() + .toUpperCase() + .replace(/[^A-Z0-9]+/g, '_') + .replace(/^_+|_+$/g, ''); +} diff --git a/apps/edr-freight-api/src/config/app.config.ts b/apps/edr-freight-api/src/config/app.config.ts index 8fa1ac47d..fa8644945 100644 --- a/apps/edr-freight-api/src/config/app.config.ts +++ b/apps/edr-freight-api/src/config/app.config.ts @@ -1,7 +1,31 @@ import { registerAs } from "@nestjs/config"; +const numberFromEnv = (key: string, fallback: number): number => { + const value = Number(process.env[key]); + return Number.isFinite(value) && value > 0 ? value : fallback; +}; + export default registerAs("app", () => ({ env: process.env.NODE_ENV ?? "development", port: parseInt(process.env.PORT ?? "3001", 10), apiPrefix: "api", + trainScheduling: { + maxTrainWeightTons: numberFromEnv("TRAIN_SCHEDULING_MAX_WEIGHT_TONS", 3500), + maxTrainLengthMeters: numberFromEnv("TRAIN_SCHEDULING_MAX_LENGTH_METERS", 760), + maxWagonsPerTrain: numberFromEnv("TRAIN_SCHEDULING_MAX_WAGONS_PER_TRAIN", 53), + }, + cbeExchange: { + /** ethio.forex CBET page — scraped for USD buying/selling rates. */ + scrapeUrl: + process.env.CBE_EXCHANGE_SCRAPE_URL ?? + process.env.CBE_EXCHANGE_API_URL ?? + "https://ethio.forex/bank/CBET", + /** @deprecated use scrapeUrl — kept for backward-compatible config reads */ + apiUrl: + process.env.CBE_EXCHANGE_SCRAPE_URL ?? + process.env.CBE_EXCHANGE_API_URL ?? + "https://ethio.forex/bank/CBET", + fallbackRate: numberFromEnv("CBE_EXCHANGE_FALLBACK_RATE", 130), + cacheTtlMs: numberFromEnv("CBE_EXCHANGE_CACHE_TTL_MS", 3_600_000), + }, })); diff --git a/apps/edr-freight-api/src/config/database.config.ts b/apps/edr-freight-api/src/config/database.config.ts index fbcbeb1be..0e7375b19 100644 --- a/apps/edr-freight-api/src/config/database.config.ts +++ b/apps/edr-freight-api/src/config/database.config.ts @@ -1,19 +1,123 @@ import { registerAs } from "@nestjs/config"; import { TypeOrmModuleOptions } from "@nestjs/typeorm"; +import { join, dirname } from "path"; +import { + DefaultPosition, + DefaultUnit, + EmployeePosition, + Employee, + OrganizationConfiguration, + GlobalOrganizationConfiguration, + OrganizationType, + Organization, + PositionConfiguration, + PositionPermission, + PositionTypeConfiguration, + PositionTypePermission, + PositionType, + Position, + Project, + GlobalUnitConfiguration, + Unit, + EmployeeSignature, + EmployeeStamp, + RecordFooter, + RecordHeader, + Seal, + AccountConfiguration, + Application, + DocumentaryRequirement, + Permission, + RolePermission, + Role, + Session, + UserCredential, + UserDocument, + UserRole, + UserVerification, + User, + Notification, + NotificationEvent, + NotificationPlaceholder, + NotificationReceiverField, + NotificationReceiver, + NotificationTemplate, +} from "@tria-plc/iamapi-common"; +import { OrganizationSetting } from "@tria-plc/iamapi-common/entities/iam/organization-structure/organization-setting.entity"; +import { APPLICATION_SEARCH_PATH } from "./ensure-postgres-schemas"; -export default registerAs( - "database", - (): TypeOrmModuleOptions => ({ +const iamEntities = [ + DefaultPosition, + DefaultUnit, + EmployeePosition, + Employee, + OrganizationConfiguration, + GlobalOrganizationConfiguration, + OrganizationSetting, + OrganizationType, + Organization, + PositionConfiguration, + PositionPermission, + PositionTypeConfiguration, + PositionTypePermission, + PositionType, + Position, + Project, + GlobalUnitConfiguration, + Unit, + EmployeeSignature, + EmployeeStamp, + RecordFooter, + RecordHeader, + Seal, + AccountConfiguration, + Application, + DocumentaryRequirement, + Permission, + RolePermission, + Role, + Session, + UserCredential, + UserDocument, + UserRole, + UserVerification, + User, + Notification, + NotificationEvent, + NotificationPlaceholder, + NotificationReceiverField, + NotificationReceiver, + NotificationTemplate, +]; + +const iamMigrationsGlob = join( + dirname(require.resolve("@tria-plc/iamapi-common/package.json")), + "dist/db/migrations/*.js", +); +const freightMigrationsGlob = join(__dirname, "../migrations/*.js"); + +export default registerAs("database", (): TypeOrmModuleOptions => { + return { type: "postgres", host: process.env.DB_HOST ?? "localhost", port: parseInt(process.env.DB_PORT ?? "5433", 10), username: process.env.DB_USER ?? "postgres", password: process.env.DB_PASSWORD ?? "", database: process.env.DB_NAME ?? "edr_freight", - entities: [__dirname + "/../**/*.entity.{ts,js}"], - migrations: [__dirname + "/../../migrations/*.{ts,js}"], - // Never enable synchronize in production. Use migrations. - synchronize: process.env.NODE_ENV === "development", + schema: "public", + extra: { + options: `-c search_path=${APPLICATION_SEARCH_PATH}`, + }, + entities: [__dirname + "/../**/*.entity.{ts,js}", ...iamEntities], + autoLoadEntities: true, + migrations: [ + // IAM schema + tables must be created before freight migrations + iamMigrationsGlob, + freightMigrationsGlob, + ], + migrationsRun: true, + // Schema changes via migrations only (synchronize breaks ITMLS backfill on existing rows). + synchronize: false, logging: process.env.NODE_ENV === "development", - }), -); + }; +}); diff --git a/apps/edr-freight-api/src/config/dmoney.config.ts b/apps/edr-freight-api/src/config/dmoney.config.ts new file mode 100644 index 000000000..7922b4aae --- /dev/null +++ b/apps/edr-freight-api/src/config/dmoney.config.ts @@ -0,0 +1,10 @@ +import { registerAs } from "@nestjs/config"; + +export default registerAs("dmoney", () => ({ + baseUrl: process.env.DMONEY_BASE_URL ?? "", + appId: process.env.DMONEY_APP_ID ?? "", + appSecret: process.env.DMONEY_APP_SECRET ?? "", + publicKey: process.env.DMONEY_PUBLIC_KEY ?? "", + privateKey: process.env.DMONEY_PRIVATE_KEY ?? "", + notifyUrl: process.env.DMONEY_NOTIFY_URL ?? "" +})); diff --git a/apps/edr-freight-api/src/config/ensure-postgres-schemas.ts b/apps/edr-freight-api/src/config/ensure-postgres-schemas.ts new file mode 100644 index 000000000..99fbcab61 --- /dev/null +++ b/apps/edr-freight-api/src/config/ensure-postgres-schemas.ts @@ -0,0 +1,50 @@ +import { DataSource, DataSourceOptions } from "typeorm"; + +/** Schemas required before TypeORM migrations and entity access. */ +export const APPLICATION_SCHEMAS = [ + "public", + "iam", + "freight", + "audit", +] as const; + +export const APPLICATION_SEARCH_PATH = APPLICATION_SCHEMAS.join(","); + +/** + * TypeORM creates the migrations table before any migration runs. If `public` was + * dropped, current_schema() is null and CREATE TABLE migrations fails. + * IAM/freight migrations assume their schemas already exist. + */ +export async function ensurePostgresSchemas( + options: DataSourceOptions, +): Promise { + const bootstrap = new DataSource({ + ...options, + entities: [], + migrations: [], + migrationsRun: false, + synchronize: false, + }); + + await bootstrap.initialize(); + + for (const schema of APPLICATION_SCHEMAS) { + if (schema === "public") { + await bootstrap.query(`CREATE SCHEMA IF NOT EXISTS public`); + await bootstrap.query(`GRANT ALL ON SCHEMA public TO public`); + await bootstrap.query(`GRANT CREATE ON SCHEMA public TO public`); + } else { + await bootstrap.query(`CREATE SCHEMA IF NOT EXISTS "${schema}"`); + await bootstrap.query(`GRANT USAGE ON SCHEMA "${schema}" TO public`); + await bootstrap.query( + `GRANT CREATE ON SCHEMA "${schema}" TO public`, + ); + } + } + + await bootstrap.query( + `SET search_path TO ${APPLICATION_SEARCH_PATH}`, + ); + + await bootstrap.destroy(); +} diff --git a/apps/edr-freight-api/src/config/rabbitmq.config.ts b/apps/edr-freight-api/src/config/rabbitmq.config.ts new file mode 100644 index 000000000..cf915b39e --- /dev/null +++ b/apps/edr-freight-api/src/config/rabbitmq.config.ts @@ -0,0 +1,11 @@ +import { registerAs } from '@nestjs/config'; + +/** + * RabbitMQ connection for the payment-event consumer (payment microservice -> freight). + * Points at the dedicated `payment` vhost on the shared broker. + */ +export default registerAs('rabbitmq', () => ({ + url: process.env.PAYMENT_RABBITMQ_URL ?? 'amqp://localhost:5672/payment', + /** Max unacked payment events held by this consumer at once. */ + prefetch: parseInt(process.env.PAYMENT_EVENTS_PREFETCH ?? '10', 10), +})); diff --git a/apps/edr-freight-api/src/config/telebirr.config.ts b/apps/edr-freight-api/src/config/telebirr.config.ts new file mode 100644 index 000000000..8e5d1712a --- /dev/null +++ b/apps/edr-freight-api/src/config/telebirr.config.ts @@ -0,0 +1,16 @@ +import { registerAs } from "@nestjs/config"; + +export default registerAs("telebirr", () => ({ + baseUrl: process.env.TELEBIRR_BASE_URL ?? "", + webBaseUrl: process.env.TELEBIRR_WEB_BASE_URL ?? "", + fabricAppId: process.env.TELEBIRR_FABRIC_APP_ID ?? "", + appSecret: process.env.TELEBIRR_APP_SECRET ?? "", + merchantAppId: process.env.TELEBIRR_MERCHANT_APP_ID ?? "", + merchantCode: process.env.TELEBIRR_MERCHANT_CODE ?? "", + notifyUrl: process.env.TELEBIRR_NOTIFY_URL ?? "", + returnUrl: process.env.TELEBIRR_RETURN_URL ?? "", + timeoutExpress: process.env.TELEBIRR_TIMEOUT_EXPRESS ?? "15m", + privateKey: process.env.TELEBIRR_PRIVATE_KEY ?? "", + publicKey: process.env.TELEBIRR_PUBLIC_KEY ?? "", + insecureTls: process.env.TELEBIRR_INSECURE_TLS === "true", +})); diff --git a/apps/edr-freight-api/src/contracts/contract-clause-packs.ts b/apps/edr-freight-api/src/contracts/contract-clause-packs.ts new file mode 100644 index 000000000..2d73cd50d --- /dev/null +++ b/apps/edr-freight-api/src/contracts/contract-clause-packs.ts @@ -0,0 +1,224 @@ +import type { + Article1Clause, + ContractClausePack, + ContractDirection, + ContractFreight, + ContractServiceScope, +} from './contract-template.types'; + +const STANDARD_CONTRACT_DOCUMENTS = [ + 'Amendments (if any)', + 'This Contract Agreement', + 'Final Minutes of Negotiation (if any)', +]; + +const PAYMENT_OBLIGATION = + 'Pay 100% transportation fees in advance per train set in accordance with Article 5.'; + +const HAZARDOUS_OBLIGATION = + 'Notify EDR 48 hours in advance for hazardous or valuable cargo.'; + +function clonePack(pack: ContractClausePack): ContractClausePack { + return { + article1: { + objective: pack.article1.objective, + scope: [...pack.article1.scope], + }, + clientObligations: [...pack.clientObligations], + providerObligations: [...pack.providerObligations], + contractDocuments: [...pack.contractDocuments], + }; +} + +function applyForwardingOverlay( + pack: ContractClausePack, + service: ContractServiceScope, +): ContractClausePack { + if (service !== 'FORWARDING') return pack; + + const next = clonePack(pack); + next.article1.scope.push( + 'First-mile and/or last-mile coordination, documentation, and handover with road or port partners where included in the agreed service scope.', + ); + next.providerObligations.push( + 'Coordinate first-mile and last-mile logistics with designated partners and keep the Client informed of handover milestones.', + ); + return next; +} + +function buildImportContainerPack(): ContractClausePack { + return { + article1: { + objective: + 'To provide railway transportation for 40ft and/or 20ft full containers from SGTD to Dire Dawa, Modjo dry port and/or Galaan Multipurpose port (GMP), and empty container return from those terminals to SGTD.', + scope: [ + 'Railway transport service on the agreed import corridor.', + 'Cargo handling at Galaan Multipurpose port (GMP) where applicable.', + ], + }, + clientObligations: [ + 'Provide shipment instructions to EDR for container movements on the agreed corridor.', + 'Meet minimum container supply per terminal (Modjo, Dire Dawa, GMP) as per EDR operational rules.', + 'Submit required documents to Djibouti Nagad station at least 24 hours before loading.', + PAYMENT_OBLIGATION, + HAZARDOUS_OBLIGATION, + ], + providerObligations: [ + 'Assign voyage per operational schedule and notify train schedule 48 hours in advance.', + 'Provide safe transportation and deliver within agreed timelines when documents are complete.', + 'Return empty containers from Dire Dawa, Modjo and GMP to SGTD within seven (7) calendar days of receipt.', + 'Maintain cargo liability insurance per wagon.', + ], + contractDocuments: [...STANDARD_CONTRACT_DOCUMENTS], + }; +} + +function buildImportBulkPack(): ContractClausePack { + return { + article1: { + objective: + 'To provide railway transportation for bulk cargo from SGTD railway freight station at Djibouti to designated Ethiopian rail terminals on the import corridor.', + scope: [ + 'Railway bulk transport service on the agreed import corridor.', + 'Loading and unloading coordination at designated terminals per EDR operational rules.', + ], + }, + clientObligations: [ + 'Provide accurate commodity description, weight, and shipment instructions for each train movement.', + 'Ensure cargo is prepared and available at origin per the agreed loading window.', + 'Submit required customs and operational documents at least 24 hours before loading where applicable.', + PAYMENT_OBLIGATION, + HAZARDOUS_OBLIGATION, + ], + providerObligations: [ + 'Assign train capacity per operational schedule and notify departure timing 48 hours in advance where practicable.', + 'Provide safe bulk transportation and deliver within agreed timelines when documents are complete.', + 'Maintain cargo liability insurance per wagon or train consist as applicable.', + ], + contractDocuments: [...STANDARD_CONTRACT_DOCUMENTS], + }; +} + +function buildExportContainerPack(): ContractClausePack { + return { + article1: { + objective: + 'To provide railway transportation for 40ft and/or 20ft full containers from designated Ethiopian dry ports and terminals to SGTD and related export corridors.', + scope: [ + 'Railway export transport service on the agreed corridor.', + 'Terminal coordination at origin yards for export dispatch where applicable.', + ], + }, + clientObligations: [ + 'Provide export shipment instructions and container release details for each movement.', + 'Ensure containers are available at origin terminals per EDR operational windows.', + 'Submit required export, customs, and operational documents at origin at least 24 hours before loading.', + PAYMENT_OBLIGATION, + HAZARDOUS_OBLIGATION, + ], + providerObligations: [ + 'Assign voyage per operational schedule and notify train schedule 48 hours in advance.', + 'Provide safe transportation to SGTD and hand over for export processing when documents are complete.', + 'Maintain cargo liability insurance per wagon.', + ], + contractDocuments: [...STANDARD_CONTRACT_DOCUMENTS], + }; +} + +function buildExportBulkPack(): ContractClausePack { + return { + article1: { + objective: + 'To provide railway transportation for bulk export cargo from designated Ethiopian rail terminals to SGTD and related export corridors.', + scope: [ + 'Railway bulk export transport on the agreed corridor.', + 'Loading coordination at origin terminals per EDR operational rules.', + ], + }, + clientObligations: [ + 'Provide accurate commodity description, weight, and export shipment instructions.', + 'Ensure bulk cargo is prepared and available at origin per the agreed loading window.', + 'Submit required export and customs documents at least 24 hours before loading where applicable.', + PAYMENT_OBLIGATION, + HAZARDOUS_OBLIGATION, + ], + providerObligations: [ + 'Assign train capacity per operational schedule and notify departure timing 48 hours in advance where practicable.', + 'Provide safe bulk transportation to SGTD within agreed timelines when documents are complete.', + 'Maintain cargo liability insurance per wagon or train consist as applicable.', + ], + contractDocuments: [...STANDARD_CONTRACT_DOCUMENTS], + }; +} + +function buildDomesticContainerPack(): ContractClausePack { + return { + article1: { + objective: + 'To provide railway transportation for 40ft and/or 20ft containers between designated Ethiopian rail terminals on the domestic corridor.', + scope: ['Domestic railway container transport between agreed origin and destination yards.'], + }, + clientObligations: [ + 'Provide shipment instructions for each domestic container movement.', + 'Ensure containers are available at origin per EDR operational rules.', + PAYMENT_OBLIGATION, + HAZARDOUS_OBLIGATION, + ], + providerObligations: [ + 'Assign voyage per operational schedule and notify train schedule 48 hours in advance where practicable.', + 'Provide safe transportation and deliver within agreed timelines when instructions are complete.', + 'Maintain cargo liability insurance per wagon.', + ], + contractDocuments: [...STANDARD_CONTRACT_DOCUMENTS], + }; +} + +function buildDomesticBulkPack(): ContractClausePack { + return { + article1: { + objective: + 'To provide railway transportation for bulk cargo between designated Ethiopian rail terminals on the domestic corridor.', + scope: ['Domestic railway bulk transport between agreed origin and destination terminals.'], + }, + clientObligations: [ + 'Provide commodity description, weight, and shipment instructions for each movement.', + 'Ensure cargo is prepared at origin per the agreed loading window.', + PAYMENT_OBLIGATION, + HAZARDOUS_OBLIGATION, + ], + providerObligations: [ + 'Assign train capacity per operational schedule and notify departure timing 48 hours in advance where practicable.', + 'Provide safe bulk transportation within agreed timelines.', + 'Maintain cargo liability insurance per wagon or train consist as applicable.', + ], + contractDocuments: [...STANDARD_CONTRACT_DOCUMENTS], + }; +} + +const BASE_PACKS: Record ContractClausePack>> = { + IMP: { + CON: buildImportContainerPack, + BULK: buildImportBulkPack, + }, + EXP: { + CON: buildExportContainerPack, + BULK: buildExportBulkPack, + }, + DOM: { + CON: buildDomesticContainerPack, + BULK: buildDomesticBulkPack, + }, +}; + +export function buildClausePack( + direction: ContractDirection, + freight: ContractFreight, + service: ContractServiceScope, +): ContractClausePack { + const base = BASE_PACKS[direction][freight](); + return applyForwardingOverlay(base, service); +} + +export function article1ObjectiveFromClause(article1: Article1Clause): string { + return article1.objective; +} diff --git a/apps/edr-freight-api/src/contracts/contract-pdf.service.ts b/apps/edr-freight-api/src/contracts/contract-pdf.service.ts new file mode 100644 index 000000000..9e0acc6fb --- /dev/null +++ b/apps/edr-freight-api/src/contracts/contract-pdf.service.ts @@ -0,0 +1,116 @@ +import { existsSync } from 'fs'; + +import { Injectable, InternalServerErrorException, Logger } from '@nestjs/common'; + +const MIN_VALID_PDF_BYTES = 2_000; + +const PDF_PRINT_STYLES = ` +`; + +@Injectable() +export class ContractPdfService { + private readonly logger = new Logger(ContractPdfService.name); + + async htmlToPdfBuffer(html: string): Promise { + const preparedHtml = this.injectPdfPrintStyles(html); + const executablePath = this.resolveExecutablePath(); + + try { + const puppeteer = await import('puppeteer'); + const launchOptions: import('puppeteer').LaunchOptions = { + headless: true, + args: [ + '--no-sandbox', + '--disable-setuid-sandbox', + '--disable-dev-shm-usage', + ], + ...(executablePath ? { executablePath } : {}), + }; + + const browser = await puppeteer.default.launch(launchOptions); + try { + const page = await browser.newPage(); + await page.setViewport({ width: 794, height: 1123, deviceScaleFactor: 1 }); + await page.setContent(preparedHtml, { + waitUntil: 'load', + timeout: 60_000, + }); + await page.emulateMediaType('print'); + await new Promise((resolve) => setTimeout(resolve, 400)); + + const pdf = await page.pdf({ + format: 'A4', + printBackground: true, + displayHeaderFooter: true, + headerTemplate: '', + footerTemplate: + '
Page of
', + margin: { top: '18mm', bottom: '22mm', left: '14mm', right: '14mm' }, + }); + + const buffer = Buffer.from(pdf); + if (!this.isValidPdf(buffer)) { + throw new Error( + `Puppeteer produced invalid PDF (${buffer.length} bytes)`, + ); + } + this.logger.log( + `Contract PDF rendered (${buffer.length} bytes) via ${executablePath ?? 'bundled Chromium'}`, + ); + return buffer; + } finally { + await browser.close(); + } + } catch (err) { + this.logger.error( + `Puppeteer PDF failed (executable=${executablePath ?? 'default'}): ${err}`, + ); + throw new InternalServerErrorException( + 'Contract PDF could not be generated. Ensure Chromium is installed or set PUPPETEER_EXECUTABLE_PATH.', + ); + } + } + + private injectPdfPrintStyles(html: string): string { + if (html.includes('contract-pdf-print-fix')) return html; + if (html.includes('')) { + return html.replace('', `${PDF_PRINT_STYLES}`); + } + return `${PDF_PRINT_STYLES}${html}`; + } + + private resolveExecutablePath(): string | undefined { + const fromEnv = process.env.PUPPETEER_EXECUTABLE_PATH?.trim(); + if (fromEnv && existsSync(fromEnv)) return fromEnv; + + const candidates = [ + '/usr/bin/chromium', + '/usr/bin/chromium-browser', + '/usr/bin/google-chrome-stable', + '/usr/bin/google-chrome', + ]; + return candidates.find((p) => existsSync(p)); + } + + private isValidPdf(buffer: Buffer): boolean { + return ( + buffer.length >= MIN_VALID_PDF_BYTES && + buffer.subarray(0, 5).toString('ascii') === '%PDF-' + ); + } +} diff --git a/apps/edr-freight-api/src/contracts/contract-pricing-schedule.builder.ts b/apps/edr-freight-api/src/contracts/contract-pricing-schedule.builder.ts new file mode 100644 index 000000000..d5439ff11 --- /dev/null +++ b/apps/edr-freight-api/src/contracts/contract-pricing-schedule.builder.ts @@ -0,0 +1,73 @@ +import { Injectable } from '@nestjs/common'; + +import { BookingPricingService } from '../modules/bookings/booking-pricing.service'; +import { Booking } from '../modules/bookings/entities/booking.entity'; +import { PriceLineItemDto } from '../modules/bookings/dto/generate-price-response.dto'; + +export interface PricingScheduleRow { + label: string; + description: string; + amount: number; + currency: string; +} + +export interface PricingSchedule { + lineItems: PricingScheduleRow[]; + surcharges: PricingScheduleRow[]; + totalAmount: number; + currency: string; + equipmentReturn?: string; + originLabel: string; + destinationLabel: string; + containerLines: Array<{ + label: string; + quantity: number; + vgmPerUnitTons: number; + }>; +} + +@Injectable() +export class ContractPricingScheduleBuilder { + constructor(private readonly pricingService: BookingPricingService) {} + + async build(booking: Booking): Promise { + const { lineItems, totalAmount, currency } = + await this.pricingService.computeContractLineItems(booking); + + const isSurcharge = (l: PriceLineItemDto) => + l.code.includes('SURCHARGE') || l.description.toLowerCase().includes('surcharge'); + + const baseLines = lineItems.filter((l) => !isSurcharge(l)); + const surchargeLines = lineItems.filter(isSurcharge); + + return { + lineItems: baseLines.map((l) => ({ + label: l.code, + description: l.description, + amount: l.amount, + currency: l.currency, + })), + surcharges: surchargeLines.map((l) => ({ + label: l.code, + description: l.description, + amount: l.amount, + currency: l.currency, + })), + totalAmount, + currency, + equipmentReturn: booking.equipmentReturn ?? '—', + originLabel: booking.originYard?.label ?? booking.originYard?.code ?? '—', + destinationLabel: + booking.destinationYard?.label ?? booking.destinationYard?.code ?? '—', + containerLines: (booking.bookingContainers ?? []).map((c) => ({ + label: + c.containerType?.label ?? + c.containerType?.code ?? + c.containerTypeId ?? + '—', + quantity: c.quantity, + vgmPerUnitTons: Number(c.vgmPerUnitTons), + })), + }; + } +} diff --git a/apps/edr-freight-api/src/contracts/contract-renderer.service.spec.ts b/apps/edr-freight-api/src/contracts/contract-renderer.service.spec.ts new file mode 100644 index 000000000..a66a516c0 --- /dev/null +++ b/apps/edr-freight-api/src/contracts/contract-renderer.service.spec.ts @@ -0,0 +1,82 @@ +import { ContractRendererService } from './contract-renderer.service'; +import { getTemplateMeta } from './contract-template.registry'; +import type { ContractViewModel } from './contract-view-model.builder'; + +describe('ContractRendererService', () => { + const renderer = new ContractRendererService(); + renderer.onModuleInit(); + + function minimalView(templateKey: string): ContractViewModel { + const template = getTemplateMeta(templateKey); + return { + bookingId: 'test-id', + reference: 'BK-TEST-001', + status: 'CONTRACT_READY', + templateKey, + template, + contractDate: '1 January 2026', + contractYear: 2026, + client: { + companyName: 'Test Co', + companyAddress: 'Addis Ababa', + companyLocation: 'Ethiopia', + phone: '+251900000000', + email: 'test@example.com', + tinNumber: '1234567890', + vatNumber: 'VAT-001', + fanNumber: 'FAN-001', + businessLicense: 'BL-001', + }, + provider: { + name: 'Ethio-Djibouti Standard Gauge Railway Share Company', + address: 'Addis Ababa, Ethiopia', + phone: '+251 11 872 0000', + email: 'info@edr.gov.et', + tinNumber: '—', + }, + schedule: { + originLabel: 'SGTD', + destinationLabel: 'Modjo', + tradeDirection: 'IMPORT', + freightType: 'CONTAINER', + serviceType: 'Rail transport', + scheduledDate: '1 January 2026', + contractType: 'NEW', + cargoDescription: 'Container cargo', + totalWeightVgm: '24 tons', + equipmentReturn: 'RETURN', + hazardousLabel: 'No', + firstMilePickupAddress: '—', + lastMileDeliveryAddress: '—', + }, + pricing: { + lineItems: [{ label: 'RAIL', description: 'Rail transport', amount: 1000, currency: 'ETB' }], + surcharges: [], + totalAmount: 1000, + currency: 'ETB', + originLabel: 'SGTD', + destinationLabel: 'Modjo', + containerLines: [{ label: '40ft', quantity: 2, vgmPerUnitTons: 12 }], + }, + signatures: [], + canSignCustomer: true, + canSignStaff: false, + hasContractDocument: false, + hasCustomerSignature: false, + hasStaffSignature: false, + }; + } + + it('renders import flagship with Nagad and Article 5', () => { + const html = renderer.render(minimalView('IMP_CON_ETB_TRANSPORT_ONLY')); + expect(html).toContain('Djibouti Nagad'); + expect(html).toContain('Article 5: Contract Price'); + expect(html).toContain('Article 2: Obligations of the Client'); + }); + + it('renders export variant without import empty-return clause', () => { + const html = renderer.render(minimalView('EXP_CON_USD_TRANSPORT_ONLY')); + expect(html).toContain('export corridors'); + expect(html).not.toContain('Return empty containers from Dire Dawa'); + }); +}); diff --git a/apps/edr-freight-api/src/contracts/contract-renderer.service.ts b/apps/edr-freight-api/src/contracts/contract-renderer.service.ts new file mode 100644 index 000000000..dd539df25 --- /dev/null +++ b/apps/edr-freight-api/src/contracts/contract-renderer.service.ts @@ -0,0 +1,51 @@ +import { Injectable, OnModuleInit } from '@nestjs/common'; +import * as fs from 'fs'; +import * as path from 'path'; +import Handlebars from 'handlebars'; + +import { ContractViewModel } from './contract-view-model.builder'; + +@Injectable() +export class ContractRendererService implements OnModuleInit { + private readonly templatesDir = path.join(__dirname, 'templates'); + private readonly compiled = new Map(); + + onModuleInit(): void { + Handlebars.registerHelper('eq', (a: unknown, b: unknown) => a === b); + + const partialsDir = path.join(this.templatesDir, '_partials'); + if (fs.existsSync(partialsDir)) { + for (const file of fs.readdirSync(partialsDir)) { + if (!file.endsWith('.hbs')) continue; + const name = file.replace(/\.hbs$/, ''); + const content = fs.readFileSync(path.join(partialsDir, file), 'utf-8'); + Handlebars.registerPartial(name, content); + } + } + } + + render(view: ContractViewModel): string { + const fileName = + view.template.templateFile ?? 'generic.hbs'; + const template = this.getCompiled(fileName); + return template({ + ...view, + paymentArticle: view.pricing.currency === 'ETB' ? 'ETB' : 'USD', + }); + } + + private getCompiled(fileName: string): Handlebars.TemplateDelegate { + const cached = this.compiled.get(fileName); + if (cached) return cached; + + const filePath = path.join(this.templatesDir, fileName); + const fallbackPath = path.join(this.templatesDir, 'generic.hbs'); + const source = fs.existsSync(filePath) + ? fs.readFileSync(filePath, 'utf-8') + : fs.readFileSync(fallbackPath, 'utf-8'); + + const compiled = Handlebars.compile(source); + this.compiled.set(fileName, compiled); + return compiled; + } +} diff --git a/apps/edr-freight-api/src/contracts/contract-template.registry.spec.ts b/apps/edr-freight-api/src/contracts/contract-template.registry.spec.ts new file mode 100644 index 000000000..2c921889a --- /dev/null +++ b/apps/edr-freight-api/src/contracts/contract-template.registry.spec.ts @@ -0,0 +1,68 @@ +import { + CONTRACT_TEMPLATE_KEYS, + CONTRACT_TEMPLATE_REGISTRY, + getTemplateMeta, + isValidTemplateKey, +} from './contract-template.registry'; + +describe('ContractTemplateRegistry', () => { + it('defines exactly 24 template keys', () => { + expect(CONTRACT_TEMPLATE_KEYS).toHaveLength(24); + expect(Object.keys(CONTRACT_TEMPLATE_REGISTRY)).toHaveLength(24); + }); + + it('keys match the direction_freight_currency_service pattern', () => { + for (const key of CONTRACT_TEMPLATE_KEYS) { + expect(isValidTemplateKey(key)).toBe(true); + } + }); + + it('each meta has non-empty obligations and article1 scope', () => { + for (const key of CONTRACT_TEMPLATE_KEYS) { + const meta = CONTRACT_TEMPLATE_REGISTRY[key]!; + expect(meta.clientObligations.length).toBeGreaterThan(0); + expect(meta.providerObligations.length).toBeGreaterThan(0); + expect(meta.article1.scope.length).toBeGreaterThan(0); + expect(meta.article1.objective.length).toBeGreaterThan(0); + expect(meta.contractDocuments.length).toBeGreaterThan(0); + } + }); + + it('IMP_CON_ETB_TRANSPORT_ONLY retains import container flagship clauses', () => { + const meta = getTemplateMeta('IMP_CON_ETB_TRANSPORT_ONLY'); + expect(meta.direction).toBe('IMP'); + expect(meta.freight).toBe('CON'); + expect(meta.article1.objective).toContain('SGTD'); + expect(meta.article1.objective).toContain('empty container return'); + const clientText = meta.clientObligations.join(' '); + expect(clientText).toContain('Djibouti Nagad'); + const providerText = meta.providerObligations.join(' '); + expect(providerText).toContain('seven (7) calendar days'); + }); + + it('EXP_CON_USD_TRANSPORT_ONLY uses export-oriented article1', () => { + const meta = getTemplateMeta('EXP_CON_USD_TRANSPORT_ONLY'); + expect(meta.direction).toBe('EXP'); + expect(meta.article1.objective).toContain('SGTD'); + expect(meta.providerObligations.join(' ')).not.toContain( + 'Return empty containers from Dire Dawa', + ); + }); + + it('FORWARDING adds scope and provider obligations', () => { + const transport = getTemplateMeta('IMP_CON_ETB_TRANSPORT_ONLY'); + const forwarding = getTemplateMeta('IMP_CON_ETB_FORWARDING'); + expect(forwarding.article1.scope.length).toBeGreaterThan( + transport.article1.scope.length, + ); + expect(forwarding.providerObligations.length).toBeGreaterThan( + transport.providerObligations.length, + ); + }); + + it('getTemplateMeta fallback includes clause arrays for unknown keys', () => { + const meta = getTemplateMeta('UNKNOWN_KEY'); + expect(meta.clientObligations.length).toBeGreaterThan(0); + expect(meta.article1.scope.length).toBeGreaterThan(0); + }); +}); diff --git a/apps/edr-freight-api/src/contracts/contract-template.registry.ts b/apps/edr-freight-api/src/contracts/contract-template.registry.ts new file mode 100644 index 000000000..3e91e1b4f --- /dev/null +++ b/apps/edr-freight-api/src/contracts/contract-template.registry.ts @@ -0,0 +1,119 @@ +import { + article1ObjectiveFromClause, + buildClausePack, +} from './contract-clause-packs'; +import type { + ContractDirection, + ContractFreight, + ContractServiceScope, + ContractTemplateMeta, +} from './contract-template.types'; + +export type { ContractTemplateMeta } from './contract-template.types'; + +const DIRECTION_LABELS: Record = { + IMP: 'Import', + EXP: 'Export', + DOM: 'Domestic', +}; + +const FREIGHT_LABELS: Record = { + CON: 'Container', + BULK: 'Bulk', +}; + +const DIRECTIONS: ContractDirection[] = ['IMP', 'EXP', 'DOM']; +const FREIGHTS: ContractFreight[] = ['CON', 'BULK']; +const CURRENCIES = ['ETB', 'USD'] as const; +const SERVICES: ContractServiceScope[] = ['TRANSPORT_ONLY', 'FORWARDING']; + +const KEY_PATTERN = + /^(IMP|EXP|DOM)_(CON|BULK)_(ETB|USD)_(TRANSPORT_ONLY|FORWARDING)$/; + +function buildMeta( + dir: ContractDirection, + freight: ContractFreight, + currency: string, + service: ContractServiceScope, +): ContractTemplateMeta { + const key = `${dir}_${freight}_${currency}_${service}`; + const dirLabel = DIRECTION_LABELS[dir]; + const freightLabel = FREIGHT_LABELS[freight]; + const serviceLabel = + service === 'FORWARDING' ? 'Rail and Forwarding' : 'Transport Only'; + + const corridor = + dir === 'IMP' + ? 'from SGTD railway freight station at Djibouti to Ethiopian dry ports and return of empty containers as applicable' + : dir === 'EXP' + ? 'from Ethiopian dry ports to SGTD and related export corridors' + : 'between designated Ethiopian rail terminals'; + + const clauses = buildClausePack(dir, freight, service); + + return { + key, + direction: dir, + freight, + currency, + serviceScope: service, + title: `${dirLabel} ${freightLabel} Transport Service by Railway (${serviceLabel})`, + directionLabel: dirLabel, + freightLabel, + whereas: `The Client has requested transportation of ${freightLabel.toLowerCase()} cargo ${corridor} using the Addis Ababa–Djibouti Railway line. The Service Provider has agreed to provide services per this contract.`, + article1Objective: article1ObjectiveFromClause(clauses.article1), + article1: clauses.article1, + clientObligations: clauses.clientObligations, + providerObligations: clauses.providerObligations, + contractDocuments: clauses.contractDocuments, + }; +} + +/** Full template matrix (24 keys). */ +export const CONTRACT_TEMPLATE_REGISTRY: Record = + {}; + +for (const dir of DIRECTIONS) { + for (const freight of FREIGHTS) { + for (const currency of CURRENCIES) { + for (const service of SERVICES) { + const meta = buildMeta(dir, freight, currency, service); + CONTRACT_TEMPLATE_REGISTRY[meta.key] = meta; + } + } + } +} + +export const CONTRACT_TEMPLATE_KEYS = Object.keys(CONTRACT_TEMPLATE_REGISTRY); + +export function listTemplateKeys(): string[] { + return CONTRACT_TEMPLATE_KEYS; +} + +export function getTemplateMeta(key: string): ContractTemplateMeta { + const found = CONTRACT_TEMPLATE_REGISTRY[key]; + if (found) return found; + + const fallbackClauses = buildClausePack('IMP', 'CON', 'TRANSPORT_ONLY'); + return { + key, + direction: 'IMP', + freight: 'CON', + currency: 'USD', + serviceScope: 'TRANSPORT_ONLY', + title: 'Freight Contract Agreement', + directionLabel: 'Freight', + freightLabel: 'Cargo', + whereas: + 'The parties agree to railway freight services as described in the schedule below.', + article1Objective: article1ObjectiveFromClause(fallbackClauses.article1), + article1: fallbackClauses.article1, + clientObligations: fallbackClauses.clientObligations, + providerObligations: fallbackClauses.providerObligations, + contractDocuments: fallbackClauses.contractDocuments, + }; +} + +export function isValidTemplateKey(key: string): boolean { + return KEY_PATTERN.test(key); +} diff --git a/apps/edr-freight-api/src/contracts/contract-template.resolver.spec.ts b/apps/edr-freight-api/src/contracts/contract-template.resolver.spec.ts new file mode 100644 index 000000000..f4cb1c4ee --- /dev/null +++ b/apps/edr-freight-api/src/contracts/contract-template.resolver.spec.ts @@ -0,0 +1,65 @@ +import { ContractTemplateResolver } from './contract-template.resolver'; +import { Booking } from '../modules/bookings/entities/booking.entity'; +import { ServiceType } from '../modules/rule-engine/entities/service-type.entity'; +import { CargoType } from '../modules/rule-engine/entities/cargo-type.entity'; + +describe('ContractTemplateResolver', () => { + const resolver = new ContractTemplateResolver(); + + function booking(partial: Partial): Booking { + return partial as Booking; + } + + it('resolves import container ETB transport-only', () => { + const key = resolver.resolve( + booking({ + tradeDirection: 'IMPORT', + freightType: 'CONTAINER', + paymentCurrency: 'ETB', + serviceType: { code: 'RAIL_ONLY', includesFirstMile: false, includesLastMile: false } as ServiceType, + }), + ); + expect(key).toBe('IMP_CON_ETB_TRANSPORT_ONLY'); + }); + + it('resolves export bulk USD forwarding', () => { + const key = resolver.resolve( + booking({ + tradeDirection: 'EXPORT', + freightType: 'BULK', + paymentCurrency: 'USD', + serviceType: { + code: 'RAIL_FORWARDING', + includesFirstMile: true, + includesLastMile: false, + } as ServiceType, + }), + ); + expect(key).toBe('EXP_BULK_USD_FORWARDING'); + }); + + it('maps BREAK_BULK cargo to BULK freight', () => { + const key = resolver.resolve( + booking({ + tradeDirection: 'IMPORT', + freightType: 'CONTAINER', + paymentCurrency: 'ETB', + cargoType: { code: 'BREAK_BULK_GENERAL' } as CargoType, + serviceType: undefined, + }), + ); + expect(key).toBe('IMP_BULK_ETB_TRANSPORT_ONLY'); + }); + + it('resolves domestic container', () => { + const key = resolver.resolve( + booking({ + tradeDirection: 'DOMESTIC', + freightType: 'CONTAINER', + paymentCurrency: 'USD', + serviceType: undefined, + }), + ); + expect(key).toBe('DOM_CON_USD_TRANSPORT_ONLY'); + }); +}); diff --git a/apps/edr-freight-api/src/contracts/contract-template.resolver.ts b/apps/edr-freight-api/src/contracts/contract-template.resolver.ts new file mode 100644 index 000000000..daa48a4e7 --- /dev/null +++ b/apps/edr-freight-api/src/contracts/contract-template.resolver.ts @@ -0,0 +1,43 @@ +import { Injectable } from '@nestjs/common'; + +import { Booking } from '../modules/bookings/entities/booking.entity'; +import { ServiceType } from '../modules/rule-engine/entities/service-type.entity'; +import { CargoType } from '../modules/rule-engine/entities/cargo-type.entity'; + +@Injectable() +export class ContractTemplateResolver { + resolve(booking: Booking): string { + const dir = + booking.tradeDirection === 'IMPORT' + ? 'IMP' + : booking.tradeDirection === 'EXPORT' + ? 'EXP' + : 'DOM'; + + let freight = booking.freightType === 'BULK' ? 'BULK' : 'CON'; + const cargoCode = (booking.cargoType as CargoType | undefined)?.code ?? ''; + if (cargoCode.startsWith('BREAK_BULK')) { + freight = 'BULK'; + } + + const currency = booking.paymentCurrency === 'ETB' ? 'ETB' : 'USD'; + const service = this.resolveServiceScope(booking.serviceType); + + return `${dir}_${freight}_${currency}_${service}`; + } + + private resolveServiceScope( + serviceType?: ServiceType | null, + ): 'TRANSPORT_ONLY' | 'FORWARDING' { + if (!serviceType) return 'TRANSPORT_ONLY'; + const code = (serviceType.code ?? '').toUpperCase(); + if ( + serviceType.includesFirstMile || + serviceType.includesLastMile || + code.includes('FORWARD') + ) { + return 'FORWARDING'; + } + return 'TRANSPORT_ONLY'; + } +} diff --git a/apps/edr-freight-api/src/contracts/contract-template.types.ts b/apps/edr-freight-api/src/contracts/contract-template.types.ts new file mode 100644 index 000000000..b056b4c69 --- /dev/null +++ b/apps/edr-freight-api/src/contracts/contract-template.types.ts @@ -0,0 +1,35 @@ +export type ContractDirection = 'IMP' | 'EXP' | 'DOM'; +export type ContractFreight = 'CON' | 'BULK'; +export type ContractServiceScope = 'TRANSPORT_ONLY' | 'FORWARDING'; + +export interface Article1Clause { + objective: string; + scope: string[]; +} + +export interface ContractClausePack { + article1: Article1Clause; + clientObligations: string[]; + providerObligations: string[]; + contractDocuments: string[]; +} + +export interface ContractTemplateMeta { + key: string; + direction: ContractDirection; + freight: ContractFreight; + currency: string; + serviceScope: ContractServiceScope; + title: string; + directionLabel: string; + freightLabel: string; + whereas: string; + /** Summary line for APIs; mirrors article1.objective */ + article1Objective: string; + article1: Article1Clause; + clientObligations: string[]; + providerObligations: string[]; + contractDocuments: string[]; + /** Optional dedicated .hbs file; otherwise uses generic.hbs */ + templateFile?: string; +} diff --git a/apps/edr-freight-api/src/contracts/contract-view-model.builder.ts b/apps/edr-freight-api/src/contracts/contract-view-model.builder.ts new file mode 100644 index 000000000..41a2f3b44 --- /dev/null +++ b/apps/edr-freight-api/src/contracts/contract-view-model.builder.ts @@ -0,0 +1,209 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; + +import { BookingsRepository } from '../modules/bookings/bookings.repository'; +import { Booking } from '../modules/bookings/entities/booking.entity'; +import { + BookingContractSignature, + ContractSignerRole, +} from '../modules/bookings/entities/booking-contract-signature.entity'; +import { ContractPricingScheduleBuilder, PricingSchedule } from './contract-pricing-schedule.builder'; +import { ContractTemplateResolver } from './contract-template.resolver'; +import { ContractTemplateMeta, getTemplateMeta } from './contract-template.registry'; + +export interface ContractSignatureView { + role: ContractSignerRole; + signerDisplayName: string; + signedAt: string; + signatureImageUrl?: string | null; +} + +export interface ContractViewModel { + bookingId: string; + reference: string; + status: string; + templateKey: string; + template: ContractTemplateMeta; + contractDate: string; + contractYear: number; + client: { + companyName: string; + companyAddress: string; + companyLocation: string; + phone: string; + email: string; + tinNumber: string; + vatNumber: string; + fanNumber: string; + businessLicense: string; + }; + provider: { + name: string; + address: string; + phone: string; + email: string; + tinNumber: string; + }; + schedule: { + originLabel: string; + destinationLabel: string; + tradeDirection: string; + freightType: string; + serviceType: string; + scheduledDate: string; + contractType: string; + cargoDescription: string; + totalWeightVgm: string; + equipmentReturn: string; + hazardousLabel: string; + firstMilePickupAddress: string; + lastMileDeliveryAddress: string; + }; + pricing: PricingSchedule; + signatures: ContractSignatureView[]; + canSignCustomer: boolean; + canSignStaff: boolean; + hasContractDocument: boolean; + hasCustomerSignature: boolean; + hasStaffSignature: boolean; +} + +@Injectable() +export class ContractViewModelBuilder { + constructor( + private readonly bookingsRepository: BookingsRepository, + private readonly templateResolver: ContractTemplateResolver, + private readonly pricingBuilder: ContractPricingScheduleBuilder, + ) {} + + async build(bookingId: string): Promise<{ booking: Booking; view: ContractViewModel }> { + const booking = await this.bookingsRepository.findByIdWithFiles(bookingId); + if (!booking) { + throw new NotFoundException(`Booking ${bookingId} not found`); + } + + const templateKey = + booking.contractTemplateKey ?? this.templateResolver.resolve(booking); + const template = getTemplateMeta(templateKey); + const pricing = await this.pricingBuilder.build(booking); + const signatures = await this.loadSignatures(bookingId); + + const hasCustomer = signatures.some((s) => s.role === 'CUSTOMER'); + const hasStaff = signatures.some((s) => s.role === 'STAFF'); + const hasContractFile = Boolean( + booking.files?.some((f) => f.code === 'contract'), + ); + + const view: ContractViewModel = { + bookingId: booking.id, + reference: booking.reference, + status: booking.status, + templateKey, + template, + contractDate: new Date().toLocaleDateString('en-GB', { + day: 'numeric', + month: 'long', + year: 'numeric', + }), + contractYear: new Date().getFullYear(), + client: { + companyName: booking.company?.name ?? 'Client', + companyAddress: this.valueOrDash(booking.company?.address), + companyLocation: this.valueOrDash(booking.company?.country), + phone: this.valueOrDash(booking.company?.phone), + email: this.valueOrDash(booking.company?.email), + tinNumber: this.valueOrDash(booking.company?.tin), + vatNumber: this.valueOrDash(booking.company?.vatNumber), + fanNumber: this.valueOrDash(booking.company?.fanNumber), + businessLicense: this.valueOrDash( + booking.company?.companyProfiles?.[0]?.businessLicense, + ), + }, + provider: { + name: 'Ethio-Djibouti Standard Gauge Railway Share Company', + address: 'Addis Ababa, Ethiopia', + phone: '+251 11 872 0000', + email: 'info@edr.gov.et', + tinNumber: '—', + }, + schedule: this.buildSchedule(booking), + pricing, + signatures, + canSignCustomer: + booking.status === 'CONTRACT_READY' && !hasCustomer, + canSignStaff: + booking.status === 'SIGNED_CUSTOMER' && hasCustomer && !hasStaff, + hasContractDocument: hasContractFile, + hasCustomerSignature: hasCustomer, + hasStaffSignature: hasStaff, + }; + + return { booking, view }; + } + + private async loadSignatures(bookingId: string): Promise { + const rows = await this.bookingsRepository.findContractSignatures(bookingId); + return rows.map((s) => this.toSignatureView(s)); + } + + toSignatureView(row: BookingContractSignature): ContractSignatureView { + return { + role: row.signerRole, + signerDisplayName: row.signerDisplayName, + signedAt: this.formatDate(row.signedAt), + signatureImageUrl: row.signatureFile?.url ?? null, + }; + } + + private buildSchedule(booking: Booking): ContractViewModel['schedule'] { + const cargoName = + booking.freightType === 'BULK' + ? booking.cargoFreeText || + booking.cargoType?.cargoTypeName || + 'Bulk commodity' + : booking.cargoType?.cargoTypeName || 'Container cargo'; + const totalWeight = Number(booking.cargoTotalWeightVgm || 0); + + return { + originLabel: this.yardLabel(booking.originYard), + destinationLabel: this.yardLabel(booking.destinationYard), + tradeDirection: this.valueOrDash(booking.tradeDirection), + freightType: this.valueOrDash(booking.freightType), + serviceType: this.valueOrDash( + booking.serviceType?.serviceName ?? booking.serviceType?.code, + ), + scheduledDate: this.formatDate(booking.scheduledDate), + contractType: this.valueOrDash(booking.contractType), + cargoDescription: this.valueOrDash(cargoName), + totalWeightVgm: + totalWeight > 0 ? `${totalWeight.toLocaleString()} tons` : '—', + equipmentReturn: this.valueOrDash(booking.equipmentReturn), + hazardousLabel: booking.isHazardous ? 'Yes' : 'No', + firstMilePickupAddress: this.valueOrDash( + booking.firstMilePickupAddress, + ), + lastMileDeliveryAddress: this.valueOrDash( + booking.lastMileDeliveryAddress, + ), + }; + } + + private yardLabel(yard?: { label?: string; code?: string } | null): string { + return this.valueOrDash(yard?.label ?? yard?.code); + } + + private formatDate(value?: Date | string | null): string { + if (!value) return '—'; + const date = value instanceof Date ? value : new Date(value); + if (Number.isNaN(date.getTime())) return '—'; + return date.toLocaleDateString('en-GB', { + day: 'numeric', + month: 'long', + year: 'numeric', + }); + } + + private valueOrDash(value?: string | number | null): string { + if (value === undefined || value === null || value === '') return '—'; + return String(value); + } +} diff --git a/apps/edr-freight-api/src/contracts/templates/_partials/article1.hbs b/apps/edr-freight-api/src/contracts/templates/_partials/article1.hbs new file mode 100644 index 000000000..d8b3d3fe1 --- /dev/null +++ b/apps/edr-freight-api/src/contracts/templates/_partials/article1.hbs @@ -0,0 +1,15 @@ +
+

Article 1: Objective and Scope of Services

+

+ 1.1 Objective. + {{template.article1.objective}} +

+ {{#if template.article1.scope.length}} +

1.2 Scope of Services.

+
    + {{#each template.article1.scope}} +
  1. {{this}}
  2. + {{/each}} +
+ {{/if}} +
diff --git a/apps/edr-freight-api/src/contracts/templates/_partials/article5_pricing.hbs b/apps/edr-freight-api/src/contracts/templates/_partials/article5_pricing.hbs new file mode 100644 index 000000000..cb3440739 --- /dev/null +++ b/apps/edr-freight-api/src/contracts/templates/_partials/article5_pricing.hbs @@ -0,0 +1,66 @@ +

Article 5: Contract Price and Terms of Payment

+
+

Contract Price

+

+ The contract price is calculated based on the agreed railway corridor, cargo details, applicable rate + schedule, and any approved operational surcharges. +

+ + + + + + + + + + + + + + + +
Corridor{{pricing.originLabel}} → {{pricing.destinationLabel}}Currency{{pricing.currency}}
Payment currency{{paymentArticle}}Equipment return{{pricing.equipmentReturn}}
+ {{#if pricing.equipmentReturn}} +

Equipment return: {{pricing.equipmentReturn}}

+ {{/if}} + +

Charges

+ + + + + + {{#each pricing.lineItems}} + + + + + + {{/each}} + {{#if pricing.surcharges.length}} + + + + {{#each pricing.surcharges}} + + + + + + {{/each}} + {{/if}} + + + + + +
ItemDescriptionAmount
{{label}}{{description}}{{currency}} {{amount}}
Surcharges and Adjustments
{{label}}{{description}}{{currency}} {{amount}}
Total contract value{{pricing.currency}} {{pricing.totalAmount}}
+

Terms of payment

+

+ Unless otherwise agreed in writing, the Client shall settle the contract value in + {{paymentArticle}} before the service is performed and in accordance with EDR payment + instructions. Bank charges, penalties, demurrage, storage, and third-party charges remain the + responsibility of the Client where applicable. +

+
diff --git a/apps/edr-freight-api/src/contracts/templates/_partials/articles_obligations.hbs b/apps/edr-freight-api/src/contracts/templates/_partials/articles_obligations.hbs new file mode 100644 index 000000000..8a82c72d4 --- /dev/null +++ b/apps/edr-freight-api/src/contracts/templates/_partials/articles_obligations.hbs @@ -0,0 +1,19 @@ +
+

Article 2: Obligations of the Client

+

The Client shall perform the following obligations in good faith and within the operational timelines communicated by EDR:

+
    + {{#each template.clientObligations}} +
  1. {{this}}
  2. + {{/each}} +
+
+ +
+

Article 3: Obligations of the Service Provider

+

EDR shall provide the agreed railway freight services in accordance with this Agreement and applicable operational rules:

+
    + {{#each template.providerObligations}} +
  1. {{this}}
  2. + {{/each}} +
+
diff --git a/apps/edr-freight-api/src/contracts/templates/_partials/contract_documents.hbs b/apps/edr-freight-api/src/contracts/templates/_partials/contract_documents.hbs new file mode 100644 index 000000000..adb1ac797 --- /dev/null +++ b/apps/edr-freight-api/src/contracts/templates/_partials/contract_documents.hbs @@ -0,0 +1,9 @@ +
+

Article 6: Contract Documents

+

The following documents form part of this Agreement and shall be read together with the signed contract:

+
    + {{#each template.contractDocuments}} +
  1. {{this}}
  2. + {{/each}} +
+
diff --git a/apps/edr-freight-api/src/contracts/templates/_partials/contract_schedule.hbs b/apps/edr-freight-api/src/contracts/templates/_partials/contract_schedule.hbs new file mode 100644 index 000000000..9531ac4ae --- /dev/null +++ b/apps/edr-freight-api/src/contracts/templates/_partials/contract_schedule.hbs @@ -0,0 +1,65 @@ +
+

Booking Schedule and Commercial Summary

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Route{{schedule.originLabel}} → {{schedule.destinationLabel}}Trade direction{{schedule.tradeDirection}}
Freight type{{schedule.freightType}}Service type{{schedule.serviceType}}
Scheduled date{{schedule.scheduledDate}}Contract type{{schedule.contractType}}
Cargo{{schedule.cargoDescription}}Total VGM{{schedule.totalWeightVgm}}
Equipment return{{schedule.equipmentReturn}}Hazardous cargo{{schedule.hazardousLabel}}
First mile{{schedule.firstMilePickupAddress}}Last mile{{schedule.lastMileDeliveryAddress}}
+ + {{#if pricing.containerLines.length}} +

Container Details

+ + + + + + + + + + {{#each pricing.containerLines}} + + + + + + {{/each}} + +
Container typeQuantityVGM / unit (tons)
{{label}}{{quantity}}{{vgmPerUnitTons}}
+ {{/if}} +
diff --git a/apps/edr-freight-api/src/contracts/templates/_partials/force_majeure.hbs b/apps/edr-freight-api/src/contracts/templates/_partials/force_majeure.hbs new file mode 100644 index 000000000..d8c1d1287 --- /dev/null +++ b/apps/edr-freight-api/src/contracts/templates/_partials/force_majeure.hbs @@ -0,0 +1,12 @@ +
+

Article 4: Force Majeure

+

+ Neither party shall be liable for delay or non-performance caused by events beyond its reasonable control, + including natural disaster, war, civil unrest, government restriction, railway interruption, port closure, + or other force majeure events interpreted under the Ethiopian Civil Code. +

+

+ The affected party shall notify the other party promptly and shall use reasonable efforts to reduce the + effect of the force majeure event on the performance of this Agreement. +

+
diff --git a/apps/edr-freight-api/src/contracts/templates/_partials/signatures_block.hbs b/apps/edr-freight-api/src/contracts/templates/_partials/signatures_block.hbs new file mode 100644 index 000000000..05dd450e4 --- /dev/null +++ b/apps/edr-freight-api/src/contracts/templates/_partials/signatures_block.hbs @@ -0,0 +1,44 @@ +
+
+

For the Service Provider

+

{{provider.name}}

+ {{#if hasStaffSignature}} + {{#each signatures}} + {{#if (eq role "STAFF")}} +
+ {{#if signatureImageUrl}}Staff signature{{/if}} +
+

Name: {{signerDisplayName}}

+

Role: Authorized EDR representative

+

Date: {{signedAt}}

+ {{/if}} + {{/each}} + {{else}} +
Signature pending
+

Name: Authorized representative

+

Role: EDR representative

+

Date:

+ {{/if}} +
+
+

For the Client

+

{{client.companyName}}

+ {{#if hasCustomerSignature}} + {{#each signatures}} + {{#if (eq role "CUSTOMER")}} +
+ {{#if signatureImageUrl}}Customer signature{{/if}} +
+

Name: {{signerDisplayName}}

+

Role: Authorized client representative

+

Date: {{signedAt}}

+ {{/if}} + {{/each}} + {{else}} +
Signature pending
+

Name: Client representative

+

Role: Authorized client representative

+

Date:

+ {{/if}} +
+
diff --git a/apps/edr-freight-api/src/contracts/templates/_partials/styles.hbs b/apps/edr-freight-api/src/contracts/templates/_partials/styles.hbs new file mode 100644 index 000000000..43a5495ec --- /dev/null +++ b/apps/edr-freight-api/src/contracts/templates/_partials/styles.hbs @@ -0,0 +1,258 @@ + diff --git a/apps/edr-freight-api/src/contracts/templates/generic.hbs b/apps/edr-freight-api/src/contracts/templates/generic.hbs new file mode 100644 index 000000000..75f795bce --- /dev/null +++ b/apps/edr-freight-api/src/contracts/templates/generic.hbs @@ -0,0 +1,91 @@ + + + + + {{template.title}} — {{reference}} + {{> styles}} + + +
+
+
+
EDR
+
+

Ethio-Djibouti Standard Gauge Railway Share Company

+

Freight Transport Contract

+
+
+ +
+

Contract Agreement

+

{{template.title}}

+

{{template.directionLabel}} • {{template.freightLabel}} • {{template.currency}} • {{template.serviceScope}}

+
+ + + + + + + + + + + + + + +
Contract Ref No.{{reference}}Contract Year{{contractYear}}
Contract Date{{contractDate}}Status{{status}}
+
+ +
+

Parties to the Agreement

+

+ This Contract Agreement is made on {{contractDate}} between the Service Provider and the Client named below. +

+ +
+
+

Service Provider

+

{{provider.name}}

+
+
Address
{{provider.address}}
+
Phone
{{provider.phone}}
+
Email
{{provider.email}}
+
TIN
{{provider.tinNumber}}
+
+
+
+

Client

+

{{client.companyName}}

+
+
Address
{{client.companyAddress}}
+
Location
{{client.companyLocation}}
+
Phone
{{client.phone}}
+
Email
{{client.email}}
+
TIN
{{client.tinNumber}}
+
VAT
{{client.vatNumber}}
+
FAN
{{client.fanNumber}}
+
Business license
{{client.businessLicense}}
+
+
+
+
+ + {{> contract_schedule}} + +
+

Whereas

+

{{template.whereas}}

+

Now therefore, the parties agree as follows:

+
+ + {{> article1}} + {{> articles_obligations}} + {{> force_majeure}} + {{> article5_pricing}} + {{> contract_documents}} + {{> signatures_block}} +
+ + diff --git a/apps/edr-freight-api/src/data-source.ts b/apps/edr-freight-api/src/data-source.ts new file mode 100644 index 000000000..2ae202ebd --- /dev/null +++ b/apps/edr-freight-api/src/data-source.ts @@ -0,0 +1,21 @@ +// apps/edr-freight-api/src/data-source.ts +import 'dotenv/config'; +import { DataSource } from 'typeorm'; +//import { ensurePostgresSchemas } from './utils/ensure-postgres-schemas'; // adjust path if needed + +export const AppDataSource = new DataSource({ + type: 'postgres', + host: process.env.DB_HOST ?? 'localhost', + port: Number(process.env.DB_PORT ?? 5433), + username: process.env.DB_USER ?? 'postgres', + password: process.env.DB_PASSWORD ?? '', + database: process.env.DB_NAME ?? 'edr_freight', + schema: 'freight', // default schema for entities without an explicit schema + entities: [__dirname + '/**/*.entity{.ts,.js}'], + migrations: [__dirname + '/migrations/*{.ts,.js}'], + synchronize: false, + logging: process.env.TYPEORM_LOGGING === 'true', +}); + +// Optional: call ensurePostgresSchemas before initializing +// But you can also run it separately. diff --git a/apps/edr-freight-api/src/main.ts b/apps/edr-freight-api/src/main.ts index 91910e8e5..a76378b0c 100644 --- a/apps/edr-freight-api/src/main.ts +++ b/apps/edr-freight-api/src/main.ts @@ -1,4 +1,6 @@ import "reflect-metadata"; +import * as dotenv from "dotenv"; +dotenv.config(); import { NestFactory } from "@nestjs/core"; import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger"; import { @@ -10,7 +12,31 @@ import { import { AppModule } from "./app.module"; async function bootstrap() { - const app = await NestFactory.create(AppModule, { cors: true }); + const app = await NestFactory.create(AppModule); + + // Dev CORS: reflect any localhost origin and allow credentials so the + // freight portal (5173), passenger portal (5174), backoffices (5183/5184) + // and any other dev port can call the API with cookies + Authorization. + // For production, restrict `origin` to known FQDNs. + + app.enableCors({ + origin: true, // reflect request origin + credentials: true, + methods: ["GET", "HEAD", "PUT", "PATCH", "POST", "DELETE", "OPTIONS"], + allowedHeaders: [ + "Content-Type", + "Accept", + "Authorization", + "X-Requested-With", + // IAM context headers required by @tria-plc/api-common's JwtGuard + "organization-unit-id", + "delegator-position-id", + "current-project-id", + "current-position-id", + ], + exposedHeaders: ["Content-Disposition"], + maxAge: 86400, // cache preflight for 24h to cut chatter in dev + }); app.setGlobalPrefix("api"); app.useGlobalPipes(createValidationPipe()); @@ -27,9 +53,12 @@ async function bootstrap() { SwaggerModule.setup("api/docs", app, document); const port = parseInt(process.env.PORT ?? "3001", 10); - await app.listen(port); + // await app.listen(port, "0.0.0.0"); + await app.listen( + + port) // eslint-disable-next-line no-console - console.log(`[freight-api] listening on http://localhost:${port}`); + console.log(`[freight-api] listening on port ${port}`); } bootstrap(); diff --git a/apps/edr-freight-api/src/migrations/1748427600000-AddServiceTypesAndCargoTypes.ts b/apps/edr-freight-api/src/migrations/1748427600000-AddServiceTypesAndCargoTypes.ts new file mode 100644 index 000000000..65052bad4 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1748427600000-AddServiceTypesAndCargoTypes.ts @@ -0,0 +1,228 @@ +import { MigrationInterface, QueryRunner, Table, TableIndex, TableForeignKey } from "typeorm"; + +export class AddServiceTypesAndCargoTypes1748427600000 implements MigrationInterface { + name = "AddServiceTypesAndCargoTypes1748427600000"; + + public async up(queryRunner: QueryRunner): Promise { + // Create service_types table + if (!(await queryRunner.hasTable("freight.service_types"))) await queryRunner.createTable( + new Table({ + name: "service_types", + schema: "freight", + columns: [ + { + name: "id", + type: "uuid", + isPrimary: true, + generationStrategy: "uuid", + default: "uuid_generate_v4()", + }, + { + name: "service_name", + type: "varchar", + length: "255", + isNullable: false, + }, + { + name: "description", + type: "text", + isNullable: true, + }, + { + name: "can_be_booked_alone", + type: "boolean", + default: true, + isNullable: false, + }, + { + name: "includes_first_mile", + type: "boolean", + default: false, + isNullable: false, + }, + { + name: "includes_last_mile", + type: "boolean", + default: false, + isNullable: false, + }, + { + name: "includes_customs", + type: "boolean", + default: false, + isNullable: false, + }, + { + name: "priority_bonus_points", + type: "int", + default: 0, + isNullable: false, + }, + { + name: "is_active", + type: "boolean", + default: true, + isNullable: false, + }, + { + name: "display_order", + type: "int", + default: 1, + isNullable: false, + }, + { + name: "created_at", + type: "timestamptz", + default: "now()", + isNullable: false, + }, + { + name: "updated_at", + type: "timestamptz", + default: "now()", + isNullable: false, + }, + { + name: "deleted_at", + type: "timestamptz", + isNullable: true, + }, + ], + }), + true, + ); + + // Create indexes for service_types + await queryRunner.createIndex( + "freight.service_types", + new TableIndex({ + name: "IDX_SERVICE_TYPES_IS_ACTIVE", + columnNames: ["is_active"], + }), + ); + await queryRunner.createIndex( + "freight.service_types", + new TableIndex({ + name: "IDX_SERVICE_TYPES_DISPLAY_ORDER", + columnNames: ["display_order"], + }), + ); + + // Create cargo_types table + if (!(await queryRunner.hasTable("freight.cargo_types"))) await queryRunner.createTable( + new Table({ + name: "cargo_types", + schema: "freight", + columns: [ + { + name: "id", + type: "uuid", + isPrimary: true, + generationStrategy: "uuid", + default: "uuid_generate_v4()", + }, + { + name: "cargo_type_name", + type: "varchar", + length: "255", + isNullable: false, + }, + { + name: "parent_group_id", + type: "uuid", + isNullable: true, + }, + { + name: "show_free_text_box", + type: "boolean", + default: false, + isNullable: false, + }, + { + name: "requires_director_approval", + type: "boolean", + default: false, + isNullable: false, + }, + { + name: "is_active", + type: "boolean", + default: true, + isNullable: false, + }, + { + name: "display_order", + type: "int", + default: 1, + isNullable: false, + }, + { + name: "created_at", + type: "timestamptz", + default: "now()", + isNullable: false, + }, + { + name: "updated_at", + type: "timestamptz", + default: "now()", + isNullable: false, + }, + { + name: "deleted_at", + type: "timestamptz", + isNullable: true, + }, + ], + }), + true, + ); + + // Create indexes for cargo_types + await queryRunner.createIndex( + "freight.cargo_types", + new TableIndex({ + name: "IDX_CARGO_TYPES_IS_ACTIVE", + columnNames: ["is_active"], + }), + ); + await queryRunner.createIndex( + "freight.cargo_types", + new TableIndex({ + name: "IDX_CARGO_TYPES_DISPLAY_ORDER", + columnNames: ["display_order"], + }), + ); + await queryRunner.createIndex( + "freight.cargo_types", + new TableIndex({ + name: "IDX_CARGO_TYPES_PARENT_GROUP_ID", + columnNames: ["parent_group_id"], + }), + ); + + // Create self-referencing foreign key for cargo_types + await queryRunner.createForeignKey( + "freight.cargo_types", + new TableForeignKey({ + name: "FK_CARGO_TYPES_PARENT_GROUP", + columnNames: ["parent_group_id"], + referencedSchema: "freight", + referencedTableName: "cargo_types", + referencedColumnNames: ["id"], + onDelete: "SET NULL", + }), + ); + } + + public async down(queryRunner: QueryRunner): Promise { + // Drop foreign key first + await queryRunner.dropForeignKey("freight.cargo_types", "FK_CARGO_TYPES_PARENT_GROUP"); + + // Drop cargo_types table + await queryRunner.dropTable("freight.cargo_types", true); + + // Drop service_types table + await queryRunner.dropTable("freight.service_types", true); + } +} diff --git a/apps/edr-freight-api/src/migrations/1748514000000-AddRuleEngineTablesAndCodes.ts b/apps/edr-freight-api/src/migrations/1748514000000-AddRuleEngineTablesAndCodes.ts new file mode 100644 index 000000000..e1a9f6aeb --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1748514000000-AddRuleEngineTablesAndCodes.ts @@ -0,0 +1,293 @@ +import { + MigrationInterface, + QueryRunner, + Table, + TableIndex, + TableForeignKey, + TableColumn, +} from 'typeorm'; + +export class AddRuleEngineTablesAndCodes1748514000000 implements MigrationInterface { + name = 'AddRuleEngineTablesAndCodes1748514000000'; + + public async up(queryRunner: QueryRunner): Promise { + // ── 1. Add `code` column to existing tables ─────────────────────────── + + if (!(await queryRunner.hasColumn('freight.service_types', 'code'))) { + await queryRunner.addColumn( + 'freight.service_types', + new TableColumn({ + name: 'code', + type: 'varchar', + length: '50', + isNullable: true, + }), + ); + } + await queryRunner.query( + `UPDATE freight.service_types SET code = upper(replace(service_name, ' ', '_')) WHERE code IS NULL OR code = ''`, + ); + await queryRunner.query( + `UPDATE freight.service_types SET code = 'SERVICE_' || substring(id::text, 1, 8) WHERE code IS NULL`, + ); + await queryRunner.query( + `ALTER TABLE freight.service_types ALTER COLUMN code SET NOT NULL`, + ); + const serviceTypesCodeIdx = await queryRunner.query( + `SELECT 1 FROM pg_indexes WHERE schemaname = 'freight' AND indexname = 'idx_service_types_code' LIMIT 1`, + ); + if (serviceTypesCodeIdx.length === 0) { + await queryRunner.createIndex( + 'freight.service_types', + new TableIndex({ name: 'IDX_service_types_code', columnNames: ['code'], isUnique: true }), + ); + } + + if (!(await queryRunner.hasColumn('freight.cargo_types', 'code'))) { + await queryRunner.addColumn( + 'freight.cargo_types', + new TableColumn({ + name: 'code', + type: 'varchar', + length: '50', + isNullable: true, + }), + ); + } + await queryRunner.query( + `UPDATE freight.cargo_types SET code = upper(replace(cargo_type_name, ' ', '_')) WHERE code IS NULL OR code = ''`, + ); + await queryRunner.query( + `UPDATE freight.cargo_types SET code = 'CARGO_' || substring(id::text, 1, 8) WHERE code IS NULL`, + ); + await queryRunner.query( + `ALTER TABLE freight.cargo_types ALTER COLUMN code SET NOT NULL`, + ); + const cargoTypesCodeIdx = await queryRunner.query( + `SELECT 1 FROM pg_indexes WHERE schemaname = 'freight' AND indexname = 'idx_cargo_types_code' LIMIT 1`, + ); + if (cargoTypesCodeIdx.length === 0) { + await queryRunner.createIndex( + 'freight.cargo_types', + new TableIndex({ name: 'IDX_cargo_types_code', columnNames: ['code'], isUnique: true }), + ); + } + + // ── 2. surcharge_types ──────────────────────────────────────────────── + + if (!(await queryRunner.hasTable('freight.surcharge_types'))) await queryRunner.createTable( + new Table({ + name: 'surcharge_types', + schema: 'freight', + columns: [ + { name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'uuid_generate_v4()' }, + { name: 'code', type: 'varchar', length: '50', isNullable: false }, + { name: 'name', type: 'varchar', length: '100', isNullable: false }, + { name: 'description', type: 'text', isNullable: true }, + { name: 'is_active', type: 'boolean', default: true }, + { name: 'created_at', type: 'timestamptz', default: 'now()' }, + { name: 'updated_at', type: 'timestamptz', default: 'now()' }, + { name: 'deleted_at', type: 'timestamptz', isNullable: true }, + ], + }), + true, + ); + await queryRunner.createIndex( + 'freight.surcharge_types', + new TableIndex({ name: 'IDX_surcharge_types_code', columnNames: ['code'], isUnique: true }), + ); + await queryRunner.createIndex( + 'freight.surcharge_types', + new TableIndex({ name: 'IDX_surcharge_types_is_active', columnNames: ['is_active'] }), + ); + + // ── 3. surcharges ───────────────────────────────────────────────────── + + if (!(await queryRunner.hasTable('freight.surcharges'))) await queryRunner.createTable( + new Table({ + name: 'surcharges', + schema: 'freight', + columns: [ + { name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'uuid_generate_v4()' }, + { name: 'surcharge_type_id', type: 'uuid', isNullable: false }, + { name: 'fee_name', type: 'varchar', length: '255', isNullable: false }, + { name: 'trigger_description', type: 'text', isNullable: true }, + { + name: 'calculation_method', + type: 'enum', + enum: ['PER_TON', 'FLAT_FEE', 'PERCENTAGE'], + default: `'PER_TON'`, + }, + { name: 'rate', type: 'numeric', precision: 10, scale: 2, isNullable: false }, + { name: 'currency', type: 'char', length: '3', default: `'USD'` }, + { name: 'apply_to_rail', type: 'boolean', default: false }, + { name: 'apply_to_first_mile', type: 'boolean', default: false }, + { name: 'apply_to_last_mile', type: 'boolean', default: false }, + { name: 'is_active', type: 'boolean', default: true }, + { name: 'created_at', type: 'timestamptz', default: 'now()' }, + { name: 'updated_at', type: 'timestamptz', default: 'now()' }, + { name: 'deleted_at', type: 'timestamptz', isNullable: true }, + ], + }), + true, + ); + await queryRunner.createForeignKey( + 'freight.surcharges', + new TableForeignKey({ + name: 'FK_surcharges_surcharge_type', + columnNames: ['surcharge_type_id'], + referencedTableName: 'freight.surcharge_types', + referencedColumnNames: ['id'], + onDelete: 'RESTRICT', + }), + ); + await queryRunner.createIndex( + 'freight.surcharges', + new TableIndex({ name: 'IDX_surcharges_surcharge_type_id', columnNames: ['surcharge_type_id'] }), + ); + await queryRunner.createIndex( + 'freight.surcharges', + new TableIndex({ name: 'IDX_surcharges_is_active', columnNames: ['is_active'] }), + ); + + // ── 4. container_types ──────────────────────────────────────────────── + + if (!(await queryRunner.hasTable('freight.container_types'))) await queryRunner.createTable( + new Table({ + name: 'container_types', + schema: 'freight', + columns: [ + { name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'uuid_generate_v4()' }, + { name: 'size_code', type: 'varchar', length: '20', isNullable: false }, + { name: 'description', type: 'varchar', length: '100', isNullable: true }, + { name: 'containers_per_wagon', type: 'int', isNullable: false }, + { name: 'is_active', type: 'boolean', default: true }, + { name: 'created_at', type: 'timestamptz', default: 'now()' }, + { name: 'updated_at', type: 'timestamptz', default: 'now()' }, + { name: 'deleted_at', type: 'timestamptz', isNullable: true }, + ], + }), + true, + ); + await queryRunner.createIndex( + 'freight.container_types', + new TableIndex({ name: 'IDX_container_types_size_code', columnNames: ['size_code'], isUnique: true }), + ); + await queryRunner.createIndex( + 'freight.container_types', + new TableIndex({ name: 'IDX_container_types_is_active', columnNames: ['is_active'] }), + ); + + // ── 5. weight_limit_rules ───────────────────────────────────────────── + + if (!(await queryRunner.hasTable('freight.weight_limit_rules'))) await queryRunner.createTable( + new Table({ + name: 'weight_limit_rules', + schema: 'freight', + columns: [ + { name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'uuid_generate_v4()' }, + { name: 'container_type_id', type: 'uuid', isNullable: false }, + { + name: 'trade_direction', + type: 'enum', + enum: ['IMPORT', 'EXPORT', 'BOTH'], + isNullable: false, + }, + { name: 'max_weight_tons', type: 'numeric', precision: 10, scale: 2, isNullable: false }, + { name: 'warning_threshold_tons', type: 'numeric', precision: 10, scale: 2, isNullable: false }, + { + name: 'exceeded_action', + type: 'enum', + enum: ['WARNING_ONLY', 'HARD_BLOCK'], + default: `'WARNING_ONLY'`, + }, + { name: 'surcharge_id', type: 'uuid', isNullable: true }, + { name: 'is_active', type: 'boolean', default: true }, + { name: 'created_at', type: 'timestamptz', default: 'now()' }, + { name: 'updated_at', type: 'timestamptz', default: 'now()' }, + { name: 'deleted_at', type: 'timestamptz', isNullable: true }, + ], + }), + true, + ); + await queryRunner.createForeignKey( + 'freight.weight_limit_rules', + new TableForeignKey({ + name: 'FK_weight_limit_rules_container_type', + columnNames: ['container_type_id'], + referencedTableName: 'freight.container_types', + referencedColumnNames: ['id'], + onDelete: 'RESTRICT', + }), + ); + await queryRunner.createForeignKey( + 'freight.weight_limit_rules', + new TableForeignKey({ + name: 'FK_weight_limit_rules_surcharge', + columnNames: ['surcharge_id'], + referencedTableName: 'freight.surcharges', + referencedColumnNames: ['id'], + onDelete: 'SET NULL', + }), + ); + await queryRunner.createIndex( + 'freight.weight_limit_rules', + new TableIndex({ name: 'IDX_weight_limit_rules_container_type_id', columnNames: ['container_type_id'] }), + ); + await queryRunner.createIndex( + 'freight.weight_limit_rules', + new TableIndex({ name: 'IDX_weight_limit_rules_surcharge_id', columnNames: ['surcharge_id'] }), + ); + await queryRunner.createIndex( + 'freight.weight_limit_rules', + new TableIndex({ name: 'IDX_weight_limit_rules_is_active', columnNames: ['is_active'] }), + ); + + // ── 6. priority_rules ───────────────────────────────────────────────── + + if (!(await queryRunner.hasTable('freight.priority_rules'))) await queryRunner.createTable( + new Table({ + name: 'priority_rules', + schema: 'freight', + columns: [ + { name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'uuid_generate_v4()' }, + { + name: 'priority_type', + type: 'enum', + enum: ['USD_PAYER', 'RAIL_AND_FORWARDING', 'GOVERNMENT_ACCOUNT', 'HIGH_VOLUME_SHIPMENT'], + isNullable: false, + }, + { name: 'rule_name', type: 'varchar', length: '255', isNullable: false }, + { name: 'description', type: 'text', isNullable: true }, + { name: 'activation_condition', type: 'text', isNullable: true }, + { name: 'bonus_points', type: 'int', default: 0 }, + { name: 'is_active', type: 'boolean', default: false }, + { name: 'created_at', type: 'timestamptz', default: 'now()' }, + { name: 'updated_at', type: 'timestamptz', default: 'now()' }, + { name: 'deleted_at', type: 'timestamptz', isNullable: true }, + ], + }), + true, + ); + await queryRunner.createIndex( + 'freight.priority_rules', + new TableIndex({ name: 'IDX_priority_rules_priority_type', columnNames: ['priority_type'], isUnique: true }), + ); + await queryRunner.createIndex( + 'freight.priority_rules', + new TableIndex({ name: 'IDX_priority_rules_is_active', columnNames: ['is_active'] }), + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropTable('freight.priority_rules', true); + await queryRunner.dropTable('freight.weight_limit_rules', true); + await queryRunner.dropTable('freight.container_types', true); + await queryRunner.dropTable('freight.surcharges', true); + await queryRunner.dropTable('freight.surcharge_types', true); + await queryRunner.dropIndex('freight.cargo_types', 'IDX_cargo_types_code'); + await queryRunner.dropColumn('freight.cargo_types', 'code'); + await queryRunner.dropIndex('freight.service_types', 'IDX_service_types_code'); + await queryRunner.dropColumn('freight.service_types', 'code'); + } +} diff --git a/apps/edr-freight-api/src/migrations/1748550000000-CreateFreightLegacyBaseline.ts b/apps/edr-freight-api/src/migrations/1748550000000-CreateFreightLegacyBaseline.ts new file mode 100644 index 000000000..2455727bf --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1748550000000-CreateFreightLegacyBaseline.ts @@ -0,0 +1,88 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Pre-ITMLS baseline for fresh databases. Older environments created `freight.bookings` + * via synchronize or manual SQL; ItmlsFullSchemaRewrite only ALTERs that table. + */ +export class CreateFreightLegacyBaseline1748550000000 implements MigrationInterface { + name = 'CreateFreightLegacyBaseline1748550000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE SCHEMA IF NOT EXISTS freight`); + + await queryRunner.query(` + DO $$ BEGIN + CREATE TYPE freight.train_status AS ENUM ( + 'AVAILABLE', 'SCHEDULED', 'IN_SERVICE', 'UNDER_MAINTENANCE', 'OUT_OF_SERVICE' + ); + EXCEPTION WHEN duplicate_object THEN NULL; + END $$; + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.trains ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + code VARCHAR(32) NOT NULL UNIQUE, + capacity_tons NUMERIC(10, 2) NOT NULL DEFAULT 0, + status freight.train_status NOT NULL DEFAULT 'AVAILABLE', + notes TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ + ); + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.bookings ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + reference VARCHAR(64) NOT NULL UNIQUE, + customer_id UUID NOT NULL, + train_id UUID, + status VARCHAR(40) NOT NULL DEFAULT 'DRAFT', + scheduled_date TIMESTAMPTZ NOT NULL DEFAULT now(), + total_amount NUMERIC(14, 2) NOT NULL DEFAULT 0, + payment_status VARCHAR(20) NOT NULL DEFAULT 'PENDING', + contract_type VARCHAR(20) NOT NULL DEFAULT 'SPOT', + previous_contract_id UUID, + trade_direction VARCHAR(10) NOT NULL DEFAULT 'IMPORT', + equipment_return VARCHAR(20) NOT NULL DEFAULT 'RETURN', + first_mile_pickup_address TEXT, + last_mile_delivery_address TEXT, + cargo_total_weight_vgm NUMERIC(12, 3) NOT NULL DEFAULT 0, + is_hazardous BOOLEAN NOT NULL DEFAULT false, + payment_currency VARCHAR(5) NOT NULL DEFAULT 'USD', + start_date DATE, + end_date DATE, + financial_terms TEXT, + version_number INT NOT NULL DEFAULT 1, + approved_by_staff_id UUID, + approved_by_staff_at TIMESTAMPTZ, + signed_by_director_id UUID, + signed_by_director_at TIMESTAMPTZ, + signed_by_ceo_id UUID, + signed_by_ceo_at TIMESTAMPTZ, + priority_score INT NOT NULL DEFAULT 0, + allow_consolidation BOOLEAN NOT NULL DEFAULT false, + consolidation_partner_id UUID, + origin_station VARCHAR(255), + destination_station VARCHAR(255), + service_type VARCHAR(100), + freight_type VARCHAR(100), + freight_subtype VARCHAR(255), + containers JSONB, + first_mile_enabled BOOLEAN DEFAULT false, + last_mile_enabled BOOLEAN DEFAULT false, + is_refrigerated BOOLEAN DEFAULT false, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ + ); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.bookings CASCADE`); + await queryRunner.query(`DROP TABLE IF EXISTS freight.trains CASCADE`); + await queryRunner.query(`DROP TYPE IF EXISTS freight.train_status`); + } +} diff --git a/apps/edr-freight-api/src/migrations/1748600000000-ItmlsFullSchemaRewrite.ts b/apps/edr-freight-api/src/migrations/1748600000000-ItmlsFullSchemaRewrite.ts new file mode 100644 index 000000000..ce5b38c15 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1748600000000-ItmlsFullSchemaRewrite.ts @@ -0,0 +1,544 @@ +import { + MigrationInterface, + QueryRunner, + Table, + TableForeignKey, + TableIndex, + TableUnique, +} from 'typeorm'; + +export class ItmlsFullSchemaRewrite1748600000000 implements MigrationInterface { + name = 'ItmlsFullSchemaRewrite1748600000000'; + + public async up(queryRunner: QueryRunner): Promise { + // ── container_types ─────────────────────────────────────────────────── + await queryRunner.query(` + DO $$ BEGIN + ALTER TABLE freight.container_types RENAME COLUMN size_code TO code; + EXCEPTION WHEN undefined_column THEN NULL; + END $$; + `); + await queryRunner.query(` + DO $$ BEGIN + ALTER TABLE freight.container_types RENAME COLUMN description TO label; + EXCEPTION WHEN undefined_column THEN NULL; + END $$; + `); + await queryRunner.query(` + ALTER TABLE freight.container_types + ADD COLUMN IF NOT EXISTS size_ft SMALLINT, + ADD COLUMN IF NOT EXISTS is_reefer BOOLEAN NOT NULL DEFAULT false, + ADD COLUMN IF NOT EXISTS is_open_top BOOLEAN NOT NULL DEFAULT false, + ADD COLUMN IF NOT EXISTS display_order INT NOT NULL DEFAULT 1; + `); + await queryRunner.query(` + ALTER TABLE freight.container_types + ADD COLUMN IF NOT EXISTS wagons_per_unit NUMERIC(4,2); + `); + await queryRunner.query(` + UPDATE freight.container_types + SET wagons_per_unit = CASE + WHEN containers_per_wagon > 0 THEN ROUND(1.0 / containers_per_wagon, 2) + ELSE 1.00 + END + WHERE wagons_per_unit IS NULL; + `); + await queryRunner.query(` + UPDATE freight.container_types + SET size_ft = CASE WHEN code LIKE '40%' OR code LIKE '%40%' THEN 40 ELSE 20 END + WHERE size_ft IS NULL; + `); + await queryRunner.query(` + ALTER TABLE freight.container_types + ALTER COLUMN wagons_per_unit SET NOT NULL, + DROP COLUMN IF EXISTS containers_per_wagon; + `); + + // ── weight_limit_rules ────────────────────────────────────────────────── + await queryRunner.query(` + DO $$ BEGIN + ALTER TABLE freight.weight_limit_rules RENAME COLUMN max_weight_tons TO max_vgm_tons; + EXCEPTION WHEN undefined_column THEN NULL; + END $$; + `); + await queryRunner.query(` + ALTER TABLE freight.weight_limit_rules + ALTER COLUMN max_vgm_tons TYPE NUMERIC(8,3); + `); + await queryRunner.query(` + ALTER TABLE freight.weight_limit_rules + ADD COLUMN IF NOT EXISTS effective_from DATE NOT NULL DEFAULT CURRENT_DATE, + ADD COLUMN IF NOT EXISTS effective_to DATE; + `); + await queryRunner.query(` + ALTER TABLE freight.weight_limit_rules + DROP COLUMN IF EXISTS warning_threshold_tons, + DROP COLUMN IF EXISTS exceeded_action, + DROP COLUMN IF EXISTS surcharge_id, + DROP COLUMN IF EXISTS is_active; + `); + + // ── priority_rules ──────────────────────────────────────────────────── + await queryRunner.query(` + ALTER TABLE freight.priority_rules + ADD COLUMN IF NOT EXISTS code VARCHAR(40), + ADD COLUMN IF NOT EXISTS label VARCHAR(100), + ADD COLUMN IF NOT EXISTS score INT NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS condition_currency VARCHAR(5); + `); + await queryRunner.query(` + UPDATE freight.priority_rules + SET code = COALESCE(code, upper(priority_type::text)), + label = COALESCE(label, rule_name), + score = COALESCE(score, bonus_points) + WHERE code IS NULL OR label IS NULL; + `); + await queryRunner.query(` + ALTER TABLE freight.priority_rules + DROP COLUMN IF EXISTS priority_type, + DROP COLUMN IF EXISTS rule_name, + DROP COLUMN IF EXISTS bonus_points, + DROP COLUMN IF EXISTS activation_condition, + DROP COLUMN IF EXISTS description; + `); + await queryRunner.query(` + ALTER TABLE freight.priority_rules + ALTER COLUMN code SET NOT NULL, + ALTER COLUMN label SET NOT NULL; + `); + await queryRunner.createIndex( + 'freight.priority_rules', + new TableIndex({ name: 'UQ_priority_rules_code', columnNames: ['code'], isUnique: true }), + ); + + // ── rates (before surcharge_types.rate_id) ──────────────────────────── + await queryRunner.createTable( + new Table({ + name: 'rates', + schema: 'freight', + columns: [ + { name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'uuid_generate_v4()' }, + { name: 'rate_type', type: 'varchar', length: '50' }, + { name: 'container_type_id', type: 'uuid', isNullable: true }, + { name: 'trade_direction', type: 'varchar', length: '10', isNullable: true }, + { name: 'currency', type: 'varchar', length: '5' }, + { name: 'rate_value', type: 'numeric', precision: 14, scale: 4 }, + { name: 'rate_unit', type: 'varchar', length: '30' }, + { name: 'status', type: 'varchar', length: '20', default: "'DRAFT'" }, + { name: 'proposed_by_staff_id', type: 'uuid' }, + { name: 'approved_by_ceo_id', type: 'uuid', isNullable: true }, + { name: 'approved_at', type: 'timestamptz', isNullable: true }, + { name: 'effective_from', type: 'date' }, + { name: 'effective_to', type: 'date', isNullable: true }, + { name: 'created_at', type: 'timestamptz', default: 'now()' }, + { name: 'updated_at', type: 'timestamptz', default: 'now()' }, + { name: 'deleted_at', type: 'timestamptz', isNullable: true }, + ], + }), + true, + ); + + // ── surcharge_types ─────────────────────────────────────────────────── + await queryRunner.query(` + DO $$ BEGIN + ALTER TABLE freight.surcharge_types RENAME COLUMN name TO label; + EXCEPTION WHEN undefined_column THEN NULL; + END $$; + `); + await queryRunner.query(` + ALTER TABLE freight.surcharge_types + ADD COLUMN IF NOT EXISTS trigger_condition VARCHAR(50), + ADD COLUMN IF NOT EXISTS rate_id UUID; + `); + await queryRunner.query(`ALTER TABLE freight.surcharge_types DROP COLUMN IF EXISTS description`); + + await queryRunner.query(`DROP TABLE IF EXISTS freight.surcharges CASCADE`); + + // ── yards ───────────────────────────────────────────────────────────── + await queryRunner.createTable( + new Table({ + name: 'yards', + schema: 'freight', + columns: [ + { name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'uuid_generate_v4()' }, + { name: 'code', type: 'varchar', length: '20' }, + { name: 'label', type: 'varchar', length: '100' }, + { name: 'country', type: 'varchar', length: '50' }, + { name: 'is_active', type: 'boolean', default: true }, + { name: 'display_order', type: 'int', default: 1 }, + { name: 'created_at', type: 'timestamptz', default: 'now()' }, + { name: 'updated_at', type: 'timestamptz', default: 'now()' }, + { name: 'deleted_at', type: 'timestamptz', isNullable: true }, + ], + }), + true, + ); + await queryRunner.createIndex( + 'freight.yards', + new TableIndex({ name: 'UQ_yards_code', columnNames: ['code'], isUnique: true }), + ); + + // ── shipping_lines ──────────────────────────────────────────────────── + await queryRunner.createTable( + new Table({ + name: 'shipping_lines', + schema: 'freight', + columns: [ + { name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'uuid_generate_v4()' }, + { name: 'code', type: 'varchar', length: '20' }, + { name: 'label', type: 'varchar', length: '100' }, + { name: 'mapped_to_code', type: 'varchar', length: '20', isNullable: true }, + { name: 'show_extra_fee_notice', type: 'boolean', default: false }, + { name: 'is_active', type: 'boolean', default: true }, + { name: 'created_at', type: 'timestamptz', default: 'now()' }, + { name: 'updated_at', type: 'timestamptz', default: 'now()' }, + { name: 'deleted_at', type: 'timestamptz', isNullable: true }, + ], + }), + true, + ); + + // ── approval_rules ──────────────────────────────────────────────────── + await queryRunner.createTable( + new Table({ + name: 'approval_rules', + schema: 'freight', + columns: [ + { name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'uuid_generate_v4()' }, + { name: 'requires_director_approval', type: 'boolean' }, + { name: 'step_order', type: 'smallint' }, + { name: 'required_role', type: 'varchar', length: '30' }, + { name: 'action_label', type: 'varchar', length: '50' }, + { name: 'blocks_role', type: 'varchar', length: '30', isNullable: true }, + { name: 'created_at', type: 'timestamptz', default: 'now()' }, + { name: 'updated_at', type: 'timestamptz', default: 'now()' }, + { name: 'deleted_at', type: 'timestamptz', isNullable: true }, + ], + }), + true, + ); + await queryRunner.createUniqueConstraint( + 'freight.approval_rules', + new TableUnique({ + name: 'UQ_approval_rules_chain_step', + columnNames: ['requires_director_approval', 'step_order'], + }), + ); + + // ── bookings ──────────────────────────────────────────────────────── + await queryRunner.query(` + ALTER TABLE freight.bookings + ADD COLUMN IF NOT EXISTS origin_yard_id UUID, + ADD COLUMN IF NOT EXISTS destination_yard_id UUID, + ADD COLUMN IF NOT EXISTS service_type_id UUID, + ADD COLUMN IF NOT EXISTS cargo_type_id UUID, + ADD COLUMN IF NOT EXISTS cargo_free_text VARCHAR(200), + ADD COLUMN IF NOT EXISTS shipping_line_id UUID, + ADD COLUMN IF NOT EXISTS pnr_code VARCHAR(50), + ADD COLUMN IF NOT EXISTS customer_signed_at TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS fully_executed_at TIMESTAMPTZ; + `); + + await queryRunner.query(` + INSERT INTO freight.yards (id, code, label, country, is_active, display_order, created_at, updated_at) + VALUES + (uuid_generate_v4(), 'KALITY', 'Kality Rail Terminal', 'Ethiopia', true, 1, now(), now()), + (uuid_generate_v4(), 'MOJO', 'Mojo Dry Port', 'Ethiopia', true, 2, now(), now()), + (uuid_generate_v4(), 'DIRE_DAWA', 'Dire Dawa Yard', 'Ethiopia', true, 3, now(), now()), + (uuid_generate_v4(), 'DJIB_PORT', 'Djibouti Port Terminal', 'Djibouti', true, 4, now(), now()), + (uuid_generate_v4(), 'NAGAD', 'Nagad Terminal, Djibouti', 'Djibouti', true, 5, now(), now()), + (uuid_generate_v4(), 'LEGACY_ORIGIN', 'Legacy Origin', 'Ethiopia', true, 99, now(), now()), + (uuid_generate_v4(), 'LEGACY_DEST', 'Legacy Destination', 'Ethiopia', true, 100, now(), now()) + ON CONFLICT (code) DO NOTHING; + `); + + await queryRunner.query(` + INSERT INTO freight.service_types (id, code, service_name, can_be_booked_alone, includes_first_mile, includes_last_mile, includes_customs, priority_bonus_points, is_active, display_order, created_at, updated_at) + SELECT uuid_generate_v4(), 'RAIL', 'Rail Transport Only', true, false, false, false, 0, true, 1, now(), now() + WHERE NOT EXISTS (SELECT 1 FROM freight.service_types LIMIT 1); + `); + await queryRunner.query(` + INSERT INTO freight.cargo_types (id, code, cargo_type_name, requires_director_approval, show_free_text_box, is_active, display_order, created_at, updated_at) + SELECT uuid_generate_v4(), 'GENERAL', 'General Cargo', false, false, true, 1, now(), now() + WHERE NOT EXISTS (SELECT 1 FROM freight.cargo_types LIMIT 1); + `); + + const hasServiceTypeCol = await queryRunner.query(` + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'freight' AND table_name = 'bookings' AND column_name = 'service_type' + LIMIT 1; + `); + + if (hasServiceTypeCol.length > 0) { + await queryRunner.query(` + UPDATE freight.bookings b + SET service_type_id = st.id + FROM freight.service_types st + WHERE b.service_type_id IS NULL + AND ( + st.code = b.service_type + OR upper(replace(st.service_name, ' ', '_')) = upper(b.service_type) + OR st.code = upper(replace(b.service_type, ' ', '_')) + ); + `); + await queryRunner.query(` + UPDATE freight.bookings b + SET cargo_type_id = ct.id + FROM freight.cargo_types ct + WHERE b.cargo_type_id IS NULL + AND ( + ct.code = upper(b.freight_type) + OR ct.code = upper(concat(b.freight_type, '_', coalesce(b.freight_subtype, ''))) + ); + `); + await queryRunner.query(` + UPDATE freight.bookings b + SET cargo_free_text = b.freight_subtype + WHERE b.cargo_free_text IS NULL AND b.freight_subtype IS NOT NULL; + `); + await queryRunner.query(` + UPDATE freight.bookings b + SET origin_yard_id = y.id + FROM freight.yards y + WHERE b.origin_yard_id IS NULL + AND (y.label ILIKE b.origin_station OR y.code = upper(replace(b.origin_station, ' ', '_'))); + `); + await queryRunner.query(` + UPDATE freight.bookings b + SET destination_yard_id = y.id + FROM freight.yards y + WHERE b.destination_yard_id IS NULL + AND (y.label ILIKE b.destination_station OR y.code = upper(replace(b.destination_station, ' ', '_'))); + `); + } + + const defaultServiceTypeId = await queryRunner.query( + `SELECT id FROM freight.service_types ORDER BY display_order LIMIT 1`, + ); + const defaultCargoTypeId = await queryRunner.query( + `SELECT id FROM freight.cargo_types ORDER BY display_order LIMIT 1`, + ); + const legacyOriginId = await queryRunner.query( + `SELECT id FROM freight.yards WHERE code = 'LEGACY_ORIGIN' LIMIT 1`, + ); + const legacyDestId = await queryRunner.query( + `SELECT id FROM freight.yards WHERE code = 'LEGACY_DEST' LIMIT 1`, + ); + + if (defaultServiceTypeId[0]?.id) { + await queryRunner.query( + `UPDATE freight.bookings SET service_type_id = $1 WHERE service_type_id IS NULL`, + [defaultServiceTypeId[0].id], + ); + } + if (defaultCargoTypeId[0]?.id) { + await queryRunner.query( + `UPDATE freight.bookings SET cargo_type_id = $1 WHERE cargo_type_id IS NULL`, + [defaultCargoTypeId[0].id], + ); + } + if (legacyOriginId[0]?.id) { + await queryRunner.query( + `UPDATE freight.bookings SET origin_yard_id = $1 WHERE origin_yard_id IS NULL`, + [legacyOriginId[0].id], + ); + } + if (legacyDestId[0]?.id) { + await queryRunner.query( + `UPDATE freight.bookings SET destination_yard_id = $1 WHERE destination_yard_id IS NULL`, + [legacyDestId[0].id], + ); + } + + const nullBookings = await queryRunner.query( + `SELECT COUNT(*)::int AS cnt FROM freight.bookings WHERE service_type_id IS NULL OR cargo_type_id IS NULL OR origin_yard_id IS NULL OR destination_yard_id IS NULL`, + ); + if (nullBookings[0]?.cnt > 0) { + await queryRunner.query(`DELETE FROM freight.bookings WHERE service_type_id IS NULL OR cargo_type_id IS NULL OR origin_yard_id IS NULL OR destination_yard_id IS NULL`); + } + + await queryRunner.query(` + ALTER TABLE freight.bookings + ALTER COLUMN service_type_id SET NOT NULL, + ALTER COLUMN cargo_type_id SET NOT NULL, + ALTER COLUMN origin_yard_id SET NOT NULL, + ALTER COLUMN destination_yard_id SET NOT NULL; + `); + + await queryRunner.query(` + ALTER TABLE freight.bookings + DROP COLUMN IF EXISTS origin_station, + DROP COLUMN IF EXISTS destination_station, + DROP COLUMN IF EXISTS service_type, + DROP COLUMN IF EXISTS freight_type, + DROP COLUMN IF EXISTS freight_subtype, + DROP COLUMN IF EXISTS containers, + DROP COLUMN IF EXISTS first_mile_enabled, + DROP COLUMN IF EXISTS last_mile_enabled, + DROP COLUMN IF EXISTS is_refrigerated; + `); + + // ── booking_container ───────────────────────────────────────────────── + await queryRunner.createTable( + new Table({ + name: 'booking_container', + schema: 'freight', + columns: [ + { name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'uuid_generate_v4()' }, + { name: 'booking_id', type: 'uuid' }, + { name: 'container_type_id', type: 'uuid' }, + { name: 'quantity', type: 'smallint' }, + { name: 'vgm_per_unit_tons', type: 'numeric', precision: 10, scale: 3 }, + { name: 'total_vgm_tons', type: 'numeric', precision: 12, scale: 3 }, + { name: 'wagons_required', type: 'numeric', precision: 6, scale: 2 }, + { name: 'weight_limit_rule_id', type: 'uuid', isNullable: true }, + { name: 'is_overweight', type: 'boolean', default: false }, + { name: 'overweight_excess_tons', type: 'numeric', precision: 10, scale: 3, isNullable: true }, + { name: 'created_at', type: 'timestamptz', default: 'now()' }, + { name: 'updated_at', type: 'timestamptz', default: 'now()' }, + { name: 'deleted_at', type: 'timestamptz', isNullable: true }, + ], + }), + true, + ); + + await queryRunner.createTable( + new Table({ + name: 'booking_rate_snapshot', + schema: 'freight', + columns: [ + { name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'uuid_generate_v4()' }, + { name: 'booking_id', type: 'uuid' }, + { name: 'rate_id', type: 'uuid' }, + { name: 'rate_type', type: 'varchar', length: '50' }, + { name: 'rate_value', type: 'numeric', precision: 14, scale: 4 }, + { name: 'rate_unit', type: 'varchar', length: '30' }, + { name: 'currency', type: 'varchar', length: '5' }, + { name: 'snapshotted_at', type: 'timestamptz' }, + { name: 'created_at', type: 'timestamptz', default: 'now()' }, + { name: 'updated_at', type: 'timestamptz', default: 'now()' }, + { name: 'deleted_at', type: 'timestamptz', isNullable: true }, + ], + }), + true, + ); + + await queryRunner.createTable( + new Table({ + name: 'booking_cargo_modifier', + schema: 'freight', + columns: [ + { name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'uuid_generate_v4()' }, + { name: 'booking_id', type: 'uuid' }, + { name: 'surcharge_type_id', type: 'uuid' }, + { name: 'trigger_value', type: 'numeric', precision: 14, scale: 4, isNullable: true }, + { name: 'calculated_amount', type: 'numeric', precision: 14, scale: 2 }, + { name: 'rate_snapshot_id', type: 'uuid' }, + { name: 'created_at', type: 'timestamptz', default: 'now()' }, + { name: 'updated_at', type: 'timestamptz', default: 'now()' }, + { name: 'deleted_at', type: 'timestamptz', isNullable: true }, + ], + }), + true, + ); + + await queryRunner.createTable( + new Table({ + name: 'booking_approval_step', + schema: 'freight', + columns: [ + { name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'uuid_generate_v4()' }, + { name: 'booking_id', type: 'uuid' }, + { name: 'approval_rule_id', type: 'uuid' }, + { name: 'step_order', type: 'smallint' }, + { name: 'required_role', type: 'varchar', length: '30' }, + { name: 'status', type: 'varchar', length: '20', default: "'PENDING'" }, + { name: 'actioned_by_staff_id', type: 'uuid', isNullable: true }, + { name: 'actioned_at', type: 'timestamptz', isNullable: true }, + { name: 'remarks', type: 'text', isNullable: true }, + { name: 'created_at', type: 'timestamptz', default: 'now()' }, + { name: 'updated_at', type: 'timestamptz', default: 'now()' }, + { name: 'deleted_at', type: 'timestamptz', isNullable: true }, + ], + }), + true, + ); + + // Foreign keys + await queryRunner.createForeignKey( + 'freight.surcharge_types', + new TableForeignKey({ + name: 'FK_surcharge_types_rate_id', + columnNames: ['rate_id'], + referencedTableName: 'rates', + referencedSchema: 'freight', + referencedColumnNames: ['id'], + }), + ); + await queryRunner.createForeignKey( + 'freight.booking_container', + new TableForeignKey({ + columnNames: ['booking_id'], + referencedTableName: 'bookings', + referencedSchema: 'freight', + referencedColumnNames: ['id'], + onDelete: 'CASCADE', + }), + ); + await queryRunner.createForeignKey( + 'freight.bookings', + new TableForeignKey({ + columnNames: ['origin_yard_id'], + referencedTableName: 'yards', + referencedSchema: 'freight', + referencedColumnNames: ['id'], + }), + ); + await queryRunner.createForeignKey( + 'freight.bookings', + new TableForeignKey({ + columnNames: ['destination_yard_id'], + referencedTableName: 'yards', + referencedSchema: 'freight', + referencedColumnNames: ['id'], + }), + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropTable('freight.booking_approval_step', true); + await queryRunner.dropTable('freight.booking_cargo_modifier', true); + await queryRunner.dropTable('freight.booking_rate_snapshot', true); + await queryRunner.dropTable('freight.booking_container', true); + + await queryRunner.query(` + ALTER TABLE freight.bookings + ADD COLUMN IF NOT EXISTS origin_station VARCHAR(255), + ADD COLUMN IF NOT EXISTS destination_station VARCHAR(255), + ADD COLUMN IF NOT EXISTS service_type VARCHAR(30), + ADD COLUMN IF NOT EXISTS freight_type VARCHAR(20), + ADD COLUMN IF NOT EXISTS freight_subtype VARCHAR(100), + ADD COLUMN IF NOT EXISTS containers JSONB, + ADD COLUMN IF NOT EXISTS first_mile_enabled BOOLEAN DEFAULT false, + ADD COLUMN IF NOT EXISTS last_mile_enabled BOOLEAN DEFAULT false, + ADD COLUMN IF NOT EXISTS is_refrigerated BOOLEAN DEFAULT false; + `); + await queryRunner.query(` + ALTER TABLE freight.bookings + DROP COLUMN IF EXISTS origin_yard_id, + DROP COLUMN IF EXISTS destination_yard_id, + DROP COLUMN IF EXISTS service_type_id, + DROP COLUMN IF EXISTS cargo_type_id, + DROP COLUMN IF EXISTS cargo_free_text, + DROP COLUMN IF EXISTS shipping_line_id, + DROP COLUMN IF EXISTS pnr_code, + DROP COLUMN IF EXISTS customer_signed_at, + DROP COLUMN IF EXISTS fully_executed_at; + `); + + await queryRunner.dropTable('freight.approval_rules', true); + await queryRunner.dropTable('freight.shipping_lines', true); + await queryRunner.dropTable('freight.yards', true); + await queryRunner.dropTable('freight.rates', true); + } +} diff --git a/apps/edr-freight-api/src/migrations/1748700000000-AddBookingsConfigForeignKeys.ts b/apps/edr-freight-api/src/migrations/1748700000000-AddBookingsConfigForeignKeys.ts new file mode 100644 index 000000000..79f3cfb94 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1748700000000-AddBookingsConfigForeignKeys.ts @@ -0,0 +1,94 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddBookingsConfigForeignKeys1748700000000 implements MigrationInterface { + name = 'AddBookingsConfigForeignKeys1748700000000'; + + public async up(queryRunner: QueryRunner): Promise { + // Ensure parent config rows exist for backfill + await queryRunner.query(` + INSERT INTO freight.service_types (id, code, service_name, can_be_booked_alone, includes_first_mile, includes_last_mile, includes_customs, priority_bonus_points, is_active, display_order, created_at, updated_at) + SELECT uuid_generate_v4(), 'RAIL', 'Rail Transport Only', true, false, false, false, 0, true, 1, now(), now() + WHERE NOT EXISTS (SELECT 1 FROM freight.service_types LIMIT 1); + `); + await queryRunner.query(` + INSERT INTO freight.cargo_types (id, code, cargo_type_name, requires_director_approval, show_free_text_box, is_active, display_order, created_at, updated_at) + SELECT uuid_generate_v4(), 'GENERAL', 'General Cargo', false, false, true, 1, now(), now() + WHERE NOT EXISTS (SELECT 1 FROM freight.cargo_types LIMIT 1); + `); + + // Clear orphan shipping_line references (nullable FK) + await queryRunner.query(` + UPDATE freight.bookings b + SET shipping_line_id = NULL + WHERE b.shipping_line_id IS NOT NULL + AND NOT EXISTS ( + SELECT 1 FROM freight.shipping_lines sl WHERE sl.id = b.shipping_line_id + ); + `); + + // Backfill required FK columns + await queryRunner.query(` + UPDATE freight.bookings + SET service_type_id = (SELECT id FROM freight.service_types ORDER BY display_order LIMIT 1) + WHERE service_type_id IS NULL + OR NOT EXISTS (SELECT 1 FROM freight.service_types st WHERE st.id = service_type_id); + `); + await queryRunner.query(` + UPDATE freight.bookings + SET cargo_type_id = (SELECT id FROM freight.cargo_types ORDER BY display_order LIMIT 1) + WHERE cargo_type_id IS NULL + OR NOT EXISTS (SELECT 1 FROM freight.cargo_types ct WHERE ct.id = cargo_type_id); + `); + + await queryRunner.query(` + DO $$ BEGIN + ALTER TABLE freight.bookings + ADD CONSTRAINT "FK_bookings_service_type_id" + FOREIGN KEY (service_type_id) + REFERENCES freight.service_types(id) + ON DELETE RESTRICT; + EXCEPTION + WHEN duplicate_object THEN NULL; + END $$; + `); + + await queryRunner.query(` + DO $$ BEGIN + ALTER TABLE freight.bookings + ADD CONSTRAINT "FK_bookings_cargo_type_id" + FOREIGN KEY (cargo_type_id) + REFERENCES freight.cargo_types(id) + ON DELETE RESTRICT; + EXCEPTION + WHEN duplicate_object THEN NULL; + END $$; + `); + + await queryRunner.query(` + DO $$ BEGIN + ALTER TABLE freight.bookings + ADD CONSTRAINT "FK_bookings_shipping_line_id" + FOREIGN KEY (shipping_line_id) + REFERENCES freight.shipping_lines(id) + ON DELETE SET NULL; + EXCEPTION + WHEN duplicate_object THEN NULL; + END $$; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.bookings + DROP CONSTRAINT IF EXISTS "FK_bookings_shipping_line_id"; + `); + await queryRunner.query(` + ALTER TABLE freight.bookings + DROP CONSTRAINT IF EXISTS "FK_bookings_cargo_type_id"; + `); + await queryRunner.query(` + ALTER TABLE freight.bookings + DROP CONSTRAINT IF EXISTS "FK_bookings_service_type_id"; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1748800000000-AddBookingsRemainingForeignKeys.ts b/apps/edr-freight-api/src/migrations/1748800000000-AddBookingsRemainingForeignKeys.ts new file mode 100644 index 000000000..50d052a24 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1748800000000-AddBookingsRemainingForeignKeys.ts @@ -0,0 +1,331 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddBookingsRemainingForeignKeys1748800000000 implements MigrationInterface { + name = 'AddBookingsRemainingForeignKeys1748800000000'; + + public async up(queryRunner: QueryRunner): Promise { + const publicCustomersExists = await queryRunner.query(` + SELECT EXISTS ( + SELECT 1 FROM information_schema.tables + WHERE table_schema = 'public' AND table_name = 'customers' + ) AS exists + `); + const hasPublicCustomers = Boolean(publicCustomersExists[0]?.exists); + + // ── freight.bookings: nullable FK cleanup ───────────────────────────── + await queryRunner.query(` + UPDATE freight.bookings b + SET train_id = NULL + WHERE b.train_id IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM freight.trains t WHERE t.id = b.train_id); + `); + await queryRunner.query(` + UPDATE freight.bookings b + SET previous_contract_id = NULL + WHERE b.previous_contract_id IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM freight.bookings pb WHERE pb.id = b.previous_contract_id); + `); + await queryRunner.query(` + UPDATE freight.bookings b + SET consolidation_partner_id = NULL + WHERE b.consolidation_partner_id IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM freight.bookings pb WHERE pb.id = b.consolidation_partner_id); + `); + + // Legacy DBs only: public.customers is created/moved in MoveCustomersToFreightSchema (174890). + if (hasPublicCustomers) { + await queryRunner.query(` + DELETE FROM freight.booking_cargo_modifier bcm + USING freight.bookings b + WHERE bcm.booking_id = b.id + AND NOT EXISTS (SELECT 1 FROM public.customers c WHERE c.id = b.customer_id); + `); + await queryRunner.query(` + DELETE FROM freight.booking_approval_step bas + USING freight.bookings b + WHERE bas.booking_id = b.id + AND NOT EXISTS (SELECT 1 FROM public.customers c WHERE c.id = b.customer_id); + `); + await queryRunner.query(` + DELETE FROM freight.booking_rate_snapshot brs + USING freight.bookings b + WHERE brs.booking_id = b.id + AND NOT EXISTS (SELECT 1 FROM public.customers c WHERE c.id = b.customer_id); + `); + await queryRunner.query(` + DELETE FROM freight.booking_container bc + USING freight.bookings b + WHERE bc.booking_id = b.id + AND NOT EXISTS (SELECT 1 FROM public.customers c WHERE c.id = b.customer_id); + `); + await queryRunner.query(` + DELETE FROM freight.bookings b + WHERE NOT EXISTS (SELECT 1 FROM public.customers c WHERE c.id = b.customer_id); + `); + + await queryRunner.query(` + DO $$ BEGIN + ALTER TABLE freight.bookings + ADD CONSTRAINT "FK_bookings_customer_id" + FOREIGN KEY (customer_id) + REFERENCES public.customers(id) + ON DELETE RESTRICT; + EXCEPTION + WHEN duplicate_object THEN NULL; + END $$; + `); + } + + // ── freight.bookings FKs ──────────────────────────────────────────── + await queryRunner.query(` + DO $$ BEGIN + ALTER TABLE freight.bookings + ADD CONSTRAINT "FK_bookings_train_id" + FOREIGN KEY (train_id) + REFERENCES freight.trains(id) + ON DELETE SET NULL; + EXCEPTION + WHEN duplicate_object THEN NULL; + END $$; + `); + + await queryRunner.query(` + DO $$ BEGIN + ALTER TABLE freight.bookings + ADD CONSTRAINT "FK_bookings_previous_contract_id" + FOREIGN KEY (previous_contract_id) + REFERENCES freight.bookings(id) + ON DELETE SET NULL; + EXCEPTION + WHEN duplicate_object THEN NULL; + END $$; + `); + + await queryRunner.query(` + DO $$ BEGIN + ALTER TABLE freight.bookings + ADD CONSTRAINT "FK_bookings_consolidation_partner_id" + FOREIGN KEY (consolidation_partner_id) + REFERENCES freight.bookings(id) + ON DELETE SET NULL; + EXCEPTION + WHEN duplicate_object THEN NULL; + END $$; + `); + + // ── freight.booking_container ───────────────────────────────────────── + await queryRunner.query(` + UPDATE freight.booking_container bc + SET weight_limit_rule_id = NULL + WHERE bc.weight_limit_rule_id IS NOT NULL + AND NOT EXISTS ( + SELECT 1 FROM freight.weight_limit_rules wlr WHERE wlr.id = bc.weight_limit_rule_id + ); + `); + + await queryRunner.query(` + DELETE FROM freight.booking_container bc + WHERE NOT EXISTS (SELECT 1 FROM freight.container_types ct WHERE ct.id = bc.container_type_id); + `); + + await queryRunner.query(` + DO $$ BEGIN + ALTER TABLE freight.booking_container + ADD CONSTRAINT "FK_booking_container_container_type_id" + FOREIGN KEY (container_type_id) + REFERENCES freight.container_types(id) + ON DELETE RESTRICT; + EXCEPTION + WHEN duplicate_object THEN NULL; + END $$; + `); + + await queryRunner.query(` + DO $$ BEGIN + ALTER TABLE freight.booking_container + ADD CONSTRAINT "FK_booking_container_weight_limit_rule_id" + FOREIGN KEY (weight_limit_rule_id) + REFERENCES freight.weight_limit_rules(id) + ON DELETE SET NULL; + EXCEPTION + WHEN duplicate_object THEN NULL; + END $$; + `); + + // ── freight.booking_rate_snapshot ───────────────────────────────────── + await queryRunner.query(` + DELETE FROM freight.booking_cargo_modifier bcm + USING freight.booking_rate_snapshot brs + WHERE bcm.rate_snapshot_id = brs.id + AND ( + NOT EXISTS (SELECT 1 FROM freight.bookings b WHERE b.id = brs.booking_id) + OR NOT EXISTS (SELECT 1 FROM freight.rates r WHERE r.id = brs.rate_id) + ); + `); + await queryRunner.query(` + DELETE FROM freight.booking_rate_snapshot brs + WHERE NOT EXISTS (SELECT 1 FROM freight.bookings b WHERE b.id = brs.booking_id) + OR NOT EXISTS (SELECT 1 FROM freight.rates r WHERE r.id = brs.rate_id); + `); + + await queryRunner.query(` + DO $$ BEGIN + ALTER TABLE freight.booking_rate_snapshot + ADD CONSTRAINT "FK_booking_rate_snapshot_booking_id" + FOREIGN KEY (booking_id) + REFERENCES freight.bookings(id) + ON DELETE CASCADE; + EXCEPTION + WHEN duplicate_object THEN NULL; + END $$; + `); + + await queryRunner.query(` + DO $$ BEGIN + ALTER TABLE freight.booking_rate_snapshot + ADD CONSTRAINT "FK_booking_rate_snapshot_rate_id" + FOREIGN KEY (rate_id) + REFERENCES freight.rates(id) + ON DELETE RESTRICT; + EXCEPTION + WHEN duplicate_object THEN NULL; + END $$; + `); + + // ── freight.booking_approval_step ───────────────────────────────────── + await queryRunner.query(` + DELETE FROM freight.booking_approval_step bas + WHERE NOT EXISTS (SELECT 1 FROM freight.bookings b WHERE b.id = bas.booking_id) + OR NOT EXISTS (SELECT 1 FROM freight.approval_rules ar WHERE ar.id = bas.approval_rule_id); + `); + + await queryRunner.query(` + DO $$ BEGIN + ALTER TABLE freight.booking_approval_step + ADD CONSTRAINT "FK_booking_approval_step_booking_id" + FOREIGN KEY (booking_id) + REFERENCES freight.bookings(id) + ON DELETE CASCADE; + EXCEPTION + WHEN duplicate_object THEN NULL; + END $$; + `); + + await queryRunner.query(` + DO $$ BEGIN + ALTER TABLE freight.booking_approval_step + ADD CONSTRAINT "FK_booking_approval_step_approval_rule_id" + FOREIGN KEY (approval_rule_id) + REFERENCES freight.approval_rules(id) + ON DELETE RESTRICT; + EXCEPTION + WHEN duplicate_object THEN NULL; + END $$; + `); + + // ── freight.booking_cargo_modifier ──────────────────────────────────── + await queryRunner.query(` + DELETE FROM freight.booking_cargo_modifier bcm + WHERE NOT EXISTS (SELECT 1 FROM freight.bookings b WHERE b.id = bcm.booking_id) + OR NOT EXISTS (SELECT 1 FROM freight.surcharge_types st WHERE st.id = bcm.surcharge_type_id) + OR NOT EXISTS ( + SELECT 1 FROM freight.booking_rate_snapshot brs WHERE brs.id = bcm.rate_snapshot_id + ); + `); + + await queryRunner.query(` + DO $$ BEGIN + ALTER TABLE freight.booking_cargo_modifier + ADD CONSTRAINT "FK_booking_cargo_modifier_booking_id" + FOREIGN KEY (booking_id) + REFERENCES freight.bookings(id) + ON DELETE CASCADE; + EXCEPTION + WHEN duplicate_object THEN NULL; + END $$; + `); + + await queryRunner.query(` + DO $$ BEGIN + ALTER TABLE freight.booking_cargo_modifier + ADD CONSTRAINT "FK_booking_cargo_modifier_surcharge_type_id" + FOREIGN KEY (surcharge_type_id) + REFERENCES freight.surcharge_types(id) + ON DELETE RESTRICT; + EXCEPTION + WHEN duplicate_object THEN NULL; + END $$; + `); + + await queryRunner.query(` + DO $$ BEGIN + ALTER TABLE freight.booking_cargo_modifier + ADD CONSTRAINT "FK_booking_cargo_modifier_rate_snapshot_id" + FOREIGN KEY (rate_snapshot_id) + REFERENCES freight.booking_rate_snapshot(id) + ON DELETE RESTRICT; + EXCEPTION + WHEN duplicate_object THEN NULL; + END $$; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.booking_cargo_modifier + DROP CONSTRAINT IF EXISTS "FK_booking_cargo_modifier_rate_snapshot_id"; + `); + await queryRunner.query(` + ALTER TABLE freight.booking_cargo_modifier + DROP CONSTRAINT IF EXISTS "FK_booking_cargo_modifier_surcharge_type_id"; + `); + await queryRunner.query(` + ALTER TABLE freight.booking_cargo_modifier + DROP CONSTRAINT IF EXISTS "FK_booking_cargo_modifier_booking_id"; + `); + + await queryRunner.query(` + ALTER TABLE freight.booking_approval_step + DROP CONSTRAINT IF EXISTS "FK_booking_approval_step_approval_rule_id"; + `); + await queryRunner.query(` + ALTER TABLE freight.booking_approval_step + DROP CONSTRAINT IF EXISTS "FK_booking_approval_step_booking_id"; + `); + + await queryRunner.query(` + ALTER TABLE freight.booking_rate_snapshot + DROP CONSTRAINT IF EXISTS "FK_booking_rate_snapshot_rate_id"; + `); + await queryRunner.query(` + ALTER TABLE freight.booking_rate_snapshot + DROP CONSTRAINT IF EXISTS "FK_booking_rate_snapshot_booking_id"; + `); + + await queryRunner.query(` + ALTER TABLE freight.booking_container + DROP CONSTRAINT IF EXISTS "FK_booking_container_weight_limit_rule_id"; + `); + await queryRunner.query(` + ALTER TABLE freight.booking_container + DROP CONSTRAINT IF EXISTS "FK_booking_container_container_type_id"; + `); + + await queryRunner.query(` + ALTER TABLE freight.bookings + DROP CONSTRAINT IF EXISTS "FK_bookings_consolidation_partner_id"; + `); + await queryRunner.query(` + ALTER TABLE freight.bookings + DROP CONSTRAINT IF EXISTS "FK_bookings_previous_contract_id"; + `); + await queryRunner.query(` + ALTER TABLE freight.bookings + DROP CONSTRAINT IF EXISTS "FK_bookings_train_id"; + `); + await queryRunner.query(` + ALTER TABLE freight.bookings + DROP CONSTRAINT IF EXISTS "FK_bookings_customer_id"; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1748900000000-MoveCustomersToFreightSchema.ts b/apps/edr-freight-api/src/migrations/1748900000000-MoveCustomersToFreightSchema.ts new file mode 100644 index 000000000..36e848e64 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1748900000000-MoveCustomersToFreightSchema.ts @@ -0,0 +1,185 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class MoveCustomersToFreightSchema1748900000000 implements MigrationInterface { + name = 'MoveCustomersToFreightSchema1748900000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.customers ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL, + first_name VARCHAR(100) NOT NULL, + last_name VARCHAR(100) NOT NULL, + email VARCHAR(150) NOT NULL UNIQUE, + phone VARCHAR(20) NOT NULL, + company_name VARCHAR(200) NOT NULL, + company_email VARCHAR(150) NOT NULL, + company_phone VARCHAR(20) NOT NULL, + company_location VARCHAR(100) NOT NULL, + company_address TEXT NOT NULL, + customer_type VARCHAR(32), + status VARCHAR(32), + contact_person_name VARCHAR(100) NOT NULL, + contact_person_phone VARCHAR(20) NOT NULL, + tin_number VARCHAR(10) NOT NULL UNIQUE, + vat_number VARCHAR(50), + fan_number VARCHAR(16) NOT NULL UNIQUE, + general_manager_name VARCHAR(100) NOT NULL, + general_manager_email VARCHAR(150) NOT NULL, + general_manager_phone VARCHAR(20) NOT NULL, + poa_name VARCHAR(100), + poa_phone VARCHAR(20), + poa_address TEXT, + poa_email VARCHAR(150), + poa_location VARCHAR(100), + notes TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ + ); + `); + + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_freight_customers_email" + ON freight.customers (email); + `); + // await queryRunner.query(` + // CREATE INDEX IF NOT EXISTS "IDX_freight_customers_user_id" + // ON freight.customers (user_id); + //`); + + // Copy rows from public.customers when that legacy table exists + await queryRunner.query(` + DO $$ + DECLARE + has_public boolean; + has_user_id boolean; + has_userid boolean; + BEGIN + SELECT EXISTS ( + SELECT 1 FROM information_schema.tables + WHERE table_schema = 'public' AND table_name = 'customers' + ) INTO has_public; + + IF NOT has_public THEN + RETURN; + END IF; + + SELECT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'public' AND table_name = 'customers' AND column_name = 'user_id' + ) INTO has_user_id; + + SELECT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'public' AND table_name = 'customers' AND column_name = 'userid' + ) INTO has_userid; + + IF has_user_id THEN + INSERT INTO freight.customers ( + id, user_id, first_name, last_name, email, phone, + company_name, company_email, company_phone, company_location, company_address, + contact_person_name, contact_person_phone, tin_number, vat_number, fan_number, + general_manager_name, general_manager_email, general_manager_phone, + poa_name, poa_phone, poa_address, poa_email, poa_location, notes, + created_at, updated_at + ) + SELECT + id, user_id, first_name, last_name, email, phone, + company_name, company_email, company_phone, company_location, company_address, + contact_person_name, contact_person_phone, tin_number, vat_number, fan_number, + general_manager_name, general_manager_email, general_manager_phone, + poa_name, poa_phone, poa_address, poa_email, poa_location, notes, + COALESCE(created_at, now()), COALESCE(updated_at, now()) + FROM public.customers + ON CONFLICT (id) DO NOTHING; + ELSIF has_userid THEN + INSERT INTO freight.customers ( + id, user_id, first_name, last_name, email, phone, + company_name, company_email, company_phone, company_location, company_address, + contact_person_name, contact_person_phone, tin_number, vat_number, fan_number, + general_manager_name, general_manager_email, general_manager_phone, + poa_name, poa_phone, poa_address, poa_email, poa_location, notes, + created_at, updated_at + ) + SELECT + id, userid, firstname, lastname, email, phone, + companyname, companyemail, companyphone, companylocation, companyaddress, + contactpersonname, contactpersonphone, tinnumber, vatnumber, fannumber, + generalmanagername, generalmanageremail, generalmanagerphone, + poaname, poaphone, poaaddress, poaemail, poalocation, notes, + COALESCE("createdAt", now()), COALESCE("updatedAt", now()) + FROM public.customers + ON CONFLICT (id) DO NOTHING; + END IF; + END $$; + `); + + await queryRunner.query(` + ALTER TABLE freight.bookings + DROP CONSTRAINT IF EXISTS "FK_bookings_customer_id"; + `); + + await queryRunner.query(` + DELETE FROM freight.booking_cargo_modifier bcm + USING freight.bookings b + WHERE bcm.booking_id = b.id + AND NOT EXISTS (SELECT 1 FROM freight.customers c WHERE c.id = b.customer_id); + `); + await queryRunner.query(` + DELETE FROM freight.booking_approval_step bas + USING freight.bookings b + WHERE bas.booking_id = b.id + AND NOT EXISTS (SELECT 1 FROM freight.customers c WHERE c.id = b.customer_id); + `); + await queryRunner.query(` + DELETE FROM freight.booking_rate_snapshot brs + USING freight.bookings b + WHERE brs.booking_id = b.id + AND NOT EXISTS (SELECT 1 FROM freight.customers c WHERE c.id = b.customer_id); + `); + await queryRunner.query(` + DELETE FROM freight.booking_container bc + USING freight.bookings b + WHERE bc.booking_id = b.id + AND NOT EXISTS (SELECT 1 FROM freight.customers c WHERE c.id = b.customer_id); + `); + await queryRunner.query(` + DELETE FROM freight.bookings b + WHERE NOT EXISTS (SELECT 1 FROM freight.customers c WHERE c.id = b.customer_id); + `); + + await queryRunner.query(` + DO $$ BEGIN + ALTER TABLE freight.bookings + ADD CONSTRAINT "FK_bookings_customer_id" + FOREIGN KEY (customer_id) + REFERENCES freight.customers(id) + ON DELETE RESTRICT; + EXCEPTION + WHEN duplicate_object THEN NULL; + END $$; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.bookings + DROP CONSTRAINT IF EXISTS "FK_bookings_customer_id"; + `); + + await queryRunner.query(` + DO $$ BEGIN + ALTER TABLE freight.bookings + ADD CONSTRAINT "FK_bookings_customer_id" + FOREIGN KEY (customer_id) + REFERENCES public.customers(id) + ON DELETE RESTRICT; + EXCEPTION + WHEN duplicate_object THEN NULL; + END $$; + `); + + await queryRunner.query(`DROP TABLE IF EXISTS freight.customers CASCADE;`); + } +} diff --git a/apps/edr-freight-api/src/migrations/1749000000000-NormalizeWeightLimitTradeDirectionBoth.ts b/apps/edr-freight-api/src/migrations/1749000000000-NormalizeWeightLimitTradeDirectionBoth.ts new file mode 100644 index 000000000..5dc1d6315 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1749000000000-NormalizeWeightLimitTradeDirectionBoth.ts @@ -0,0 +1,29 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Align weight_limit_rules.trade_direction with app code: IMPORT, EXPORT, BOTH (not ANY). + */ +export class NormalizeWeightLimitTradeDirectionBoth1749000000000 + implements MigrationInterface +{ + name = 'NormalizeWeightLimitTradeDirectionBoth1749000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + DO $$ BEGIN + UPDATE freight.weight_limit_rules + SET trade_direction = 'BOTH' + WHERE trade_direction::text = 'ANY'; + + UPDATE freight.weight_limit_rules + SET trade_direction = 'IMPORT' + WHERE trade_direction IS NULL; + EXCEPTION WHEN undefined_table OR undefined_column THEN NULL; + END $$; + `); + } + + public async down(_queryRunner: QueryRunner): Promise { + // No-op: ANY is not a valid enum value in PostgreSQL. + } +} diff --git a/apps/edr-freight-api/src/migrations/1749100000000-CreateFreightFilesTable.ts b/apps/edr-freight-api/src/migrations/1749100000000-CreateFreightFilesTable.ts new file mode 100644 index 000000000..ebb194833 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1749100000000-CreateFreightFilesTable.ts @@ -0,0 +1,37 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateFreightFilesTable1749100000000 implements MigrationInterface { + name = 'CreateFreightFilesTable1749100000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.files ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + resource_id UUID NOT NULL, + resource VARCHAR(100) NOT NULL, + code VARCHAR(100) NOT NULL, + name VARCHAR(500) NOT NULL, + url TEXT NOT NULL, + size INTEGER NOT NULL, + mime_type VARCHAR(255) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ + ); + `); + + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_freight_files_resource" + ON freight.files (resource_id, resource); + `); + + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_freight_files_resource_code" + ON freight.files (resource_id, resource, code); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.files CASCADE`); + } +} diff --git a/apps/edr-freight-api/src/migrations/1749200000000-BookingFlowRefactor.ts b/apps/edr-freight-api/src/migrations/1749200000000-BookingFlowRefactor.ts new file mode 100644 index 000000000..162672727 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1749200000000-BookingFlowRefactor.ts @@ -0,0 +1,50 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class BookingFlowRefactor1749200000000 implements MigrationInterface { + name = 'BookingFlowRefactor1749200000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.booking_review_note ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + booking_id UUID NOT NULL REFERENCES freight.bookings(id) ON DELETE CASCADE, + author_id UUID, + note TEXT NOT NULL, + type VARCHAR(30) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ + ); + CREATE INDEX IF NOT EXISTS idx_booking_review_note_booking_id + ON freight.booking_review_note(booking_id); + `); + + await queryRunner.query(` + ALTER TABLE freight.bookings + ADD COLUMN IF NOT EXISTS marketing_approved_by_id UUID, + ADD COLUMN IF NOT EXISTS marketing_approved_at TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS contract_summary TEXT, + ADD COLUMN IF NOT EXISTS locked_at TIMESTAMPTZ; + `); + + await queryRunner.query(` + UPDATE freight.bookings SET status = 'SUBMITTED' + WHERE status IN ('RFQ_SUBMITTED', 'QUOTATION_SENT', 'QUOTATION_APPROVED'); + UPDATE freight.bookings SET status = 'REJECTED' + WHERE status = 'QUOTATION_REJECTED'; + UPDATE freight.bookings SET status = 'CANCELLED' + WHERE status = 'CANCELLED'; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.bookings + DROP COLUMN IF EXISTS locked_at, + DROP COLUMN IF EXISTS contract_summary, + DROP COLUMN IF EXISTS marketing_approved_at, + DROP COLUMN IF EXISTS marketing_approved_by_id; + `); + await queryRunner.query(`DROP TABLE IF EXISTS freight.booking_review_note;`); + } +} diff --git a/apps/edr-freight-api/src/migrations/1749200000000-CreateCompaniesModule.ts b/apps/edr-freight-api/src/migrations/1749200000000-CreateCompaniesModule.ts new file mode 100644 index 000000000..c02c9fb5b --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1749200000000-CreateCompaniesModule.ts @@ -0,0 +1,115 @@ +import { MigrationInterface, QueryRunner, Table, TableIndex, TableUnique } from 'typeorm'; + +export class CreateCompaniesModule1749200000000 implements MigrationInterface { + name = 'CreateCompaniesModule1749200000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.createTable( + new Table({ + schema: 'freight', + name: 'companies', + columns: [ + { name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'gen_random_uuid()' }, + { name: 'name', type: 'varchar', length: '200' }, + { name: 'type', type: 'varchar', length: '32' }, + { name: 'status', type: 'varchar', length: '32', default: "'pending'" }, + { name: 'tin', type: 'varchar', length: '10', isUnique: true }, + { name: 'vat_number', type: 'varchar', length: '50', isNullable: true }, + { name: 'business_license', type: 'varchar', length: '100', isNullable: true }, + { name: 'fan_number', type: 'varchar', length: '16', isNullable: true }, + { name: 'country', type: 'varchar', length: '32', default: "'Ethiopia'" }, + { name: 'address', type: 'text', isNullable: true }, + { name: 'phone', type: 'varchar', length: '20', isNullable: true }, + { name: 'email', type: 'varchar', length: '150', isNullable: true }, + { name: 'website', type: 'varchar', length: '200', isNullable: true }, + { name: 'attributes', type: 'jsonb', isNullable: true }, + { name: 'created_at', type: 'timestamptz', default: 'now()' }, + { name: 'updated_at', type: 'timestamptz', default: 'now()' }, + { name: 'deleted_at', type: 'timestamptz', isNullable: true }, + ], + }), + true, + ); + + await queryRunner.createTable( + new Table({ + schema: 'freight', + name: 'external_profiles', + columns: [ + { name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'gen_random_uuid()' }, + { name: 'user_id', type: 'uuid' }, + { name: 'company_id', type: 'uuid' }, + { name: 'first_name', type: 'varchar', length: '100' }, + { name: 'last_name', type: 'varchar', length: '100' }, + { name: 'email', type: 'varchar', length: '150', isUnique: true }, + { name: 'phone', type: 'varchar', length: '20', isNullable: true }, + { name: 'national_id', type: 'varchar', length: '50', isNullable: true }, + { name: 'job_title', type: 'varchar', length: '100', isNullable: true }, + { name: 'is_primary_contact', type: 'boolean', default: false }, + { name: 'created_at', type: 'timestamptz', default: 'now()' }, + { name: 'updated_at', type: 'timestamptz', default: 'now()' }, + { name: 'deleted_at', type: 'timestamptz', isNullable: true }, + ], + foreignKeys: [ + { + columnNames: ['company_id'], + referencedTableName: 'companies', + referencedSchema: 'freight', + referencedColumnNames: ['id'], + }, + ], + }), + true, + ); + + await queryRunner.createTable( + new Table({ + schema: 'freight', + name: 'ff_clients', + columns: [ + { name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'gen_random_uuid()' }, + { name: 'forwarder_company_id', type: 'uuid' }, + { name: 'client_company_id', type: 'uuid' }, + { name: 'relationship_type', type: 'varchar', length: '32', default: "'managed_account'" }, + { name: 'can_book_on_behalf', type: 'boolean', default: true }, + { name: 'can_view_documents', type: 'boolean', default: true }, + { name: 'created_at', type: 'timestamptz', default: 'now()' }, + { name: 'updated_at', type: 'timestamptz', default: 'now()' }, + { name: 'deleted_at', type: 'timestamptz', isNullable: true }, + ], + foreignKeys: [ + { + columnNames: ['forwarder_company_id'], + referencedTableName: 'companies', + referencedSchema: 'freight', + referencedColumnNames: ['id'], + }, + { + columnNames: ['client_company_id'], + referencedTableName: 'companies', + referencedSchema: 'freight', + referencedColumnNames: ['id'], + }, + ], + }), + true, + ); + + await queryRunner.createIndex('freight.companies', new TableIndex({ columnNames: ['tin'] })); + await queryRunner.createIndex('freight.companies', new TableIndex({ columnNames: ['type'] })); + await queryRunner.createIndex('freight.external_profiles', new TableIndex({ columnNames: ['user_id'] })); + await queryRunner.createIndex('freight.external_profiles', new TableIndex({ columnNames: ['company_id'] })); + await queryRunner.createIndex('freight.ff_clients', new TableIndex({ columnNames: ['forwarder_company_id'] })); + await queryRunner.createIndex('freight.ff_clients', new TableIndex({ columnNames: ['client_company_id'] })); + + await queryRunner.createUniqueConstraint('freight.ff_clients', new TableUnique({ + columnNames: ['forwarder_company_id', 'client_company_id'], + })); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropTable('freight.ff_clients'); + await queryRunner.dropTable('freight.external_profiles'); + await queryRunner.dropTable('freight.companies'); + } +} diff --git a/apps/edr-freight-api/src/migrations/1749300000000-AddBookingFreightType.ts b/apps/edr-freight-api/src/migrations/1749300000000-AddBookingFreightType.ts new file mode 100644 index 000000000..795d93fc3 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1749300000000-AddBookingFreightType.ts @@ -0,0 +1,71 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddBookingFreightType1749300000000 implements MigrationInterface { + name = 'AddBookingFreightType1749300000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.bookings + ADD COLUMN IF NOT EXISTS freight_type VARCHAR(20); + `); + + await queryRunner.query(` + UPDATE freight.bookings b + SET freight_type = 'CONTAINER' + WHERE EXISTS ( + SELECT 1 FROM freight.booking_container bc WHERE bc.booking_id = b.id + ); + `); + + await queryRunner.query(` + UPDATE freight.bookings b + SET freight_type = 'BULK' + WHERE freight_type IS NULL + AND b.cargo_type_id IS NOT NULL + AND EXISTS ( + SELECT 1 FROM freight.cargo_types ct + WHERE ct.id = b.cargo_type_id AND ct.requires_director_approval = true + ); + `); + + await queryRunner.query(` + UPDATE freight.bookings + SET freight_type = 'CONTAINER' + WHERE freight_type IS NULL; + `); + + await queryRunner.query(` + ALTER TABLE freight.bookings + ALTER COLUMN cargo_type_id DROP NOT NULL; + `); + + await queryRunner.query(` + ALTER TABLE freight.bookings + ALTER COLUMN freight_type SET NOT NULL; + `); + + await queryRunner.query(` + ALTER TABLE freight.bookings + ADD CONSTRAINT chk_bookings_freight_type + CHECK (freight_type IN ('CONTAINER', 'BULK')); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.bookings DROP CONSTRAINT IF EXISTS chk_bookings_freight_type; + `); + await queryRunner.query(` + ALTER TABLE freight.bookings DROP COLUMN IF EXISTS freight_type; + `); + await queryRunner.query(` + UPDATE freight.bookings SET cargo_type_id = ( + SELECT id FROM freight.cargo_types LIMIT 1 + ) WHERE cargo_type_id IS NULL; + `); + await queryRunner.query(` + ALTER TABLE freight.bookings + ALTER COLUMN cargo_type_id SET NOT NULL; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1749300000000-AddFanNumberToCompanies.ts b/apps/edr-freight-api/src/migrations/1749300000000-AddFanNumberToCompanies.ts new file mode 100644 index 000000000..4df7ea4ce --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1749300000000-AddFanNumberToCompanies.ts @@ -0,0 +1,20 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddFanNumberToCompanies1749300000000 implements MigrationInterface { + name = 'AddFanNumberToCompanies1749300000000'; + + public async up(queryRunner: QueryRunner): Promise { + // fan_number may already exist when CreateCompaniesModule ran with the full schema + await queryRunner.query(` + ALTER TABLE freight.companies + ADD COLUMN IF NOT EXISTS fan_number varchar(16) NULL; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.companies + DROP COLUMN IF EXISTS fan_number; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1749400000000-AddContractSignatures.ts b/apps/edr-freight-api/src/migrations/1749400000000-AddContractSignatures.ts new file mode 100644 index 000000000..8126b91ca --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1749400000000-AddContractSignatures.ts @@ -0,0 +1,45 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddContractSignatures1749400000000 implements MigrationInterface { + name = 'AddContractSignatures1749400000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.bookings + ADD COLUMN IF NOT EXISTS contract_template_key VARCHAR(80), + ADD COLUMN IF NOT EXISTS contract_generated_at TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS pricing_breakdown JSONB; + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.booking_contract_signatures ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + booking_id UUID NOT NULL REFERENCES freight.bookings(id) ON DELETE CASCADE, + signer_role VARCHAR(20) NOT NULL, + signer_user_id UUID, + signer_display_name VARCHAR(200) NOT NULL, + signed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + signature_file_id UUID REFERENCES freight.files(id) ON DELETE SET NULL, + consent_text TEXT, + ip_address VARCHAR(64), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ, + CONSTRAINT uq_booking_contract_signatures_role + UNIQUE (booking_id, signer_role) + ); + CREATE INDEX IF NOT EXISTS idx_booking_contract_signatures_booking_id + ON freight.booking_contract_signatures(booking_id); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.booking_contract_signatures;`); + await queryRunner.query(` + ALTER TABLE freight.bookings + DROP COLUMN IF EXISTS pricing_breakdown, + DROP COLUMN IF EXISTS contract_generated_at, + DROP COLUMN IF EXISTS contract_template_key; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1749400000000-AddTrainScheduling.ts b/apps/edr-freight-api/src/migrations/1749400000000-AddTrainScheduling.ts new file mode 100644 index 000000000..5e61797da --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1749400000000-AddTrainScheduling.ts @@ -0,0 +1,153 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddTrainScheduling1749400000000 implements MigrationInterface { + name = 'AddTrainScheduling1749400000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.wagon_types ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + code VARCHAR(32) NOT NULL UNIQUE, + name VARCHAR(100) NOT NULL, + capacity_tons NUMERIC(10,3) NOT NULL, + length_meters NUMERIC(10,3) NOT NULL, + max_wagons_per_train INT NULL, + supported_load_types TEXT[] NOT NULL DEFAULT '{}', + is_active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ NULL + ); + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.locomotives ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + code VARCHAR(32) NOT NULL UNIQUE, + name VARCHAR(100) NULL, + max_pull_weight_tons NUMERIC(10,3) NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'AVAILABLE', + available_from TIMESTAMPTZ NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ NULL + ); + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.train_sets ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + locomotive_id UUID NOT NULL, + total_weight_tons NUMERIC(10,3) NOT NULL, + total_length_meters NUMERIC(10,3) NOT NULL, + wagon_count INT NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'DRAFT', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ NULL, + CONSTRAINT fk_train_sets_locomotive FOREIGN KEY (locomotive_id) + REFERENCES freight.locomotives(id) + ); + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.train_set_wagons ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + train_set_id UUID NOT NULL, + wagon_type_id UUID NOT NULL, + sequence_no INT NOT NULL, + capacity_tons NUMERIC(10,3) NOT NULL, + length_meters NUMERIC(10,3) NOT NULL, + assigned_weight_tons NUMERIC(10,3) NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ NULL, + CONSTRAINT uq_train_set_wagons_sequence UNIQUE (train_set_id, sequence_no), + CONSTRAINT fk_train_set_wagons_train_set FOREIGN KEY (train_set_id) + REFERENCES freight.train_sets(id) ON DELETE CASCADE, + CONSTRAINT fk_train_set_wagons_wagon_type FOREIGN KEY (wagon_type_id) + REFERENCES freight.wagon_types(id) + ); + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.train_schedules ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + train_set_id UUID NOT NULL UNIQUE, + origin_station_id UUID NOT NULL, + destination_station_id UUID NOT NULL, + scheduled_departure_date TIMESTAMPTZ NOT NULL, + scheduled_arrival_date TIMESTAMPTZ NULL, + status VARCHAR(20) NOT NULL DEFAULT 'DRAFT', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ NULL, + CONSTRAINT fk_train_schedules_train_set FOREIGN KEY (train_set_id) + REFERENCES freight.train_sets(id), + CONSTRAINT fk_train_schedules_origin FOREIGN KEY (origin_station_id) + REFERENCES freight.yards(id), + CONSTRAINT fk_train_schedules_destination FOREIGN KEY (destination_station_id) + REFERENCES freight.yards(id) + ); + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.train_schedule_bookings ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + train_schedule_id UUID NOT NULL, + booking_id UUID NOT NULL UNIQUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ NULL, + CONSTRAINT uq_train_schedule_booking UNIQUE (train_schedule_id, booking_id), + CONSTRAINT fk_train_schedule_bookings_schedule FOREIGN KEY (train_schedule_id) + REFERENCES freight.train_schedules(id) ON DELETE CASCADE, + CONSTRAINT fk_train_schedule_bookings_booking FOREIGN KEY (booking_id) + REFERENCES freight.bookings(id) + ); + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.wagon_booking_allocations ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + train_set_wagon_id UUID NOT NULL, + booking_id UUID NOT NULL, + allocated_weight_tons NUMERIC(10,3) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ NULL, + CONSTRAINT fk_wagon_booking_allocations_wagon FOREIGN KEY (train_set_wagon_id) + REFERENCES freight.train_set_wagons(id) ON DELETE CASCADE, + CONSTRAINT fk_wagon_booking_allocations_booking FOREIGN KEY (booking_id) + REFERENCES freight.bookings(id) + ); + `); + + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_locomotives_status + ON freight.locomotives(status); + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_train_sets_status + ON freight.train_sets(status); + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_train_schedules_departure_status + ON freight.train_schedules(scheduled_departure_date, status); + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_wagon_booking_allocations_booking + ON freight.wagon_booking_allocations(booking_id); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.wagon_booking_allocations;`); + await queryRunner.query(`DROP TABLE IF EXISTS freight.train_schedule_bookings;`); + await queryRunner.query(`DROP TABLE IF EXISTS freight.train_schedules;`); + await queryRunner.query(`DROP TABLE IF EXISTS freight.train_set_wagons;`); + await queryRunner.query(`DROP TABLE IF EXISTS freight.train_sets;`); + await queryRunner.query(`DROP TABLE IF EXISTS freight.locomotives;`); + await queryRunner.query(`DROP TABLE IF EXISTS freight.wagon_types;`); + } +} diff --git a/apps/edr-freight-api/src/migrations/1749500000000-AddCompanyIdToBookings.ts b/apps/edr-freight-api/src/migrations/1749500000000-AddCompanyIdToBookings.ts new file mode 100644 index 000000000..25fbe1806 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1749500000000-AddCompanyIdToBookings.ts @@ -0,0 +1,59 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddCompanyIdToBookings1749500000000 implements MigrationInterface { + name = 'AddCompanyIdToBookings1749500000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.bookings + ALTER COLUMN customer_id DROP NOT NULL; + `); + + await queryRunner.query(` + ALTER TABLE freight.bookings + ADD COLUMN IF NOT EXISTS company_id UUID; + `); + + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_bookings_company_id + ON freight.bookings(company_id); + `); + + await queryRunner.query(` + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'FK_bookings_company_id' + ) THEN + ALTER TABLE freight.bookings + ADD CONSTRAINT "FK_bookings_company_id" + FOREIGN KEY (company_id) + REFERENCES freight.companies(id); + END IF; + END $$; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.bookings + DROP CONSTRAINT IF EXISTS "FK_bookings_company_id"; + `); + + await queryRunner.query(` + UPDATE freight.bookings SET customer_id = company_id WHERE customer_id IS NULL AND company_id IS NOT NULL; + `); + + await queryRunner.query(` + ALTER TABLE freight.bookings + ALTER COLUMN customer_id SET NOT NULL; + `); + await queryRunner.query(` + DROP INDEX IF EXISTS freight.idx_bookings_company_id; + `); + await queryRunner.query(` + ALTER TABLE freight.bookings + DROP COLUMN IF EXISTS company_id; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1749600000000-AddBlocksRoleToApprovalStep.ts b/apps/edr-freight-api/src/migrations/1749600000000-AddBlocksRoleToApprovalStep.ts new file mode 100644 index 000000000..a1830c370 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1749600000000-AddBlocksRoleToApprovalStep.ts @@ -0,0 +1,19 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddBlocksRoleToApprovalStep1749600000000 implements MigrationInterface { + name = 'AddBlocksRoleToApprovalStep1749600000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.booking_approval_step + ADD COLUMN IF NOT EXISTS blocks_role VARCHAR(30) NULL; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.booking_approval_step + DROP COLUMN IF EXISTS blocks_role; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1749700000000-SeedDefaultApprovalRules.ts b/apps/edr-freight-api/src/migrations/1749700000000-SeedDefaultApprovalRules.ts new file mode 100644 index 000000000..f972f50c2 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1749700000000-SeedDefaultApprovalRules.ts @@ -0,0 +1,48 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Seed ITMLS US-06 approval chains if missing (standard + bulk). + */ +export class SeedDefaultApprovalRules1749700000000 implements MigrationInterface { + name = 'SeedDefaultApprovalRules1749700000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + INSERT INTO freight.approval_rules + (id, requires_director_approval, step_order, required_role, action_label, blocks_role, created_at, updated_at) + SELECT uuid_generate_v4(), false, 1, 'LINE_STAFF', 'Review & Approve', NULL, now(), now() + WHERE NOT EXISTS ( + SELECT 1 FROM freight.approval_rules + WHERE requires_director_approval = false AND step_order = 1 AND deleted_at IS NULL + ); + + INSERT INTO freight.approval_rules + (id, requires_director_approval, step_order, required_role, action_label, blocks_role, created_at, updated_at) + SELECT uuid_generate_v4(), false, 2, 'DIRECTOR', 'Final Signature', 'LINE_STAFF', now(), now() + WHERE NOT EXISTS ( + SELECT 1 FROM freight.approval_rules + WHERE requires_director_approval = false AND step_order = 2 AND deleted_at IS NULL + ); + + INSERT INTO freight.approval_rules + (id, requires_director_approval, step_order, required_role, action_label, blocks_role, created_at, updated_at) + SELECT uuid_generate_v4(), true, 1, 'DIRECTOR', 'Review & Approve', 'LINE_STAFF', now(), now() + WHERE NOT EXISTS ( + SELECT 1 FROM freight.approval_rules + WHERE requires_director_approval = true AND step_order = 1 AND deleted_at IS NULL + ); + + INSERT INTO freight.approval_rules + (id, requires_director_approval, step_order, required_role, action_label, blocks_role, created_at, updated_at) + SELECT uuid_generate_v4(), true, 2, 'CEO', 'Final Signature', NULL, now(), now() + WHERE NOT EXISTS ( + SELECT 1 FROM freight.approval_rules + WHERE requires_director_approval = true AND step_order = 2 AND deleted_at IS NULL + ); + `); + } + + public async down(_queryRunner: QueryRunner): Promise { + // Keep seeded rules on rollback to avoid breaking in-flight bookings. + } +} diff --git a/apps/edr-freight-api/src/migrations/1749800000000-AddShippingLinesCodeUniqueIndex.ts b/apps/edr-freight-api/src/migrations/1749800000000-AddShippingLinesCodeUniqueIndex.ts new file mode 100644 index 000000000..374fea724 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1749800000000-AddShippingLinesCodeUniqueIndex.ts @@ -0,0 +1,28 @@ +import { MigrationInterface, QueryRunner, TableIndex } from 'typeorm'; + +/** + * shipping_lines was created without a unique index on code; seeder upserts require it. + */ +export class AddShippingLinesCodeUniqueIndex1749800000000 implements MigrationInterface { + name = 'AddShippingLinesCodeUniqueIndex1749800000000'; + + public async up(queryRunner: QueryRunner): Promise { + const existing = await queryRunner.query( + `SELECT 1 FROM pg_indexes WHERE schemaname = 'freight' AND tablename = 'shipping_lines' AND indexdef ILIKE '%UNIQUE%code%' LIMIT 1`, + ); + if (existing.length === 0) { + await queryRunner.createIndex( + 'freight.shipping_lines', + new TableIndex({ + name: 'UQ_shipping_lines_code', + columnNames: ['code'], + isUnique: true, + }), + ); + } + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropIndex('freight.shipping_lines', 'UQ_shipping_lines_code'); + } +} diff --git a/apps/edr-freight-api/src/migrations/1749900000000-CreateFileUploadSettingsTables.ts b/apps/edr-freight-api/src/migrations/1749900000000-CreateFileUploadSettingsTables.ts new file mode 100644 index 000000000..cbbf914d4 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1749900000000-CreateFileUploadSettingsTables.ts @@ -0,0 +1,63 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * file_upload_settings / file_upload_fields entities had no migration; seeder requires both tables. + */ +export class CreateFileUploadSettingsTables1749900000000 implements MigrationInterface { + name = 'CreateFileUploadSettingsTables1749900000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.file_upload_settings ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + code VARCHAR(128) NOT NULL, + label VARCHAR(256) NOT NULL, + description TEXT, + entity VARCHAR(32) NOT NULL DEFAULT 'other', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ + ); + `); + + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_file_upload_settings_code" + ON freight.file_upload_settings (code); + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.file_upload_fields ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + setting_id UUID NOT NULL, + file_key VARCHAR(128) NOT NULL, + file_label VARCHAR(256) NOT NULL, + help_text TEXT, + is_required BOOLEAN NOT NULL DEFAULT false, + is_multiple BOOLEAN NOT NULL DEFAULT false, + max_files INTEGER NOT NULL DEFAULT 1, + allowed_extensions TEXT[] NOT NULL DEFAULT '{}'::text[], + max_size_mb INTEGER NOT NULL DEFAULT 10, + display_order INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ, + CONSTRAINT "CHK_file_upload_fields_max_files" CHECK (max_files > 0), + CONSTRAINT "CHK_file_upload_fields_max_size_mb" CHECK (max_size_mb > 0), + CONSTRAINT "FK_file_upload_fields_setting" + FOREIGN KEY (setting_id) + REFERENCES freight.file_upload_settings(id) + ON DELETE CASCADE + ); + `); + + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_file_upload_fields_setting_file_key" + ON freight.file_upload_fields (setting_id, file_key); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.file_upload_fields CASCADE`); + await queryRunner.query(`DROP TABLE IF EXISTS freight.file_upload_settings CASCADE`); + } +} diff --git a/apps/edr-freight-api/src/migrations/1750000000000-AddCompanyContactColumns.ts b/apps/edr-freight-api/src/migrations/1750000000000-AddCompanyContactColumns.ts new file mode 100644 index 000000000..56d41edf9 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1750000000000-AddCompanyContactColumns.ts @@ -0,0 +1,21 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddCompanyContactColumns1750000000000 implements MigrationInterface { + name = 'AddCompanyContactColumns1750000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE freight.companies ADD COLUMN IF NOT EXISTS contact_person_name VARCHAR(100);`); + await queryRunner.query(`ALTER TABLE freight.companies ADD COLUMN IF NOT EXISTS contact_person_phone VARCHAR(20);`); + await queryRunner.query(`ALTER TABLE freight.companies ADD COLUMN IF NOT EXISTS general_manager_name VARCHAR(100);`); + await queryRunner.query(`ALTER TABLE freight.companies ADD COLUMN IF NOT EXISTS general_manager_email VARCHAR(150);`); + await queryRunner.query(`ALTER TABLE freight.companies ADD COLUMN IF NOT EXISTS general_manager_phone VARCHAR(20);`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE freight.companies DROP COLUMN IF EXISTS general_manager_phone;`); + await queryRunner.query(`ALTER TABLE freight.companies DROP COLUMN IF EXISTS general_manager_email;`); + await queryRunner.query(`ALTER TABLE freight.companies DROP COLUMN IF EXISTS general_manager_name;`); + await queryRunner.query(`ALTER TABLE freight.companies DROP COLUMN IF EXISTS contact_person_phone;`); + await queryRunner.query(`ALTER TABLE freight.companies DROP COLUMN IF EXISTS contact_person_name;`); + } +} diff --git a/apps/edr-freight-api/src/migrations/1750000000000-AddTrainExtendedColumns.ts b/apps/edr-freight-api/src/migrations/1750000000000-AddTrainExtendedColumns.ts new file mode 100644 index 000000000..b249bd198 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1750000000000-AddTrainExtendedColumns.ts @@ -0,0 +1,45 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Train entity gained extended fields; baseline trains table only had code/capacity/status/notes. + */ +export class AddTrainExtendedColumns1750000000000 implements MigrationInterface { + name = 'AddTrainExtendedColumns1750000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.trains + ADD COLUMN IF NOT EXISTS train_number VARCHAR(20), + ADD COLUMN IF NOT EXISTS train_name VARCHAR(100), + ADD COLUMN IF NOT EXISTS route_id UUID, + ADD COLUMN IF NOT EXISTS origin_station_id UUID, + ADD COLUMN IF NOT EXISTS destination_station_id UUID, + ADD COLUMN IF NOT EXISTS departure_time TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS arrival_time TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS locomotive_number VARCHAR(50), + ADD COLUMN IF NOT EXISTS remarks TEXT; + `); + + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_trains_train_number" + ON freight.trains (train_number) + WHERE train_number IS NOT NULL; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_trains_train_number"`); + await queryRunner.query(` + ALTER TABLE freight.trains + DROP COLUMN IF EXISTS remarks, + DROP COLUMN IF EXISTS locomotive_number, + DROP COLUMN IF EXISTS arrival_time, + DROP COLUMN IF EXISTS departure_time, + DROP COLUMN IF EXISTS destination_station_id, + DROP COLUMN IF EXISTS origin_station_id, + DROP COLUMN IF EXISTS route_id, + DROP COLUMN IF EXISTS train_name, + DROP COLUMN IF EXISTS train_number; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1750000000000-CreateFacilitiesTable.ts b/apps/edr-freight-api/src/migrations/1750000000000-CreateFacilitiesTable.ts new file mode 100644 index 000000000..32e1ff653 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1750000000000-CreateFacilitiesTable.ts @@ -0,0 +1,138 @@ +import { MigrationInterface, QueryRunner, Table, TableIndex } from 'typeorm'; + +export class CreateFacilitiesTable1750000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.createTable( + new Table({ + schema: 'freight', + name: 'facilities', + columns: [ + { + name: 'id', + type: 'uuid', + isPrimary: true, + generationStrategy: 'uuid', + default: 'gen_random_uuid()', + }, + { + name: 'code', + type: 'varchar', + length: '40', + isUnique: true, + }, + { + name: 'name', + type: 'varchar', + length: '160', + }, + { + name: 'description', + type: 'text', + isNullable: true, + }, + { + name: 'facility_type', + type: 'varchar', + length: '32', + }, + { + name: 'facility_status', + type: 'varchar', + length: '32', + default: "'ACTIVE'", + }, + { + name: 'location_name', + type: 'varchar', + length: '200', + isNullable: true, + }, + { + name: 'country', + type: 'varchar', + length: '100', + isNullable: true, + }, + { + name: 'city', + type: 'varchar', + length: '100', + isNullable: true, + }, + { + name: 'address', + type: 'text', + isNullable: true, + }, + { + name: 'latitude', + type: 'numeric', + precision: 10, + scale: 8, + isNullable: true, + }, + { + name: 'longitude', + type: 'numeric', + precision: 11, + scale: 8, + isNullable: true, + }, + { + name: 'capacity', + type: 'numeric', + precision: 14, + scale: 3, + isNullable: true, + }, + { + name: 'is_active', + type: 'boolean', + default: true, + }, + { + name: 'notes', + type: 'text', + isNullable: true, + }, + { + name: 'created_at', + type: 'timestamp', + default: 'CURRENT_TIMESTAMP', + }, + { + name: 'updated_at', + type: 'timestamp', + default: 'CURRENT_TIMESTAMP', + }, + { + name: 'deleted_at', + type: 'timestamp', + isNullable: true, + }, + ], + }), + ); + + await queryRunner.createIndex( + 'freight.facilities', + new TableIndex({ + name: 'idx_facilities_code', + columnNames: ['code'], + isUnique: true, + }), + ); + + await queryRunner.createIndex( + 'freight.facilities', + new TableIndex({ + name: 'idx_facilities_status', + columnNames: ['facility_status'], + }), + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropTable('freight.facilities'); + } +} diff --git a/apps/edr-freight-api/src/migrations/1750000000001-AddFacilityIdToWarehouses.ts b/apps/edr-freight-api/src/migrations/1750000000001-AddFacilityIdToWarehouses.ts new file mode 100644 index 000000000..82f2d92fd --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1750000000001-AddFacilityIdToWarehouses.ts @@ -0,0 +1,52 @@ +import { MigrationInterface, QueryRunner, TableColumn, TableForeignKey } from 'typeorm'; + +export class AddFacilityIdToWarehouses1750000000001 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + const table = await queryRunner.getTable('freight.warehouses'); + if (!table) { + // warehouses table doesn't exist yet, skip this migration + return; + } + + const hasColumn = table.columns.some((col) => col.name === 'facility_id'); + if (hasColumn) { + // Column already exists, skip + return; + } + + await queryRunner.addColumn( + 'freight.warehouses', + new TableColumn({ + name: 'facility_id', + type: 'uuid', + isNullable: true, + }), + ); + + await queryRunner.createForeignKey( + 'freight.warehouses', + new TableForeignKey({ + columnNames: ['facility_id'], + referencedColumnNames: ['id'], + referencedTableName: 'facilities', + referencedSchema: 'freight', + onDelete: 'SET NULL', + }), + ); + } + + public async down(queryRunner: QueryRunner): Promise { + const table = await queryRunner.getTable('freight.warehouses'); + if (!table) { + return; + } + const foreignKey = table.foreignKeys.find((fk) => fk.columnNames.includes('facility_id')); + if (foreignKey) { + await queryRunner.dropForeignKey('freight.warehouses', foreignKey); + } + const hasColumn = table.columns.some((col) => col.name === 'facility_id'); + if (hasColumn) { + await queryRunner.dropColumn('freight.warehouses', 'facility_id'); + } + } +} diff --git a/apps/edr-freight-api/src/migrations/1750000000002-AddProofOfDeliveryToCargoes.ts b/apps/edr-freight-api/src/migrations/1750000000002-AddProofOfDeliveryToCargoes.ts new file mode 100644 index 000000000..67957f437 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1750000000002-AddProofOfDeliveryToCargoes.ts @@ -0,0 +1,48 @@ +import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm'; + +/** + * Proof of Delivery (customer pickup) capture on cargoes: + * receiver name, delivered/picked-up timestamp, and delivery remarks. + */ +export class AddProofOfDeliveryToCargoes1750000000002 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + const table = await queryRunner.getTable('freight.cargoes'); + if (!table) { + // cargoes table doesn't exist yet, skip this migration + return; + } + + const columnsToAdd = [ + { name: 'receiver_name', type: 'varchar', isNullable: true }, + { name: 'delivered_at', type: 'timestamp', isNullable: true }, + { name: 'delivery_remarks', type: 'text', isNullable: true }, + ]; + + const columnsToCreate = columnsToAdd.filter( + (col) => !table.columns.some((c) => c.name === col.name), + ); + + if (columnsToCreate.length > 0) { + await queryRunner.addColumns( + 'freight.cargoes', + columnsToCreate.map((col) => new TableColumn(col)), + ); + } + } + + public async down(queryRunner: QueryRunner): Promise { + const table = await queryRunner.getTable('freight.cargoes'); + if (!table) { + return; + } + + const columnNames = ['receiver_name', 'delivered_at', 'delivery_remarks']; + const columnsToRemove = columnNames.filter((name) => + table.columns.some((c) => c.name === name), + ); + + if (columnsToRemove.length > 0) { + await queryRunner.dropColumns('freight.cargoes', columnsToRemove); + } + } +} diff --git a/apps/edr-freight-api/src/migrations/1750000000003-AddWarehouseInspection.ts b/apps/edr-freight-api/src/migrations/1750000000003-AddWarehouseInspection.ts new file mode 100644 index 000000000..88ef8d3a5 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1750000000003-AddWarehouseInspection.ts @@ -0,0 +1,75 @@ +import { MigrationInterface, QueryRunner, Table, TableColumn } from 'typeorm'; + +/** + * Batch 4.5 — warehouse inspection reports + inventory inspection status. + */ +export class AddWarehouseInspection1750000000003 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + // inventory.inspection_status + const inventoryTable = await queryRunner.getTable('freight.warehouse_inventory'); + if (inventoryTable) { + const hasColumn = inventoryTable.columns.some((col) => col.name === 'inspection_status'); + if (!hasColumn) { + await queryRunner.addColumn( + 'freight.warehouse_inventory', + new TableColumn({ name: 'inspection_status', type: 'varchar', length: '20', isNullable: true }), + ); + } + } + + // warehouse_inspection_reports table + const inspectionTable = await queryRunner.getTable('freight.warehouse_inspection_reports'); + if (!inspectionTable) { + await queryRunner.createTable( + new Table({ + schema: 'freight', + name: 'warehouse_inspection_reports', + columns: [ + { name: 'id', type: 'uuid', isPrimary: true, default: 'uuid_generate_v4()' }, + { name: 'inventory_id', type: 'uuid' }, + { name: 'booking_id', type: 'uuid', isNullable: true }, + { name: 'customer_id', type: 'uuid', isNullable: true }, + { name: 'report_type', type: 'varchar', length: '32', default: "'INSPECTION'" }, + { name: 'inspection_status', type: 'varchar', length: '20', default: "'NEEDS_REVIEW'" }, + { name: 'has_damage', type: 'boolean', default: false }, + { name: 'damage_description', type: 'text', isNullable: true }, + { name: 'has_weight_loss', type: 'boolean', default: false }, + { name: 'expected_weight', type: 'numeric', precision: 14, scale: 3, isNullable: true }, + { name: 'actual_weight', type: 'numeric', precision: 14, scale: 3, isNullable: true }, + { name: 'weight_loss', type: 'numeric', precision: 14, scale: 3, isNullable: true }, + { name: 'weight_loss_unit', type: 'varchar', length: '12', isNullable: true }, + { name: 'has_missing_items', type: 'boolean', default: false }, + { name: 'missing_items_description', type: 'text', isNullable: true }, + { name: 'remarks', type: 'text', isNullable: true }, + { name: 'inspected_by_id', type: 'uuid', isNullable: true }, + { name: 'inspected_at', type: 'timestamptz', isNullable: true }, + { name: 'created_at', type: 'timestamptz', default: 'now()' }, + { name: 'updated_at', type: 'timestamptz', default: 'now()' }, + { name: 'deleted_at', type: 'timestamptz', isNullable: true }, + ], + indices: [ + { name: 'idx_wir_inventory', columnNames: ['inventory_id'] }, + { name: 'idx_wir_booking', columnNames: ['booking_id'] }, + { name: 'idx_wir_status', columnNames: ['inspection_status'] }, + ], + }), + true, + ); + } + } + + public async down(queryRunner: QueryRunner): Promise { + const inspectionTable = await queryRunner.getTable('freight.warehouse_inspection_reports'); + if (inspectionTable) { + await queryRunner.dropTable('freight.warehouse_inspection_reports', true); + } + + const inventoryTable = await queryRunner.getTable('freight.warehouse_inventory'); + if (inventoryTable) { + const hasColumn = inventoryTable.columns.some((col) => col.name === 'inspection_status'); + if (hasColumn) { + await queryRunner.dropColumn('freight.warehouse_inventory', 'inspection_status'); + } + } + } +} diff --git a/apps/edr-freight-api/src/migrations/1750100000000-AddRoutesAndExtendLocomotives.ts b/apps/edr-freight-api/src/migrations/1750100000000-AddRoutesAndExtendLocomotives.ts new file mode 100644 index 000000000..421f66ef9 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1750100000000-AddRoutesAndExtendLocomotives.ts @@ -0,0 +1,87 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddRoutesAndExtendLocomotives1750100000000 implements MigrationInterface { + name = 'AddRoutesAndExtendLocomotives1750100000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.locomotives + ADD COLUMN IF NOT EXISTS locomotive_type VARCHAR(20) NOT NULL DEFAULT 'DIESEL', + ADD COLUMN IF NOT EXISTS max_train_length_meters NUMERIC(10,3) NOT NULL DEFAULT 760, + ADD COLUMN IF NOT EXISTS power_kw NUMERIC(10,3) NULL, + ADD COLUMN IF NOT EXISTS traction_force_kn NUMERIC(10,3) NULL, + ADD COLUMN IF NOT EXISTS max_speed_kmh NUMERIC(10,3) NULL; + `); + + await queryRunner.query(` + UPDATE freight.locomotives + SET status = 'OUT_OF_SERVICE' + WHERE status = 'INACTIVE'; + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.routes ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(120) NOT NULL UNIQUE, + origin_yard_id UUID NOT NULL REFERENCES freight.yards(id), + destination_yard_id UUID NOT NULL REFERENCES freight.yards(id), + is_active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ NULL + ); + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.route_milestones ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + route_id UUID NOT NULL REFERENCES freight.routes(id) ON DELETE CASCADE, + yard_id UUID NOT NULL REFERENCES freight.yards(id), + sequence_no INT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ NULL, + CONSTRAINT uq_route_milestones_route_sequence UNIQUE (route_id, sequence_no) + ); + `); + + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_routes_origin_yard_id + ON freight.routes(origin_yard_id); + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_routes_destination_yard_id + ON freight.routes(destination_yard_id); + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_routes_is_active + ON freight.routes(is_active); + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_route_milestones_route_id + ON freight.route_milestones(route_id); + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_route_milestones_yard_id + ON freight.route_milestones(yard_id); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.route_milestones;`); + await queryRunner.query(`DROP TABLE IF EXISTS freight.routes;`); + await queryRunner.query(` + ALTER TABLE freight.locomotives + DROP COLUMN IF EXISTS max_speed_kmh, + DROP COLUMN IF EXISTS traction_force_kn, + DROP COLUMN IF EXISTS power_kw, + DROP COLUMN IF EXISTS max_train_length_meters, + DROP COLUMN IF EXISTS locomotive_type; + `); + await queryRunner.query(` + UPDATE freight.locomotives + SET status = 'INACTIVE' + WHERE status = 'OUT_OF_SERVICE'; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1750100000000-CreateFleetCrudTables.ts b/apps/edr-freight-api/src/migrations/1750100000000-CreateFleetCrudTables.ts new file mode 100644 index 000000000..1763a9db0 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1750100000000-CreateFleetCrudTables.ts @@ -0,0 +1,127 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateFleetCrudTables1750100000000 implements MigrationInterface { + name = 'CreateFleetCrudTables1750100000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE SCHEMA IF NOT EXISTS freight`); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.wagons ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + wagon_number VARCHAR NOT NULL UNIQUE, + wagon_type_id UUID NOT NULL, + train_id UUID, + sequence_number INT, + tare_weight NUMERIC(10, 2) NOT NULL, + max_payload_weight NUMERIC(10, 2) NOT NULL, + status VARCHAR NOT NULL DEFAULT 'AVAILABLE', + notes TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ + ); + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.containers ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + container_number VARCHAR NOT NULL UNIQUE, + container_type_id UUID NOT NULL, + wagon_id UUID, + position INT, + tare_weight NUMERIC(10, 2) NOT NULL, + max_gross_weight NUMERIC(10, 2) NOT NULL, + seal_number VARCHAR, + status VARCHAR NOT NULL DEFAULT 'AVAILABLE', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ + ); + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.cargoes ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + cargo_reference VARCHAR NOT NULL UNIQUE, + shipment_id UUID NOT NULL, + container_id UUID NOT NULL, + cargo_type_id UUID, + description TEXT, + quantity NUMERIC(12, 3) NOT NULL, + weight NUMERIC(10, 2) NOT NULL, + volume NUMERIC(10, 2), + status VARCHAR NOT NULL DEFAULT 'PENDING', + loaded_at TIMESTAMP, + unloaded_at TIMESTAMP, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ + ); + `); + + await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_wagons_train_id" ON freight.wagons (train_id)`); + await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_containers_wagon_id" ON freight.containers (wagon_id)`); + await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_cargoes_container_id" ON freight.cargoes (container_id)`); + + await queryRunner.query(` + DO $$ BEGIN + ALTER TABLE freight.wagons + ADD CONSTRAINT "FK_wagons_train_id" + FOREIGN KEY (train_id) REFERENCES freight.trains(id) ON DELETE SET NULL; + EXCEPTION WHEN duplicate_object THEN NULL; + END $$; + `); + + await queryRunner.query(` + DO $$ BEGIN + ALTER TABLE freight.wagons + ADD CONSTRAINT "FK_wagons_wagon_type_id" + FOREIGN KEY (wagon_type_id) REFERENCES freight.wagon_types(id); + EXCEPTION WHEN duplicate_object THEN NULL; + END $$; + `); + + await queryRunner.query(` + DO $$ BEGIN + ALTER TABLE freight.containers + ADD CONSTRAINT "FK_containers_wagon_id" + FOREIGN KEY (wagon_id) REFERENCES freight.wagons(id) ON DELETE SET NULL; + EXCEPTION WHEN duplicate_object THEN NULL; + END $$; + `); + + await queryRunner.query(` + DO $$ BEGIN + ALTER TABLE freight.containers + ADD CONSTRAINT "FK_containers_container_type_id" + FOREIGN KEY (container_type_id) REFERENCES freight.container_types(id); + EXCEPTION WHEN duplicate_object THEN NULL; + END $$; + `); + + await queryRunner.query(` + DO $$ BEGIN + ALTER TABLE freight.cargoes + ADD CONSTRAINT "FK_cargoes_container_id" + FOREIGN KEY (container_id) REFERENCES freight.containers(id) ON DELETE RESTRICT; + EXCEPTION WHEN duplicate_object THEN NULL; + END $$; + `); + + await queryRunner.query(` + DO $$ BEGIN + ALTER TABLE freight.cargoes + ADD CONSTRAINT "FK_cargoes_cargo_type_id" + FOREIGN KEY (cargo_type_id) REFERENCES freight.cargo_types(id); + EXCEPTION WHEN duplicate_object THEN NULL; + END $$; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.cargoes CASCADE`); + await queryRunner.query(`DROP TABLE IF EXISTS freight.containers CASCADE`); + await queryRunner.query(`DROP TABLE IF EXISTS freight.wagons CASCADE`); + } +} diff --git a/apps/edr-freight-api/src/migrations/1750200000000-AddPhysicalWagonToTrainSetWagons.ts b/apps/edr-freight-api/src/migrations/1750200000000-AddPhysicalWagonToTrainSetWagons.ts new file mode 100644 index 000000000..9ce1db6c1 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1750200000000-AddPhysicalWagonToTrainSetWagons.ts @@ -0,0 +1,48 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddPhysicalWagonToTrainSetWagons1750200000000 implements MigrationInterface { + name = 'AddPhysicalWagonToTrainSetWagons1750200000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.train_set_wagons + ADD COLUMN IF NOT EXISTS physical_wagon_id UUID NULL; + `); + + await queryRunner.query(` + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM information_schema.table_constraints + WHERE constraint_schema = 'freight' + AND table_name = 'train_set_wagons' + AND constraint_name = 'fk_train_set_wagons_physical_wagon' + ) THEN + ALTER TABLE freight.train_set_wagons + ADD CONSTRAINT fk_train_set_wagons_physical_wagon + FOREIGN KEY (physical_wagon_id) + REFERENCES freight.wagons(id) + ON DELETE SET NULL; + END IF; + END $$; + `); + + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_train_set_wagons_physical_wagon + ON freight.train_set_wagons(physical_wagon_id); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_train_set_wagons_physical_wagon;`); + await queryRunner.query(` + ALTER TABLE freight.train_set_wagons + DROP CONSTRAINT IF EXISTS fk_train_set_wagons_physical_wagon; + `); + await queryRunner.query(` + ALTER TABLE freight.train_set_wagons + DROP COLUMN IF EXISTS physical_wagon_id; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1750200000000-SeedDefaultWagonTypes.ts b/apps/edr-freight-api/src/migrations/1750200000000-SeedDefaultWagonTypes.ts new file mode 100644 index 000000000..06dc0b917 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1750200000000-SeedDefaultWagonTypes.ts @@ -0,0 +1,46 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class SeedDefaultWagonTypes1750200000000 implements MigrationInterface { + name = 'SeedDefaultWagonTypes1750200000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + INSERT INTO freight.wagon_types ( + code, + name, + capacity_tons, + length_meters, + max_wagons_per_train, + supported_load_types, + is_active + ) + VALUES + ('NW7', 'Double deck sedan wagon', 22, 26.066, NULL, ARRAY['vehicles', 'sedan'], true), + ('NW5', 'Flat wagon (container)', 70, 14.000, 53, ARRAY['container', 'steel', 'machinery'], true), + ('PW2', 'Box wagon', 70, 17.066, 18, ARRAY['general cargo', 'break bulk'], true), + ('GW2', 'Tank wagon', 70, 12.228, 37, ARRAY['liquid', 'fuel'], true), + ('CW4', 'Gondola covered wagon', 70, 13.976, 37, ARRAY['covered bulk cargo'], true), + ('CW3', 'Gondola open wagon', 70, 13.976, NULL, ARRAY['open bulk cargo'], true), + ('KW2', 'Hopper covered wagon', 69, 16.466, NULL, ARRAY['bulk grains'], true), + ('KW3', 'Hopper open wagon', 70, 14.400, NULL, ARRAY['coal', 'bulk cargo'], true), + ('NW6', 'Flat wagon (long cargo)', 70, 18.560, NULL, ARRAY['long cargo'], true), + ('BW1', 'Refrigerated wagon', 38, 21.996, NULL, ARRAY['refrigerated cargo'], true) + ON CONFLICT (code) DO UPDATE SET + name = EXCLUDED.name, + capacity_tons = EXCLUDED.capacity_tons, + length_meters = EXCLUDED.length_meters, + max_wagons_per_train = EXCLUDED.max_wagons_per_train, + supported_load_types = EXCLUDED.supported_load_types, + is_active = true, + deleted_at = NULL, + updated_at = now(); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + DELETE FROM freight.wagon_types + WHERE code IN ('NW7', 'NW5', 'PW2', 'GW2', 'CW4', 'CW3', 'KW2', 'KW3', 'NW6', 'BW1'); + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1750300000000-AddCurrentLocationToWagons.ts b/apps/edr-freight-api/src/migrations/1750300000000-AddCurrentLocationToWagons.ts new file mode 100644 index 000000000..454218fd7 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1750300000000-AddCurrentLocationToWagons.ts @@ -0,0 +1,48 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddCurrentLocationToWagons1750300000000 implements MigrationInterface { + name = 'AddCurrentLocationToWagons1750300000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.wagons + ADD COLUMN IF NOT EXISTS current_location_yard_id UUID NULL; + `); + + await queryRunner.query(` + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM information_schema.table_constraints + WHERE constraint_schema = 'freight' + AND table_name = 'wagons' + AND constraint_name = 'FK_wagons_current_location_yard_id' + ) THEN + ALTER TABLE freight.wagons + ADD CONSTRAINT "FK_wagons_current_location_yard_id" + FOREIGN KEY (current_location_yard_id) + REFERENCES freight.yards(id) + ON DELETE SET NULL; + END IF; + END $$; + `); + + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_wagons_current_location_yard_id" + ON freight.wagons(current_location_yard_id); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX IF EXISTS freight."IDX_wagons_current_location_yard_id";`); + await queryRunner.query(` + ALTER TABLE freight.wagons + DROP CONSTRAINT IF EXISTS "FK_wagons_current_location_yard_id"; + `); + await queryRunner.query(` + ALTER TABLE freight.wagons + DROP COLUMN IF EXISTS current_location_yard_id; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1750300000000-AddRouteToTrainSchedules.ts b/apps/edr-freight-api/src/migrations/1750300000000-AddRouteToTrainSchedules.ts new file mode 100644 index 000000000..027ebfe98 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1750300000000-AddRouteToTrainSchedules.ts @@ -0,0 +1,44 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddRouteToTrainSchedules1750300000000 implements MigrationInterface { + name = 'AddRouteToTrainSchedules1750300000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.train_schedules + ADD COLUMN IF NOT EXISTS route_id UUID NULL; + `); + + await queryRunner.query(` + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'fk_train_schedules_route' + ) THEN + ALTER TABLE freight.train_schedules + ADD CONSTRAINT fk_train_schedules_route + FOREIGN KEY (route_id) REFERENCES freight.routes(id); + END IF; + END $$; + `); + + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_train_schedules_route_id + ON freight.train_schedules(route_id); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_train_schedules_route_id;`); + await queryRunner.query(` + ALTER TABLE freight.train_schedules + DROP CONSTRAINT IF EXISTS fk_train_schedules_route; + `); + await queryRunner.query(` + ALTER TABLE freight.train_schedules + DROP COLUMN IF EXISTS route_id; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1750400000000-AddSchedulingAllocationEnhancements.ts b/apps/edr-freight-api/src/migrations/1750400000000-AddSchedulingAllocationEnhancements.ts new file mode 100644 index 000000000..a0ca64303 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1750400000000-AddSchedulingAllocationEnhancements.ts @@ -0,0 +1,321 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddSchedulingAllocationEnhancements1750400000000 + implements MigrationInterface +{ + name = 'AddSchedulingAllocationEnhancements1750400000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.bookings + ADD COLUMN IF NOT EXISTS wagons_required NUMERIC(6,2) NULL, + ADD COLUMN IF NOT EXISTS scheduling_status VARCHAR(30) NOT NULL DEFAULT 'NOT_SCHEDULED', + ADD COLUMN IF NOT EXISTS hold_started_at TIMESTAMPTZ NULL, + ADD COLUMN IF NOT EXISTS hold_expires_at TIMESTAMPTZ NULL, + ADD COLUMN IF NOT EXISTS scheduled_at TIMESTAMPTZ NULL; + `); + + await queryRunner.query(` + ALTER TABLE freight.train_schedules + ADD COLUMN IF NOT EXISTS train_number VARCHAR(20) NULL, + ADD COLUMN IF NOT EXISTS direction VARCHAR(10) NULL, + ADD COLUMN IF NOT EXISTS actual_departure_at TIMESTAMPTZ NULL, + ADD COLUMN IF NOT EXISTS actual_arrival_at TIMESTAMPTZ NULL, + ADD COLUMN IF NOT EXISTS prepared_by_user_id UUID NULL, + ADD COLUMN IF NOT EXISTS checked_by_user_id UUID NULL, + ADD COLUMN IF NOT EXISTS max_wagons INT NOT NULL DEFAULT 53; + `); + + await queryRunner.query(` + ALTER TABLE freight.train_set_wagons + ADD COLUMN IF NOT EXISTS physical_wagon_id UUID NULL, + ADD COLUMN IF NOT EXISTS status VARCHAR(20) NOT NULL DEFAULT 'PLANNED'; + `); + + await queryRunner.query(` + ALTER TABLE freight.wagon_booking_allocations + ADD COLUMN IF NOT EXISTS load_type VARCHAR(20) NULL, + ADD COLUMN IF NOT EXISTS status VARCHAR(20) NOT NULL DEFAULT 'PLANNED', + ADD COLUMN IF NOT EXISTS confirmed_at TIMESTAMPTZ NULL, + ADD COLUMN IF NOT EXISTS confirmed_by_user_id UUID NULL; + `); + + await queryRunner.query(` + ALTER TABLE freight.wagon_types + ADD COLUMN IF NOT EXISTS equated_length_m NUMERIC(10,3) NULL, + ADD COLUMN IF NOT EXISTS tare_weight_tons NUMERIC(10,3) NULL, + ADD COLUMN IF NOT EXISTS supports_container BOOLEAN NOT NULL DEFAULT false, + ADD COLUMN IF NOT EXISTS max_container_gross_t NUMERIC(10,3) NULL; + `); + + await queryRunner.query(` + ALTER TABLE freight.wagons + ADD COLUMN IF NOT EXISTS train_set_wagon_id UUID NULL, + ADD COLUMN IF NOT EXISTS current_train_schedule_id UUID NULL; + `); + + await queryRunner.query(` + ALTER TABLE freight.containers + ADD COLUMN IF NOT EXISTS booking_id UUID NULL, + ADD COLUMN IF NOT EXISTS wagon_booking_allocation_id UUID NULL, + ADD COLUMN IF NOT EXISTS booking_container_id UUID NULL; + `); + + await queryRunner.query(` + ALTER TABLE freight.cargoes + ADD COLUMN IF NOT EXISTS wagon_booking_allocation_id UUID NULL, + ADD COLUMN IF NOT EXISTS booking_id UUID NULL, + ADD COLUMN IF NOT EXISTS load_type VARCHAR(20) NULL; + `); + + await queryRunner.query(` + ALTER TABLE freight.cargoes + ALTER COLUMN container_id DROP NOT NULL; + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.wagon_allocation_container_items ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + wagon_booking_allocation_id UUID NOT NULL, + booking_container_id UUID NULL, + container_id UUID NULL, + container_number VARCHAR(64) NULL, + container_type_id UUID NOT NULL, + position_on_wagon SMALLINT NULL, + seal_number VARCHAR(64) NULL, + chassis_number VARCHAR(64) NULL, + gross_weight_tons NUMERIC(10,3) NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ NULL, + CONSTRAINT fk_waci_allocation FOREIGN KEY (wagon_booking_allocation_id) + REFERENCES freight.wagon_booking_allocations(id) ON DELETE CASCADE, + CONSTRAINT fk_waci_booking_container FOREIGN KEY (booking_container_id) + REFERENCES freight.booking_container(id) ON DELETE SET NULL, + CONSTRAINT fk_waci_container FOREIGN KEY (container_id) + REFERENCES freight.containers(id) ON DELETE SET NULL, + CONSTRAINT fk_waci_container_type FOREIGN KEY (container_type_id) + REFERENCES freight.container_types(id) + ); + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.wagon_allocation_bulk_loads ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + wagon_booking_allocation_id UUID NOT NULL UNIQUE, + booking_id UUID NOT NULL, + cargo_type_id UUID NULL, + cargo_description TEXT NULL, + pricing_unit VARCHAR(20) NOT NULL DEFAULT 'PER_TON', + quantity NUMERIC(12,3) NOT NULL DEFAULT 0, + weight_tons NUMERIC(10,3) NOT NULL DEFAULT 0, + truck_plate_number VARCHAR(32) NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ NULL, + CONSTRAINT fk_wabl_allocation FOREIGN KEY (wagon_booking_allocation_id) + REFERENCES freight.wagon_booking_allocations(id) ON DELETE CASCADE, + CONSTRAINT fk_wabl_booking FOREIGN KEY (booking_id) + REFERENCES freight.bookings(id), + CONSTRAINT fk_wabl_cargo_type FOREIGN KEY (cargo_type_id) + REFERENCES freight.cargo_types(id) ON DELETE SET NULL + ); + `); + + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_bookings_scheduling_status + ON freight.bookings(scheduling_status); + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_train_schedules_train_number + ON freight.train_schedules(train_number); + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_train_set_wagons_physical_wagon + ON freight.train_set_wagons(physical_wagon_id); + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_wagons_train_set_wagon_id + ON freight.wagons(train_set_wagon_id); + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_wagons_current_train_schedule_id + ON freight.wagons(current_train_schedule_id); + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_waci_allocation + ON freight.wagon_allocation_container_items(wagon_booking_allocation_id); + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_wabl_booking + ON freight.wagon_allocation_bulk_loads(booking_id); + `); + + await queryRunner.query(` + DO $$ BEGIN + ALTER TABLE freight.train_set_wagons + ADD CONSTRAINT fk_train_set_wagons_physical_wagon + FOREIGN KEY (physical_wagon_id) REFERENCES freight.wagons(id) ON DELETE SET NULL; + EXCEPTION WHEN duplicate_object THEN NULL; + END $$; + `); + await queryRunner.query(` + DO $$ BEGIN + ALTER TABLE freight.wagons + ADD CONSTRAINT fk_wagons_train_set_wagon + FOREIGN KEY (train_set_wagon_id) REFERENCES freight.train_set_wagons(id) ON DELETE SET NULL; + EXCEPTION WHEN duplicate_object THEN NULL; + END $$; + `); + await queryRunner.query(` + DO $$ BEGIN + ALTER TABLE freight.wagons + ADD CONSTRAINT fk_wagons_current_train_schedule + FOREIGN KEY (current_train_schedule_id) REFERENCES freight.train_schedules(id) ON DELETE SET NULL; + EXCEPTION WHEN duplicate_object THEN NULL; + END $$; + `); + await queryRunner.query(` + DO $$ BEGIN + ALTER TABLE freight.containers + ADD CONSTRAINT fk_containers_booking + FOREIGN KEY (booking_id) REFERENCES freight.bookings(id) ON DELETE SET NULL; + EXCEPTION WHEN duplicate_object THEN NULL; + END $$; + `); + await queryRunner.query(` + DO $$ BEGIN + ALTER TABLE freight.containers + ADD CONSTRAINT fk_containers_wagon_allocation + FOREIGN KEY (wagon_booking_allocation_id) REFERENCES freight.wagon_booking_allocations(id) ON DELETE SET NULL; + EXCEPTION WHEN duplicate_object THEN NULL; + END $$; + `); + await queryRunner.query(` + DO $$ BEGIN + ALTER TABLE freight.containers + ADD CONSTRAINT fk_containers_booking_container + FOREIGN KEY (booking_container_id) REFERENCES freight.booking_container(id) ON DELETE SET NULL; + EXCEPTION WHEN duplicate_object THEN NULL; + END $$; + `); + await queryRunner.query(` + DO $$ BEGIN + ALTER TABLE freight.cargoes + ADD CONSTRAINT fk_cargoes_wagon_allocation + FOREIGN KEY (wagon_booking_allocation_id) REFERENCES freight.wagon_booking_allocations(id) ON DELETE SET NULL; + EXCEPTION WHEN duplicate_object THEN NULL; + END $$; + `); + await queryRunner.query(` + DO $$ BEGIN + ALTER TABLE freight.cargoes + ADD CONSTRAINT fk_cargoes_booking + FOREIGN KEY (booking_id) REFERENCES freight.bookings(id) ON DELETE SET NULL; + EXCEPTION WHEN duplicate_object THEN NULL; + END $$; + `); + + await queryRunner.query(` + UPDATE freight.wagon_types SET + equated_length_m = 1.3, + tare_weight_tons = 22.4, + supports_container = true, + max_container_gross_t = 30.48 + WHERE code = 'NW5'; + `); + await queryRunner.query(` + UPDATE freight.wagon_types SET + equated_length_m = 1.6, + tare_weight_tons = 25.2, + supports_container = false + WHERE code = 'PW2'; + `); + await queryRunner.query(` + UPDATE freight.wagon_types SET + equated_length_m = 1.5, + tare_weight_tons = 25.2, + supports_container = false + WHERE code = 'KW2'; + `); + await queryRunner.query(` + UPDATE freight.wagon_types SET + equated_length_m = 1.3, + tare_weight_tons = 23.4, + supports_container = false + WHERE code = 'CW3'; + `); + await queryRunner.query(` + UPDATE freight.wagon_types SET + equated_length_m = 1.3, + tare_weight_tons = 24.8, + supports_container = false + WHERE code = 'CW4'; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.wagon_allocation_bulk_loads;`); + await queryRunner.query(`DROP TABLE IF EXISTS freight.wagon_allocation_container_items;`); + + await queryRunner.query(` + ALTER TABLE freight.cargoes + ALTER COLUMN container_id SET NOT NULL; + `); + + await queryRunner.query(` + ALTER TABLE freight.bookings + DROP COLUMN IF EXISTS wagons_required, + DROP COLUMN IF EXISTS scheduling_status, + DROP COLUMN IF EXISTS hold_started_at, + DROP COLUMN IF EXISTS hold_expires_at, + DROP COLUMN IF EXISTS scheduled_at; + `); + await queryRunner.query(` + ALTER TABLE freight.train_schedules + DROP COLUMN IF EXISTS train_number, + DROP COLUMN IF EXISTS direction, + DROP COLUMN IF EXISTS actual_departure_at, + DROP COLUMN IF EXISTS actual_arrival_at, + DROP COLUMN IF EXISTS prepared_by_user_id, + DROP COLUMN IF EXISTS checked_by_user_id, + DROP COLUMN IF EXISTS max_wagons; + `); + await queryRunner.query(` + ALTER TABLE freight.train_set_wagons + DROP COLUMN IF EXISTS physical_wagon_id, + DROP COLUMN IF EXISTS status; + `); + await queryRunner.query(` + ALTER TABLE freight.wagon_booking_allocations + DROP COLUMN IF EXISTS load_type, + DROP COLUMN IF EXISTS status, + DROP COLUMN IF EXISTS confirmed_at, + DROP COLUMN IF EXISTS confirmed_by_user_id; + `); + await queryRunner.query(` + ALTER TABLE freight.wagon_types + DROP COLUMN IF EXISTS equated_length_m, + DROP COLUMN IF EXISTS tare_weight_tons, + DROP COLUMN IF EXISTS supports_container, + DROP COLUMN IF EXISTS max_container_gross_t; + `); + await queryRunner.query(` + ALTER TABLE freight.wagons + DROP COLUMN IF EXISTS train_set_wagon_id, + DROP COLUMN IF EXISTS current_train_schedule_id; + `); + await queryRunner.query(` + ALTER TABLE freight.containers + DROP COLUMN IF EXISTS booking_id, + DROP COLUMN IF EXISTS wagon_booking_allocation_id, + DROP COLUMN IF EXISTS booking_container_id; + `); + await queryRunner.query(` + ALTER TABLE freight.cargoes + DROP COLUMN IF EXISTS wagon_booking_allocation_id, + DROP COLUMN IF EXISTS booking_id, + DROP COLUMN IF EXISTS load_type; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1750400000000-SeedEdRWagonFleet.ts b/apps/edr-freight-api/src/migrations/1750400000000-SeedEdRWagonFleet.ts new file mode 100644 index 000000000..0a4e96276 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1750400000000-SeedEdRWagonFleet.ts @@ -0,0 +1,214 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +type FleetRow = { + code: string; + name: string; + count: number; + start: number; + end: number; + capacityTons: number; + tareWeight: number; + lengthMeters: number; + supportedLoadTypes: string[]; +}; + +const FLEET: FleetRow[] = [ + { + code: 'PW2', + name: 'Box wagon', + count: 220, + start: 1, + end: 220, + capacityTons: 70, + tareWeight: 25.2, + lengthMeters: 17.066, + supportedLoadTypes: ['BULK', 'GENERAL_CARGO', 'BAGGED_CARGO', 'BOXED_CARGO'], + }, + { + code: 'CW4', + name: 'Gondola wagon covered', + count: 110, + start: 221, + end: 330, + capacityTons: 70, + tareWeight: 24.8, + lengthMeters: 13.976, + supportedLoadTypes: ['CONTAINER'], + }, + { + code: 'CW3', + name: 'Gondola wagon', + count: 20, + start: 331, + end: 350, + capacityTons: 70, + tareWeight: 23.4, + lengthMeters: 13.976, + supportedLoadTypes: ['BULK', 'COAL', 'ORE'], + }, + { + code: 'KW2', + name: 'Hopper wagon covered', + count: 20, + start: 351, + end: 370, + capacityTons: 69, + tareWeight: 25.2, + lengthMeters: 16.466, + supportedLoadTypes: ['BULK', 'GRAIN'], + }, + { + code: 'KW3', + name: 'Hopper wagon', + count: 20, + start: 371, + end: 390, + capacityTons: 70, + tareWeight: 24, + lengthMeters: 14.4, + supportedLoadTypes: ['BULK', 'COAL'], + }, + { + code: 'NW5', + name: 'Flat wagon container', + count: 550, + start: 391, + end: 940, + capacityTons: 70, + tareWeight: 0, + lengthMeters: 14, + supportedLoadTypes: ['CONTAINER'], + }, +]; + +const wagonNumber = (sequence: number) => `ER${String(sequence).padStart(4, '0')}`; + +export class SeedEdRWagonFleet1750400000000 implements MigrationInterface { + name = 'SeedEdRWagonFleet1750400000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + UPDATE freight.wagon_types + SET name = 'Flat wagon container', + capacity_tons = 70, + length_meters = 14.000, + supported_load_types = ARRAY['CONTAINER'], + max_wagons_per_train = 53, + is_active = true, + deleted_at = NULL, + updated_at = now() + WHERE code = 'NW5'; + `); + + const [defaultLocation] = await queryRunner.query(` + SELECT id + FROM freight.yards + WHERE code IN ('DJIBOUTI', 'DJIB_PORT', 'NAGAD') + OR lower(label) LIKE '%djibouti%' + ORDER BY + CASE code + WHEN 'DJIBOUTI' THEN 1 + WHEN 'DJIB_PORT' THEN 2 + WHEN 'NAGAD' THEN 3 + ELSE 4 + END, + display_order ASC + LIMIT 1; + `); + const defaultLocationYardId = defaultLocation?.id ?? null; + + for (const row of FLEET) { + await queryRunner.query( + ` + INSERT INTO freight.wagon_types ( + code, + name, + capacity_tons, + length_meters, + max_wagons_per_train, + supported_load_types, + is_active + ) + VALUES ($1, $2, $3, $4, $5, $6::text[], true) + ON CONFLICT (code) DO UPDATE SET + name = EXCLUDED.name, + capacity_tons = EXCLUDED.capacity_tons, + length_meters = EXCLUDED.length_meters, + max_wagons_per_train = EXCLUDED.max_wagons_per_train, + supported_load_types = EXCLUDED.supported_load_types, + is_active = true, + deleted_at = NULL, + updated_at = now(); + `, + [ + row.code, + row.name, + row.capacityTons, + row.lengthMeters, + row.supportedLoadTypes.includes('CONTAINER') ? 53 : 37, + row.supportedLoadTypes, + ], + ); + + const [typeRecord] = await queryRunner.query( + `SELECT id FROM freight.wagon_types WHERE code = $1 LIMIT 1;`, + [row.code], + ); + + if (!typeRecord?.id) { + throw new Error(`wagon_type_seed_failed:${row.code}`); + } + + if (row.end - row.start + 1 !== row.count) { + throw new Error(`wagon_range_mismatch:${row.code}`); + } + + for (let sequence = row.start; sequence <= row.end; sequence += 1) { + await queryRunner.query( + ` + INSERT INTO freight.wagons ( + wagon_number, + wagon_type_id, + tare_weight, + max_payload_weight, + current_location_yard_id, + status, + notes + ) + VALUES ($1, $2, $3, $4, $5, $6, $7) + ON CONFLICT (wagon_number) DO UPDATE SET + wagon_type_id = EXCLUDED.wagon_type_id, + tare_weight = EXCLUDED.tare_weight, + max_payload_weight = EXCLUDED.max_payload_weight, + current_location_yard_id = CASE + WHEN freight.wagons.train_id IS NULL THEN EXCLUDED.current_location_yard_id + ELSE freight.wagons.current_location_yard_id + END, + status = CASE + WHEN freight.wagons.train_id IS NULL THEN EXCLUDED.status + ELSE freight.wagons.status + END, + notes = EXCLUDED.notes, + updated_at = now(); + `, + [ + wagonNumber(sequence), + typeRecord.id, + row.tareWeight, + row.capacityTons, + defaultLocationYardId, + defaultLocationYardId ? 'IMPORT_READY' : 'AVAILABLE', + `Seeded Ethio-Djibouti Railway ${row.code} fleet record.`, + ], + ); + } + } + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + DELETE FROM freight.wagons + WHERE wagon_number BETWEEN 'ER0001' AND 'ER0940'; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1750500000000-AddWagonReadiness.ts b/apps/edr-freight-api/src/migrations/1750500000000-AddWagonReadiness.ts new file mode 100644 index 000000000..17cc23f9a --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1750500000000-AddWagonReadiness.ts @@ -0,0 +1,25 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddWagonReadiness1750500000000 implements MigrationInterface { + name = 'AddWagonReadiness1750500000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.wagons + ADD COLUMN IF NOT EXISTS readiness VARCHAR(20) NOT NULL DEFAULT 'IMPORT_READY' + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_wagons_readiness + ON freight.wagons (readiness) + WHERE deleted_at IS NULL + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_wagons_readiness`); + await queryRunner.query(` + ALTER TABLE freight.wagons + DROP COLUMN IF EXISTS readiness + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1750600000000-AddGovernmentBookingFields.ts b/apps/edr-freight-api/src/migrations/1750600000000-AddGovernmentBookingFields.ts new file mode 100644 index 000000000..ce833e4ac --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1750600000000-AddGovernmentBookingFields.ts @@ -0,0 +1,46 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddGovernmentBookingFields1750600000000 implements MigrationInterface { + name = 'AddGovernmentBookingFields1750600000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.bookings + ADD COLUMN IF NOT EXISTS is_government BOOLEAN NOT NULL DEFAULT false + `); + await queryRunner.query(` + ALTER TABLE freight.bookings + ADD COLUMN IF NOT EXISTS government_institution VARCHAR(255) NULL + `); + await queryRunner.query(` + ALTER TABLE freight.bookings + ALTER COLUMN company_id DROP NOT NULL + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_bookings_is_government + ON freight.bookings (is_government) + WHERE is_government = true AND deleted_at IS NULL + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_bookings_is_government`); + await queryRunner.query(` + UPDATE freight.bookings + SET company_id = '00000000-0000-0000-0000-000000000000' + WHERE company_id IS NULL + `); + await queryRunner.query(` + ALTER TABLE freight.bookings + ALTER COLUMN company_id SET NOT NULL + `); + await queryRunner.query(` + ALTER TABLE freight.bookings + DROP COLUMN IF EXISTS government_institution + `); + await queryRunner.query(` + ALTER TABLE freight.bookings + DROP COLUMN IF EXISTS is_government + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1750700000000-CreateSchedulingEvents.ts b/apps/edr-freight-api/src/migrations/1750700000000-CreateSchedulingEvents.ts new file mode 100644 index 000000000..9f92f70b4 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1750700000000-CreateSchedulingEvents.ts @@ -0,0 +1,32 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateSchedulingEvents1750700000000 implements MigrationInterface { + name = 'CreateSchedulingEvents1750700000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.scheduling_events ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + train_schedule_id UUID NOT NULL, + trigger VARCHAR(40) NOT NULL, + actor_user_id UUID NULL, + reason TEXT NULL, + plan_snapshot JSONB NOT NULL DEFAULT '{}', + displaced_booking_ids JSONB NOT NULL DEFAULT '[]', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ NULL + ) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_scheduling_events_train_schedule_id + ON freight.scheduling_events (train_schedule_id) + WHERE deleted_at IS NULL + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_scheduling_events_train_schedule_id`); + await queryRunner.query(`DROP TABLE IF EXISTS freight.scheduling_events`); + } +} diff --git a/apps/edr-freight-api/src/migrations/1750800000000-FixContainerWagonsPerUnit.ts b/apps/edr-freight-api/src/migrations/1750800000000-FixContainerWagonsPerUnit.ts new file mode 100644 index 000000000..1bb1a9518 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1750800000000-FixContainerWagonsPerUnit.ts @@ -0,0 +1,47 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** 20ft = 0.5 wagon slots (2 per wagon); 40ft = 1.0 wagon slot (1 per wagon). */ +export class FixContainerWagonsPerUnit1750800000000 implements MigrationInterface { + name = 'FixContainerWagonsPerUnit1750800000000'; + + public async up(queryRunner: QueryRunner): Promise { + const hasContainerTypes = await queryRunner.hasTable('freight.container_types'); + if (!hasContainerTypes) { + return; + } + + await queryRunner.query(` + UPDATE freight.container_types + SET wagons_per_unit = 0.50 + WHERE size_ft = 20 OR code LIKE '20%'; + `); + await queryRunner.query(` + UPDATE freight.container_types + SET wagons_per_unit = 1.00 + WHERE size_ft = 40 OR code LIKE '40%'; + `); + + const hasBookingContainer = await queryRunner.hasTable('freight.booking_container'); + if (!hasBookingContainer) { + return; + } + + await queryRunner.query(` + UPDATE freight.booking_container bc + SET wagons_required = CEILING(bc.quantity * ct.wagons_per_unit) + FROM freight.container_types ct + WHERE ct.id = bc.container_type_id; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + const hasContainerTypes = await queryRunner.hasTable('freight.container_types'); + if (!hasContainerTypes) { + return; + } + + await queryRunner.query(` + UPDATE freight.container_types SET wagons_per_unit = 1.00; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1750900000000-AddContainerNumberToBookingContainer.ts b/apps/edr-freight-api/src/migrations/1750900000000-AddContainerNumberToBookingContainer.ts new file mode 100644 index 000000000..eb28da8f3 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1750900000000-AddContainerNumberToBookingContainer.ts @@ -0,0 +1,39 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddContainerNumberToBookingContainer1750900000000 implements MigrationInterface { + name = "AddContainerNumberToBookingContainer1750900000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.booking_container + ALTER COLUMN container_type_id DROP NOT NULL; + `); + + await queryRunner.query(` + ALTER TABLE freight.booking_container + ADD COLUMN container_number varchar(64); + `); + + await queryRunner.query(` + ALTER TABLE freight.wagon_allocation_container_items + ALTER COLUMN container_type_id DROP NOT NULL; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.wagon_allocation_container_items + ALTER COLUMN container_type_id SET NOT NULL; + `); + + await queryRunner.query(` + ALTER TABLE freight.booking_container + DROP COLUMN container_number; + `); + + await queryRunner.query(` + ALTER TABLE freight.booking_container + ALTER COLUMN container_type_id SET NOT NULL; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1751000000000-CreateTrainSchedulingGlobalRules.ts b/apps/edr-freight-api/src/migrations/1751000000000-CreateTrainSchedulingGlobalRules.ts new file mode 100644 index 000000000..ac9741c5a --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1751000000000-CreateTrainSchedulingGlobalRules.ts @@ -0,0 +1,35 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class CreateTrainSchedulingGlobalRules1751000000000 implements MigrationInterface { + name = "CreateTrainSchedulingGlobalRules1751000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE freight.train_scheduling_global_rules ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + max_train_length_meters numeric(10, 2) NOT NULL DEFAULT 760, + max_train_weight_tons numeric(10, 3) NOT NULL DEFAULT 3500, + max_wagons_per_train integer NOT NULL DEFAULT 53, + max_20ft_container_weight_tons numeric(8, 3) NOT NULL DEFAULT 30, + max_20ft_pair_weight_diff_tons numeric(8, 3) NOT NULL DEFAULT 10, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz NULL + ); + `); + + await queryRunner.query(` + INSERT INTO freight.train_scheduling_global_rules ( + max_train_length_meters, + max_train_weight_tons, + max_wagons_per_train, + max_20ft_container_weight_tons, + max_20ft_pair_weight_diff_tons + ) VALUES (760, 3500, 53, 30, 10); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.train_scheduling_global_rules;`); + } +} diff --git a/apps/edr-freight-api/src/migrations/1751000000001-AddDeletedAtToTrainSchedulingGlobalRules.ts b/apps/edr-freight-api/src/migrations/1751000000001-AddDeletedAtToTrainSchedulingGlobalRules.ts new file mode 100644 index 000000000..fd72f6c3b --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1751000000001-AddDeletedAtToTrainSchedulingGlobalRules.ts @@ -0,0 +1,21 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddDeletedAtToTrainSchedulingGlobalRules1751000000001 + implements MigrationInterface +{ + name = "AddDeletedAtToTrainSchedulingGlobalRules1751000000001"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.train_scheduling_global_rules + ADD COLUMN IF NOT EXISTS deleted_at timestamptz NULL; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.train_scheduling_global_rules + DROP COLUMN IF EXISTS deleted_at; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1752000000000-CreateCompanyProfiles.ts b/apps/edr-freight-api/src/migrations/1752000000000-CreateCompanyProfiles.ts new file mode 100644 index 000000000..a27cef3ad --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1752000000000-CreateCompanyProfiles.ts @@ -0,0 +1,103 @@ +import { + MigrationInterface, + QueryRunner, + Table, + TableIndex, + TableForeignKey, +} from "typeorm"; + +export class CreateCompanyProfiles1752000000000 implements MigrationInterface { + name = "CreateCompanyProfiles1752000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.createTable( + new Table({ + schema: "freight", + name: "company_profiles", + columns: [ + { + name: "id", + type: "uuid", + isPrimary: true, + generationStrategy: "uuid", + default: "gen_random_uuid()", + }, + { name: "company_id", type: "uuid" }, + { name: "type", type: "varchar", length: "32" }, + { name: "reference", type: "varchar", length: "20", isUnique: true }, + { + name: "status", + type: "varchar", + length: "32", + default: "'active'", + }, + { + name: "business_license", + type: "varchar", + length: "100", + isNullable: true, + }, + { name: "attributes", type: "jsonb", isNullable: true }, + { name: "created_at", type: "timestamptz", default: "now()" }, + { name: "updated_at", type: "timestamptz", default: "now()" }, + { name: "deleted_at", type: "timestamptz", isNullable: true }, + ], + }), + true, + ); + + await queryRunner.createForeignKey( + "freight.company_profiles", + new TableForeignKey({ + columnNames: ["company_id"], + referencedTableName: "companies", + referencedSchema: "freight", + referencedColumnNames: ["id"], + }), + ); + + await queryRunner.createIndex( + "freight.company_profiles", + new TableIndex({ columnNames: ["company_id"] }), + ); + await queryRunner.createIndex( + "freight.company_profiles", + new TableIndex({ columnNames: ["type"] }), + ); + + await queryRunner.query( + `CREATE SEQUENCE IF NOT EXISTS freight.seq_company_profile_ex START WITH 1`, + ); + await queryRunner.query( + `CREATE SEQUENCE IF NOT EXISTS freight.seq_company_profile_im START WITH 1`, + ); + await queryRunner.query( + `CREATE SEQUENCE IF NOT EXISTS freight.seq_company_profile_ffe START WITH 1`, + ); + await queryRunner.query( + `CREATE SEQUENCE IF NOT EXISTS freight.seq_company_profile_fwj START WITH 1`, + ); + await queryRunner.query( + `CREATE SEQUENCE IF NOT EXISTS freight.seq_company_profile_tr START WITH 1`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropTable("freight.company_profiles"); + await queryRunner.query( + `DROP SEQUENCE IF EXISTS freight.seq_company_profile_ex`, + ); + await queryRunner.query( + `DROP SEQUENCE IF EXISTS freight.seq_company_profile_im`, + ); + await queryRunner.query( + `DROP SEQUENCE IF EXISTS freight.seq_company_profile_ffe`, + ); + await queryRunner.query( + `DROP SEQUENCE IF EXISTS freight.seq_company_profile_fwj`, + ); + await queryRunner.query( + `DROP SEQUENCE IF EXISTS freight.seq_company_profile_tr`, + ); + } +} diff --git a/apps/edr-freight-api/src/migrations/1752000000001-MoveBusinessLicenseToProfile.ts b/apps/edr-freight-api/src/migrations/1752000000001-MoveBusinessLicenseToProfile.ts new file mode 100644 index 000000000..f15996773 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1752000000001-MoveBusinessLicenseToProfile.ts @@ -0,0 +1,39 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class MoveBusinessLicenseToProfile1752000000001 + implements MigrationInterface +{ + name = 'MoveBusinessLicenseToProfile1752000000001'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + UPDATE freight.company_profiles cp + SET business_license = c.business_license + FROM freight.companies c + WHERE cp.company_id = c.id AND c.business_license IS NOT NULL + `); + + await queryRunner.query( + `ALTER TABLE freight.companies DROP COLUMN IF EXISTS business_license`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.companies ADD COLUMN business_license varchar(100) NULL`, + ); + + await queryRunner.query(` + UPDATE freight.companies c + SET business_license = cp.business_license + FROM ( + SELECT DISTINCT ON (cp2.company_id) + cp2.company_id, cp2.business_license + FROM freight.company_profiles cp2 + WHERE cp2.business_license IS NOT NULL + ORDER BY cp2.company_id, cp2.created_at + ) cp + WHERE cp.company_id = c.id + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1770000000000-CreateVehiclesTable.ts b/apps/edr-freight-api/src/migrations/1770000000000-CreateVehiclesTable.ts new file mode 100644 index 000000000..2cf09c9f5 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1770000000000-CreateVehiclesTable.ts @@ -0,0 +1,39 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateVehiclesTable1770000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + DO $$ + BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'vehicles' AND table_schema = 'freight') THEN + CREATE TABLE freight.vehicles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + plate_number VARCHAR NOT NULL UNIQUE, + registration_number VARCHAR NOT NULL UNIQUE, + vehicle_type VARCHAR NOT NULL, + manufacturer VARCHAR NOT NULL, + model VARCHAR NOT NULL, + year INTEGER NOT NULL, + fuel_type VARCHAR NOT NULL, + capacity NUMERIC NOT NULL, + status VARCHAR DEFAULT 'ACTIVE' NOT NULL, + description TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, + deleted_at TIMESTAMP NULL + ); + + CREATE INDEX idx_vehicles_plate_number ON freight.vehicles(plate_number); + CREATE INDEX idx_vehicles_registration_number ON freight.vehicles(registration_number); + CREATE INDEX idx_vehicles_status ON freight.vehicles(status); + CREATE INDEX idx_vehicles_vehicle_type ON freight.vehicles(vehicle_type); + CREATE INDEX idx_vehicles_manufacturer ON freight.vehicles(manufacturer); + END IF; + END $$; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.vehicles CASCADE;`); + } +} diff --git a/apps/edr-freight-api/src/migrations/1780639311366-CreatePaymentTable.ts b/apps/edr-freight-api/src/migrations/1780639311366-CreatePaymentTable.ts new file mode 100644 index 000000000..0382c834b --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1780639311366-CreatePaymentTable.ts @@ -0,0 +1,98 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class CreatePaymentTable1780639311366 implements MigrationInterface { + name = "CreatePaymentTable1780639311366"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TYPE freight.payments_type_enum AS ENUM ('booking'); + `); + + await queryRunner.query(` + CREATE TYPE freight.payments_method_enum AS ENUM ('telebirr', 'cbe-birr', 'ebirr'); + `); + + await queryRunner.query(` + CREATE TYPE freight.payments_currency_enum AS ENUM ('ETB', 'USD'); + `); + + await queryRunner.query(` + CREATE TYPE freight.payments_status_enum AS ENUM ( + 'action-required', + 'processing', + 'success', + 'failed', + 'canceled', + 'refunded' + ); + `); + + await queryRunner.query(` + CREATE TABLE freight.payments ( + id uuid NOT NULL DEFAULT uuid_generate_v4(), + + ref_id varchar(255) NOT NULL, + + type freight.payments_type_enum NOT NULL, + + method freight.payments_method_enum NOT NULL, + + currency freight.payments_currency_enum NOT NULL, + + amount numeric NOT NULL, + + raw_initiation jsonb NOT NULL DEFAULT '{}'::jsonb, + + client_action json, + + merchant_order_id varchar(255) NOT NULL, + + transaction_id varchar(255), + + status freight.payments_status_enum NOT NULL DEFAULT 'action-required', + + paid_at date, + + refunded_at date, + + expires_at date, + + failer_code varchar(30), + + failer_message varchar(255), + + reason varchar(255), + + created_at TIMESTAMP NOT NULL DEFAULT now(), + + CONSTRAINT PK_payments PRIMARY KEY (id), + + CONSTRAINT UQ_payments_merchant_order_id UNIQUE (merchant_order_id), + + CONSTRAINT UQ_payments_transaction_id UNIQUE (transaction_id) + ); +`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + DROP TABLE IF EXISTS freight.payments; + `); + + await queryRunner.query(` + DROP TYPE IF EXISTS freight.payments_status_enum; + `); + + await queryRunner.query(` + DROP TYPE IF EXISTS freight.payments_currency_enum; + `); + + await queryRunner.query(` + DROP TYPE IF EXISTS freight.payments_method_enum; + `); + + await queryRunner.query(` + DROP TYPE IF EXISTS freight.payments_type_enum; + `); + } +} \ No newline at end of file diff --git a/apps/edr-freight-api/src/migrations/1780639978834-AlterClientActionToJsonb.ts b/apps/edr-freight-api/src/migrations/1780639978834-AlterClientActionToJsonb.ts new file mode 100644 index 000000000..723331ab3 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1780639978834-AlterClientActionToJsonb.ts @@ -0,0 +1,28 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AlterClientActionToJsonb1780639978834 implements MigrationInterface { + name = "AlterClientActionToJsonb1780639978834"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.payments + ALTER COLUMN client_action TYPE jsonb + USING client_action::jsonb; + `); + + await queryRunner.query(` + ALTER TABLE freight.payments + ALTER COLUMN client_action DROP DEFAULT; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.payments + ALTER COLUMN client_action TYPE json + USING client_action::json; + `); + } + + +} diff --git a/apps/edr-freight-api/src/migrations/1780644945086-UpdatePaymentTimestamp.ts b/apps/edr-freight-api/src/migrations/1780644945086-UpdatePaymentTimestamp.ts new file mode 100644 index 000000000..6cfc7fc8f --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1780644945086-UpdatePaymentTimestamp.ts @@ -0,0 +1,33 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class UpdatePaymentTimestamp1780644945086 implements MigrationInterface { + name = "UpdatePaymentTimestamp1780644945086"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.payments + ALTER COLUMN refunded_at TYPE timestamp + USING refunded_at::timestamp; + `); + + await queryRunner.query(` + ALTER TABLE freight.payments + ALTER COLUMN expires_at TYPE timestamp + USING expires_at::timestamp; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.payments + ALTER COLUMN refunded_at TYPE timestamptz + USING refunded_at::timestamptz; + `); + + await queryRunner.query(` + ALTER TABLE freight.payments + ALTER COLUMN expires_at TYPE timestamptz + USING expires_at::timestamptz; + `); + } +} \ No newline at end of file diff --git a/apps/edr-freight-api/src/migrations/1781000000000-AddLocomotiveReadiness.ts b/apps/edr-freight-api/src/migrations/1781000000000-AddLocomotiveReadiness.ts new file mode 100644 index 000000000..f6d87f41d --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1781000000000-AddLocomotiveReadiness.ts @@ -0,0 +1,25 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddLocomotiveReadiness1781000000000 implements MigrationInterface { + name = 'AddLocomotiveReadiness1781000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.locomotives + ADD COLUMN IF NOT EXISTS readiness VARCHAR(20) NOT NULL DEFAULT 'IMPORT_READY' + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_locomotives_readiness + ON freight.locomotives (readiness) + WHERE deleted_at IS NULL + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_locomotives_readiness`); + await queryRunner.query(` + ALTER TABLE freight.locomotives + DROP COLUMN IF EXISTS readiness + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1781000000001-CreateTrainCheckpointEvents.ts b/apps/edr-freight-api/src/migrations/1781000000001-CreateTrainCheckpointEvents.ts new file mode 100644 index 000000000..7d9ce745a --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1781000000001-CreateTrainCheckpointEvents.ts @@ -0,0 +1,35 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateTrainCheckpointEvents1781000000001 implements MigrationInterface { + name = 'CreateTrainCheckpointEvents1781000000001'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.train_checkpoint_events ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + train_schedule_id UUID NOT NULL REFERENCES freight.train_schedules(id) ON DELETE CASCADE, + yard_id UUID NOT NULL, + sequence_no INT NOT NULL, + kind VARCHAR(20) NOT NULL, + occurred_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + note TEXT NULL, + recorded_by_user_id UUID NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ NULL + ) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_train_checkpoint_events_schedule + ON freight.train_checkpoint_events (train_schedule_id, sequence_no) + WHERE deleted_at IS NULL + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP INDEX IF EXISTS freight.idx_train_checkpoint_events_schedule`, + ); + await queryRunner.query(`DROP TABLE IF EXISTS freight.train_checkpoint_events`); + } +} diff --git a/apps/edr-freight-api/src/migrations/1781000000002-AddBatchBookingFields.ts b/apps/edr-freight-api/src/migrations/1781000000002-AddBatchBookingFields.ts new file mode 100644 index 000000000..0287b55be --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1781000000002-AddBatchBookingFields.ts @@ -0,0 +1,45 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddBatchBookingFields1781000000002 implements MigrationInterface { + name = 'AddBatchBookingFields1781000000002'; + + public async up(queryRunner: QueryRunner): Promise { + // Booking → target schedule (pool membership) + 1h pay-window deadline. + await queryRunner.query(` + ALTER TABLE freight.bookings + ADD COLUMN IF NOT EXISTS train_schedule_id UUID NULL, + ADD COLUMN IF NOT EXISTS payment_deadline TIMESTAMPTZ NULL + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_bookings_train_schedule_id + ON freight.bookings (train_schedule_id) + WHERE deleted_at IS NULL + `); + + // TrainSchedule → booking-window status (OPEN/FULL/CLOSED). + await queryRunner.query(` + ALTER TABLE freight.train_schedules + ADD COLUMN IF NOT EXISTS booking_window_status VARCHAR(10) NOT NULL DEFAULT 'OPEN' + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_train_schedules_booking_window_status + ON freight.train_schedules (booking_window_status) + WHERE deleted_at IS NULL + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP INDEX IF EXISTS freight.idx_train_schedules_booking_window_status`, + ); + await queryRunner.query( + `ALTER TABLE freight.train_schedules DROP COLUMN IF EXISTS booking_window_status`, + ); + await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_bookings_train_schedule_id`); + await queryRunner.query(` + ALTER TABLE freight.bookings + DROP COLUMN IF EXISTS train_schedule_id, + DROP COLUMN IF EXISTS payment_deadline + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1781000000003-AddSelectedForBatchStatus.ts b/apps/edr-freight-api/src/migrations/1781000000003-AddSelectedForBatchStatus.ts new file mode 100644 index 000000000..1ba9670b2 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1781000000003-AddSelectedForBatchStatus.ts @@ -0,0 +1,36 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddSelectedForBatchStatus1781000000003 implements MigrationInterface { + name = 'AddSelectedForBatchStatus1781000000003'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.bookings + ADD COLUMN IF NOT EXISTS selected_for_batch_at TIMESTAMPTZ NULL + `); + + await queryRunner.query(` + UPDATE freight.bookings + SET + status = 'SELECTED_FOR_BATCH', + selected_for_batch_at = COALESCE( + payment_deadline - INTERVAL '5 minutes', + updated_at + ) + WHERE status = 'AWAITING_PAYMENT' + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + UPDATE freight.bookings + SET status = 'AWAITING_PAYMENT' + WHERE status = 'SELECTED_FOR_BATCH' + `); + + await queryRunner.query(` + ALTER TABLE freight.bookings + DROP COLUMN IF EXISTS selected_for_batch_at + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1781000000004-AddDomesticWeightLimitTradeDirection.ts b/apps/edr-freight-api/src/migrations/1781000000004-AddDomesticWeightLimitTradeDirection.ts new file mode 100644 index 000000000..59b1de22e --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1781000000004-AddDomesticWeightLimitTradeDirection.ts @@ -0,0 +1,30 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Allow DOMESTIC trade direction on weight_limit_rules (domestic corridor bookings). + */ +export class AddDomesticWeightLimitTradeDirection1781000000004 + implements MigrationInterface +{ + name = 'AddDomesticWeightLimitTradeDirection1781000000004'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + DO $$ BEGIN + ALTER TYPE freight.weight_limit_rules_trade_direction_enum ADD VALUE 'DOMESTIC'; + EXCEPTION + WHEN duplicate_object THEN NULL; + WHEN undefined_object THEN + BEGIN + ALTER TYPE weight_limit_rules_trade_direction_enum ADD VALUE 'DOMESTIC'; + EXCEPTION + WHEN duplicate_object THEN NULL; + END; + END $$; + `); + } + + public async down(_queryRunner: QueryRunner): Promise { + // PostgreSQL does not support removing enum values safely. + } +} diff --git a/apps/edr-freight-api/src/migrations/1781000000005-CreateTrainCompositionRemovalLog.ts b/apps/edr-freight-api/src/migrations/1781000000005-CreateTrainCompositionRemovalLog.ts new file mode 100644 index 000000000..475a52574 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1781000000005-CreateTrainCompositionRemovalLog.ts @@ -0,0 +1,81 @@ +import { MigrationInterface, QueryRunner, Table, TableIndex } from 'typeorm'; + +export class CreateTrainCompositionRemovalLog1781000000005 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.createTable( + new Table({ + schema: 'freight', + name: 'train_composition_removal_logs', + columns: [ + { + name: 'id', + type: 'uuid', + isPrimary: true, + default: 'uuid_generate_v4()', + }, + { + name: 'schedule_id', + type: 'uuid', + isNullable: false, + }, + { + name: 'booking_id', + type: 'uuid', + isNullable: false, + }, + { + name: 'booking_reference', + type: 'varchar', + length: '64', + isNullable: true, + }, + { + name: 'removed_by_user_id', + type: 'uuid', + isNullable: true, + }, + { + name: 'removed_at', + type: 'timestamptz', + default: 'NOW()', + isNullable: false, + }, + { + name: 'notes', + type: 'text', + isNullable: true, + }, + { + name: 'created_at', + type: 'timestamptz', + default: 'NOW()', + isNullable: false, + }, + { + name: 'updated_at', + type: 'timestamptz', + default: 'NOW()', + isNullable: false, + }, + { + name: 'deleted_at', + type: 'timestamptz', + isNullable: true, + }, + ], + }), + true, + ); + + await queryRunner.createIndex( + 'freight.train_composition_removal_logs', + new TableIndex({ + columnNames: ['schedule_id'], + }), + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropTable('freight.train_composition_removal_logs', true); + } +} diff --git a/apps/edr-freight-api/src/migrations/1782000000000-WagonLocomotiveYardLink.ts b/apps/edr-freight-api/src/migrations/1782000000000-WagonLocomotiveYardLink.ts new file mode 100644 index 000000000..68100f367 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1782000000000-WagonLocomotiveYardLink.ts @@ -0,0 +1,88 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class WagonLocomotiveYardLink1782000000000 implements MigrationInterface { + name = 'WagonLocomotiveYardLink1782000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.wagons + ADD COLUMN IF NOT EXISTS "current_yard_id" UUID NULL; + `); + await queryRunner.query(` + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'FK_wagon_current_yard' + ) THEN + ALTER TABLE freight.wagons + ADD CONSTRAINT "FK_wagon_current_yard" + FOREIGN KEY ("current_yard_id") REFERENCES freight.yards(id) ON DELETE SET NULL; + END IF; + END $$; + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_wagon_current_yard_id" + ON freight.wagons ("current_yard_id"); + `); + + await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_wagons_readiness`); + await queryRunner.query(`ALTER TABLE freight.wagons DROP COLUMN IF EXISTS readiness;`); + + await queryRunner.query(` + ALTER TABLE freight.locomotives + ADD COLUMN IF NOT EXISTS "current_yard_id" UUID NULL; + `); + await queryRunner.query(` + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'FK_locomotive_current_yard' + ) THEN + ALTER TABLE freight.locomotives + ADD CONSTRAINT "FK_locomotive_current_yard" + FOREIGN KEY ("current_yard_id") REFERENCES freight.yards(id) ON DELETE SET NULL; + END IF; + END $$; + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_locomotive_current_yard_id" + ON freight.locomotives ("current_yard_id"); + `); + + await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_locomotives_readiness`); + await queryRunner.query(`ALTER TABLE freight.locomotives DROP COLUMN IF EXISTS readiness;`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.wagons + ADD COLUMN IF NOT EXISTS readiness VARCHAR(20) NOT NULL DEFAULT 'IMPORT_READY'; + `); + await queryRunner.query(` + ALTER TABLE freight.locomotives + ADD COLUMN IF NOT EXISTS readiness VARCHAR(20) NOT NULL DEFAULT 'IMPORT_READY'; + `); + + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_wagons_readiness + ON freight.wagons (readiness) + WHERE deleted_at IS NULL; + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_locomotives_readiness + ON freight.locomotives (readiness) + WHERE deleted_at IS NULL; + `); + + await queryRunner.query(`DROP INDEX IF EXISTS freight."IDX_wagon_current_yard_id"`); + await queryRunner.query(`DROP INDEX IF EXISTS freight."IDX_locomotive_current_yard_id"`); + await queryRunner.query(` + ALTER TABLE freight.wagons DROP CONSTRAINT IF EXISTS "FK_wagon_current_yard"; + `); + await queryRunner.query(` + ALTER TABLE freight.locomotives DROP CONSTRAINT IF EXISTS "FK_locomotive_current_yard"; + `); + await queryRunner.query(`ALTER TABLE freight.wagons DROP COLUMN IF EXISTS "current_yard_id";`); + await queryRunner.query(`ALTER TABLE freight.locomotives DROP COLUMN IF EXISTS "current_yard_id";`); + } +} diff --git a/apps/edr-freight-api/src/migrations/1782000000001-AddPaymentWebhookEventAndRefund.ts b/apps/edr-freight-api/src/migrations/1782000000001-AddPaymentWebhookEventAndRefund.ts new file mode 100644 index 000000000..e1cddc05b --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1782000000001-AddPaymentWebhookEventAndRefund.ts @@ -0,0 +1,62 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddPaymentWebhookEventAndRefund1782000000001 implements MigrationInterface { + name = "AddPaymentWebhookEventAndRefund1782000000001"; + + public async up(queryRunner: QueryRunner): Promise { + // Enum for webhook provider — shares the same values as payments_method_enum + // but is a separate type so both tables remain independently evolvable. + await queryRunner.query(` + CREATE TYPE freight.payment_webhook_method_enum AS ENUM ('telebirr', 'cbe-birr', 'ebirr'); + `); + + await queryRunner.query(` + CREATE TABLE freight.payment_webhook_events ( + id uuid NOT NULL DEFAULT uuid_generate_v4(), + provider freight.payment_webhook_method_enum NOT NULL, + external_event_id varchar(255) NOT NULL, + merchant_order_id varchar(255), + provider_txn_id varchar(255), + signature_valid boolean NOT NULL, + status varchar(100) NOT NULL, + payload jsonb NOT NULL, + received_at TIMESTAMP NOT NULL DEFAULT now(), + processed_at TIMESTAMP, + processing_error text, + + CONSTRAINT PK_payment_webhook_events PRIMARY KEY (id), + CONSTRAINT UQ_payment_webhook_events_provider_event UNIQUE (provider, external_event_id) + ); + `); + + await queryRunner.query(` + CREATE INDEX IDX_payment_webhook_events_merchant_order_id + ON freight.payment_webhook_events (merchant_order_id); + `); + + await queryRunner.query(` + CREATE TABLE freight.payment_refunds ( + id uuid NOT NULL DEFAULT uuid_generate_v4(), + payment_id uuid NOT NULL, + amount_minor int NOT NULL, + reason varchar(255), + provider_refund_id varchar(255), + status varchar(50) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT now(), + + CONSTRAINT PK_payment_refunds PRIMARY KEY (id), + CONSTRAINT FK_payment_refunds_payment + FOREIGN KEY (payment_id) + REFERENCES freight.payments (id) + ON DELETE RESTRICT + ); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.payment_refunds;`); + await queryRunner.query(`DROP INDEX IF EXISTS freight.IDX_payment_webhook_events_merchant_order_id;`); + await queryRunner.query(`DROP TABLE IF EXISTS freight.payment_webhook_events;`); + await queryRunner.query(`DROP TYPE IF EXISTS freight.payment_webhook_method_enum;`); + } +} diff --git a/apps/edr-freight-api/src/migrations/1782000000002-ExtendPaymentMethodEnum.ts b/apps/edr-freight-api/src/migrations/1782000000002-ExtendPaymentMethodEnum.ts new file mode 100644 index 000000000..607926e92 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1782000000002-ExtendPaymentMethodEnum.ts @@ -0,0 +1,16 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class ExtendPaymentMethodEnum1782000000002 implements MigrationInterface { + name = "ExtendPaymentMethodEnum1782000000002"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TYPE freight.payments_method_enum ADD VALUE IF NOT EXISTS 'waafi';`); + await queryRunner.query(`ALTER TYPE freight.payments_method_enum ADD VALUE IF NOT EXISTS 'card';`); + await queryRunner.query(`ALTER TYPE freight.payments_method_enum ADD VALUE IF NOT EXISTS 'dmoney';`); + } + + public async down(_queryRunner: QueryRunner): Promise { + // PostgreSQL does not support removing enum values directly. + // To roll back, recreate the type without the added values and update the column. + } +} diff --git a/apps/edr-freight-api/src/migrations/1783000000000-ReplacePriorityRulesWithPriorityConfigs.ts b/apps/edr-freight-api/src/migrations/1783000000000-ReplacePriorityRulesWithPriorityConfigs.ts new file mode 100644 index 000000000..b8eccbbaf --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1783000000000-ReplacePriorityRulesWithPriorityConfigs.ts @@ -0,0 +1,39 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class ReplacePriorityRulesWithPriorityConfigs1783000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE freight.priority_configs ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + type VARCHAR(20) NOT NULL CHECK (type IN ('WAGON', 'CURRENCY')), + label VARCHAR(100) NOT NULL, + currency VARCHAR(5) NULL, + min_wagon_count INT NOT NULL, + max_wagon_count INT NOT NULL, + score_points INT NOT NULL DEFAULT 0, + is_active BOOLEAN NOT NULL DEFAULT false, + display_order INT NOT NULL DEFAULT 1, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ NULL, + CONSTRAINT chk_wagon_range CHECK (min_wagon_count <= max_wagon_count), + CONSTRAINT chk_currency_for_type CHECK ( + (type = 'WAGON' AND currency IS NULL) OR + (type = 'CURRENCY' AND currency IS NOT NULL) + ) + ); + `); + + await queryRunner.query(` + CREATE INDEX idx_priority_configs_type_active ON freight.priority_configs (type, is_active); + `); + + await queryRunner.query(` + CREATE INDEX idx_priority_configs_currency_type ON freight.priority_configs (currency, type); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.priority_configs;`); + } +} diff --git a/apps/edr-freight-api/src/migrations/1784000000000-CreateSavedSignatures.ts b/apps/edr-freight-api/src/migrations/1784000000000-CreateSavedSignatures.ts new file mode 100644 index 000000000..e0c36f67b --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1784000000000-CreateSavedSignatures.ts @@ -0,0 +1,22 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateSavedSignatures1784000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE freight.saved_signatures ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL, + signer_display_name VARCHAR(200) NOT NULL, + signature_file_id UUID NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ NULL, + CONSTRAINT uq_saved_signatures_user_id UNIQUE (user_id) + ); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.saved_signatures;`); + } +} diff --git a/apps/edr-freight-api/src/migrations/1784000000001-SeedWagonsWithYardAssignment.ts b/apps/edr-freight-api/src/migrations/1784000000001-SeedWagonsWithYardAssignment.ts new file mode 100644 index 000000000..a615fe9a4 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1784000000001-SeedWagonsWithYardAssignment.ts @@ -0,0 +1,156 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Full wagon re-seed — runs in this order: + * + * 1. DELETE all existing wagons (hard delete, not soft). + * 2. UPSERT all 10 standard wagon types so they are guaranteed to exist. + * 3. INSERT 50 wagons per wagon type (500 total), distributed evenly across + * the 5 main operational yards (10 wagons per yard per type): + * + * KALITY — Kality Rail Terminal + * MOJO — Mojo Dry Port + * DIRE_DAWA — Dire Dawa Yard + * DJIB_PORT — Djibouti Port Terminal + * NAGAD — Nagad Terminal, Djibouti + * + * Wagon numbers follow the pattern -NNNN (e.g. NW5-0001 … NW5-0050). + * Yard IDs are fetched live from freight.yards so the migration is safe across + * all environments regardless of UUID values. + */ +export class SeedWagonsWithYardAssignment1784000000001 + implements MigrationInterface +{ + name = 'SeedWagonsWithYardAssignment1784000000001'; + + public async up(queryRunner: QueryRunner): Promise { + // ── STEP 1: Remove all wagons ────────────────────────────────────────── + await queryRunner.query(`DELETE FROM freight.wagons;`); + + // ── STEP 2: Ensure all 10 wagon types exist ──────────────────────────── + await queryRunner.query(` + INSERT INTO freight.wagon_types ( + code, + name, + capacity_tons, + length_meters, + max_wagons_per_train, + supported_load_types, + is_active, + tare_weight_tons + ) + VALUES + ('NW7', 'Double deck sedan wagon', 22, 26.066, NULL, ARRAY['vehicles', 'sedan'], true, 18.0), + ('NW5', 'Flat wagon (container)', 70, 14.000, 53, ARRAY['container', 'steel', 'machinery'], true, 22.0), + ('PW2', 'Box wagon', 70, 17.066, 18, ARRAY['general cargo', 'break bulk'], true, 20.0), + ('GW2', 'Tank wagon', 70, 12.228, 37, ARRAY['liquid', 'fuel'], true, 25.0), + ('CW4', 'Gondola covered wagon', 70, 13.976, 37, ARRAY['covered bulk cargo'], true, 22.0), + ('CW3', 'Gondola open wagon', 70, 13.976, NULL, ARRAY['open bulk cargo'], true, 20.0), + ('KW2', 'Hopper covered wagon', 69, 16.466, NULL, ARRAY['bulk grains'], true, 22.0), + ('KW3', 'Hopper open wagon', 70, 14.400, NULL, ARRAY['coal', 'bulk cargo'], true, 20.0), + ('NW6', 'Flat wagon (long cargo)', 70, 18.560, NULL, ARRAY['long cargo'], true, 22.0), + ('BW1', 'Refrigerated wagon', 38, 21.996, NULL, ARRAY['refrigerated cargo'], true, 24.0) + ON CONFLICT (code) DO UPDATE SET + name = EXCLUDED.name, + capacity_tons = EXCLUDED.capacity_tons, + length_meters = EXCLUDED.length_meters, + max_wagons_per_train = EXCLUDED.max_wagons_per_train, + supported_load_types = EXCLUDED.supported_load_types, + is_active = true, + tare_weight_tons = EXCLUDED.tare_weight_tons, + deleted_at = NULL, + updated_at = now(); + `); + + // ── STEP 3: Seed 50 wagons per type across 5 yards ──────────────────── + await queryRunner.query(` + DO $$ + DECLARE + wt RECORD; + yard_kality UUID; + yard_mojo UUID; + yard_dire_dawa UUID; + yard_djib_port UUID; + yard_nagad UUID; + yards UUID[]; + i INT; + yard_id UUID; + wagon_num TEXT; + v_tare NUMERIC; + v_payload NUMERIC; + BEGIN + -- Fetch yard IDs by code (safe across envs — UUIDs differ per DB) + SELECT id INTO yard_kality FROM freight.yards WHERE code = 'KALITY' LIMIT 1; + SELECT id INTO yard_mojo FROM freight.yards WHERE code = 'MOJO' LIMIT 1; + SELECT id INTO yard_dire_dawa FROM freight.yards WHERE code = 'DIRE_DAWA' LIMIT 1; + SELECT id INTO yard_djib_port FROM freight.yards WHERE code = 'DJIB_PORT' LIMIT 1; + SELECT id INTO yard_nagad FROM freight.yards WHERE code = 'NAGAD' LIMIT 1; + + IF yard_kality IS NULL OR yard_mojo IS NULL OR yard_dire_dawa IS NULL + OR yard_djib_port IS NULL OR yard_nagad IS NULL + THEN + RAISE EXCEPTION 'One or more operational yards not found. Run the yards seed first.'; + END IF; + + yards := ARRAY[ + yard_kality, + yard_mojo, + yard_dire_dawa, + yard_djib_port, + yard_nagad + ]; + + FOR wt IN + SELECT id, code, capacity_tons, tare_weight_tons + FROM freight.wagon_types + WHERE is_active = true + ORDER BY code + LOOP + v_tare := COALESCE(wt.tare_weight_tons, 20.0); + v_payload := COALESCE(wt.capacity_tons, 60.0); + + FOR i IN 1 .. 50 LOOP + wagon_num := wt.code || '-' || LPAD(i::TEXT, 4, '0'); + yard_id := yards[ ((i - 1) % 5) + 1 ]; -- round-robin: 1→K, 2→M, 3→D, 4→J, 5→N, 6→K … + + INSERT INTO freight.wagons ( + id, + wagon_number, + wagon_type_id, + tare_weight, + max_payload_weight, + status, + current_yard_id, + train_id, + sequence_number, + notes, + train_set_wagon_id, + current_train_schedule_id, + created_at, + updated_at + ) + VALUES ( + uuid_generate_v4(), + wagon_num, + wt.id, + v_tare, + v_payload, + 'Available', + yard_id, + NULL, NULL, NULL, NULL, NULL, + now(), now() + ) + ON CONFLICT (wagon_number) DO NOTHING; + END LOOP; + + RAISE NOTICE 'Seeded 50 wagons for type %.', wt.code; + END LOOP; + END $$; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + // Remove all seeded wagons (full wipe — mirrors what up() did) + await queryRunner.query(`DELETE FROM freight.wagons;`); + } +} diff --git a/apps/edr-freight-api/src/migrations/1784100000000-AddBookingRouteDayIndex.ts b/apps/edr-freight-api/src/migrations/1784100000000-AddBookingRouteDayIndex.ts new file mode 100644 index 000000000..544600bef --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1784100000000-AddBookingRouteDayIndex.ts @@ -0,0 +1,21 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Day-level booking pool: customers select a DAY (route + day), not a specific + * train. The batch engine's pool query filters bookings on + * (origin_yard_id, destination_yard_id, scheduled_date, status); this partial + * index backs that scan. + */ +export class AddBookingRouteDayIndex1784100000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_bookings_route_day + ON freight.bookings (origin_yard_id, destination_yard_id, scheduled_date, status) + WHERE deleted_at IS NULL; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_bookings_route_day;`); + } +} diff --git a/apps/edr-freight-api/src/migrations/1790000000000-AddWarehouseAllocationAndFeeRules.ts b/apps/edr-freight-api/src/migrations/1790000000000-AddWarehouseAllocationAndFeeRules.ts new file mode 100644 index 000000000..05b1259f4 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1790000000000-AddWarehouseAllocationAndFeeRules.ts @@ -0,0 +1,125 @@ +import { MigrationInterface, QueryRunner, Table, TableColumn } from 'typeorm'; + +/** + * Batch 5 — warehouse allocation rules, storage/demurrage fee rules, + * and demurrage lifecycle timestamps on inventory. + */ +export class AddWarehouseAllocationAndFeeRules1790000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.createTable( + new Table({ + schema: 'freight', + name: 'warehouse_allocation_rules', + columns: [ + { name: 'id', type: 'uuid', isPrimary: true, default: 'uuid_generate_v4()' }, + { name: 'name', type: 'varchar', length: '160' }, + { name: 'priority', type: 'int', default: 100 }, + { name: 'freight_type', type: 'varchar', length: '16', isNullable: true }, + { name: 'trade_direction', type: 'varchar', length: '16', isNullable: true }, + { name: 'cargo_type_code', type: 'varchar', length: '50', isNullable: true }, + { name: 'container_status', type: 'varchar', length: '24', isNullable: true }, + { name: 'requires_inspection', type: 'boolean', isNullable: true }, + { name: 'target_facility_code', type: 'varchar', length: '40', isNullable: true }, + { name: 'target_yard_code', type: 'varchar', length: '40' }, + { name: 'target_warehouse_code', type: 'varchar', length: '40', isNullable: true }, + { name: 'target_zone_code', type: 'varchar', length: '40', isNullable: true }, + { name: 'storage_type', type: 'varchar', length: '80', isNullable: true }, + { name: 'is_active', type: 'boolean', default: true }, + { name: 'created_at', type: 'timestamptz', default: 'now()' }, + { name: 'updated_at', type: 'timestamptz', default: 'now()' }, + { name: 'deleted_at', type: 'timestamptz', isNullable: true }, + ], + indices: [ + { name: 'idx_war_priority', columnNames: ['priority'] }, + { name: 'idx_war_active', columnNames: ['is_active'] }, + ], + }), + true, + ); + + await queryRunner.createTable( + new Table({ + schema: 'freight', + name: 'warehouse_fee_rules', + columns: [ + { name: 'id', type: 'uuid', isPrimary: true, default: 'uuid_generate_v4()' }, + { name: 'name', type: 'varchar', length: '160' }, + { name: 'rule_type', type: 'varchar', length: '20' }, + { name: 'priority', type: 'int', default: 100 }, + { name: 'freight_type', type: 'varchar', length: '16', isNullable: true }, + { name: 'trade_direction', type: 'varchar', length: '16', isNullable: true }, + { name: 'cargo_type_code', type: 'varchar', length: '50', isNullable: true }, + { name: 'container_type', type: 'varchar', length: '40', isNullable: true }, + { name: 'facility_id', type: 'uuid', isNullable: true }, + { name: 'warehouse_id', type: 'uuid', isNullable: true }, + { name: 'yard_id', type: 'uuid', isNullable: true }, + { name: 'zone_id', type: 'uuid', isNullable: true }, + { name: 'free_days', type: 'int', default: 0 }, + { name: 'rate_per_day', type: 'numeric', precision: 14, scale: 2, default: 0 }, + { name: 'currency', type: 'varchar', length: '8', default: "'USD'" }, + { name: 'is_active', type: 'boolean', default: true }, + { name: 'created_at', type: 'timestamptz', default: 'now()' }, + { name: 'updated_at', type: 'timestamptz', default: 'now()' }, + { name: 'deleted_at', type: 'timestamptz', isNullable: true }, + ], + indices: [ + { name: 'idx_wfr_type', columnNames: ['rule_type'] }, + { name: 'idx_wfr_active', columnNames: ['is_active'] }, + ], + }), + true, + ); + + const inventoryTable = await queryRunner.getTable('freight.warehouse_inventory'); + if (inventoryTable) { + const columnsToAdd = [ + { name: 'inspection_started_at', type: 'timestamptz', isNullable: true }, + { name: 'inspection_completed_at', type: 'timestamptz', isNullable: true }, + { name: 'ready_for_pickup_at', type: 'timestamptz', isNullable: true }, + { name: 'release_date', type: 'timestamptz', isNullable: true }, + { name: 'gate_cleared_at', type: 'timestamptz', isNullable: true }, + ]; + + const columnsToCreate = columnsToAdd.filter( + (col) => !inventoryTable.columns.some((c) => c.name === col.name), + ); + + if (columnsToCreate.length > 0) { + await queryRunner.addColumns( + 'freight.warehouse_inventory', + columnsToCreate.map((col) => new TableColumn(col)), + ); + } + } + } + + public async down(queryRunner: QueryRunner): Promise { + const inventoryTable = await queryRunner.getTable('freight.warehouse_inventory'); + if (inventoryTable) { + const columnNames = [ + 'inspection_started_at', + 'inspection_completed_at', + 'ready_for_pickup_at', + 'release_date', + 'gate_cleared_at', + ]; + const columnsToRemove = columnNames.filter((name) => + inventoryTable.columns.some((c) => c.name === name), + ); + + if (columnsToRemove.length > 0) { + await queryRunner.dropColumns('freight.warehouse_inventory', columnsToRemove); + } + } + + const feeRulesTable = await queryRunner.getTable('freight.warehouse_fee_rules'); + if (feeRulesTable) { + await queryRunner.dropTable('freight.warehouse_fee_rules', true); + } + + const allocationRulesTable = await queryRunner.getTable('freight.warehouse_allocation_rules'); + if (allocationRulesTable) { + await queryRunner.dropTable('freight.warehouse_allocation_rules', true); + } + } +} diff --git a/apps/edr-freight-api/src/migrations/1790000000000-CreateWarehouseModule.ts b/apps/edr-freight-api/src/migrations/1790000000000-CreateWarehouseModule.ts new file mode 100644 index 000000000..b921a7194 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1790000000000-CreateWarehouseModule.ts @@ -0,0 +1,124 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateWarehouseModule1790000000000 implements MigrationInterface { + name = 'CreateWarehouseModule1790000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.warehouses ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(160) NOT NULL, + code VARCHAR(40) NOT NULL UNIQUE, + type VARCHAR(32) NOT NULL, + station_id UUID NULL, + location_name VARCHAR(200) NULL, + capacity_weight NUMERIC(14,3) NULL, + capacity_containers INT NULL, + current_weight NUMERIC(14,3) NOT NULL DEFAULT 0, + current_containers INT NOT NULL DEFAULT 0, + status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE', + is_active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ NULL + ); + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.warehouse_yards ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + warehouse_id UUID NOT NULL REFERENCES freight.warehouses(id) ON DELETE CASCADE, + name VARCHAR(160) NOT NULL, + code VARCHAR(40) NOT NULL, + type VARCHAR(32) NOT NULL, + capacity_weight NUMERIC(14,3) NULL, + capacity_containers INT NULL, + current_weight NUMERIC(14,3) NOT NULL DEFAULT 0, + current_containers INT NOT NULL DEFAULT 0, + status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE', + is_active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ NULL, + CONSTRAINT uq_warehouse_yards_code UNIQUE (warehouse_id, code) + ); + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.warehouse_zones ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + yard_id UUID NOT NULL REFERENCES freight.warehouse_yards(id) ON DELETE CASCADE, + name VARCHAR(160) NOT NULL, + code VARCHAR(40) NOT NULL, + type VARCHAR(32) NOT NULL, + capacity_weight NUMERIC(14,3) NULL, + capacity_containers INT NULL, + current_weight NUMERIC(14,3) NOT NULL DEFAULT 0, + current_containers INT NOT NULL DEFAULT 0, + status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE', + is_active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ NULL, + CONSTRAINT uq_warehouse_zones_code UNIQUE (yard_id, code) + ); + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.warehouse_inventory ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + warehouse_id UUID NOT NULL REFERENCES freight.warehouses(id), + yard_id UUID NOT NULL REFERENCES freight.warehouse_yards(id), + zone_id UUID NOT NULL REFERENCES freight.warehouse_zones(id), + booking_id UUID NOT NULL, + cargo_id UUID NULL, + container_id UUID NULL, + goods_id UUID NULL, + quantity NUMERIC(12,3) NOT NULL DEFAULT 0, + weight NUMERIC(14,3) NOT NULL DEFAULT 0, + volume NUMERIC(12,3) NULL, + status VARCHAR(32) NOT NULL DEFAULT 'ARRIVED_AT_WAREHOUSE', + arrived_at TIMESTAMPTZ NULL, + inspected_at TIMESTAMPTZ NULL, + ready_for_loading_at TIMESTAMPTZ NULL, + notes TEXT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ NULL + ); + `); + + const indexes: Array<[string, string, string]> = [ + ['idx_warehouses_type', 'warehouses', 'type'], + ['idx_warehouses_status', 'warehouses', 'status'], + ['idx_warehouses_station_id', 'warehouses', 'station_id'], + ['idx_warehouse_yards_warehouse_id', 'warehouse_yards', 'warehouse_id'], + ['idx_warehouse_yards_type', 'warehouse_yards', 'type'], + ['idx_warehouse_yards_status', 'warehouse_yards', 'status'], + ['idx_warehouse_zones_yard_id', 'warehouse_zones', 'yard_id'], + ['idx_warehouse_zones_type', 'warehouse_zones', 'type'], + ['idx_warehouse_zones_status', 'warehouse_zones', 'status'], + ['idx_warehouse_inventory_warehouse_id', 'warehouse_inventory', 'warehouse_id'], + ['idx_warehouse_inventory_yard_id', 'warehouse_inventory', 'yard_id'], + ['idx_warehouse_inventory_zone_id', 'warehouse_inventory', 'zone_id'], + ['idx_warehouse_inventory_booking_id', 'warehouse_inventory', 'booking_id'], + ['idx_warehouse_inventory_cargo_id', 'warehouse_inventory', 'cargo_id'], + ['idx_warehouse_inventory_container_id', 'warehouse_inventory', 'container_id'], + ['idx_warehouse_inventory_goods_id', 'warehouse_inventory', 'goods_id'], + ['idx_warehouse_inventory_status', 'warehouse_inventory', 'status'], + ]; + + for (const [indexName, table, column] of indexes) { + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS ${indexName} ON freight.${table}(${column});`, + ); + } + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.warehouse_inventory;`); + await queryRunner.query(`DROP TABLE IF EXISTS freight.warehouse_zones;`); + await queryRunner.query(`DROP TABLE IF EXISTS freight.warehouse_yards;`); + await queryRunner.query(`DROP TABLE IF EXISTS freight.warehouses;`); + } +} diff --git a/apps/edr-freight-api/src/migrations/1790000000001-AddWarehouseFeeInvoices.ts b/apps/edr-freight-api/src/migrations/1790000000001-AddWarehouseFeeInvoices.ts new file mode 100644 index 000000000..c34eb240a --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1790000000001-AddWarehouseFeeInvoices.ts @@ -0,0 +1,88 @@ +import { MigrationInterface, QueryRunner, Table } from 'typeorm'; + +/** Batch 6 — warehouse fee invoices + invoice items. */ +export class AddWarehouseFeeInvoices1790000000001 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.createTable( + new Table({ + schema: 'freight', + name: 'warehouse_fee_invoices', + columns: [ + { name: 'id', type: 'uuid', isPrimary: true, default: 'uuid_generate_v4()' }, + { name: 'invoice_number', type: 'varchar', length: '40', isUnique: true }, + { name: 'booking_id', type: 'uuid', isNullable: true }, + { name: 'customer_id', type: 'uuid', isNullable: true }, + { name: 'inventory_id', type: 'uuid' }, + { name: 'facility_id', type: 'uuid', isNullable: true }, + { name: 'warehouse_id', type: 'uuid', isNullable: true }, + { name: 'yard_id', type: 'uuid', isNullable: true }, + { name: 'zone_id', type: 'uuid', isNullable: true }, + { name: 'invoice_type', type: 'varchar', length: '32', default: "'MIXED_WAREHOUSE_FEES'" }, + { name: 'status', type: 'varchar', length: '20', default: "'DRAFT'" }, + { name: 'subtotal_amount', type: 'numeric', precision: 14, scale: 2, default: 0 }, + { name: 'tax_amount', type: 'numeric', precision: 14, scale: 2, default: 0 }, + { name: 'total_amount', type: 'numeric', precision: 14, scale: 2, default: 0 }, + { name: 'paid_amount', type: 'numeric', precision: 14, scale: 2, default: 0 }, + { name: 'balance_amount', type: 'numeric', precision: 14, scale: 2, default: 0 }, + { name: 'currency', type: 'varchar', length: '8', default: "'USD'" }, + { name: 'period_start', type: 'timestamptz', isNullable: true }, + { name: 'period_end', type: 'timestamptz', isNullable: true }, + { name: 'issued_at', type: 'timestamptz', isNullable: true }, + { name: 'due_date', type: 'timestamptz', isNullable: true }, + { name: 'paid_at', type: 'timestamptz', isNullable: true }, + { name: 'cancelled_at', type: 'timestamptz', isNullable: true }, + { name: 'payments', type: 'jsonb', default: "'[]'" }, + { name: 'notes', type: 'text', isNullable: true }, + { name: 'created_at', type: 'timestamptz', default: 'now()' }, + { name: 'updated_at', type: 'timestamptz', default: 'now()' }, + { name: 'deleted_at', type: 'timestamptz', isNullable: true }, + ], + indices: [ + { name: 'idx_wfi_booking', columnNames: ['booking_id'] }, + { name: 'idx_wfi_inventory', columnNames: ['inventory_id'] }, + { name: 'idx_wfi_status', columnNames: ['status'] }, + ], + }), + true, + ); + + await queryRunner.createTable( + new Table({ + schema: 'freight', + name: 'warehouse_fee_invoice_items', + columns: [ + { name: 'id', type: 'uuid', isPrimary: true, default: 'uuid_generate_v4()' }, + { name: 'invoice_id', type: 'uuid' }, + { name: 'fee_rule_id', type: 'uuid', isNullable: true }, + { name: 'fee_type', type: 'varchar', length: '32' }, + { name: 'description', type: 'varchar', length: '255' }, + { name: 'quantity', type: 'numeric', precision: 12, scale: 2, default: 1 }, + { name: 'unit_rate', type: 'numeric', precision: 14, scale: 2, default: 0 }, + { name: 'amount', type: 'numeric', precision: 14, scale: 2, default: 0 }, + { name: 'currency', type: 'varchar', length: '8', default: "'USD'" }, + { name: 'chargeable_days', type: 'int', isNullable: true }, + { name: 'free_days', type: 'int', isNullable: true }, + { name: 'created_at', type: 'timestamptz', default: 'now()' }, + { name: 'updated_at', type: 'timestamptz', default: 'now()' }, + { name: 'deleted_at', type: 'timestamptz', isNullable: true }, + ], + foreignKeys: [ + { + columnNames: ['invoice_id'], + referencedSchema: 'freight', + referencedTableName: 'warehouse_fee_invoices', + referencedColumnNames: ['id'], + onDelete: 'CASCADE', + }, + ], + indices: [{ name: 'idx_wfii_invoice', columnNames: ['invoice_id'] }], + }), + true, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropTable('freight.warehouse_fee_invoice_items', true); + await queryRunner.dropTable('freight.warehouse_fee_invoices', true); + } +} diff --git a/apps/edr-freight-api/src/migrations/1790000000001-WarehouseBatch2.ts b/apps/edr-freight-api/src/migrations/1790000000001-WarehouseBatch2.ts new file mode 100644 index 000000000..a1431d7bd --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1790000000001-WarehouseBatch2.ts @@ -0,0 +1,123 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class WarehouseBatch21790000000001 implements MigrationInterface { + name = 'WarehouseBatch21790000000001'; + + public async up(queryRunner: QueryRunner): Promise { + // ── Capacity columns (weight + volume) on warehouse / yard / zone ────── + for (const table of ['warehouses', 'warehouse_yards', 'warehouse_zones']) { + await queryRunner.query(` + ALTER TABLE freight.${table} + ADD COLUMN IF NOT EXISTS max_weight NUMERIC(14,3) NULL, + ADD COLUMN IF NOT EXISTS max_volume NUMERIC(14,3) NULL, + ADD COLUMN IF NOT EXISTS current_volume NUMERIC(14,3) NOT NULL DEFAULT 0; + `); + // Backfill max_weight from the Batch 1 capacity_weight column. + await queryRunner.query(` + UPDATE freight.${table} SET max_weight = capacity_weight WHERE max_weight IS NULL; + `); + } + + // ── Inventory lifecycle: migrate Batch 1 statuses to Batch 2 set ─────── + await queryRunner.query(` + ALTER TABLE freight.warehouse_inventory + ALTER COLUMN status SET DEFAULT 'RECEIVED'; + `); + await queryRunner.query(` + UPDATE freight.warehouse_inventory SET status = 'RECEIVED' WHERE status = 'ARRIVED_AT_WAREHOUSE'; + `); + await queryRunner.query(` + UPDATE freight.warehouse_inventory SET status = 'STORED' WHERE status = 'UNDER_INSPECTION'; + `); + + // ── New lifecycle timestamps ────────────────────────────────────────── + await queryRunner.query(` + ALTER TABLE freight.warehouse_inventory + ADD COLUMN IF NOT EXISTS stored_at TIMESTAMPTZ NULL, + ADD COLUMN IF NOT EXISTS reserved_at TIMESTAMPTZ NULL, + ADD COLUMN IF NOT EXISTS loaded_at TIMESTAMPTZ NULL, + ADD COLUMN IF NOT EXISTS dispatched_at TIMESTAMPTZ NULL; + `); + + // booking_id becomes nullable (inventory can exist before booking linkage). + await queryRunner.query(` + ALTER TABLE freight.warehouse_inventory ALTER COLUMN booking_id DROP NOT NULL; + `); + + // ── Movement history ────────────────────────────────────────────────── + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.warehouse_inventory_movement ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + inventory_id UUID NOT NULL REFERENCES freight.warehouse_inventory(id) ON DELETE CASCADE, + from_warehouse_id UUID NOT NULL, + from_yard_id UUID NOT NULL, + from_zone_id UUID NOT NULL, + to_warehouse_id UUID NOT NULL, + to_yard_id UUID NOT NULL, + to_zone_id UUID NOT NULL, + remarks TEXT NULL, + moved_by VARCHAR(120) NULL, + moved_at TIMESTAMPTZ NOT NULL DEFAULT now(), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ NULL + ); + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_warehouse_inventory_movement_inventory_id + ON freight.warehouse_inventory_movement(inventory_id); + `); + + // ── Activity log ────────────────────────────────────────────────────── + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.warehouse_activity_log ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + inventory_id UUID NULL, + warehouse_id UUID NULL, + activity_type VARCHAR(40) NOT NULL, + description TEXT NULL, + performed_by VARCHAR(120) NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ NULL + ); + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_warehouse_activity_log_inventory_id + ON freight.warehouse_activity_log(inventory_id); + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_warehouse_activity_log_warehouse_id + ON freight.warehouse_activity_log(warehouse_id); + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_warehouse_activity_log_activity_type + ON freight.warehouse_activity_log(activity_type); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.warehouse_activity_log;`); + await queryRunner.query(`DROP TABLE IF EXISTS freight.warehouse_inventory_movement;`); + + await queryRunner.query(` + ALTER TABLE freight.warehouse_inventory + DROP COLUMN IF EXISTS stored_at, + DROP COLUMN IF EXISTS reserved_at, + DROP COLUMN IF EXISTS loaded_at, + DROP COLUMN IF EXISTS dispatched_at; + `); + await queryRunner.query(` + ALTER TABLE freight.warehouse_inventory ALTER COLUMN status SET DEFAULT 'RECEIVED'; + `); + + for (const table of ['warehouses', 'warehouse_yards', 'warehouse_zones']) { + await queryRunner.query(` + ALTER TABLE freight.${table} + DROP COLUMN IF EXISTS max_weight, + DROP COLUMN IF EXISTS max_volume, + DROP COLUMN IF EXISTS current_volume; + `); + } + } +} diff --git a/apps/edr-freight-api/src/migrations/1790000000002-WarehouseBatch3.ts b/apps/edr-freight-api/src/migrations/1790000000002-WarehouseBatch3.ts new file mode 100644 index 000000000..4dcc9329c --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1790000000002-WarehouseBatch3.ts @@ -0,0 +1,44 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Batch 3 — Warehouse → Loading → Train Departure visibility. + * Adds the warehouse_loadings record (inventory ↔ wagon). Does NOT touch any + * scheduling / wagon tables — the warehouse only reads from those. + */ +export class WarehouseBatch31790000000002 implements MigrationInterface { + name = 'WarehouseBatch31790000000002'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.warehouse_loadings ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + warehouse_inventory_id UUID NOT NULL REFERENCES freight.warehouse_inventory(id) ON DELETE CASCADE, + booking_id UUID NULL, + wagon_id UUID NOT NULL, + loaded_at TIMESTAMPTZ NOT NULL DEFAULT now(), + loaded_by VARCHAR(120) NULL, + loaded_weight NUMERIC(14,3) NULL, + notes TEXT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ NULL + ); + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_warehouse_loadings_inventory_id + ON freight.warehouse_loadings(warehouse_inventory_id); + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_warehouse_loadings_booking_id + ON freight.warehouse_loadings(booking_id); + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_warehouse_loadings_wagon_id + ON freight.warehouse_loadings(wagon_id); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.warehouse_loadings;`); + } +} diff --git a/apps/edr-freight-api/src/modules/auth/freight-auth.module.ts b/apps/edr-freight-api/src/modules/auth/freight-auth.module.ts new file mode 100644 index 000000000..a689ba24e --- /dev/null +++ b/apps/edr-freight-api/src/modules/auth/freight-auth.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; + +import { FreightMeController } from './freight-me.controller'; +import { FreightMeService } from './freight-me.service'; + +@Module({ + controllers: [FreightMeController], + providers: [FreightMeService], +}) +export class FreightAuthModule {} diff --git a/apps/edr-freight-api/src/modules/auth/freight-me.controller.ts b/apps/edr-freight-api/src/modules/auth/freight-me.controller.ts new file mode 100644 index 000000000..b85ecea84 --- /dev/null +++ b/apps/edr-freight-api/src/modules/auth/freight-me.controller.ts @@ -0,0 +1,23 @@ +import { Controller, Get, UseGuards } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { CurrentUser } from '@tria-plc/api-common/modules/auth/decorators/current-user.decorator'; +import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard'; +import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; + +import { FreightMeService } from './freight-me.service'; + +@ApiTags('auth') +@Controller('me') +@ApiBearerAuth() +export class FreightMeController { + constructor(private readonly freightMeService: FreightMeService) {} + + @Get() + @UseGuards(JwtGuard) + @ApiOperation({ + summary: 'Current user with flat permissionKeys for backoffice gating', + }) + getMe(@CurrentUser() user: TCurrentUser) { + return this.freightMeService.getEnrichedProfile(user); + } +} diff --git a/apps/edr-freight-api/src/modules/auth/freight-me.service.ts b/apps/edr-freight-api/src/modules/auth/freight-me.service.ts new file mode 100644 index 000000000..50c90213b --- /dev/null +++ b/apps/edr-freight-api/src/modules/auth/freight-me.service.ts @@ -0,0 +1,57 @@ +import { Injectable } from '@nestjs/common'; +import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; + +import { + collectPermissionKeys, + isSuperAdmin, +} from '../../common/freight-permission.util'; +import { PERMISSIONS_CATALOG } from '../../seed/freight-permissions.registry'; + +@Injectable() +export class FreightMeService { + getEnrichedProfile(user: TCurrentUser) { + const employee = user.employee + ? [ + { + id: user.employee.id, + organizationId: user.employee.organizationId, + unitId: user.employee.unitId, + name: user.employee.name, + positions: user.employee.position + ? [ + { + id: user.employee.position.id, + key: user.employee.position.key, + employeePositionId: user.employee.position.employeePositionId, + name: user.employee.position.name, + isDelegate: user.employee.position.isDelegate, + parentPositionId: user.employee.position.parentPositionId, + permissions: user.employee.position.permissions ?? [], + }, + ] + : [], + }, + ] + : []; + + const permissionKeys = collectPermissionKeys(user); + + return { + id: user.id, + email: user.email, + name: user.name, + username: user.username, + phoneNumber: user.phoneNumber, + userType: user.userType, + status: user.status, + hasFinishedRegistration: user.hasFinishedRegistration, + hasFinishedDMSOnboarding: user.hasFinishedDMSOnboarding, + roles: user.roles, + permissions: user.permissions, + employee, + permissionKeys, + isSuperAdmin: isSuperAdmin(user), + permissionsCatalog: PERMISSIONS_CATALOG, + }; + } +} diff --git a/apps/edr-freight-api/src/modules/backoffice/backoffice.controller.ts b/apps/edr-freight-api/src/modules/backoffice/backoffice.controller.ts new file mode 100644 index 000000000..b3305ba68 --- /dev/null +++ b/apps/edr-freight-api/src/modules/backoffice/backoffice.controller.ts @@ -0,0 +1,68 @@ +import { + Body, + Controller, + Get, + Param, + ParseUUIDPipe, + Post, + Query, + Put, +} from "@nestjs/common"; +import { ApiOperation, ApiTags } from "@nestjs/swagger"; + +import { FreightAdmin } from "../../common/booking-guards"; +import { BackofficeService } from "./backoffice.service"; +import { CreateOrganizationUserDto } from "./dto/create-organization-user.dto"; +import { UpdateEmployeeUserRolesDto } from "./dto/update-employee-user-roles.dto"; + +@ApiTags("backoffice") +@Controller("backoffice") +@FreightAdmin() +export class BackofficeController { + constructor(private readonly backofficeService: BackofficeService) {} + + @Post("organizations/:orgId/users") + @ApiOperation({ summary: "Create an organization user without assigning positions" }) + createOrganizationUser( + @Param("orgId", ParseUUIDPipe) organizationId: string, + @Body() dto: CreateOrganizationUserDto, + ) { + return this.backofficeService.createOrganizationUser(organizationId, dto); + } + + @Get("organizations/:orgId/employees") + @ApiOperation({ summary: "Get deduplicated organization employees for backoffice" }) + getOrganizationEmployees( + @Param("orgId", ParseUUIDPipe) organizationId: string, + @Query("skip") skip?: string, + @Query("take") take?: string, + ) { + return this.backofficeService.getOrganizationEmployees(organizationId, { + skip, + take, + }); + } + + @Get("organizations/:orgId/employee-users/:userId/roles") + @ApiOperation({ summary: "Get org-scoped roles assigned to an employee user" }) + getEmployeeUserRoles( + @Param("orgId", ParseUUIDPipe) organizationId: string, + @Param("userId", ParseUUIDPipe) userId: string, + ) { + return this.backofficeService.getEmployeeUserRoles(organizationId, userId); + } + + @Put("organizations/:orgId/employee-users/:userId/roles") + @ApiOperation({ summary: "Replace org-scoped roles assigned to an employee user" }) + replaceEmployeeUserRoles( + @Param("orgId", ParseUUIDPipe) organizationId: string, + @Param("userId", ParseUUIDPipe) userId: string, + @Body() dto: UpdateEmployeeUserRolesDto, + ) { + return this.backofficeService.replaceEmployeeUserRoles( + organizationId, + userId, + dto.roleIds, + ); + } +} diff --git a/apps/edr-freight-api/src/modules/backoffice/backoffice.module.ts b/apps/edr-freight-api/src/modules/backoffice/backoffice.module.ts new file mode 100644 index 000000000..90c1a7c79 --- /dev/null +++ b/apps/edr-freight-api/src/modules/backoffice/backoffice.module.ts @@ -0,0 +1,31 @@ +import { Module } from "@nestjs/common"; +import { TypeOrmModule } from "@nestjs/typeorm"; +import { + Employee, + Organization, + UserCredential, +} from "@tria-plc/iamapi-common"; + +import { Role } from "@tria-plc/iamapi-common/entities/iam/user/role.entity"; +import { UserRole } from "@tria-plc/iamapi-common/entities/iam/user/user-role.entity"; +import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity"; + +import { BackofficeController } from "./backoffice.controller"; +import { BackofficeService } from "./backoffice.service"; + +@Module({ + imports: [ + TypeOrmModule.forFeature([ + Employee, + Organization, + Role, + User, + UserCredential, + UserRole, + ]), + ], + controllers: [BackofficeController], + providers: [BackofficeService], + exports: [BackofficeService], +}) +export class BackofficeModule {} diff --git a/apps/edr-freight-api/src/modules/backoffice/backoffice.service.ts b/apps/edr-freight-api/src/modules/backoffice/backoffice.service.ts new file mode 100644 index 000000000..7c7805b28 --- /dev/null +++ b/apps/edr-freight-api/src/modules/backoffice/backoffice.service.ts @@ -0,0 +1,425 @@ +import { + BadRequestException, + Injectable, + NotFoundException, +} from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { hashPassword } from "@tria-plc/api-common/utils/argon"; +import { EUserStatus } from "@tria-plc/api-common/utils/enums/user.enum"; +import { DataSource, EntityManager, In, IsNull, Repository } from "typeorm"; + +import { Employee, Organization, UserCredential } from "@tria-plc/iamapi-common"; +import { Role } from "@tria-plc/iamapi-common/entities/iam/user/role.entity"; +import { UserRole } from "@tria-plc/iamapi-common/entities/iam/user/user-role.entity"; +import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity"; + +import { CreateOrganizationUserDto } from "./dto/create-organization-user.dto"; + +const RESERVED_ROLE_KEYS = new Set([ + "super_admin", + "organization_admin", + "unit_admin", +]); +const DEFAULT_USER_PASSWORD = "12345678"; +const ORGANIZATION_ADMIN_ROLE_KEY = "organization_admin"; +const EDR_ORG_MANAGER_ROLE_KEY = "edr_org_manager"; + +@Injectable() +export class BackofficeService { + constructor( + @InjectRepository(Employee) + private readonly employeeRepository: Repository, + @InjectRepository(Organization) + private readonly organizationRepository: Repository, + @InjectRepository(Role) + private readonly roleRepository: Repository, + @InjectRepository(UserRole) + private readonly userRoleRepository: Repository, + @InjectRepository(User) + private readonly userRepository: Repository, + private readonly dataSource: DataSource, + ) {} + + async createOrganizationUser( + organizationId: string, + dto: CreateOrganizationUserDto, + ) { + const organizationExists = await this.organizationRepository.exists({ + where: { id: organizationId }, + }); + + if (!organizationExists) { + throw new NotFoundException("organization_not_found"); + } + + const email = dto.email.trim().toLowerCase(); + const username = dto.username.trim().toLowerCase(); + const phoneNumber = dto.phoneNumber?.trim() || undefined; + const assignOrganizationAdmin = dto.assignOrganizationAdmin === true; + const name = { + en: dto.name.en.trim(), + ...(dto.name.am?.trim() ? { am: dto.name.am.trim() } : {}), + }; + + const existingUsers = await this.userRepository.find({ + where: [{ email }, { username }], + select: { id: true, email: true, username: true }, + }); + + const emailUser = existingUsers.find((user) => user.email === email); + const usernameUser = existingUsers.find((user) => user.username === username); + + if (emailUser && usernameUser && emailUser.id !== usernameUser.id) { + throw new BadRequestException("email_or_username_already_in_use"); + } + + const existingUser = emailUser ?? usernameUser; + const hashedPassword = await hashPassword(DEFAULT_USER_PASSWORD); + + return this.dataSource.transaction(async (manager) => { + let user = existingUser; + + if (!user) { + user = await manager.getRepository(User).save( + manager.getRepository(User).create({ + email, + username, + phoneNumber, + name, + isActive: true, + hasSetPassword: true, + status: EUserStatus.ACCEPTED, + }), + ); + } else { + await manager.getRepository(User).update( + { id: user.id }, + { + email, + username, + phoneNumber, + name, + isActive: true, + hasSetPassword: true, + status: EUserStatus.ACCEPTED, + }, + ); + } + + const activeCredentialExists = await manager.getRepository(UserCredential).exists({ + where: { + userId: user.id, + isActive: true, + }, + }); + + if (!activeCredentialExists) { + await manager.getRepository(UserCredential).insert({ + userId: user.id, + password: hashedPassword, + isActive: true, + }); + } + + let employee = await manager.getRepository(Employee).findOne({ + where: { + userId: user.id, + organizationId, + isCurrent: true, + }, + relations: { + user: true, + employeePositions: { + position: true, + }, + }, + }); + + if (!employee) { + const insertResult = await manager.getRepository(Employee).insert({ + userId: user.id, + organizationId, + isCurrent: true, + name, + }); + + employee = await manager.getRepository(Employee).findOne({ + where: { id: insertResult.identifiers[0]?.id as string }, + relations: { + user: true, + employeePositions: { + position: true, + }, + }, + }); + } else { + await manager.getRepository(Employee).update( + { id: employee.id }, + { name }, + ); + + employee = await manager.getRepository(Employee).findOne({ + where: { id: employee.id }, + relations: { + user: true, + employeePositions: { + position: true, + }, + }, + }); + } + + if (!employee) { + throw new NotFoundException("employee_create_failed"); + } + + const userId = user.id; + + if (!userId) { + throw new NotFoundException("user_create_failed"); + } + + if (assignOrganizationAdmin) { + await this.ensureOrganizationAdminAccess(manager, organizationId, userId); + } + + return employee; + }); + } + + async getEmployeeUserRoles(organizationId: string, userId: string) { + await this.assertUserBelongsToOrganization(organizationId, userId); + + const userRoles = await this.userRoleRepository.find({ + where: { + userId, + organizationId, + unitId: IsNull(), + }, + relations: { + role: true, + }, + order: { + role: { + key: "ASC", + }, + }, + }); + + return userRoles + .map((userRole) => userRole.role) + .filter((role): role is Role => Boolean(role)) + .map((role) => ({ + id: role.id, + key: role.key, + name: role.name, + })); + } + + async getOrganizationEmployees( + organizationId: string, + query: { skip?: string; take?: string }, + ) { + const organizationExists = await this.organizationRepository.exists({ + where: { id: organizationId }, + }); + + if (!organizationExists) { + throw new NotFoundException("organization_not_found"); + } + + const take = Number.parseInt(query.take ?? "1000", 10); + const skip = Number.parseInt(query.skip ?? "0", 10); + + const employees = await this.employeeRepository.find({ + where: { + organizationId, + isCurrent: true, + }, + relations: { + user: true, + employeePositions: { + position: true, + }, + }, + order: { + createdAt: "DESC", + }, + }); + + const deduplicated = this.mergeEmployeesByUser(employees); + + return { + count: deduplicated.length, + items: deduplicated.slice(skip, skip + take), + }; + } + + async replaceEmployeeUserRoles( + organizationId: string, + userId: string, + roleIds: string[], + ) { + await this.assertUserBelongsToOrganization(organizationId, userId); + + const uniqueRoleIds = [...new Set(roleIds)]; + const roles = uniqueRoleIds.length + ? await this.roleRepository.find({ + where: { + id: In(uniqueRoleIds), + }, + }) + : []; + + if (roles.length !== uniqueRoleIds.length) { + throw new NotFoundException("one_or_more_roles_not_found"); + } + + const reservedRoles = roles.filter((role) => RESERVED_ROLE_KEYS.has(role.key)); + if (reservedRoles.length) { + throw new BadRequestException("reserved_roles_must_use_admin_actions"); + } + + await this.dataSource.transaction(async (manager) => { + await manager.getRepository(UserRole).delete({ + userId, + organizationId, + unitId: IsNull(), + }); + + if (!roles.length) { + return; + } + + await manager.getRepository(UserRole).insert( + roles.map((role) => ({ + userId, + roleId: role.id, + organizationId, + })), + ); + }); + + return this.getEmployeeUserRoles(organizationId, userId); + } + + private async assertUserBelongsToOrganization( + organizationId: string, + userId: string, + ) { + const exists = await this.userRepository + .createQueryBuilder("user") + .innerJoin( + "user.employee", + "employee", + "employee.organizationId = :organizationId AND employee.isCurrent = true", + { organizationId }, + ) + .where("user.id = :userId", { userId }) + .getExists(); + + if (!exists) { + throw new NotFoundException("user_not_found_in_organization"); + } + } + + private mergeEmployeesByUser(employees: Employee[]) { + const employeesByUserId = new Map(); + + for (const employee of employees) { + const userId = employee.userId; + const employeeId = employee.id; + + if (!userId) { + if (employeeId) { + employeesByUserId.set(employeeId, employee); + } + continue; + } + + const existing = employeesByUserId.get(userId); + + if (!existing) { + employeesByUserId.set(userId, employee); + continue; + } + + const existingPositions = existing.employeePositions ?? []; + const nextPositions = employee.employeePositions ?? []; + const mergedEmployeePositions = Array.from( + new Map( + [...existingPositions, ...nextPositions].map((employeePosition) => [ + employeePosition.id, + employeePosition, + ]), + ).values(), + ); + + employeesByUserId.set(userId, { + ...existing, + ...employee, + id: existing.id, + user: existing.user ?? employee.user, + userId, + name: existing.name ?? employee.name, + status: existing.status ?? employee.status, + employeePositions: mergedEmployeePositions, + }); + } + + return [...employeesByUserId.values()]; + } + + private async ensureOrganizationAdminAccess( + manager: EntityManager, + organizationId: string, + userId: string, + ) { + const roles = await manager.getRepository(Role).find({ + where: [ + { key: ORGANIZATION_ADMIN_ROLE_KEY }, + { key: EDR_ORG_MANAGER_ROLE_KEY }, + ], + select: { id: true, key: true }, + }); + + const requiredRoles = [ORGANIZATION_ADMIN_ROLE_KEY, EDR_ORG_MANAGER_ROLE_KEY].map((key) => { + const role = roles.find((item) => item.key === key); + + if (!role?.id) { + throw new NotFoundException(`required_role_not_seeded:${key}`); + } + + return { + id: role.id, + key: role.key, + }; + }); + + const existingRoleIds = new Set( + ( + await manager.getRepository(UserRole).find({ + where: { + userId, + organizationId, + }, + select: { roleId: true }, + }) + ).map((userRole) => userRole.roleId), + ); + + const rolesToInsert = requiredRoles + .filter((role) => !existingRoleIds.has(role.id)) + .map((role) => ({ + userId, + roleId: role.id, + organizationId, + })); + + if (!rolesToInsert.length) { + return; + } + + await manager.getRepository(UserRole).insert(rolesToInsert); + } +} diff --git a/apps/edr-freight-api/src/modules/backoffice/dto/create-organization-user.dto.ts b/apps/edr-freight-api/src/modules/backoffice/dto/create-organization-user.dto.ts new file mode 100644 index 000000000..cf324a501 --- /dev/null +++ b/apps/edr-freight-api/src/modules/backoffice/dto/create-organization-user.dto.ts @@ -0,0 +1,39 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsBoolean, IsEmail, IsObject, IsOptional, IsString, MinLength } from "class-validator"; + +class CreateOrganizationUserNameDto { + @ApiProperty() + @IsString() + @MinLength(1) + en!: string; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + am?: string; +} + +export class CreateOrganizationUserDto { + @ApiProperty() + @IsEmail() + email!: string; + + @ApiProperty() + @IsString() + @MinLength(1) + username!: string; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + phoneNumber?: string; + + @ApiProperty({ type: CreateOrganizationUserNameDto }) + @IsObject() + name!: CreateOrganizationUserNameDto; + + @ApiProperty({ required: false, default: false }) + @IsOptional() + @IsBoolean() + assignOrganizationAdmin?: boolean; +} diff --git a/apps/edr-freight-api/src/modules/backoffice/dto/update-employee-user-roles.dto.ts b/apps/edr-freight-api/src/modules/backoffice/dto/update-employee-user-roles.dto.ts new file mode 100644 index 000000000..f216939b1 --- /dev/null +++ b/apps/edr-freight-api/src/modules/backoffice/dto/update-employee-user-roles.dto.ts @@ -0,0 +1,9 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsArray, IsUUID } from "class-validator"; + +export class UpdateEmployeeUserRolesDto { + @ApiProperty({ type: [String] }) + @IsArray() + @IsUUID("4", { each: true }) + roleIds!: string[]; +} diff --git a/apps/edr-freight-api/src/modules/billing/billing.controller.ts b/apps/edr-freight-api/src/modules/billing/billing.controller.ts index 05ea8af67..5a801cf73 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.controller.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.controller.ts @@ -1,11 +1,12 @@ import { Controller, Get, Param, ParseUUIDPipe } from "@nestjs/common"; import { ApiOperation, ApiTags } from "@nestjs/swagger"; +import { FreightAdmin } from "../../common/booking-guards"; import { BillingService } from "./billing.service"; @ApiTags("billing") -// @UseGuards(JwtAuthGuard) — TODO: integrate @edr/auth @Controller("billing") +@FreightAdmin() export class BillingController { constructor(private readonly billingService: BillingService) {} diff --git a/apps/edr-freight-api/src/modules/billing/entities/invoice.entity.ts b/apps/edr-freight-api/src/modules/billing/entities/invoice.entity.ts index 0031834f5..e2a6f7cc2 100644 --- a/apps/edr-freight-api/src/modules/billing/entities/invoice.entity.ts +++ b/apps/edr-freight-api/src/modules/billing/entities/invoice.entity.ts @@ -2,7 +2,7 @@ import { BaseEntity } from "@edr/api-common"; import { Freight } from "@edr/types"; import { Column, Entity } from "typeorm"; -@Entity({ name: "invoices" }) +@Entity({schema:"freight", name: "invoices" }) export class Invoice extends BaseEntity { @Column({ name: "booking_id", type: "uuid" }) bookingId!: string; diff --git a/apps/edr-freight-api/src/modules/bookings/booking-contract.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-contract.service.ts new file mode 100644 index 000000000..6601ca704 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/booking-contract.service.ts @@ -0,0 +1,336 @@ +import { + BadRequestException, + forwardRef, + Inject, + Injectable, + Logger, + NotFoundException, +} from '@nestjs/common'; +import { Readable } from 'stream'; + +import { ContractPdfService } from '../../contracts/contract-pdf.service'; +import { ContractRendererService } from '../../contracts/contract-renderer.service'; +import { getTemplateMeta } from '../../contracts/contract-template.registry'; +import { ContractTemplateResolver } from '../../contracts/contract-template.resolver'; +import { ContractViewModelBuilder } from '../../contracts/contract-view-model.builder'; +import { MinioService } from '../minio/minio.service'; +import { FilesService } from '../files/files.service'; +import { FileRecord } from '../files/entities/file.entity'; +import { BookingsRepository } from './bookings.repository'; +import { Booking } from './entities/booking.entity'; +import { assertBookingStatus } from './booking-status.util'; +import { ContractViewDto } from './dto/contract-view.dto'; +import { SignContractDto } from './dto/sign-contract.dto'; +import { ContractSignerRole } from './entities/booking-contract-signature.entity'; +import { BookingBatchService } from '../train-scheduling/booking-batch.service'; +import { SignaturesService } from '../signatures/signatures.service'; + +@Injectable() +export class BookingContractService { + private readonly logger = new Logger(BookingContractService.name); + + constructor( + private readonly bookingsRepository: BookingsRepository, + private readonly filesService: FilesService, + private readonly minioService: MinioService, + private readonly templateResolver: ContractTemplateResolver, + private readonly viewModelBuilder: ContractViewModelBuilder, + private readonly renderer: ContractRendererService, + private readonly pdfService: ContractPdfService, + @Inject(forwardRef(() => BookingBatchService)) + private readonly bookingBatchService: BookingBatchService, + private readonly signaturesService: SignaturesService, + ) {} + + buildContractSummary(booking: Booking): string { + const direction = + booking.tradeDirection === 'IMPORT' + ? 'Import' + : booking.tradeDirection === 'EXPORT' + ? 'Export' + : booking.tradeDirection; + + const cargo = booking.cargoType; + const isBulk = booking.freightType === 'BULK'; + + let cargoLabel: string; + if (isBulk) { + cargoLabel = `Bulk (${booking.cargoFreeText || cargo?.cargoTypeName || 'Commodity'})`; + } else { + const lines = + booking.bookingContainers?.map((bc) => { + const label = bc.containerType?.label ?? bc.containerType?.code ?? 'Container'; + return `${bc.quantity}× ${label}`; + }) ?? []; + cargoLabel = + lines.length > 0 + ? `Container (${lines.join(', ')})` + : 'Container (Standard)'; + } + + return `Operation: ${direction} | Cargo Type: ${cargoLabel}`; + } + + async getSummary(bookingId: string): Promise<{ summary: string }> { + const booking = await this.requireBooking(bookingId); + const summary = booking.contractSummary ?? this.buildContractSummary(booking); + return { summary }; + } + + async getContractView( + bookingId: string, + viewerUserId?: string, + ): Promise { + const { view } = await this.viewModelBuilder.build(bookingId); + await this.inlineSignatureImages(view.signatures); + const html = this.renderer.render(view); + const savedSignature = viewerUserId + ? ((await this.signaturesService.getForUser(viewerUserId)) ?? undefined) + : undefined; + return { + bookingId: view.bookingId, + reference: view.reference, + status: view.status, + templateKey: view.templateKey, + title: view.template.title, + html, + canSignCustomer: view.canSignCustomer, + canSignStaff: view.canSignStaff, + hasContractDocument: view.hasContractDocument, + signatures: view.signatures, + savedSignature, + pricingSchedule: view.pricing as unknown as Record, + }; + } + + async generateContract(bookingId: string): Promise { + const booking = await this.requireBooking(bookingId); + assertBookingStatus(booking, ['APPROVED']); + + const templateKey = this.templateResolver.resolve(booking); + const summary = this.buildContractSummary(booking); + + // PDF rendering (Puppeteer/Chromium) is best-effort and must NOT block the contract + // from becoming ready — the document is (re)rendered lazily on view/download. + try { + await this.upsertContractPdf(bookingId, booking.reference, templateKey); + } catch (err) { + this.logger.warn( + `Contract PDF deferred for ${booking.reference}: ${err}. It will render on view/download once Chromium is available.`, + ); + } + + const now = new Date(); + const updated = await this.bookingsRepository.update(bookingId, { + status: 'CONTRACT_READY', + contractSummary: summary, + contractTemplateKey: templateKey, + contractGeneratedAt: now, + } as never); + return updated!; + } + + async streamContract(bookingId: string) { + const booking = await this.requireBooking(bookingId); + const templateKey = + booking.contractTemplateKey ?? this.templateResolver.resolve(booking); + const record = await this.upsertContractPdf( + bookingId, + booking.reference, + templateKey, + ); + return this.filesService.streamById(record.id); + } + + async signContract( + bookingId: string, + dto: SignContractDto, + options: { signerUserId?: string; ipAddress?: string }, + ): Promise { + const booking = await this.requireBooking(bookingId); + const role = dto.role as ContractSignerRole; + + if (role === 'CUSTOMER') { + assertBookingStatus(booking, ['CONTRACT_READY']); + const existing = await this.bookingsRepository.findContractSignature( + bookingId, + 'CUSTOMER', + ); + if (existing) { + throw new BadRequestException('Customer has already signed this contract'); + } + } else { + assertBookingStatus(booking, ['SIGNED_CUSTOMER']); + const existing = await this.bookingsRepository.findContractSignature( + bookingId, + 'STAFF', + ); + if (existing) { + throw new BadRequestException('Staff has already signed this contract'); + } + } + + const buffer = this.decodeSignatureImage(dto.signatureImageBase64); + const sigFile: Express.Multer.File = { + fieldname: `signature_${role.toLowerCase()}`, + originalname: `signature-${role.toLowerCase()}-${booking.reference}.png`, + encoding: '7bit', + mimetype: 'image/png', + size: buffer.length, + buffer, + stream: Readable.from(buffer), + destination: '', + filename: '', + path: '', + }; + + const fileRecord = await this.filesService.upsertByCode({ + resourceId: bookingId, + resource: 'bookings', + code: role === 'CUSTOMER' ? 'signature_customer' : 'signature_staff', + file: sigFile, + }); + + const now = new Date(); + await this.bookingsRepository.saveContractSignature({ + bookingId, + signerRole: role, + signerUserId: options.signerUserId ?? null, + signerDisplayName: dto.signerDisplayName, + signedAt: now, + signatureFileId: fileRecord.id, + consentText: dto.consentText ?? null, + ipAddress: options.ipAddress ?? null, + }); + + // Persist the just-used signature to the signer's reusable profile so they + // don't have to redraw it on the next contract. Best-effort: a failure here + // must never block contract execution. + if (options.signerUserId) { + try { + await this.signaturesService.upsertForUser({ + userId: options.signerUserId, + signerDisplayName: dto.signerDisplayName, + signatureImageBase64: dto.signatureImageBase64, + }); + } catch (err) { + this.logger.warn( + `Could not save reusable signature for user ${options.signerUserId}: ${err}`, + ); + } + } + + const updates: Record = {}; + + if (role === 'CUSTOMER') { + updates.status = 'SIGNED_CUSTOMER'; + updates.customerSignedAt = now; + } else { + updates.status = 'FULLY_EXECUTED'; + updates.fullyExecutedAt = now; + updates.marketingApprovedAt = now; + updates.marketingApprovedById = options.signerUserId ?? null; + updates.lockedAt = now; + } + + const updated = await this.bookingsRepository.update(bookingId, updates as never); + if (role === 'STAFF' && updated?.trainScheduleId) { + this.bookingBatchService.enqueueScheduleProcessing(updated.trainScheduleId); + } + try { + await this.upsertContractPdf( + bookingId, + booking.reference, + booking.contractTemplateKey ?? this.templateResolver.resolve(booking), + ); + } catch (err) { + this.logger.warn( + `Signed-contract PDF deferred for ${booking.reference}: ${err}. It will render on view/download.`, + ); + } + return updated!; + } + + async getSignatures(bookingId: string) { + const rows = await this.bookingsRepository.findContractSignatures(bookingId); + const views = rows.map((r) => this.viewModelBuilder.toSignatureView(r)); + await this.inlineSignatureImages(views); + return { signatures: views }; + } + + private async upsertContractPdf( + bookingId: string, + reference: string, + templateKey: string, + ): Promise { + const { view } = await this.viewModelBuilder.build(bookingId); + view.templateKey = templateKey; + view.template = getTemplateMeta(templateKey); + await this.inlineSignatureImages(view.signatures); + + const html = this.renderer.render(view); + const pdfBuffer = await this.pdfService.htmlToPdfBuffer(html); + const file: Express.Multer.File = { + fieldname: 'contract', + originalname: `contract-${reference}.pdf`, + encoding: '7bit', + mimetype: 'application/pdf', + size: pdfBuffer.length, + buffer: pdfBuffer, + stream: Readable.from(pdfBuffer), + destination: '', + filename: '', + path: '', + }; + + return this.filesService.upsertByCode({ + resourceId: bookingId, + resource: 'bookings', + code: 'contract', + file, + }); + } + + private async inlineSignatureImages( + signatures: Array<{ signatureImageUrl?: string | null }>, + ): Promise { + for (const sig of signatures) { + if (!sig.signatureImageUrl) continue; + try { + if (sig.signatureImageUrl.startsWith('data:')) continue; + const objectName = this.minioService.getObjectNameFromUrl( + sig.signatureImageUrl, + ); + const stream = await this.minioService.getFileStream(objectName); + const buffer = await this.streamToBuffer(stream); + sig.signatureImageUrl = `data:image/png;base64,${buffer.toString( + 'base64', + )}`; + } catch { + /* keep original url */ + } + } + } + + private streamToBuffer(stream: Readable): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + stream.on('data', (chunk: Buffer | string) => { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + }); + stream.on('error', reject); + stream.on('end', () => resolve(Buffer.concat(chunks))); + }); + } + + private decodeSignatureImage(base64: string): Buffer { + const raw = base64.includes(',') ? base64.split(',')[1]! : base64; + return Buffer.from(raw, 'base64'); + } + + private async requireBooking(id: string): Promise { + const booking = await this.bookingsRepository.findByIdWithFiles(id); + if (!booking) throw new NotFoundException(`Booking ${id} not found`); + return booking; + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-freight.util.ts b/apps/edr-freight-api/src/modules/bookings/booking-freight.util.ts new file mode 100644 index 000000000..e3e97f301 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/booking-freight.util.ts @@ -0,0 +1,44 @@ +import { BadRequestException } from '@nestjs/common'; + +import { FREIGHT_TYPES, FreightType } from './entities/booking.entity'; +import { BookingFreightShapeInput } from './dto/validators/booking-freight.validator'; + +/** Normalize and validate booking freight shape (used on create and after update merge). */ +export function assertFreightShape(input: BookingFreightShapeInput): void { + if (!input.freightType || !FREIGHT_TYPES.includes(input.freightType as FreightType)) { + throw new BadRequestException( + `freightType is required and must be one of: ${FREIGHT_TYPES.join(', ')}`, + ); + } + // + + const containers = input.containers ?? []; + const hasContainers = containers.length > 0; + const hasCargoType = Boolean(input.cargoTypeId); + + if (input.freightType === 'BULK') { + if (hasContainers) { + throw new BadRequestException( + 'BULK freight cannot include container lines; use cargoTypeId only', + ); + } + if (!hasCargoType) { + throw new BadRequestException('cargoTypeId is required for BULK freight'); + } + return; + } + + if (hasCargoType) { + throw new BadRequestException('cargoTypeId is not allowed for CONTAINER freight'); + } + if (!hasContainers) { + throw new BadRequestException( + 'CONTAINER freight requires at least one container line with containerTypeId', + ); + } + for (const line of containers) { + if (!line.containerTypeId) { + throw new BadRequestException('Each container line must include containerTypeId'); + } + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-list-tabs.config.ts b/apps/edr-freight-api/src/modules/bookings/booking-list-tabs.config.ts new file mode 100644 index 000000000..2140a7688 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/booking-list-tabs.config.ts @@ -0,0 +1,54 @@ +export const BOOKING_LIST_TAB_KEYS = [ + 'all', + 'intake', + 'in_approval', + 'approved_contract', + 'payment', + 'operations', + 'completed', + 'closed', +] as const; + +export type BookingListTabKey = (typeof BOOKING_LIST_TAB_KEYS)[number]; + +export const BOOKING_LIST_TABS: ReadonlyArray<{ + key: BookingListTabKey; + statuses: readonly string[] | null; +}> = [ + { key: 'all', statuses: null }, + { key: 'intake', statuses: ['SUBMITTED', 'PENDING_CONSOLIDATION'] }, + { + key: 'in_approval', + statuses: ['PENDING_APPROVAL', 'APPROVED_PENDING_SIGNATURE'], + }, + { + key: 'approved_contract', + statuses: ['APPROVED', 'CONTRACT_READY', 'SIGNED_CUSTOMER', 'FULLY_EXECUTED'], + }, + { key: 'payment', statuses: ['FULLY_EXECUTED'] }, + { + key: 'operations', + statuses: ['IN_TRANSIT', 'PAID'], + }, + { key: 'completed', statuses: ['COMPLETED'] }, + { key: 'closed', statuses: ['REJECTED', 'CANCELLED'] }, +]; + +export function mapStatusCountsToTabs( + statusCounts: Record, +): Record { + const result = {} as Record; + + for (const tab of BOOKING_LIST_TABS) { + if (!tab.statuses?.length) { + result[tab.key] = Object.values(statusCounts).reduce((sum, n) => sum + n, 0); + continue; + } + result[tab.key] = tab.statuses.reduce( + (sum, status) => sum + (statusCounts[status] ?? 0), + 0, + ); + } + + return result; +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-next-step.util.ts b/apps/edr-freight-api/src/modules/bookings/booking-next-step.util.ts new file mode 100644 index 000000000..b79c4ef20 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/booking-next-step.util.ts @@ -0,0 +1,73 @@ +import { BookingApprovalStep } from './entities/booking-approval-step.entity'; +import { Booking } from './entities/booking.entity'; + +export interface BookingNextStep { + action: string; + description: string; + requiredRole?: string; +} + +export function computeNextStep( + booking: Pick, + nextPendingStep?: Pick | null, +): BookingNextStep | null { + const { status } = booking; + + switch (status) { + case 'PRICE_CHANGED_PENDING_CONFIRM': + return { + action: 'CONFIRM_SUBMIT', + description: 'Price has changed since preview; confirm to submit booking', + }; + case 'SUBMITTED': + return { + action: 'ACCEPT_INTAKE', + description: 'Line Staff must accept the submission to begin approval', + }; + case 'PENDING_APPROVAL': + case 'APPROVED_PENDING_SIGNATURE': + if (nextPendingStep) { + return { + action: 'APPROVE_STEP', + requiredRole: nextPendingStep.requiredRole, + description: `${nextPendingStep.requiredRole} must approve step ${nextPendingStep.stepOrder}`, + }; + } + return { + action: 'APPROVE_STEP', + description: 'Complete the pending approval step in sequence', + }; + case 'APPROVED': + return { + action: 'CUSTOMER_SIGN', + description: 'Contract generated; customer must sign', + }; + case 'CONTRACT_READY': + return { + action: 'CUSTOMER_SIGN', + description: 'Customer must sign the contract', + }; + case 'SIGNED_CUSTOMER': + return { + action: 'STAFF_SIGN', + description: 'Internal staff must counter-sign the contract', + }; + case 'FULLY_EXECUTED': + return { + action: 'AWAIT_PAYMENT', + description: 'Awaiting customer payment', + }; + case 'PAID': + return { + action: 'START_TRANSIT', + description: 'Mark shipment as in transit', + }; + case 'IN_TRANSIT': + return { + action: 'COMPLETE', + description: 'Mark shipment complete', + }; + default: + return null; + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-payment.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-payment.service.ts new file mode 100644 index 000000000..21473eeb8 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/booking-payment.service.ts @@ -0,0 +1,55 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { BookingsRepository } from './bookings.repository'; +import { Booking } from './entities/booking.entity'; +import { assertBookingStatus } from './booking-status.util'; +import { InAppPaymentReceiptDto } from './dto/pay-booking.dto'; +import { PaymentService } from '../payment/payment.service'; +import { PaymentStatus } from '../payment/entities/payment.entity'; +import { PaymentMethodTypeEnum } from '../payment/payments.dto'; +export interface InAppPaymentReceipt extends InAppPaymentReceiptDto { } + +const NON_TERMINAL_STATUSES: PaymentStatus[] = [ + "action-required", + "processing", + "success", +]; + +@Injectable() +export class BookingPaymentService { + constructor( + private readonly bookingsRepository: BookingsRepository, + private readonly paymentService: PaymentService, + ) { } + + async pay(bookingId: string): Promise<{ redirectUrl: string }> { + const booking = await this.requireBooking(bookingId); + assertBookingStatus(booking, ['FULLY_EXECUTED', 'SELECTED_FOR_BATCH', 'AWAITING_PAYMENT', '']); + + const existing = await this.paymentService.findBookingById(bookingId); + if (existing && NON_TERMINAL_STATUSES.includes(existing.status)) { + if (existing.clientAction) { + const action = existing.clientAction as { type?: string; url?: string }; + if (action.type === "REDIRECT" && action.url) { + return { redirectUrl: action.url }; + } + } + } + + const resp = await this.paymentService.initiatePayment({ + bookingId, + method: PaymentMethodTypeEnum.TELEBIRR, + platform: "web", + }); + + const action = resp.clientAction as { type?: string; url?: string } | undefined; + return { + redirectUrl: action?.type === "REDIRECT" ? (action.url ?? "") : "", + }; + } + + private async requireBooking(id: string): Promise { + const booking = await this.bookingsRepository.findById(id); + if (!booking) throw new NotFoundException(`Booking ${id} not found`); + return booking; + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts new file mode 100644 index 000000000..4ba93626f --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts @@ -0,0 +1,129 @@ +import { BookingPricingService } from './booking-pricing.service'; +import type { Booking } from './entities/booking.entity'; +import type { Rate } from '../rule-engine/entities/rate.entity'; + +const MOCK_CBE_RATE = 130; + +describe('BookingPricingService — domestic corridor', () => { + const intercityBulkUsd: Rate = { + id: 'rate-intercity-bulk-usd', + rateType: 'INTERCITY_BULK', + currency: 'USD', + rateValue: 35, + rateUnit: 'PER_TON', + status: 'LIVE', + containerTypeId: null, + } as Rate; + + const intercityContainerUsd: Rate = { + id: 'rate-intercity-container-usd', + rateType: 'INTERCITY_CONTAINER', + currency: 'USD', + rateValue: 400, + rateUnit: 'PER_CONTAINER', + status: 'LIVE', + containerTypeId: null, + } as Rate; + + let service: BookingPricingService; + let bookingsRepository: { calculateWagonCount: jest.Mock }; + let ratesService: { findLiveRates: jest.Mock }; + let cbeExchangeService: { getUsdToEtbRate: jest.Mock }; + + beforeEach(() => { + bookingsRepository = { calculateWagonCount: jest.fn().mockResolvedValue(2) }; + ratesService = { + findLiveRates: jest.fn().mockResolvedValue([intercityBulkUsd, intercityContainerUsd]), + }; + cbeExchangeService = { + getUsdToEtbRate: jest.fn().mockResolvedValue(MOCK_CBE_RATE), + }; + + service = new BookingPricingService( + bookingsRepository as never, + {} as never, + {} as never, + ratesService as never, + {} as never, + cbeExchangeService as never, + ); + }); + + it('prices domestic bulk in ETB using INTERCITY_BULK USD rate × CBE exchange rate', async () => { + const booking = { + id: 'b-1', + freightType: 'BULK', + tradeDirection: 'DOMESTIC', + paymentCurrency: 'ETB', + cargoTotalWeightVgm: 120, + bookingContainers: [], + } as unknown as Booking; + + const result = await ( + service as unknown as { + computeBaseRailLinesWithRates: ( + b: Booking, + input: { containers: [] }, + ) => Promise<{ lineItems: Array<{ amount: number; code: string; currency: string }> }>; + } + ).computeBaseRailLinesWithRates(booking, { containers: [] }); + + expect(result.lineItems).toHaveLength(1); + expect(result.lineItems[0].code).toBe('INTERCITY_BULK'); + expect(result.lineItems[0].currency).toBe('ETB'); + expect(result.lineItems[0].amount).toBe(Math.round(35 * 120 * MOCK_CBE_RATE)); + }); + + it('prices domestic bulk in USD using INTERCITY_BULK USD rate directly', async () => { + const booking = { + id: 'b-1-usd', + freightType: 'BULK', + tradeDirection: 'DOMESTIC', + paymentCurrency: 'USD', + cargoTotalWeightVgm: 120, + bookingContainers: [], + } as unknown as Booking; + + const result = await ( + service as unknown as { + computeBaseRailLinesWithRates: ( + b: Booking, + input: { containers: [] }, + ) => Promise<{ lineItems: Array<{ amount: number; code: string; currency: string }> }>; + } + ).computeBaseRailLinesWithRates(booking, { containers: [] }); + + expect(result.lineItems).toHaveLength(1); + expect(result.lineItems[0].code).toBe('INTERCITY_BULK'); + expect(result.lineItems[0].currency).toBe('USD'); + expect(result.lineItems[0].amount).toBe(35 * 120); + }); + + it('prices domestic container in ETB using INTERCITY_CONTAINER USD fallback × CBE rate', async () => { + const booking = { + id: 'b-2', + freightType: 'CONTAINER', + tradeDirection: 'DOMESTIC', + paymentCurrency: 'ETB', + cargoTotalWeightVgm: 50, + bookingContainers: [], + } as unknown as Booking; + + const result = await ( + service as unknown as { + computeBaseRailLinesWithRates: ( + b: Booking, + input: { + containers: Array<{ containerTypeId: string; quantity: number }>; + }, + ) => Promise<{ lineItems: Array<{ amount: number; code: string; currency: string }> }>; + } + ).computeBaseRailLinesWithRates(booking, { + containers: [{ containerTypeId: 'ct-20', quantity: 3 }], + }); + + expect(result.lineItems.some((l) => l.code === 'INTERCITY_CONTAINER')).toBe(true); + const line = result.lineItems.find((l) => l.code === 'INTERCITY_CONTAINER')!; + expect(line.currency).toBe('ETB'); + }); +}); diff --git a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts new file mode 100644 index 000000000..14d8a8dbe --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts @@ -0,0 +1,390 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; + +import { ContainerTypesService } from '../rule-engine/services/container-types.service'; +import { RatesService } from '../rule-engine/services/rates.service'; +import { ServiceTypesService } from '../rule-engine/services/service-types.service'; +import { Rate } from '../rule-engine/entities/rate.entity'; +import { CbeExchangeService } from '../cbe-exchange/cbe-exchange.service'; +import { + AppliedCargoModifier, + BookingEvaluationInput, + RuleEngineService, +} from '../rule-engine/rule-engine.service'; +import { BookingsRepository } from './bookings.repository'; +import { GeneratePriceResponseDto, PriceLineItemDto } from './dto/generate-price-response.dto'; +import { Booking } from './entities/booking.entity'; +import { assertBookingStatus } from './booking-status.util'; + +export interface ComputedPriceResult { + lineItems: PriceLineItemDto[]; + totalAmount: number; + currency: string; + usedRates: Rate[]; + appliedModifiers: AppliedCargoModifier[]; + priorityScore: number; + warnings: string[]; + hardBlocked: string[]; +} + +type StoredPricingBreakdown = { + lineItems?: PriceLineItemDto[]; + totalAmount?: number; + currency?: string; + generatedAt?: string; +} | null; + +@Injectable() +export class BookingPricingService { + constructor( + private readonly bookingsRepository: BookingsRepository, + private readonly ruleEngineService: RuleEngineService, + private readonly containerTypesService: ContainerTypesService, + private readonly ratesService: RatesService, + private readonly serviceTypesService: ServiceTypesService, + private readonly cbeExchangeService: CbeExchangeService, + ) {} + + async generatePrice(bookingId: string): Promise { + const booking = await this.requireBooking(bookingId); + assertBookingStatus(booking, ['DRAFT', 'CHANGES_REQUESTED']); + + const computed = await this.computePriceForBooking(booking); + this.ruleEngineService.assertNoHardBlocks({ + priorityScore: computed.priorityScore, + appliedModifiers: computed.appliedModifiers, + containerWeightResults: [], + warnings: computed.warnings, + hardBlocked: computed.hardBlocked, + requiresDirectorApproval: false, + }); + + await this.bookingsRepository.update(bookingId, { + totalAmount: computed.totalAmount, + priorityScore: computed.priorityScore, + pricingBreakdown: { + lineItems: computed.lineItems, + totalAmount: computed.totalAmount, + currency: computed.currency, + generatedAt: new Date().toISOString(), + }, + } as never); + + return { + bookingId, + totalAmount: computed.totalAmount, + currency: computed.currency, + lineItems: computed.lineItems, + warnings: computed.warnings, + }; + } + + async computePriceForBooking(booking: Booking): Promise { + const evalInput = await this.buildEvalInputForBooking(booking); + const ruleResult = await this.ruleEngineService.evaluate(evalInput); + + const paymentCurrency = booking.paymentCurrency; + const isEtbBooking = paymentCurrency === 'ETB'; + const usdToEtb = isEtbBooking ? await this.cbeExchangeService.getUsdToEtbRate() : 1; + + const lineItems: PriceLineItemDto[] = []; + let total = 0; + + const { lineItems: baseLines, usedRates: baseRates } = + await this.computeBaseRailLinesWithRates(booking, evalInput); + for (const line of baseLines) { + lineItems.push(line); + total += line.amount; + } + + const liveRates = await this.ratesService.findLiveRates(); + const rateById = new Map(liveRates.map((r) => [r.id, r])); + const usedRatesMap = new Map(baseRates.map((r) => [r.id, r])); + + for (const mod of ruleResult.appliedModifiers) { + const usdAmount = mod.calculatedAmount; + const convertedAmount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount; + const item: PriceLineItemDto = { + code: mod.surchargeTypeCode, + description: `Surcharge: ${mod.surchargeTypeCode}`, + amount: convertedAmount, + currency: paymentCurrency, + }; + lineItems.push(item); + total += convertedAmount; + + const rate = rateById.get(mod.rateId); + if (rate) usedRatesMap.set(rate.id, rate); + } + + return { + lineItems, + totalAmount: total, + currency: booking.paymentCurrency, + usedRates: [...usedRatesMap.values()], + appliedModifiers: ruleResult.appliedModifiers, + priorityScore: ruleResult.priorityScore, + warnings: ruleResult.warnings, + hardBlocked: ruleResult.hardBlocked, + }; + } + + pricesMatch(stored: StoredPricingBreakdown, computed: ComputedPriceResult): boolean { + if (!stored?.lineItems?.length) return false; + if (Number(stored.totalAmount) !== computed.totalAmount) return false; + return ( + this.lineItemsSignature(stored.lineItems) === + this.lineItemsSignature(computed.lineItems) + ); + } + + async createPricingSnapshots( + bookingId: string, + usedRates: Rate[], + appliedModifiers: AppliedCargoModifier[], + ): Promise { + await this.bookingsRepository.clearPricingArtifacts(bookingId); + const snapshots = await this.ruleEngineService.snapshotRates(bookingId, usedRates); + + const snapshotByRateId = new Map(snapshots.map((s) => [s.rateId, s.id])); + const rows = appliedModifiers + .map((m) => { + const snapshotId = snapshotByRateId.get(m.rateId); + if (!snapshotId) return null; + return { + bookingId, + surchargeTypeId: m.surchargeTypeId, + triggerValue: m.triggerValue, + calculatedAmount: m.calculatedAmount, + rateSnapshotId: snapshotId, + }; + }) + .filter((r): r is NonNullable => r !== null); + + if (rows.length > 0) { + await this.bookingsRepository.createCargoModifiers(rows); + } + } + + async buildEvalInputForBooking(booking: Booking): Promise { + const containers = await Promise.all( + (booking.bookingContainers ?? []) + .filter((bc): bc is typeof bc & { containerTypeId: string } => bc.containerTypeId != null) + .map(async (bc) => { + const ct = await this.containerTypesService.findById(bc.containerTypeId); + const vgm = Number(bc.vgmPerUnitTons); + const qty = bc.quantity; + return { + containerTypeId: bc.containerTypeId, + quantity: qty, + vgmPerUnitTons: vgm, + totalVgmTons: qty * vgm, + isReefer: ct.isReefer, + }; + }), + ); + // Wagon count is persisted per container line at booking creation; sum it. + const totalWagons = + booking.freightType === 'CONTAINER' + ? Math.ceil( + (booking.bookingContainers ?? []).reduce( + (sum, bc) => sum + Number(bc.wagonsRequired ?? 0), + 0, + ), + ) + : 0; + + return { + freightType: booking.freightType as 'CONTAINER' | 'BULK', + cargoTypeId: booking.cargoTypeId ?? null, + serviceTypeId: booking.serviceTypeId, + paymentCurrency: booking.paymentCurrency, + tradeDirection: booking.tradeDirection, + isHazardous: booking.isHazardous, + isGovernment: booking.isGovernment, + allowConsolidation: booking.allowConsolidation, + shippingLineId: booking.shippingLineId, + totalWagons, + containers, + }; + } + + private async requireBooking(id: string): Promise { + const booking = await this.bookingsRepository.findByIdWithFiles(id); + if (!booking) throw new NotFoundException(`Booking ${id} not found`); + return booking; + } + + /** Line items for contract schedule (uses stored breakdown or recomputes). */ + async computeContractLineItems(booking: Booking): Promise<{ + lineItems: PriceLineItemDto[]; + totalAmount: number; + currency: string; + }> { + const stored = booking.pricingBreakdown as StoredPricingBreakdown; + + if (stored?.lineItems?.length) { + return { + lineItems: stored.lineItems, + totalAmount: Number(stored.totalAmount ?? booking.totalAmount), + currency: stored.currency ?? booking.paymentCurrency, + }; + } + + const computed = await this.computePriceForBooking(booking); + + if (computed.lineItems.length === 0) { + const total = Number(booking.totalAmount); + return { + lineItems: [ + { + code: 'TOTAL', + description: 'Contract total', + amount: total, + currency: booking.paymentCurrency, + }, + ], + totalAmount: total, + currency: booking.paymentCurrency, + }; + } + + return { + lineItems: computed.lineItems, + totalAmount: computed.totalAmount || Number(booking.totalAmount), + currency: computed.currency, + }; + } + + /** Recompute priority on submit (USD + service tier). */ + async computeSubmitPriorityScore(booking: Booking): Promise { + const evalInput = await this.buildEvalInputForBooking(booking); + const ruleResult = await this.ruleEngineService.evaluate(evalInput); + let score = ruleResult.priorityScore; + + const serviceType = await this.serviceTypesService.findById(booking.serviceTypeId); + if (booking.paymentCurrency === 'USD' && serviceType) { + const code = (serviceType.code ?? '').toUpperCase(); + const hasForwarding = + serviceType.includesFirstMile || + serviceType.includesLastMile || + code.includes('FORWARD') || + code.includes('Y'); + const railOnly = code.includes('RAIL') && !hasForwarding; + + if (hasForwarding) score += 1000; + else if (railOnly || code.includes('X')) score += 500; + } + + return score; + } + + private async computeBaseRailLinesWithRates( + booking: Booking, + evalInput: BookingEvaluationInput, + ): Promise<{ lineItems: PriceLineItemDto[]; usedRates: Rate[] }> { + const liveRates = await this.ratesService.findLiveRates(); + const paymentCurrency = booking.paymentCurrency; + const isEtbBooking = paymentCurrency === 'ETB'; + const usdToEtb = isEtbBooking ? await this.cbeExchangeService.getUsdToEtbRate() : 1; + const isBulk = booking.freightType === 'BULK'; + + const rateType = + booking.tradeDirection === 'IMPORT' + ? isBulk + ? 'BULK_IMPORT' + : 'CONTAINER_IMPORT' + : booking.tradeDirection === 'EXPORT' + ? isBulk + ? 'BULK_EXPORT' + : 'CONTAINER_EXPORT' + : isBulk + ? 'INTERCITY_BULK' + : 'INTERCITY_CONTAINER'; + + const lines: PriceLineItemDto[] = []; + const usedRatesMap = new Map(); + const wagonCount = await this.bookingsRepository.calculateWagonCount(booking.id); + + for (const container of evalInput.containers) { + const rate = this.pickRate(liveRates, rateType, container.containerTypeId, 'USD'); + if (!rate) continue; + + usedRatesMap.set(rate.id, rate); + const usdAmount = this.amountForRate(rate, container.quantity, wagonCount); + const amount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount; + lines.push({ + code: rateType, + description: `Base rail (${rateType})`, + amount, + currency: paymentCurrency, + }); + } + + if (lines.length === 0) { + const fallback = liveRates.find( + (r) => r.rateType === rateType && r.currency === 'USD' && r.status === 'LIVE', + ); + if (fallback) { + usedRatesMap.set(fallback.id, fallback); + const bulkTons = Number(booking.cargoTotalWeightVgm ?? 0); + const quantity = + isBulk && fallback.rateUnit === 'PER_TON' ? Math.max(bulkTons, 0) : 1; + const usdAmount = this.amountForRate(fallback, quantity, wagonCount); + const amount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount; + lines.push({ + code: rateType, + description: `Base rail (${rateType})`, + amount, + currency: paymentCurrency, + }); + } + } + + return { lineItems: lines, usedRates: [...usedRatesMap.values()] }; + } + + private pickRate( + rates: Rate[], + rateType: string, + containerTypeId: string, + currency: string, + ): Rate | undefined { + return ( + rates.find( + (r) => + r.rateType === rateType && + r.currency === currency && + r.containerTypeId === containerTypeId, + ) ?? + rates.find((r) => r.rateType === rateType && r.currency === currency && !r.containerTypeId) + ); + } + + private amountForRate(rate: Rate, quantity: number, wagonCount: number): number { + const value = Number(rate.rateValue); + switch (rate.rateUnit) { + case 'PER_CONTAINER': + return value * quantity; + case 'PER_WAGON': + return value * wagonCount; + case 'PER_TON': + return value * quantity; + case 'FLAT': + return value; + default: + return value * quantity; + } + } + + private lineItemsSignature(items: PriceLineItemDto[]): string { + return JSON.stringify( + [...items] + .map((item) => ({ + code: item.code, + amount: item.amount, + currency: item.currency, + })) + .sort((a, b) => a.code.localeCompare(b.code)), + ); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-reference-data.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-reference-data.service.ts new file mode 100644 index 000000000..8a2d52172 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/booking-reference-data.service.ts @@ -0,0 +1,188 @@ +import { Inject, Injectable } from "@nestjs/common"; +import { In, Not } from "typeorm"; + +import { CargoType } from "../rule-engine/entities/cargo-type.entity"; +import { ContainerType } from "../rule-engine/entities/container-type.entity"; +import { + CARGO_TYPES_REPOSITORY, + ICargoTypesRepository, +} from "../rule-engine/interfaces/cargo-types.repository.interface"; +import { + CONTAINER_TYPES_REPOSITORY, + IContainerTypesRepository, +} from "../rule-engine/interfaces/container-types.repository.interface"; +import { + IServiceTypesRepository, + SERVICE_TYPES_REPOSITORY, +} from "../rule-engine/interfaces/service-types.repository.interface"; +import { + IShippingLinesRepository, + SHIPPING_LINES_REPOSITORY, +} from "../rule-engine/interfaces/shipping-lines.repository.interface"; +import { + IYardsRepository, + YARDS_REPOSITORY, +} from "../rule-engine/interfaces/yards.repository.interface"; +import { + BookingReferenceCargoTypeChildDto, + BookingReferenceCargoTypeGroupDto, + BookingReferenceContainerSizeGroupDto, + BookingReferenceContainerTypeDto, + BookingReferenceDataDto, + BookingReferenceServiceDto, + BookingReferenceShippingLineDto, + BookingReferenceYardDto, +} from "./dto/booking-reference-data.dto"; + +const LEGACY_YARD_CODES = ["LEGACY_ORIGIN", "LEGACY_DEST"] as const; + +export function buildCargoTypeTree( + rows: CargoType[], +): BookingReferenceCargoTypeGroupDto[] { + const active = rows.filter((r) => r.isActive); + const parents = active + .filter((r) => !r.parentGroupId) + .sort( + (a, b) => a.displayOrder - b.displayOrder || a.code.localeCompare(b.code), + ); + + return parents.map((parent) => { + const children = active + .filter((r) => r.parentGroupId === parent.id) + .sort( + (a, b) => + a.displayOrder - b.displayOrder || a.code.localeCompare(b.code), + ) + .map( + (child): BookingReferenceCargoTypeChildDto => ({ + id: child.id, + name: child.cargoTypeName, + code: child.code, + show_free_text_box: child.showFreeTextBox, + }), + ); + + const group: BookingReferenceCargoTypeGroupDto = { + id: parent.id, + name: parent.cargoTypeName, + code: parent.code, + }; + if (children.length > 0) { + group.children = children; + } + return group; + }); +} + +export function groupContainersBySize( + rows: ContainerType[], +): BookingReferenceContainerSizeGroupDto[] { + const active = rows.filter((r) => r.isActive); + const bySize = new Map(); + + for (const ct of active) { + const sizeKey = + ct.sizeFt != null && ct.sizeFt > 0 ? `${ct.sizeFt}ft` : "other"; + const list = bySize.get(sizeKey) ?? []; + list.push(ct); + bySize.set(sizeKey, list); + } + + const sortSizeKey = (key: string): number => { + if (key === "other") return Number.MAX_SAFE_INTEGER; + const n = parseInt(key, 10); + return Number.isNaN(n) ? Number.MAX_SAFE_INTEGER - 1 : n; + }; + + return [...bySize.entries()] + .sort(([a], [b]) => sortSizeKey(a) - sortSizeKey(b)) + .map(([size, types]) => ({ + size, + types: types + .sort( + (a, b) => + (a.displayOrder ?? 0) - (b.displayOrder ?? 0) || + a.code.localeCompare(b.code), + ) + .map( + (ct): BookingReferenceContainerTypeDto => ({ + id: ct.id, + name: ct.label?.trim() ? ct.label : ct.code, + code: ct.code, + is_reefer: ct.isReefer ?? false, + wagons_per_unit: Number(ct.wagonsPerUnit ?? 1), + }), + ), + })); +} + +@Injectable() +export class BookingReferenceDataService { + constructor( + @Inject(YARDS_REPOSITORY) + private readonly yardsRepository: IYardsRepository, + @Inject(CONTAINER_TYPES_REPOSITORY) + private readonly containerTypesRepository: IContainerTypesRepository, + @Inject(SERVICE_TYPES_REPOSITORY) + private readonly serviceTypesRepository: IServiceTypesRepository, + @Inject(SHIPPING_LINES_REPOSITORY) + private readonly shippingLinesRepository: IShippingLinesRepository, + @Inject(CARGO_TYPES_REPOSITORY) + private readonly cargoTypesRepository: ICargoTypesRepository, + ) { } + + async getReferenceData(): Promise { + const [yards, containerTypes, serviceTypes, shippingLines, cargoTypes] = + await Promise.all([ + this.yardsRepository.findAll({ + where: { + isActive: true, + code: Not(In([...LEGACY_YARD_CODES])), + }, + order: { displayOrder: "ASC", code: "ASC" }, + }), + this.containerTypesRepository.findAll({ + where: { isActive: true }, + order: { displayOrder: "ASC", code: "ASC" }, + }), + this.serviceTypesRepository.findAll({ + where: { isActive: true }, + order: { displayOrder: "ASC", code: "ASC" }, + }), + this.shippingLinesRepository.findAll({ + where: { isActive: true }, + order: { label: "ASC", code: "ASC" }, + }), + this.cargoTypesRepository.findAll({ + where: { isActive: true }, + order: { displayOrder: "ASC", code: "ASC" }, + }), + ]); + + return { + yard: yards.map( + (y): BookingReferenceYardDto => ({ + id: y.id, + name: y.label, + code: y.code, + country: y.country, + }), + ), + containers: groupContainersBySize(containerTypes), + service: serviceTypes.map( + (s): BookingReferenceServiceDto => ({ + name: s.serviceName, + ...s, + }), + ), + shipping_line: shippingLines.map( + (sl): BookingReferenceShippingLineDto => ({ + id: sl.id, + name: sl.label, + code: sl.code, + }), + ), + cargo_type: buildCargoTypeTree(cargoTypes), + }; + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-status.util.ts b/apps/edr-freight-api/src/modules/bookings/booking-status.util.ts new file mode 100644 index 000000000..fe9152149 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/booking-status.util.ts @@ -0,0 +1,10 @@ +import { ConflictException } from '@nestjs/common'; +import { Booking } from './entities/booking.entity'; + +export function assertBookingStatus(booking: Booking, allowed: string[]): void { + if (!allowed.includes(booking.status)) { + throw new ConflictException( + `Cannot perform this action on status "${booking.status}". Allowed: ${allowed.join(', ')}`, + ); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts new file mode 100644 index 000000000..c41013ddb --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts @@ -0,0 +1,448 @@ +import { + BadRequestException, + forwardRef, + Inject, + Injectable, +} from '@nestjs/common'; +import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; + +import { assertCanApproveBookingStep } from '../../common/freight-permission.util'; +import { RuleEngineService } from '../rule-engine/rule-engine.service'; +import { BookingContractService } from './booking-contract.service'; +import { BookingPricingService } from './booking-pricing.service'; +import { BookingsRepository } from './bookings.repository'; +import { assertBookingStatus } from './booking-status.util'; +import { computeNextStep, type BookingNextStep } from './booking-next-step.util'; +import { SubmitBookingResponseDto } from './dto/submit-booking-response.dto'; +import { PriceLineItemDto } from './dto/generate-price-response.dto'; +import { Booking } from './entities/booking.entity'; +import { BookingsService } from './bookings.service'; + +@Injectable() +export class BookingTransitionService { + constructor( + private readonly bookingsRepository: BookingsRepository, + private readonly ruleEngineService: RuleEngineService, + private readonly pricingService: BookingPricingService, + private readonly contractService: BookingContractService, + @Inject(forwardRef(() => BookingsService)) + private readonly bookingsService: BookingsService, + ) {} + + async submit(bookingId: string): Promise { + const booking = await this.bookingsService.findById(bookingId); + assertBookingStatus(booking, ['DRAFT', 'CHANGES_REQUESTED']); + + if (Number(booking.totalAmount) <= 0) { + throw new BadRequestException( + 'Generate a price before submitting (POST /bookings/:id/generate-price)', + ); + } + + const computed = await this.pricingService.computePriceForBooking(booking); + this.ruleEngineService.assertNoHardBlocks({ + priorityScore: computed.priorityScore, + appliedModifiers: computed.appliedModifiers, + containerWeightResults: [], + warnings: computed.warnings, + hardBlocked: computed.hardBlocked, + requiresDirectorApproval: false, + }); + + const stored = booking.pricingBreakdown as { + lineItems?: PriceLineItemDto[]; + totalAmount?: number; + } | null; + const unchanged = this.pricingService.pricesMatch(stored, computed); + const priorityScore = await this.pricingService.computeSubmitPriorityScore(booking); + + if (unchanged) { + await this.pricingService.createPricingSnapshots( + bookingId, + computed.usedRates, + computed.appliedModifiers, + ); + + const updated = await this.bookingsRepository.update(bookingId, { + status: 'SUBMITTED', + priorityScore, + } as never); + + // Auto-consolidate now: a partial-wagon booking either pairs with a waiting + // partner (both → SUBMITTED) or is parked as PENDING_CONSOLIDATION until one + // arrives. The returned status reflects that outcome. + const finalBooking = await this.bookingsService.runConsolidationOnSubmit( + updated!.id, + ); + return { + bookingId: finalBooking.id, + status: finalBooking.status, + priceChanged: false, + totalAmount: Number(finalBooking.totalAmount), + currency: finalBooking.paymentCurrency, + lineItems: computed.lineItems, + }; + } + + const previousTotalAmount = Number(booking.totalAmount); + await this.bookingsRepository.update(bookingId, { + totalAmount: computed.totalAmount, + priorityScore: computed.priorityScore, + pricingBreakdown: { + lineItems: computed.lineItems, + totalAmount: computed.totalAmount, + currency: computed.currency, + generatedAt: new Date().toISOString(), + }, + status: 'PRICE_CHANGED_PENDING_CONFIRM', + } as never); + + const updatedBooking = await this.bookingsService.findById(bookingId); + return { + bookingId: updatedBooking.id, + status: updatedBooking.status, + priceChanged: true, + previousTotalAmount, + totalAmount: computed.totalAmount, + currency: computed.currency, + lineItems: computed.lineItems, + message: 'Price has changed since preview. Confirm to submit with the updated price.', + }; + } + + async confirmSubmit(bookingId: string): Promise { + const booking = await this.bookingsService.findById(bookingId); + assertBookingStatus(booking, ['PRICE_CHANGED_PENDING_CONFIRM']); + + if (Number(booking.totalAmount) <= 0) { + throw new BadRequestException('No price to confirm'); + } + + const computed = await this.pricingService.computePriceForBooking(booking); + this.ruleEngineService.assertNoHardBlocks({ + priorityScore: computed.priorityScore, + appliedModifiers: computed.appliedModifiers, + containerWeightResults: [], + warnings: computed.warnings, + hardBlocked: computed.hardBlocked, + requiresDirectorApproval: false, + }); + + await this.pricingService.createPricingSnapshots( + bookingId, + computed.usedRates, + computed.appliedModifiers, + ); + + const priorityScore = await this.pricingService.computeSubmitPriorityScore(booking); + const updated = await this.bookingsRepository.update(bookingId, { + status: 'SUBMITTED', + priorityScore, + totalAmount: computed.totalAmount, + pricingBreakdown: { + lineItems: computed.lineItems, + totalAmount: computed.totalAmount, + currency: computed.currency, + generatedAt: new Date().toISOString(), + }, + } as never); + + // Same consolidation treatment as the direct submit path. + const finalBooking = await this.bookingsService.runConsolidationOnSubmit( + updated!.id, + ); + return { + bookingId: finalBooking.id, + status: finalBooking.status, + priceChanged: false, + totalAmount: Number(finalBooking.totalAmount), + currency: finalBooking.paymentCurrency, + lineItems: computed.lineItems, + message: 'Booking submitted with confirmed price.', + }; + } + + async requestChanges( + bookingId: string, + note: string, + actorId: string, + ): Promise { + const booking = await this.bookingsService.findById(bookingId); + assertBookingStatus(booking, ['SUBMITTED']); + + await this.bookingsRepository.createReviewNote( + bookingId, + note, + 'CHANGES_REQUESTED', + actorId, + ); + + const updated = await this.bookingsRepository.update(bookingId, { + status: 'CHANGES_REQUESTED', + } as never); + return this.bookingsService.findById(updated!.id); + } + + /** Auto-create booking approval steps from system rules when none exist yet. */ + private async ensureBookingApprovalSteps(booking: Booking): Promise { + if ((booking.approvalSteps?.length ?? 0) > 0) return; + + await this.ruleEngineService.instantiateApprovalSteps(booking.id, { + freightType: booking.freightType as 'CONTAINER' | 'BULK', + cargoTypeId: booking.cargoTypeId, + }); + } + + async acceptIntake(bookingId: string, actorId: string): Promise { + const booking = await this.bookingsService.findById(bookingId); + // Only SUBMITTED bookings are acceptable. A booking that still needs + // consolidation sits in PENDING_CONSOLIDATION (resolved at submit time) and + // is therefore never offered for accept until a partner moves it to SUBMITTED. + assertBookingStatus(booking, ['SUBMITTED']); + + await this.ruleEngineService.instantiateApprovalSteps(bookingId, { + freightType: booking.freightType as 'CONTAINER' | 'BULK', + cargoTypeId: booking.cargoTypeId, + }); + + const updated = await this.bookingsRepository.update(bookingId, { + status: 'PENDING_APPROVAL', + approvedByStaffId: actorId, + approvedByStaffAt: new Date(), + } as never); + return this.bookingsService.findById(updated!.id); + } + + async staffReject( + bookingId: string, + reason: string, + actorId: string, + ): Promise { + const booking = await this.bookingsService.findById(bookingId); + assertBookingStatus(booking, ['SUBMITTED', 'PENDING_APPROVAL']); + + await this.bookingsRepository.createReviewNote( + bookingId, + reason, + 'REJECTION', + actorId, + ); + + const updated = await this.bookingsRepository.update(bookingId, { + status: 'REJECTED', + } as never); + return this.bookingsService.findById(updated!.id); + } + + async approveStep( + bookingId: string, + stepId: string, + actorId: string, + requiredRole: string, + authUser?: TCurrentUser, + ): Promise { + if (authUser) { + assertCanApproveBookingStep(authUser, requiredRole); + } + + let booking = await this.bookingsService.findById(bookingId); + assertBookingStatus(booking, [ + 'PENDING_APPROVAL', + 'APPROVED_PENDING_SIGNATURE', + ]); + + if ((booking.approvalSteps?.length ?? 0) === 0) { + await this.ensureBookingApprovalSteps(booking); + booking = await this.bookingsService.findById(bookingId); + } + + const step = await this.bookingsRepository.findApprovalStepById( + bookingId, + stepId, + ); + if (!step || step.status !== 'PENDING') { + throw new BadRequestException('Approval step not found or already actioned'); + } + + const next = await this.bookingsRepository.findNextPendingApprovalStep(bookingId); + if (!next || next.id !== step.id) { + throw new BadRequestException( + 'Approval steps must be completed in order', + ); + } + + if (step.requiredRole !== requiredRole) { + throw new BadRequestException( + `Step requires role ${step.requiredRole}, not ${requiredRole}`, + ); + } + + const blocksRole = step.blocksRole; + if (blocksRole && blocksRole === requiredRole) { + throw new BadRequestException(`Role ${requiredRole} is blocked for this step`); + } + + await this.bookingsRepository.completeApprovalStep(step.id, actorId, 'APPROVED'); + + const updates: Record = {}; + const now = new Date(); + + if (requiredRole === 'LINE_STAFF') { + updates.status = 'APPROVED_PENDING_SIGNATURE'; + updates.approvedByStaffId = actorId; + updates.approvedByStaffAt = now; + } else if (requiredRole === 'DIRECTOR') { + updates.signedByDirectorId = actorId; + updates.signedByDirectorAt = now; + } else if (requiredRole === 'CEO') { + updates.signedByCeoId = actorId; + updates.signedByCeoAt = now; + } + + const allDone = await this.bookingsRepository.allApprovalStepsComplete(bookingId); + if (allDone) { + updates.status = 'APPROVED'; + } + + if (Object.keys(updates).length > 0) { + await this.bookingsRepository.update(bookingId, updates as never); + } + + if (allDone) { + const generated = await this.contractService.generateContract(bookingId); + return this.bookingsService.findById(generated.id); + } + + return this.bookingsService.findById(bookingId); + } + + async rejectStep( + bookingId: string, + stepId: string, + actorId: string, + reason: string, + ): Promise { + const booking = await this.bookingsService.findById(bookingId); + assertBookingStatus(booking, ['PENDING_APPROVAL', 'APPROVED_PENDING_SIGNATURE']); + + const step = await this.bookingsRepository.findApprovalStepById( + bookingId, + stepId, + ); + if (!step) throw new BadRequestException('Approval step not found'); + + await this.bookingsRepository.completeApprovalStep( + step.id, + actorId, + 'REJECTED', + reason, + ); + + await this.bookingsRepository.createReviewNote( + bookingId, + reason, + 'REJECTION', + actorId, + ); + + const updated = await this.bookingsRepository.update(bookingId, { + status: 'REJECTED', + } as never); + return this.bookingsService.findById(updated!.id); + } + + async customerSign(bookingId: string): Promise { + const booking = await this.bookingsService.findById(bookingId); + assertBookingStatus(booking, ['CONTRACT_READY']); + + const updated = await this.bookingsRepository.update(bookingId, { + status: 'SIGNED_CUSTOMER', + customerSignedAt: new Date(), + } as never); + return this.bookingsService.findById(updated!.id); + } + + async marketingApprove(bookingId: string, actorId: string): Promise { + const booking = await this.bookingsService.findById(bookingId); + assertBookingStatus(booking, ['SIGNED_CUSTOMER']); + + const updated = await this.bookingsRepository.update(bookingId, { + status: 'FULLY_EXECUTED', + fullyExecutedAt: new Date(), + marketingApprovedById: actorId, + marketingApprovedAt: new Date(), + lockedAt: new Date(), + } as never); + return this.bookingsService.findById(updated!.id); + } + + async startTransit(bookingId: string): Promise { + const booking = await this.bookingsService.findById(bookingId); + assertBookingStatus(booking, ['PAID']); + + const updated = await this.bookingsRepository.update(bookingId, { + status: 'IN_TRANSIT', + } as never); + return this.bookingsService.findById(updated!.id); + } + + async complete(bookingId: string): Promise { + const booking = await this.bookingsService.findById(bookingId); + assertBookingStatus(booking, ['IN_TRANSIT']); + + const updated = await this.bookingsRepository.update(bookingId, { + status: 'COMPLETED', + endDate: new Date(), + } as never); + return this.bookingsService.findById(updated!.id); + } + + async cancel(bookingId: string, reason: string): Promise { + const booking = await this.bookingsService.findById(bookingId); + assertBookingStatus(booking, [ + 'DRAFT', + 'SUBMITTED', + 'PRICE_CHANGED_PENDING_CONFIRM', + 'CHANGES_REQUESTED', + 'PENDING_APPROVAL', + 'CONTRACT_READY', + ]); + + await this.bookingsRepository.createReviewNote( + bookingId, + reason, + 'REJECTION', + ); + + const updated = await this.bookingsRepository.update(bookingId, { + status: 'CANCELLED', + } as never); + return this.bookingsService.findById(updated!.id); + } + + async enrichBookingResponse(booking: Booking): Promise { + const note = await this.bookingsRepository.findLatestReviewNote( + booking.id, + 'CHANGES_REQUESTED', + ); + const summary = + booking.contractSummary ?? + this.contractService.buildContractSummary(booking); + const nextPending = + booking.status === 'PENDING_APPROVAL' || + booking.status === 'APPROVED_PENDING_SIGNATURE' + ? await this.bookingsRepository.findNextPendingApprovalStep(booking.id) + : null; + const nextStep = computeNextStep(booking, nextPending); + return { + ...booking, + latestChangeRequestNote: note?.note ?? null, + contractSummary: summary, + nextStep, + }; + } +} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts index 4ed037cfc..ae0f1765a 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -6,43 +6,539 @@ import { HttpCode, Param, ParseUUIDPipe, + Patch, Post, Query, -} from "@nestjs/common"; -import { ApiOperation, ApiTags } from "@nestjs/swagger"; + Request, + Res, + UnauthorizedException, + UploadedFiles, + UseInterceptors, +} from '@nestjs/common'; +import { CurrentUser } from '@edr/api-common'; +import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; +import { BookingStaff } from '../../common/booking-guards'; +import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; +import { AnyFilesInterceptor } from '@nestjs/platform-express'; +import { + ApiBearerAuth, + ApiBody, + ApiConsumes, + ApiOkResponse, + ApiOperation, + ApiTags, +} from '@nestjs/swagger'; +import type { Response } from 'express'; -import { BookingsService } from "./bookings.service"; -import { CreateBookingDto } from "./dto/create-booking.dto"; -import { FilterBookingDto } from "./dto/filter-booking.dto"; +import { BookingContractService } from './booking-contract.service'; +import { BookingPricingService } from './booking-pricing.service'; +import { BookingTransitionService } from './booking-transition.service'; +import { BookingReferenceDataService } from './booking-reference-data.service'; +import { BookingsService } from './bookings.service'; +import { BookingReferenceDataDto } from './dto/booking-reference-data.dto'; +import { CreateBookingDto } from './dto/create-booking.dto'; +import { BookingListSummaryDto } from './dto/booking-list-summary.dto'; +import { FilterBookingDto } from './dto/filter-booking.dto'; +import { GeneratePriceResponseDto } from './dto/generate-price-response.dto'; +import { SubmitBookingResponseDto } from './dto/submit-booking-response.dto'; +import { + ApproveStepDto, + CancelBookingDto, + RejectStepDto, + RequestChangesDto, + StaffRejectDto, +} from './dto/request-changes.dto'; +import { ContractViewDto } from './dto/contract-view.dto'; +import { SignContractDto } from './dto/sign-contract.dto'; +import { UpdateBookingDto } from './dto/update-booking.dto'; +import { + type AuthUserPayload, + resolveAuthUserId, +} from '../../common/resolve-auth-user-id'; +import { assertFreightPermission, hasFreightPermission } from '../../common/freight-permission.util'; -@ApiTags("bookings") -// @UseGuards(JwtAuthGuard) — TODO: integrate @edr/auth -@Controller("bookings") +@ApiTags('bookings') +@Controller('bookings') +@ApiBearerAuth() export class BookingsController { - constructor(private readonly bookingsService: BookingsService) {} + constructor( + private readonly bookingsService: BookingsService, + private readonly bookingReferenceDataService: BookingReferenceDataService, + private readonly pricingService: BookingPricingService, + private readonly transitionService: BookingTransitionService, + private readonly contractService: BookingContractService, + ) {} @Post() - @ApiOperation({ summary: "Create a new freight booking" }) - create(@Body() dto: CreateBookingDto) { - return this.bookingsService.create(dto); + @UseInterceptors(AnyFilesInterceptor()) + @ApiConsumes('multipart/form-data') + @ApiOperation({ summary: 'Create a new freight booking (DRAFT)' }) + @ApiBody({ type: CreateBookingDto }) + async create( + @Body() dto: CreateBookingDto, + @UploadedFiles() files: Express.Multer.File[], + @CurrentUser() user: TCurrentUser, + ) { + if (dto.isGovernment) { + assertFreightPermission(user, FREIGHT_PERMS.bookings.staffAccept); + } + const result = await this.bookingsService.create(dto, files ?? [], user?.id); + + // Staff-created commercial bookings skip the draft stage: auto generate-price + submit. + const isStaff = hasFreightPermission(user, FREIGHT_PERMS.bookings.staffAccept); + if (isStaff && !dto.isGovernment) { + try { + await this.pricingService.generatePrice(result.booking.id); + await this.transitionService.submit(result.booking.id); + const submitted = await this.bookingsService.findById(result.booking.id); + return { booking: submitted, warnings: result.warnings }; + } catch { + // If auto-pricing/submit fails, fall back to the DRAFT so staff can finish manually. + return result; + } + } + return result; + } + + @Patch(':id') + @UseInterceptors(AnyFilesInterceptor()) + @ApiConsumes('multipart/form-data') + @ApiOperation({ + summary: 'Update booking', + description: 'Allowed when status is DRAFT or CHANGES_REQUESTED.', + }) + @ApiBody({ type: UpdateBookingDto }) + update( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: UpdateBookingDto, + @UploadedFiles() files: Express.Multer.File[], + ) { + return this.bookingsService.update(id, dto, files ?? []); } @Get() - @ApiOperation({ summary: "List freight bookings (paginated)" }) - findAll(@Query() filter: FilterBookingDto) { - return this.bookingsService.findAll(filter); + @ApiOperation({ summary: 'List freight bookings (paginated)' }) + async findAll( + @Query() filter: FilterBookingDto, + @CurrentUser() user: TCurrentUser, + ) { + // Staff (backoffice) see every booking. Customers (portal) are always + // force-scoped to their own company, regardless of any companyId they pass. + if (hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { + return this.bookingsService.findAll(filter); + } + const userId = user?.id; + if (!userId) throw new UnauthorizedException('Authentication required'); + const companyId = + await this.bookingsService.resolveCustomerCompanyId(userId); + // No linked company yet → no bookings to show (avoids leaking all bookings). + if (!companyId) return { items: [], total: 0 }; + return this.bookingsService.findAll(filter, companyId); } - @Get(":id") - @ApiOperation({ summary: "Get a freight booking by ID" }) - findOne(@Param("id", ParseUUIDPipe) id: string) { - return this.bookingsService.findById(id); + @Get('list-summary') + @ApiOperation({ summary: 'Booking list metrics and tab counts (backoffice)' }) + @ApiOkResponse({ type: BookingListSummaryDto }) + findListSummary(@Query() filter: FilterBookingDto) { + return this.bookingsService.getListSummary(filter); } - @Delete(":id") + @Get('my') + @ApiOperation({ + summary: "List the current customer's bookings ready for payment", + description: + 'Bookings owned by the authenticated user\'s company that are payable ' + + '(FULLY_EXECUTED, SELECTED_FOR_BATCH, AWAITING_PAYMENT) and not yet PAID.', + }) + findMyPayable( + @CurrentUser() user: AuthUserPayload, + @Query() filter: FilterBookingDto, + ) { + return this.bookingsService.findMyPayable(resolveAuthUserId(user), filter); + } + + @Get('queues/:queue') + @ApiOperation({ + summary: 'List bookings for a dashboard queue', + description: 'Queues: intake, approval, signatures, marketing, finance', + }) + findQueue( + @Param('queue') queue: string, + @Query() filter: FilterBookingDto, + @Query('excludeBulk') excludeBulk?: string, + ) { + return this.bookingsService.findQueue(queue, filter, { + excludeBulk: excludeBulk === 'true', + }); + } + + @Get('reference-data') + @ApiOperation({ summary: 'Booking form catalog' }) + @ApiOkResponse({ type: BookingReferenceDataDto }) + getReferenceData(): Promise { + return this.bookingReferenceDataService.getReferenceData(); + } + + @Get('by-reference/:reference') + @ApiOperation({ summary: 'Get booking by reference' }) + async findByReference( + @Param('reference') reference: string, + @CurrentUser() user: TCurrentUser, + ) { + const booking = await this.bookingsService.findByReference(reference); + // Staff see any booking; customers only their own company's. + if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { + await this.bookingsService.assertCustomerCanAccessBooking( + user?.id, + booking, + ); + } + return this.transitionService.enrichBookingResponse(booking); + } + + @Get(':id') + @ApiOperation({ summary: 'Get booking by ID' }) + async findOne( + @Param('id', ParseUUIDPipe) id: string, + @CurrentUser() user: TCurrentUser, + ) { + const booking = await this.bookingsService.findById(id); + // Staff see any booking; customers only their own company's. + if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { + await this.bookingsService.assertCustomerCanAccessBooking( + user?.id, + booking, + ); + } + return this.transitionService.enrichBookingResponse(booking); + } + + @Get(':id/tracking') + @ApiOperation({ + summary: 'Shipment tracking timeline for a booking', + description: + "Returns the booking's consignment (once dispatched) and its ordered " + + 'tracking events. Scoped to the customer\'s own company.', + }) + async findTracking( + @Param('id', ParseUUIDPipe) id: string, + @CurrentUser() user: TCurrentUser, + ) { + const booking = await this.bookingsService.findById(id); + // Staff see any booking; customers only their own company's. + if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { + await this.bookingsService.assertCustomerCanAccessBooking( + user?.id, + booking, + ); + } + return this.bookingsService.getBookingTracking(id); + } + + @Delete(':id') @HttpCode(204) - @ApiOperation({ summary: "Soft-delete a freight booking" }) - remove(@Param("id", ParseUUIDPipe) id: string) { + @ApiOperation({ summary: 'Soft-delete DRAFT booking' }) + remove(@Param('id', ParseUUIDPipe) id: string) { return this.bookingsService.remove(id); } + + @Post(':id/documents') + @UseInterceptors(AnyFilesInterceptor()) + @ApiConsumes('multipart/form-data') + @ApiOperation({ summary: 'Upload documents for a booking (DRAFT only)' }) + async uploadDocuments( + @Param('id', ParseUUIDPipe) id: string, + @UploadedFiles() files: Express.Multer.File[], + ) { + const booking = await this.bookingsService.uploadDocuments(id, files ?? []); + return this.transitionService.enrichBookingResponse(booking); + } + + @Post(':id/generate-price') + @ApiOperation({ + summary: 'Generate price preview (DRAFT or CHANGES_REQUESTED)', + description: + 'Computes and stores a price preview on the booking. Does not create rate snapshots.', + }) + @ApiOkResponse({ type: GeneratePriceResponseDto }) + generatePrice(@Param('id', ParseUUIDPipe) id: string) { + return this.pricingService.generatePrice(id); + } + + @Post(':id/submit') + @ApiOperation({ + summary: 'Customer submit booking', + description: + 'Recomputes price against live rates. If unchanged, creates rate snapshots and submits. If changed, updates the booking price and returns priceChanged=true for confirmation.', + }) + @ApiOkResponse({ type: SubmitBookingResponseDto }) + submit(@Param('id', ParseUUIDPipe) id: string) { + return this.transitionService.submit(id); + } + + @Post(':id/confirm-submit') + @ApiOperation({ + summary: 'Confirm submit after price change', + description: + 'Creates rate snapshots for the updated booking price and moves the booking to SUBMITTED.', + }) + @ApiOkResponse({ type: SubmitBookingResponseDto }) + confirmSubmit(@Param('id', ParseUUIDPipe) id: string) { + return this.transitionService.confirmSubmit(id); + } + + @Post(':id/staff/request-changes') + @BookingStaff(FREIGHT_PERMS.bookings.requestChanges) + @ApiOperation({ summary: 'Staff return booking for customer updates' }) + async requestChanges( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: RequestChangesDto, + @CurrentUser() user: AuthUserPayload, + ) { + const booking = await this.transitionService.requestChanges( + id, + dto.note, + resolveAuthUserId(user), + ); + return this.transitionService.enrichBookingResponse(booking); + } + + @Post(':id/staff/accept') + @BookingStaff(FREIGHT_PERMS.bookings.staffAccept) + @ApiOperation({ summary: 'Staff accept intake → start approval chain' }) + async acceptIntake( + @Param('id', ParseUUIDPipe) id: string, + @CurrentUser() user: AuthUserPayload, + ) { + const booking = await this.transitionService.acceptIntake( + id, + resolveAuthUserId(user), + ); + return this.transitionService.enrichBookingResponse(booking); + } + + @Post(':id/staff/reject') + @BookingStaff(FREIGHT_PERMS.bookings.reject) + @ApiOperation({ summary: 'Staff final reject' }) + async staffReject( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: StaffRejectDto, + @CurrentUser() user: AuthUserPayload, + ) { + const booking = await this.transitionService.staffReject( + id, + dto.reason, + resolveAuthUserId(user), + ); + return this.transitionService.enrichBookingResponse(booking); + } + + @Post(':id/government-expedite') + @BookingStaff(FREIGHT_PERMS.bookings.staffAccept) + @ApiOperation({ summary: 'Expedite government booking to PAID / ELIGIBLE for scheduling' }) + async governmentExpedite( + @Param('id', ParseUUIDPipe) id: string, + @CurrentUser() user: AuthUserPayload, + ) { + const booking = await this.bookingsService.governmentExpedite( + id, + resolveAuthUserId(user), + ); + return this.transitionService.enrichBookingResponse(booking); + } + + @Post(':id/approval-steps/:stepId/approve') + @BookingStaff([ + FREIGHT_PERMS.bookings.approveLineStaff, + FREIGHT_PERMS.bookings.approveDirector, + FREIGHT_PERMS.bookings.approveCeo, + ]) + @ApiOperation({ summary: 'Approve one approval step in sequence' }) + async approveStep( + @Param('id', ParseUUIDPipe) id: string, + @Param('stepId', ParseUUIDPipe) stepId: string, + @Body() dto: ApproveStepDto, + @CurrentUser() user: TCurrentUser, + ) { + const booking = await this.transitionService.approveStep( + id, + stepId, + resolveAuthUserId(user), + dto.requiredRole, + user, + ); + return this.transitionService.enrichBookingResponse(booking); + } + + @Post(':id/approval-steps/:stepId/reject') + @BookingStaff(FREIGHT_PERMS.bookings.rejectApproval) + @ApiOperation({ summary: 'Reject at approval step' }) + async rejectStep( + @Param('id', ParseUUIDPipe) id: string, + @Param('stepId', ParseUUIDPipe) stepId: string, + @Body() dto: RejectStepDto, + @CurrentUser() user: AuthUserPayload, + ) { + const booking = await this.transitionService.rejectStep( + id, + stepId, + resolveAuthUserId(user), + dto.reason, + ); + return this.transitionService.enrichBookingResponse(booking); + } + + @Post(':id/contract/generate') + @BookingStaff(FREIGHT_PERMS.bookings.generateContract) + @ApiOperation({ summary: 'Generate contract PDF from template' }) + async generateContract(@Param('id', ParseUUIDPipe) id: string) { + const booking = await this.contractService.generateContract(id); + return this.transitionService.enrichBookingResponse(booking); + } + + @Get(':id/contract/view') + @ApiOkResponse({ type: ContractViewDto }) + @ApiOperation({ summary: 'Contract HTML view for portal and backoffice' }) + getContractView( + @Param('id', ParseUUIDPipe) id: string, + @Request() req: { user?: { id?: string; sub?: string } }, + ) { + const userId = req.user?.id ?? req.user?.sub; + return this.contractService.getContractView(id, userId); + } + + @Get(':id/contract/document') + @ApiOperation({ summary: 'Download contract PDF' }) + async downloadContractDocument( + @Param('id', ParseUUIDPipe) id: string, + @Res() res: Response, + ): Promise { + const { stream, record } = await this.contractService.streamContract(id); + res.setHeader('Content-Type', record.mimeType ?? 'application/pdf'); + res.setHeader( + 'Content-Disposition', + `attachment; filename="${record.name}"`, + ); + stream.pipe(res); + } + + @Get(':id/contract') + @ApiOperation({ summary: 'Download contract file (alias)' }) + async downloadContract( + @Param('id', ParseUUIDPipe) id: string, + @Res() res: Response, + ): Promise { + return this.downloadContractDocument(id, res); + } + + @Post(':id/contract/sign') + @ApiOperation({ summary: 'Apply digital signature (customer or staff)' }) + async signContract( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: SignContractDto, + @Request() req: { user?: { id?: string; sub?: string }; ip?: string }, + ) { + const userId = req.user?.id ?? req.user?.sub; + const booking = await this.contractService.signContract(id, dto, { + signerUserId: userId, + ipAddress: req.ip, + }); + return this.transitionService.enrichBookingResponse(booking); + } + + @Get(':id/contract/signatures') + @ApiOperation({ summary: 'List contract signatures' }) + getContractSignatures(@Param('id', ParseUUIDPipe) id: string) { + return this.contractService.getSignatures(id); + } + + @Get(':id/summary') + @ApiOperation({ summary: 'Contract summary string for dashboard' }) + getSummary(@Param('id', ParseUUIDPipe) id: string) { + return this.contractService.getSummary(id); + } + + @Post(':id/customer/sign') + @ApiOperation({ + summary: 'Customer digital signature (deprecated — use POST contract/sign)', + }) + async customerSign( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: SignContractDto, + @Request() req: { user?: { id?: string; sub?: string }; ip?: string }, + ) { + const payload: SignContractDto = { ...dto, role: 'CUSTOMER' }; + const booking = await this.contractService.signContract(id, payload, { + signerUserId: req.user?.id ?? req.user?.sub, + ipAddress: req.ip, + }); + return this.transitionService.enrichBookingResponse(booking); + } + + @Post(':id/marketing/approve') + @BookingStaff(FREIGHT_PERMS.bookings.signStaff) + @ApiOperation({ + summary: 'Staff contract signature and fully execute (use contract/sign STAFF preferred)', + }) + async marketingApprove( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: SignContractDto, + @CurrentUser() user: AuthUserPayload, + @Request() req: { ip?: string }, + ) { + const payload: SignContractDto = { + ...dto, + role: 'STAFF', + }; + const booking = await this.contractService.signContract(id, payload, { + signerUserId: resolveAuthUserId(user), + ipAddress: req.ip, + }); + return this.transitionService.enrichBookingResponse(booking); + } + + @Post(':id/operations/start-transit') + @BookingStaff(FREIGHT_PERMS.bookings.operations) + @ApiOperation({ summary: 'Mark in transit' }) + async startTransit(@Param('id', ParseUUIDPipe) id: string) { + const booking = await this.transitionService.startTransit(id); + return this.transitionService.enrichBookingResponse(booking); + } + + @Post(':id/operations/complete') + @BookingStaff(FREIGHT_PERMS.bookings.operations) + @ApiOperation({ summary: 'Mark completed' }) + async complete(@Param('id', ParseUUIDPipe) id: string) { + const booking = await this.transitionService.complete(id); + return this.transitionService.enrichBookingResponse(booking); + } + + @Post(':id/cancel') + @BookingStaff(FREIGHT_PERMS.bookings.cancel) + @ApiOperation({ summary: 'Cancel booking' }) + async cancel( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: CancelBookingDto, + ) { + const booking = await this.transitionService.cancel(id, dto.reason); + return this.transitionService.enrichBookingResponse(booking); + } + + @Post(':id/consolidation') + @ApiOperation({ summary: 'Request freight consolidation' }) + requestConsolidation(@Param('id', ParseUUIDPipe) id: string) { + return this.bookingsService.requestConsolidation(id); + } + + @Delete(':id/consolidation') + @ApiOperation({ summary: 'Remove consolidation pairing' }) + removeConsolidation(@Param('id', ParseUUIDPipe) id: string) { + return this.bookingsService.removeConsolidation(id); + } + + @Get(':id/consolidation') + @ApiOperation({ summary: 'Get consolidation details' }) + getConsolidationDetails(@Param('id', ParseUUIDPipe) id: string) { + return this.bookingsService.getConsolidationDetails(id); + } } diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts index 98bf64196..f55a5a0f8 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts @@ -1,15 +1,75 @@ -import { Module } from "@nestjs/common"; -import { TypeOrmModule } from "@nestjs/typeorm"; +import { Module, forwardRef } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; -import { BookingsController } from "./bookings.controller"; -import { BookingsRepository } from "./bookings.repository"; -import { BookingsService } from "./bookings.service"; -import { Booking } from "./entities/booking.entity"; +// import { CustomersModule } from '../customers/customers.module'; +import { CompaniesModule } from '../companies/companies.module'; +import { FilesModule } from '../files/files.module'; +import { MinioModule } from '../minio/minio.module'; +import { RuleEngineModule } from '../rule-engine/rule-engine.module'; +import { SignaturesModule } from '../signatures/signatures.module'; +import { BookingContractService } from './booking-contract.service'; +import { BookingPaymentService } from './booking-payment.service'; +import { BookingPricingService } from './booking-pricing.service'; +import { BookingReferenceDataService } from './booking-reference-data.service'; +import { BookingTransitionService } from './booking-transition.service'; +import { BookingsController } from './bookings.controller'; +import { PayController } from './pay.controller'; +import { BookingsRepository } from './bookings.repository'; +import { ConsolidationService } from './consolidation.service'; +import { BookingsService } from './bookings.service'; +import { BookingApprovalStep } from './entities/booking-approval-step.entity'; +import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity'; +import { BookingContainer } from './entities/booking-container.entity'; +import { BookingRateSnapshot } from './entities/booking-rate-snapshot.entity'; +import { BookingContractSignature } from './entities/booking-contract-signature.entity'; +import { BookingReviewNote } from './entities/booking-review-note.entity'; +import { Booking } from './entities/booking.entity'; +import { ContractPdfService } from '../../contracts/contract-pdf.service'; +import { ContractPricingScheduleBuilder } from '../../contracts/contract-pricing-schedule.builder'; +import { ContractRendererService } from '../../contracts/contract-renderer.service'; +import { ContractTemplateResolver } from '../../contracts/contract-template.resolver'; +import { ContractViewModelBuilder } from '../../contracts/contract-view-model.builder'; +import { PaymentModule } from '../payment/payment.module'; +import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module'; +import { CbeExchangeService } from '../cbe-exchange/cbe-exchange.service'; @Module({ - imports: [TypeOrmModule.forFeature([Booking])], - controllers: [BookingsController], - providers: [BookingsService, BookingsRepository], - exports: [BookingsService], + imports: [ + TypeOrmModule.forFeature([ + Booking, + BookingContainer, + BookingCargoModifier, + BookingApprovalStep, + BookingRateSnapshot, + BookingReviewNote, + BookingContractSignature, + ]), + PaymentModule, + forwardRef(() => TrainSchedulingModule), + FilesModule, + MinioModule, + CompaniesModule, + // CustomersModule, + RuleEngineModule, + SignaturesModule, + ], + controllers: [BookingsController, PayController], + providers: [ + BookingsService, + BookingsRepository, + ConsolidationService, + BookingReferenceDataService, + BookingPricingService, + BookingTransitionService, + BookingContractService, + BookingPaymentService, + ContractTemplateResolver, + ContractViewModelBuilder, + ContractPricingScheduleBuilder, + ContractRendererService, + ContractPdfService, + CbeExchangeService, + ], + exports: [BookingsService, BookingsRepository], }) export class BookingsModule {} diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.repository.spec.ts b/apps/edr-freight-api/src/modules/bookings/bookings.repository.spec.ts new file mode 100644 index 000000000..7d4ff199c --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.spec.ts @@ -0,0 +1,70 @@ +import { DataSource, Repository } from 'typeorm'; + +import { Booking } from './entities/booking.entity'; +import { BookingsRepository } from './bookings.repository'; + +function mockQueryBuilder() { + const qb = { + leftJoinAndSelect: jest.fn().mockReturnThis(), + leftJoin: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + addOrderBy: jest.fn().mockReturnThis(), + skip: jest.fn().mockReturnThis(), + take: jest.fn().mockReturnThis(), + getMany: jest.fn(), + getManyAndCount: jest.fn().mockResolvedValue([[], 0]), + }; + return qb; +} + +describe('BookingsRepository', () => { + let repository: jest.Mocked>; + let dataSource: { getRepository: jest.Mock }; + let bookingsRepository: BookingsRepository; + + beforeEach(() => { + repository = { + createQueryBuilder: jest.fn(), + } as unknown as jest.Mocked>; + dataSource = { getRepository: jest.fn() }; + bookingsRepository = new BookingsRepository(repository, dataSource as unknown as DataSource); + }); + + it('findEligibleForScheduling does not filter by schedule date', async () => { + const qb = mockQueryBuilder(); + const bookings = [ + { id: 'b1', scheduledDate: new Date('2026-06-20T08:00:00.000Z') }, + { id: 'b2', scheduledDate: new Date('2026-06-21T14:00:00.000Z') }, + ]; + qb.getMany.mockResolvedValue(bookings); + repository.createQueryBuilder.mockReturnValue(qb as never); + + const result = await bookingsRepository.findEligibleForScheduling({ + originStationId: 'yard-origin', + destinationStationId: 'yard-destination', + freightType: 'CONTAINER', + }); + + expect(result).toHaveLength(2); + const dateFilters = qb.andWhere.mock.calls.filter(([clause]) => + String(clause).includes('scheduled_date'), + ); + expect(dateFilters).toHaveLength(0); + }); + + it('applyListFilters excludes assigned bookings when assignedToSchedule is false', async () => { + const qb = mockQueryBuilder(); + repository.createQueryBuilder.mockReturnValue(qb as never); + dataSource.getRepository.mockReturnValue({ find: jest.fn().mockResolvedValue([]) }); + + await bookingsRepository.findAllPaginated({ + page: 1, + pageSize: 10, + assignedToSchedule: 'false', + }); + + expect(qb.andWhere).toHaveBeenCalledWith(expect.stringContaining('NOT EXISTS')); + }); +}); diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts index 5a3838c02..b173bfe68 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -1,15 +1,48 @@ -import { BaseRepository } from "@edr/api-common"; -import { Injectable } from "@nestjs/common"; -import { InjectRepository } from "@nestjs/typeorm"; -import { Repository } from "typeorm"; +import { BaseRepository } from '@edr/api-common'; +import { SchedulingStatus } from '@edr/types'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { DataSource, EntityManager, FindOptionsWhere, In, Repository, SelectQueryBuilder } from 'typeorm'; -import { Booking } from "./entities/booking.entity"; +import { ContainerType } from '../rule-engine/entities/container-type.entity'; +import { BookingApprovalStep } from './entities/booking-approval-step.entity'; +import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity'; +import { BookingContainer } from './entities/booking-container.entity'; +import { BookingRateSnapshot } from './entities/booking-rate-snapshot.entity'; +import { BookingReviewNote, ReviewNoteType } from './entities/booking-review-note.entity'; +import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity'; +import { Booking } from './entities/booking.entity'; +import { + BookingContractSignature, + ContractSignerRole, +} from './entities/booking-contract-signature.entity'; +import { FileRecord } from '../files/entities/file.entity'; +import { ContainerWeightResult } from '../rule-engine/rule-engine.service'; + +export interface BookingListFilterOptions { + statuses?: string[]; + status?: string; + schedulingStatuses?: string[]; + assignedToSchedule?: 'true' | 'false'; + companyId?: string; + contractType?: string; + serviceTypeId?: string; + cargoTypeId?: string; + freightType?: string; + tradeDirection?: string; + paymentCurrency?: string; + paymentStatus?: string; + excludePaymentStatus?: string; + allowConsolidation?: boolean; + consolidationPaired?: string; +} @Injectable() export class BookingsRepository extends BaseRepository { constructor( @InjectRepository(Booking) repository: Repository, + private readonly dataSource: DataSource, ) { super(repository); } @@ -18,4 +51,868 @@ export class BookingsRepository extends BaseRepository { findByReference(reference: string): Promise { return this.repository.findOne({ where: { reference } }); } + + /** Count bookings created in a specific year. */ + async countByYear(year: number): Promise { + const startDate = new Date(year, 0, 1); + const endDate = new Date(year + 1, 0, 1); + + return this.repository + .createQueryBuilder('booking') + .where('booking.created_at >= :startDate', { startDate }) + .andWhere('booking.created_at < :endDate', { endDate }) + .getCount(); + } + + /** Find a booking by reference with files and relations. */ + async findByReferenceWithFiles(reference: string): Promise { + return this.findByIdWithFiles( + ( + await this.repository.findOne({ where: { reference }, select: ['id'] }) + )?.id ?? '', + ); + } + + /** Find a booking by ID with files, containers, and config relations. */ + async findByIdWithFiles(id: string): Promise { + if (!id) return null; + + const booking = await this.repository + .createQueryBuilder('booking') + .leftJoinAndSelect('booking.bookingContainers', 'bc') + .leftJoinAndSelect('bc.containerType', 'ct') + .leftJoinAndSelect('booking.company', 'company') + // .leftJoinAndSelect('booking.customer', 'customer') + .leftJoinAndSelect('booking.train', 'train') + .leftJoinAndSelect('booking.serviceType', 'st') + .leftJoinAndSelect('booking.cargoType', 'cargo') + .leftJoinAndSelect('booking.originYard', 'oy') + .leftJoinAndSelect('booking.destinationYard', 'dy') + .leftJoinAndSelect('booking.shippingLine', 'sl') + .leftJoinAndSelect('booking.approvalSteps', 'steps') + .leftJoinAndSelect('booking.rateSnapshots', 'snapshots') + .leftJoinAndSelect('booking.cargoModifiers', 'modifiers') + .leftJoinAndSelect('booking.reviewNotes', 'reviewNotes') + .leftJoinAndSelect('booking.consolidationPartner', 'consolidationPartner') + .where('booking.id = :id', { id }) + .leftJoinAndMapMany( + 'booking.files', + FileRecord, + 'file', + "file.resource_id = booking.id AND file.resource = 'bookings'", + ) + .getOne(); + + return booking ?? null; + } + + /** Persist booking container rows with weight rule results. */ + async createContainers( + bookingId: string, + containers: Array<{ + containerTypeId: string; + quantity: number; + vgmPerUnitTons: number; + weightResult: ContainerWeightResult; + }>, + ): Promise { + const containerRepo = this.dataSource.getRepository(BookingContainer); + const typeRepo = this.dataSource.getRepository(ContainerType); + const saved: BookingContainer[] = []; + + for (const item of containers) { + const ct = await typeRepo.findOne({ where: { id: item.containerTypeId } }); + const wagonsPerUnit = ct ? Number(ct.wagonsPerUnit) : 1; + const totalVgm = item.quantity * item.vgmPerUnitTons; + const wagonsRequired = Math.ceil(item.quantity * wagonsPerUnit); + + const row = containerRepo.create({ + bookingId, + containerTypeId: item.containerTypeId, + quantity: item.quantity, + vgmPerUnitTons: item.vgmPerUnitTons, + totalVgmTons: totalVgm, + wagonsRequired, + weightLimitRuleId: item.weightResult.weightLimitRuleId, + isOverweight: item.weightResult.isOverweight, + overweightExcessTons: item.weightResult.overweightExcessTons, + }); + saved.push(await containerRepo.save(row)); + } + + return saved; + } + + /** SQL aggregate wagon count for a booking. */ + async calculateWagonCount(bookingId: string): Promise { + const result = await this.dataSource + .createQueryBuilder() + .select('CEILING(SUM(bc.quantity * ct.wagons_per_unit))', 'total') + .from(BookingContainer, 'bc') + .innerJoin(ContainerType, 'ct', 'ct.id = bc.container_type_id') + .where('bc.booking_id = :bookingId', { bookingId }) + .getRawOne<{ total: string }>(); + + return Number(result?.total ?? 0); + } + + /** + * Find another booking whose container quantity complements this one to fill whole wagon(s) + * (same route, same container type, partial wagon on both sides). + */ + async findComplementaryConsolidationPartner( + booking: Booking, + slot: { + containerTypeId: string; + quantity: number; + containersPerWagon: number; + }, + ): Promise { + const { containerTypeId, quantity, containersPerWagon: perWagon } = slot; + + return this.repository + .createQueryBuilder('b') + .innerJoinAndSelect('b.bookingContainers', 'bc') + .innerJoin('bc.containerType', 'ct') + .where('b.id != :bookingId', { bookingId: booking.id }) + .andWhere('b.allowConsolidation = true') + .andWhere('b.consolidationPartnerId IS NULL') + // Only pair bookings the customer has committed (SUBMITTED) or that are + // already waiting (PENDING_CONSOLIDATION). DRAFT bookings are excluded so + // pairing never prematurely submits an unfinished/unpriced draft. + .andWhere('b.status IN (:...statuses)', { + statuses: ['SUBMITTED', 'PENDING_CONSOLIDATION'], + }) + .andWhere('b.originYardId = :originYardId', { + originYardId: booking.originYardId, + }) + .andWhere('b.destinationYardId = :destinationYardId', { + destinationYardId: booking.destinationYardId, + }) + .andWhere('b.tradeDirection = :tradeDirection', { + tradeDirection: booking.tradeDirection, + }) + .andWhere('bc.containerTypeId = :containerTypeId', { containerTypeId }) + .andWhere('(bc.quantity % :perWagon) > 0', { perWagon }) + .andWhere('((:quantity + bc.quantity) % :perWagon) = 0', { + quantity, + perWagon, + }) + .orderBy('b.createdAt', 'ASC') + .getOne(); + } + + /** Try each partial-wagon line until a complementary partner booking is found. */ + async findConsolidationPartner( + booking: Booking, + slots: Array<{ + containerTypeId: string; + quantity: number; + containersPerWagon: number; + }>, + ): Promise { + for (const slot of slots) { + const partner = await this.findComplementaryConsolidationPartner(booking, slot); + if (partner) return partner; + } + return null; + } + + /** + * Pair two bookings for consolidation. Both return to SUBMITTED so staff can + * accept them into the approval chain; the link itself (consolidationPartnerId) + * marks them as consolidated in the UI. + */ + async pairConsolidation(bookingId: string, partnerId: string): Promise { + await this.repository.update(bookingId, { + consolidationPartnerId: partnerId, + status: 'SUBMITTED', + } as never); + await this.repository.update(partnerId, { + consolidationPartnerId: bookingId, + status: 'SUBMITTED', + } as never); + } + + /** Park a booking that needs consolidation but has no partner yet. */ + async parkForConsolidation(bookingId: string): Promise { + await this.repository.update(bookingId, { + consolidationPartnerId: null, + status: 'PENDING_CONSOLIDATION', + } as never); + } + + /** Un-pair a consolidation. */ + async unpairConsolidation(bookingId: string, partnerId: string): Promise { + await this.repository.update(bookingId, { + consolidationPartnerId: null, + status: 'PENDING_CONSOLIDATION', + } as never); + await this.repository.update(partnerId, { + consolidationPartnerId: null, + status: 'PENDING_CONSOLIDATION', + } as never); + } + + /** Delete all containers for a booking (used on draft update). */ + async deleteContainers(bookingId: string): Promise { + await this.dataSource.getRepository(BookingContainer).delete({ bookingId }); + } + + /** Lowest-order pending approval step (sequential enforcement). */ + async findNextPendingApprovalStep( + bookingId: string, + ): Promise { + return this.dataSource.getRepository(BookingApprovalStep).findOne({ + where: { bookingId, status: 'PENDING' }, + order: { stepOrder: 'ASC' }, + }); + } + + async findApprovalStepById( + bookingId: string, + stepId: string, + ): Promise { + return this.dataSource.getRepository(BookingApprovalStep).findOne({ + where: { bookingId, id: stepId }, + }); + } + + /** Get pending approval step for a role (must match next in sequence). */ + async findPendingApprovalStep( + bookingId: string, + requiredRole: string, + ): Promise { + const next = await this.findNextPendingApprovalStep(bookingId); + if (!next || next.requiredRole !== requiredRole) return null; + return next; + } + + /** Mark an approval step complete. */ + async completeApprovalStep( + stepId: string, + actorId: string, + status: 'APPROVED' | 'REJECTED', + remarks?: string, + ): Promise { + await this.dataSource.getRepository(BookingApprovalStep).update(stepId, { + status, + actionedByStaffId: actorId, + actionedAt: new Date(), + remarks, + }); + } + + /** Check if all approval steps are approved. */ + async allApprovalStepsComplete(bookingId: string): Promise { + const pending = await this.dataSource.getRepository(BookingApprovalStep).count({ + where: { bookingId, status: 'PENDING' }, + }); + return pending === 0; + } + + /** Persist cargo modifiers linked to rate snapshots. */ + async createCargoModifiers( + rows: Array<{ + bookingId: string; + surchargeTypeId: string; + triggerValue: number | null; + calculatedAmount: number; + rateSnapshotId: string; + }>, + ): Promise { + const repo = this.dataSource.getRepository(BookingCargoModifier); + const saved: BookingCargoModifier[] = []; + for (const row of rows) { + saved.push(await repo.save(repo.create(row))); + } + return saved; + } + + /** Find rate snapshot by rate id for a booking. */ + async findRateSnapshotByRateId( + bookingId: string, + rateId: string, + ): Promise { + return this.dataSource.getRepository(BookingRateSnapshot).findOne({ + where: { bookingId, rateId }, + }); + } + + async createReviewNote( + bookingId: string, + note: string, + type: ReviewNoteType, + authorId?: string, + ): Promise { + const repo = this.dataSource.getRepository(BookingReviewNote); + return repo.save( + repo.create({ bookingId, note, type, authorId: authorId ?? null }), + ); + } + + async findLatestReviewNote( + bookingId: string, + type?: ReviewNoteType, + ): Promise { + const repo = this.dataSource.getRepository(BookingReviewNote); + return repo.findOne({ + where: type ? { bookingId, type } : { bookingId }, + order: { createdAt: 'DESC' }, + }); + } + + async clearPricingArtifacts(bookingId: string): Promise { + await this.dataSource.getRepository(BookingCargoModifier).delete({ bookingId }); + await this.dataSource.getRepository(BookingRateSnapshot).delete({ bookingId }); + } + + async hasPricingArtifacts(bookingId: string): Promise { + const snapshotCount = await this.dataSource + .getRepository(BookingRateSnapshot) + .count({ where: { bookingId } }); + const modifierCount = await this.dataSource + .getRepository(BookingCargoModifier) + .count({ where: { bookingId } }); + return snapshotCount > 0 || modifierCount > 0; + } + + async invalidatePricingPreview(bookingId: string): Promise { + if (await this.hasPricingArtifacts(bookingId)) { + await this.clearPricingArtifacts(bookingId); + } + await this.update(bookingId, { + totalAmount: 0, + pricingBreakdown: null, + } as never); + } + + /** Queue listing with optional bulk exclusion for LINE_STAFF. */ + async findQueue(options: { + status: string | string[]; + page?: number; + pageSize?: number; + excludeBulk?: boolean; + sortBy?: string; + sortOrder?: 'ASC' | 'DESC'; + }): Promise<{ items: Booking[]; total: number }> { + const page = options.page ?? 1; + const pageSize = options.pageSize ?? 20; + const statuses = Array.isArray(options.status) ? options.status : [options.status]; + + const qb = this.repository + .createQueryBuilder('booking') + .leftJoinAndSelect('booking.company', 'company') + .leftJoinAndSelect('booking.originYard', 'originYard') + .leftJoinAndSelect('booking.destinationYard', 'destinationYard') + .leftJoinAndSelect('booking.cargoType', 'cargo') + .leftJoinAndSelect('booking.serviceType', 'serviceType') + .leftJoinAndSelect('booking.approvalSteps', 'approvalSteps') + .where('booking.status IN (:...statuses)', { statuses }); + + if (options.excludeBulk) { + qb.andWhere("booking.freight_type = 'CONTAINER'"); + } + + const sortField = + options.sortBy === 'priorityScore' + ? 'booking.priorityScore' + : 'booking.createdAt'; + qb.orderBy(sortField, options.sortOrder ?? 'DESC'); + + const [items, total] = await qb + .skip((page - 1) * pageSize) + .take(pageSize) + .getManyAndCount(); + + return { items, total }; + } + + /** Paginated list with optional multi-status filter (API tab queues). */ + async findAllPaginated(options: BookingListFilterOptions & { + page: number; + pageSize: number; + sortBy?: string; + sortOrder?: 'ASC' | 'DESC'; + }): Promise<{ items: Booking[]; total: number }> { + const page = options.page; + const pageSize = options.pageSize; + + const qb = this.repository + .createQueryBuilder('booking') + .leftJoinAndSelect('booking.company', 'company') + .leftJoinAndSelect('booking.originYard', 'originYard') + .leftJoinAndSelect('booking.destinationYard', 'destinationYard') + .leftJoinAndSelect('booking.serviceType', 'serviceType') + .leftJoinAndSelect('booking.approvalSteps', 'approvalSteps') + .leftJoinAndSelect('booking.consolidationPartner', 'consolidationPartner') + .where('booking.deleted_at IS NULL'); + + this.applyListFilters(qb, options); + + if (options.sortBy === 'isGovernment') { + qb.orderBy('booking.isGovernment', 'DESC') + .addOrderBy('booking.priorityScore', 'DESC') + .addOrderBy('booking.scheduledDate', 'ASC'); + } else { + const sortField = + options.sortBy === 'priorityScore' + ? 'booking.priorityScore' + : options.sortBy === 'scheduledDate' + ? 'booking.scheduledDate' + : 'booking.createdAt'; + qb.orderBy(sortField, options.sortOrder ?? 'DESC'); + } + + const [items, total] = await qb + .skip((page - 1) * pageSize) + .take(pageSize) + .getManyAndCount(); + + if (items.length) { + const links = await this.dataSource.getRepository(TrainScheduleBooking).find({ + where: { bookingId: In(items.map((item) => item.id)) }, + select: { bookingId: true, trainScheduleId: true }, + }); + const scheduleByBooking = new Map(links.map((link) => [link.bookingId, link.trainScheduleId])); + for (const item of items) { + (item as Booking & { trainScheduleId?: string | null }).trainScheduleId = + scheduleByBooking.get(item.id) ?? null; + } + } + + return { items, total }; + } + + async getStatusCounts(): Promise> { + const rows = await this.repository + .createQueryBuilder('booking') + .select('booking.status', 'status') + .addSelect('COUNT(*)::int', 'count') + .where('booking.deleted_at IS NULL') + .groupBy('booking.status') + .getRawMany<{ status: string; count: string }>(); + + return Object.fromEntries( + rows.map((row) => [row.status, Number(row.count)]), + ); + } + + async getListSummaryMetrics( + options: BookingListFilterOptions & { + page: number; + pageSize: number; + needsActionStatuses: readonly string[]; + urgentPriorityThreshold: number; + }, + ): Promise<{ + inQueue: number; + onThisPage: number; + needsAction: number; + urgent: number; + }> { + const baseQb = () => { + const qb = this.repository + .createQueryBuilder('booking') + .where('booking.deleted_at IS NULL'); + this.applyListFilters(qb, options); + return qb; + }; + + const inQueue = await baseQb().getCount(); + + const needsAction = await baseQb() + .andWhere('booking.status IN (:...needsActionStatuses)', { + needsActionStatuses: [...options.needsActionStatuses], + }) + .getCount(); + + const urgent = await baseQb() + .andWhere('booking.priority_score >= :urgentPriorityThreshold', { + urgentPriorityThreshold: options.urgentPriorityThreshold, + }) + .getCount(); + + const offset = (options.page - 1) * options.pageSize; + const onThisPage = Math.min( + options.pageSize, + Math.max(0, inQueue - offset), + ); + + return { inQueue, onThisPage, needsAction, urgent }; + } + + private applyListFilters( + qb: SelectQueryBuilder, + options: BookingListFilterOptions, + ): void { + if (options.statuses?.length) { + qb.andWhere('booking.status IN (:...statuses)', { + statuses: options.statuses, + }); + } else if (options.status) { + qb.andWhere('booking.status = :status', { status: options.status }); + } + + if (options.companyId) { + qb.andWhere('booking.company_id = :companyId', { + companyId: options.companyId, + }); + } + if (options.contractType) { + qb.andWhere('booking.contract_type = :contractType', { + contractType: options.contractType, + }); + } + if (options.serviceTypeId) { + qb.andWhere('booking.service_type_id = :serviceTypeId', { + serviceTypeId: options.serviceTypeId, + }); + } + if (options.cargoTypeId) { + qb.andWhere('booking.cargo_type_id = :cargoTypeId', { + cargoTypeId: options.cargoTypeId, + }); + } + if (options.freightType) { + qb.andWhere('booking.freight_type = :freightType', { + freightType: options.freightType, + }); + } + if (options.tradeDirection) { + qb.andWhere('booking.trade_direction = :tradeDirection', { + tradeDirection: options.tradeDirection, + }); + } + if (options.paymentCurrency) { + qb.andWhere('booking.payment_currency = :paymentCurrency', { + paymentCurrency: options.paymentCurrency, + }); + } + if (options.paymentStatus) { + qb.andWhere('booking.payment_status = :paymentStatus', { + paymentStatus: options.paymentStatus, + }); + } + if (options.excludePaymentStatus) { + qb.andWhere('booking.payment_status != :excludePaymentStatus', { + excludePaymentStatus: options.excludePaymentStatus, + }); + } + if (options.allowConsolidation !== undefined) { + qb.andWhere('booking.allow_consolidation = :allowConsolidation', { + allowConsolidation: options.allowConsolidation, + }); + } + if (options.consolidationPaired === 'true') { + qb.andWhere('booking.consolidation_partner_id IS NOT NULL'); + } else if (options.consolidationPaired === 'false') { + qb.andWhere('booking.consolidation_partner_id IS NULL'); + } + if (options.schedulingStatuses?.length) { + qb.andWhere('booking.scheduling_status IN (:...schedulingStatuses)', { + schedulingStatuses: options.schedulingStatuses, + }); + } + if (options.assignedToSchedule === 'true') { + qb.andWhere( + `EXISTS ( + SELECT 1 FROM freight.train_schedule_bookings tsb + WHERE tsb.booking_id = booking.id AND tsb.deleted_at IS NULL + )`, + ); + } else if (options.assignedToSchedule === 'false') { + qb.andWhere( + `NOT EXISTS ( + SELECT 1 FROM freight.train_schedule_bookings tsb + WHERE tsb.booking_id = booking.id AND tsb.deleted_at IS NULL + )`, + ); + } + } + + async findAndCountFiltered(where: FindOptionsWhere, options: { + skip: number; + take: number; + order: Record; + }): Promise<[Booking[], number]> { + return this.repository.findAndCount({ + where, + skip: options.skip, + take: options.take, + order: options.order, + }); + } + + findContractSignatures(bookingId: string): Promise { + return this.dataSource.getRepository(BookingContractSignature).find({ + where: { bookingId }, + relations: ['signatureFile'], + order: { signedAt: 'ASC' }, + }); + } + + findContractSignature( + bookingId: string, + role: ContractSignerRole, + ): Promise { + return this.dataSource.getRepository(BookingContractSignature).findOne({ + where: { bookingId, signerRole: role }, + relations: ['signatureFile'], + }); + } + + async saveContractSignature( + data: Partial, + ): Promise { + const repo = this.dataSource.getRepository(BookingContractSignature); + const existing = await repo.findOne({ + where: { + bookingId: data.bookingId!, + signerRole: data.signerRole!, + }, + }); + if (existing) { + Object.assign(existing, data); + return repo.save(existing); + } + return repo.save(repo.create(data)); + } + + private bookingRepo(manager?: EntityManager) { + return manager ? manager.getRepository(Booking) : this.repository; + } + + findEligibleForScheduling(options: { + freightType?: string; + originStationId?: string; + destinationStationId?: string; + schedulingStatus?: string; + trainScheduleId?: string; + /** + * EAT calendar day (yyyy-MM-dd). With day-level pooling the staff wizard sees + * the whole (route, day) pool rather than bookings pre-targeted to one train. + */ + day?: string; + }): Promise { + const qb = this.repository + .createQueryBuilder('booking') + .leftJoinAndSelect('booking.company', 'company') + .leftJoinAndSelect('booking.originYard', 'originYard') + .leftJoinAndSelect('booking.destinationYard', 'destinationYard') + .leftJoinAndSelect('booking.bookingContainers', 'bookingContainer') + .leftJoinAndSelect('bookingContainer.containerType', 'containerType') + .leftJoinAndSelect('booking.cargoType', 'cargoType') + .leftJoin( + TrainScheduleBooking, + 'scheduleBooking', + 'scheduleBooking.booking_id = booking.id', + ) + .where('booking.status = :paidStatus', { paidStatus: 'PAID' }) + .andWhere('scheduleBooking.id IS NULL'); + + // Day-level pooling: customers no longer set train_schedule_id, so the wizard + // surfaces the whole (route, EAT day) pool. Fall back to the legacy + // single-schedule filter only when no day is supplied (e.g. a staff-pinned + // booking that still carries train_schedule_id). + if (options.day) { + qb.andWhere( + `DATE(booking.scheduled_date AT TIME ZONE 'Africa/Addis_Ababa') = :day`, + { day: options.day }, + ); + } else if (options.trainScheduleId) { + qb.andWhere('booking.train_schedule_id = :trainScheduleId', { + trainScheduleId: options.trainScheduleId, + }); + } + + if (options.freightType) { + qb.andWhere('booking.freightType = :freightType', { freightType: options.freightType }); + } + + if (options.originStationId) { + qb.andWhere('booking.originYardId = :originStationId', { + originStationId: options.originStationId, + }); + } + if (options.destinationStationId) { + qb.andWhere('booking.destinationYardId = :destinationStationId', { + destinationStationId: options.destinationStationId, + }); + } + if (options.schedulingStatus) { + qb.andWhere('booking.scheduling_status = :schedulingStatus', { + schedulingStatus: options.schedulingStatus, + }); + } + + return qb + .orderBy('booking.priority_score', 'DESC') + .addOrderBy('booking.scheduled_date', 'ASC') + .addOrderBy('booking.created_at', 'ASC') + .getMany(); + } + + /** + * Ready, not-yet-allocated bookings targeting a schedule (the batch pool). + * Commercial = FULLY_EXECUTED; government = APPROVED or PAID (skips contract). + * Ordered government → priority → contract-sign time. + */ + findBatchPool(scheduleId: string): Promise { + return this.repository + .createQueryBuilder('booking') + .leftJoinAndSelect('booking.company', 'company') + .leftJoinAndSelect('booking.bookingContainers', 'bookingContainer') + .leftJoin(TrainScheduleBooking, 'sb', 'sb.booking_id = booking.id') + .where('booking.train_schedule_id = :scheduleId', { scheduleId }) + .andWhere('sb.id IS NULL') + .andWhere( + `((booking.is_government = false AND booking.status = 'FULLY_EXECUTED') + OR (booking.is_government = true AND booking.status IN ('APPROVED','PAID')))`, + ) + .orderBy('booking.is_government', 'DESC') + .addOrderBy('booking.priority_score', 'DESC') + .addOrderBy('booking.fully_executed_at', 'ASC') + .addOrderBy('booking.created_at', 'ASC') + .getMany(); + } + + /** + * Day-level batch pool: ready, not-yet-allocated bookings on a route for one + * EAT calendar day, regardless of which train they end up on. Same status + * rules and ordering as {@link findBatchPool}, but keyed on + * (origin, destination, day) instead of train_schedule_id — the engine then + * distributes these across all trains departing that day. + */ + findBatchPoolByRouteDay( + originYardId: string, + destinationYardId: string, + day: string, + ): Promise { + return this.repository + .createQueryBuilder('booking') + .leftJoinAndSelect('booking.company', 'company') + .leftJoinAndSelect('booking.bookingContainers', 'bookingContainer') + .leftJoin(TrainScheduleBooking, 'sb', 'sb.booking_id = booking.id') + .where('booking.origin_yard_id = :originYardId', { originYardId }) + .andWhere('booking.destination_yard_id = :destinationYardId', { + destinationYardId, + }) + .andWhere( + `DATE(booking.scheduled_date AT TIME ZONE 'Africa/Addis_Ababa') = :day`, + { day }, + ) + .andWhere('sb.id IS NULL') + .andWhere( + `((booking.is_government = false AND booking.status = 'FULLY_EXECUTED') + OR (booking.is_government = true AND booking.status IN ('APPROVED','PAID')))`, + ) + .orderBy('booking.is_government', 'DESC') + .addOrderBy('booking.priority_score', 'DESC') + .addOrderBy('booking.fully_executed_at', 'ASC') + .addOrderBy('booking.created_at', 'ASC') + .getMany(); + } + + /** Every booking that targeted a schedule (any status) — for the batch monitoring board. */ + findAllBySchedule(scheduleId: string): Promise { + return this.repository + .createQueryBuilder('booking') + .leftJoinAndSelect('booking.company', 'company') + .leftJoinAndSelect('booking.bookingContainers', 'bookingContainer') + .where('booking.train_schedule_id = :scheduleId', { scheduleId }) + .orderBy('booking.is_government', 'DESC') + .addOrderBy('booking.priority_score', 'DESC') + .addOrderBy('booking.created_at', 'ASC') + .getMany(); + } + + /** Bookings currently reserved (SELECTED_FOR_BATCH) against a schedule. */ + findReservedForSchedule(scheduleId: string): Promise { + return this.repository + .createQueryBuilder('booking') + .leftJoinAndSelect('booking.company', 'company') + .leftJoinAndSelect('booking.bookingContainers', 'bookingContainer') + .where('booking.train_schedule_id = :scheduleId', { scheduleId }) + .andWhere(`booking.status IN ('SELECTED_FOR_BATCH', 'AWAITING_PAYMENT')`) + .getMany(); + } + + /** PAID bookings targeting a schedule that have no train_schedule_bookings link yet. */ + findPaidUnlinkedForSchedule(scheduleId: string): Promise { + return this.repository + .createQueryBuilder('booking') + .leftJoinAndSelect('booking.company', 'company') + .leftJoinAndSelect('booking.bookingContainers', 'bookingContainer') + .leftJoin( + TrainScheduleBooking, + 'scheduleBooking', + 'scheduleBooking.booking_id = booking.id', + ) + .where('booking.train_schedule_id = :scheduleId', { scheduleId }) + .andWhere(`booking.status = 'PAID'`) + .andWhere('scheduleBooking.id IS NULL') + .orderBy('booking.priority_score', 'DESC') + .addOrderBy('booking.created_at', 'ASC') + .getMany(); + } + + /** Commercial bookings already allocated to a schedule, lowest-priority first (for government preempt). */ + findAllocatedCommercialForSchedule(scheduleId: string): Promise { + return this.repository + .createQueryBuilder('booking') + .leftJoinAndSelect('booking.bookingContainers', 'bookingContainer') + .innerJoin( + TrainScheduleBooking, + 'sb', + 'sb.booking_id = booking.id AND sb.train_schedule_id = :scheduleId', + { scheduleId }, + ) + .where('booking.is_government = false') + .orderBy('booking.priority_score', 'ASC') + .addOrderBy('booking.created_at', 'DESC') + .getMany(); + } + + findByIdsForScheduling(bookingIds: string[], manager?: EntityManager): Promise { + if (!bookingIds.length) return Promise.resolve([]); + return this.bookingRepo(manager).find({ + where: { id: In(bookingIds) }, + relations: { + company: true, + originYard: true, + destinationYard: true, + bookingContainers: { containerType: true }, + cargoType: true, + }, + order: { priorityScore: 'DESC', createdAt: 'ASC' }, + }); + } + + async updateSchedulingFields( + bookingId: string, + fields: Partial< + Pick< + Booking, + 'schedulingStatus' | 'wagonsRequired' | 'scheduledAt' | 'holdStartedAt' | 'holdExpiresAt' + > + >, + manager?: EntityManager, + ): Promise { + await this.bookingRepo(manager).update(bookingId, fields as never); + } + + async setHoldWindowOnPaid(bookingId: string, manager?: EntityManager): Promise { + const now = new Date(); + const expires = new Date(now.getTime() + 3 * 60 * 60 * 1000); + await this.updateSchedulingFields( + bookingId, + { + schedulingStatus: SchedulingStatus.Holding, + holdStartedAt: now, + holdExpiresAt: expires, + }, + manager, + ); + } } diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index d535ed396..4c8ef2cab 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -1,52 +1,1039 @@ -import { Injectable, NotFoundException } from "@nestjs/common"; +import { + BadRequestException, + ConflictException, + ForbiddenException, + forwardRef, + Inject, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { Freight, SchedulingStatus } from '@edr/types'; +// import { CustomersService } from '../customers/customers.service'; +import { CompaniesService } from '../companies/companies.service'; +import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service'; +import { eatDay } from '../train-scheduling/batch-window.util'; +import { FilesService } from '../files/files.service'; +import { MinioService } from '../minio/minio.service'; +import { ContainerTypesService } from '../rule-engine/services/container-types.service'; +import { + BookingEvaluationInput, + RuleEngineService, +} from '../rule-engine/rule-engine.service'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { DataSource, In } from 'typeorm'; -import { BookingsRepository } from "./bookings.repository"; -import { CreateBookingDto } from "./dto/create-booking.dto"; -import { FilterBookingDto } from "./dto/filter-booking.dto"; -import { Booking } from "./entities/booking.entity"; +import { deriveTradeDirection } from '../../common/derive-trade-direction.util'; +import { Yard } from '../rule-engine/entities/yard.entity'; +import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; +import { BookingsRepository } from './bookings.repository'; +import { ConsolidationService } from './consolidation.service'; +import { assertFreightShape } from './booking-freight.util'; +import { CreateBookingContainerDto, CreateBookingDto } from './dto/create-booking.dto'; +import { mapStatusCountsToTabs } from './booking-list-tabs.config'; +import { BookingListSummaryDto } from './dto/booking-list-summary.dto'; +import { FilterBookingDto } from './dto/filter-booking.dto'; +import { UpdateBookingDto } from './dto/update-booking.dto'; +import { + BOOKING_STATUSES, + CUSTOMER_EDITABLE_STATUSES, + FreightType, +} from './entities/booking.entity'; +import { Booking } from './entities/booking.entity'; +import { FileRecord } from '../files/entities/file.entity'; + +const URGENT_PRIORITY_THRESHOLD = 1000; +const NEEDS_ACTION_STATUSES = [ + 'SUBMITTED', + 'PENDING_APPROVAL', + 'APPROVED_PENDING_SIGNATURE', +] as const; @Injectable() export class BookingsService { - constructor(private readonly bookingsRepository: BookingsRepository) {} + constructor( + @InjectDataSource() private readonly dataSource: DataSource, + private readonly bookingsRepository: BookingsRepository, + private readonly filesService: FilesService, + private readonly minioService: MinioService, + // private readonly customersService: CustomersService, + private readonly companiesService: CompaniesService, + @Inject(forwardRef(() => TrainSchedulingService)) + private readonly trainSchedulingService: TrainSchedulingService, + private readonly ruleEngineService: RuleEngineService, + private readonly containerTypesService: ContainerTypesService, + private readonly consolidationService: ConsolidationService, + ) {} + + /** Resolve trade direction from yard countries; reject client mismatch. */ + private async resolveTradeDirectionForBooking( + originYardId: string, + destinationYardId: string, + provided?: string, + ): Promise { + const yards = await this.dataSource.getRepository(Yard).find({ + where: { id: In([originYardId, destinationYardId]) }, + }); + const origin = yards.find((y) => y.id === originYardId); + const destination = yards.find((y) => y.id === destinationYardId); + if (!origin) { + throw new BadRequestException(`Origin yard ${originYardId} not found`); + } + if (!destination) { + throw new BadRequestException(`Destination yard ${destinationYardId} not found`); + } + if (originYardId === destinationYardId) { + throw new BadRequestException('Origin and destination yards must differ'); + } + + const expected = deriveTradeDirection(origin, destination); + if (provided && provided !== expected) { + throw new BadRequestException( + `tradeDirection must be ${expected} for the selected yard pair (got ${provided})`, + ); + } + return expected; + } + + /** Generate a unique booking reference number. */ + private async generateReference(): Promise { + const year = new Date().getFullYear(); + const count = await this.bookingsRepository.countByYear(year); + return `BK-${year}-${String(count + 1).padStart(6, '0')}`; + } + + /** Build evaluation input from booking freight shape. */ + private async buildEvalInput(dto: { + freightType: FreightType; + cargoTypeId?: string | null; + serviceTypeId: string; + paymentCurrency: string; + tradeDirection: string; + isHazardous?: boolean; + isGovernment?: boolean; + allowConsolidation?: boolean; + shippingLineId?: string | null; + containers: CreateBookingContainerDto[]; + }): Promise { + const containerLines = + dto.freightType === 'CONTAINER' ? dto.containers : []; + + const containers = await Promise.all( + containerLines.map(async (c) => { + const ct = await this.containerTypesService.findById(c.containerTypeId); + const totalVgmTons = c.quantity * c.vgmPerUnitTons; + return { + containerTypeId: c.containerTypeId, + quantity: c.quantity, + vgmPerUnitTons: c.vgmPerUnitTons, + totalVgmTons, + isReefer: ct.isReefer, + wagonsRequired: c.quantity * (Number(ct.wagonsPerUnit) || 1), + }; + }), + ); + const totalWagons = Math.ceil( + containers.reduce((sum, c) => sum + c.wagonsRequired, 0), + ); + + return { + freightType: dto.freightType, + cargoTypeId: dto.cargoTypeId ?? null, + serviceTypeId: dto.serviceTypeId, + paymentCurrency: dto.paymentCurrency, + tradeDirection: dto.tradeDirection, + isHazardous: dto.isHazardous ?? false, + isGovernment: dto.isGovernment ?? false, + allowConsolidation: + dto.freightType === 'CONTAINER' ? dto.allowConsolidation : false, + shippingLineId: dto.shippingLineId, + totalWagons, + containers, + }; + } + + /** + * Enable consolidation when any container line leaves a wagon partially filled + * (e.g. 1×20ft on a 2-slot wagon, 1×10ft on a 4-slot wagon). + * + * Partial-wagon cargo ALWAYS consolidates — the customer cannot opt out of a + * half-empty wagon, so `explicit === false` is ignored when consolidation is + * actually needed. The opt-in flag only matters for cargo that already fills + * whole wagons (where consolidation is moot anyway). + */ + private async resolveConsolidation( + containers: CreateBookingContainerDto[], + explicit?: boolean, + ): Promise { + const needs = await this.consolidationService.needsConsolidation( + containers.map((c) => ({ + containerTypeId: c.containerTypeId, + quantity: c.quantity, + })), + ); + if (needs) return true; + return explicit ?? false; + } + + /** Search for a complementary partner; pair or queue as PENDING_CONSOLIDATION. */ + private async tryAutoConsolidate(booking: Booking): Promise<{ + booking: Booking; + messages: string[]; + }> { + const messages: string[] = []; + + if (!booking.allowConsolidation || booking.consolidationPartnerId) { + return { booking, messages }; + } + + const slots = await this.consolidationService.slotsFromBooking(booking); + if (slots.length === 0) { + return { booking, messages }; + } + + const partner = await this.bookingsRepository.findConsolidationPartner( + booking, + slots, + ); + + if (partner) { + await this.bookingsRepository.pairConsolidation(booking.id, partner.id); + const paired = await this.findById(booking.id); + messages.push( + this.consolidationService.describePaired(partner.reference, slots), + ); + return { booking: paired, messages }; + } + + // No partner yet — park the booking so it waits. Applies both pre-submit + // (DRAFT) and at submit time (SUBMITTED); accepted/approved bookings never + // reach this method. + if (booking.status === 'DRAFT' || booking.status === 'SUBMITTED') { + await this.bookingsRepository.parkForConsolidation(booking.id); + } + + const pending = await this.findById(booking.id); + messages.push(this.consolidationService.describePending(pending, slots)); + return { booking: pending, messages }; + } + + /** + * Run consolidation right after a booking reaches SUBMITTED. If a complementary + * partner already exists, both are paired and moved (back) to SUBMITTED so staff + * can accept them. Otherwise the booking is parked in PENDING_CONSOLIDATION and + * waits for a later complementary booking to complete the wagon. + * + * Returns the re-fetched booking, so callers can reflect the resulting status + * (SUBMITTED when paired/not-needed, PENDING_CONSOLIDATION when waiting). + */ + async runConsolidationOnSubmit(bookingId: string): Promise { + const booking = await this.findById(bookingId); + + // Already paired (e.g. a partner submitted first) — nothing to do. + if (booking.consolidationPartnerId) { + return booking; + } + + const result = await this.tryAutoConsolidate(booking); + return result.booking; + } /** Create a new freight booking. */ - async create(dto: CreateBookingDto): Promise { - return this.bookingsRepository.create({ - ...dto, - scheduledDate: new Date(dto.scheduledDate), + async create( + dto: CreateBookingDto, + files: Express.Multer.File[], + userId?: string, + ): Promise<{ booking: Booking; warnings: string[] }> { + const warnings: string[] = []; + + // let customerId = dto.customerId; + // if (!customerId) { + // if (!userId) { + // throw new BadRequestException( + // 'customerId is required or must be resolvable from auth token', + // ); + // } + // const customer = await this.customersService.findByUserId(userId); + // customerId = customer.id; + // } + + const isGovernment = dto.isGovernment === true; + + let companyId: string | null | undefined = dto.companyId; + if (isGovernment) { + if (!dto.governmentInstitution?.trim()) { + throw new BadRequestException('governmentInstitution is required for government bookings'); + } + companyId = dto.companyId ?? null; + } else if (!companyId) { + if (!userId) { + throw new BadRequestException( + 'companyId is required or must be resolvable from auth token', + ); + } + const { company } = await this.companiesService.getCompanyInfoByUserId(userId); + companyId = company.id; + } + + if (dto.trainScheduleId) { + // Staff manual pin: the schedule must be OPEN and on the same route. + const schedule = await this.dataSource + .getRepository(TrainSchedule) + .findOne({ where: { id: dto.trainScheduleId } }); + if (!schedule) { + throw new BadRequestException(`Train schedule ${dto.trainScheduleId} not found`); + } + if (schedule.bookingWindowStatus !== 'OPEN') { + throw new BadRequestException('Selected schedule is no longer accepting bookings'); + } + if ( + schedule.originStationId !== dto.originYardId || + schedule.destinationStationId !== dto.destinationYardId + ) { + throw new BadRequestException('Selected schedule is not on the booking route'); + } + } else { + // Day-level pool: the customer picked a DAY — require that the route has at + // least one OPEN departure on that EAT day. The batch engine assigns the + // train later. + const day = eatDay(new Date(dto.scheduledDate)); + const hasDeparture = + await this.trainSchedulingService.existsOpenScheduleOnRouteDay( + dto.originYardId, + dto.destinationYardId, + day, + ); + if (!hasDeparture) { + throw new BadRequestException( + 'No departures available on the selected day for this route', + ); + } + } + + const reference = dto.reference || (await this.generateReference()); + const containers = dto.containers ?? []; + assertFreightShape({ + freightType: dto.freightType, + cargoTypeId: dto.cargoTypeId, + containers, }); + + const tradeDirection = await this.resolveTradeDirectionForBooking( + dto.originYardId, + dto.destinationYardId, + dto.tradeDirection, + ); + + const allowConsolidation = + dto.freightType === 'CONTAINER' + ? await this.resolveConsolidation(containers, dto.allowConsolidation) + : false; + + const evalInput = await this.buildEvalInput({ + freightType: dto.freightType as FreightType, + cargoTypeId: dto.freightType === 'BULK' ? dto.cargoTypeId : null, + serviceTypeId: dto.serviceTypeId, + paymentCurrency: dto.paymentCurrency, + tradeDirection, + isHazardous: dto.isHazardous, + isGovernment, + allowConsolidation, + shippingLineId: dto.shippingLineId, + containers, + }); + const ruleResult = await this.ruleEngineService.evaluate(evalInput); + this.ruleEngineService.assertNoHardBlocks(ruleResult); + + warnings.push(...ruleResult.warnings); + + const booking = await this.bookingsRepository.create({ + reference, + companyId: companyId ?? null, + isGovernment, + governmentInstitution: isGovernment ? dto.governmentInstitution!.trim() : null, + trainId: dto.trainId, + trainScheduleId: dto.trainScheduleId ?? null, + contractType: dto.contractType, + previousContractId: dto.previousContractId, + serviceTypeId: dto.serviceTypeId, + firstMilePickupAddress: dto.firstMilePickupAddress, + lastMileDeliveryAddress: dto.lastMileDeliveryAddress, + equipmentReturn: dto.equipmentReturn, + originYardId: dto.originYardId, + destinationYardId: dto.destinationYardId, + tradeDirection, + freightType: dto.freightType, + cargoTypeId: dto.freightType === 'BULK' ? dto.cargoTypeId! : null, + cargoFreeText: dto.cargoFreeText, + shippingLineId: dto.shippingLineId, + cargoTotalWeightVgm: dto.cargoTotalWeightVgm, + isHazardous: dto.isHazardous ?? false, + paymentCurrency: dto.paymentCurrency, + pnrCode: dto.pnrCode, + financialTerms: dto.financialTerms, + scheduledDate: new Date(dto.scheduledDate), + startDate: dto.startDate ? new Date(dto.startDate) : undefined, + endDate: dto.endDate ? new Date(dto.endDate) : undefined, + status: 'DRAFT', + allowConsolidation, + priorityScore: ruleResult.priorityScore, + totalAmount: 0, + paymentStatus: 'PENDING', + }); + + if (dto.freightType === 'CONTAINER') { + await this.bookingsRepository.createContainers( + booking.id, + containers.map((c, i) => ({ + containerTypeId: c.containerTypeId, + quantity: c.quantity, + vgmPerUnitTons: c.vgmPerUnitTons, + weightResult: ruleResult.containerWeightResults[i], + })), + ); + const wagonCount = await this.bookingsRepository.calculateWagonCount(booking.id); + warnings.push(`Estimated wagons required: ${wagonCount}`); + } + + if (files.length > 0) { + try { + await this.filesService.uploadMany(booking.id, 'bookings', files); + } catch { + warnings.push('File upload failed — booking was created without attached files.'); + } + } + + let full = await this.findById(booking.id); + + if (allowConsolidation) { + const consolidation = await this.tryAutoConsolidate(full); + full = consolidation.booking; + warnings.push(...consolidation.messages); + } + + return { booking: full, warnings }; + } + + /** Update a draft booking. */ + async update( + id: string, + dto: UpdateBookingDto, + files: Express.Multer.File[], + ): Promise<{ booking: Booking; warnings: string[] }> { + const existing = await this.findById(id); + if (!CUSTOMER_EDITABLE_STATUSES.includes(existing.status as never)) { + throw new BadRequestException( + 'Only DRAFT or CHANGES_REQUESTED bookings can be updated', + ); + } + + const warnings: string[] = []; + const freightType = (dto.freightType ?? existing.freightType) as FreightType; + let containers = + dto.containers ?? + (existing.bookingContainers ?? []) + .filter((bc): bc is typeof bc & { containerTypeId: string } => bc.containerTypeId != null) + .map((bc) => ({ + containerTypeId: bc.containerTypeId, + quantity: bc.quantity, + vgmPerUnitTons: Number(bc.vgmPerUnitTons), + })); + + let cargoTypeId = + dto.cargoTypeId !== undefined ? dto.cargoTypeId : existing.cargoTypeId; + + if (freightType === 'BULK') { + containers = []; + if (dto.containers !== undefined) { + await this.bookingsRepository.deleteContainers(id); + } + } else { + cargoTypeId = null; + if (dto.cargoTypeId !== undefined && dto.cargoTypeId !== null) { + throw new BadRequestException('cargoTypeId is not allowed for CONTAINER freight'); + } + } + + assertFreightShape({ freightType, cargoTypeId, containers }); + + const originYardId = dto.originYardId ?? existing.originYardId; + const destinationYardId = dto.destinationYardId ?? existing.destinationYardId; + const tradeDirection = await this.resolveTradeDirectionForBooking( + originYardId, + destinationYardId, + dto.tradeDirection, + ); + + const allowConsolidation = + freightType === 'CONTAINER' + ? await this.resolveConsolidation( + containers, + dto.allowConsolidation ?? existing.allowConsolidation, + ) + : false; + + const evalInput = await this.buildEvalInput({ + freightType, + cargoTypeId, + serviceTypeId: dto.serviceTypeId ?? existing.serviceTypeId, + paymentCurrency: dto.paymentCurrency ?? existing.paymentCurrency, + tradeDirection, + isHazardous: dto.isHazardous ?? existing.isHazardous, + allowConsolidation, + shippingLineId: dto.shippingLineId ?? existing.shippingLineId ?? undefined, + containers, + }); + + const ruleResult = await this.ruleEngineService.evaluate(evalInput); + this.ruleEngineService.assertNoHardBlocks(ruleResult); + warnings.push(...ruleResult.warnings); + + const pricingFieldsChanged = this.pricingRelevantFieldsChanged( + existing, + dto, + freightType, + cargoTypeId, + allowConsolidation, + containers, + ); + + const updates: Record = { + ...dto, + freightType, + cargoTypeId: freightType === 'BULK' ? cargoTypeId : null, + allowConsolidation, + priorityScore: ruleResult.priorityScore, + tradeDirection, + }; + if (dto.scheduledDate) updates.scheduledDate = new Date(dto.scheduledDate); + if (dto.startDate) updates.startDate = new Date(dto.startDate); + if (dto.endDate) updates.endDate = new Date(dto.endDate); + delete updates.containers; + + await this.bookingsRepository.update(id, updates); + + if (freightType === 'CONTAINER' && dto.containers) { + await this.bookingsRepository.deleteContainers(id); + await this.bookingsRepository.createContainers( + id, + dto.containers.map((c, i) => ({ + containerTypeId: c.containerTypeId, + quantity: c.quantity, + vgmPerUnitTons: c.vgmPerUnitTons, + weightResult: ruleResult.containerWeightResults[i], + })), + ); + } + + if (pricingFieldsChanged) { + await this.bookingsRepository.invalidatePricingPreview(id); + } + + if (files.length > 0) { + await this.filesService.uploadMany(id, 'bookings', files); + } + + let booking = await this.findById(id); + + if (allowConsolidation && !booking.consolidationPartnerId) { + const consolidation = await this.tryAutoConsolidate(booking); + booking = consolidation.booking; + warnings.push(...consolidation.messages); + } + + return { booking, warnings }; + } + + /** Parse comma-separated scheduling status query values. */ + private parseSchedulingStatusFilter(filter: FilterBookingDto): { + schedulingStatuses?: string[]; + } { + const raw = filter.schedulingStatuses; + if (!raw) return {}; + const schedulingStatuses = raw + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + return schedulingStatuses.length ? { schedulingStatuses } : {}; + } + + /** Parse comma-separated or repeated status query values. */ + private parseStatusFilter(filter: FilterBookingDto): { + statuses?: string[]; + status?: string; + } { + const allowed = new Set(BOOKING_STATUSES); + const raw = filter.statuses; + const statusList = raw + ? raw + .split(',') + .map((s) => s.trim()) + .filter((s) => allowed.has(s)) + : []; + + if (statusList.length > 0) { + return { statuses: statusList }; + } + if (filter.status && allowed.has(filter.status)) { + return { status: filter.status }; + } + return {}; } /** Return a paginated list of bookings matching the filter. */ async findAll( filter: FilterBookingDto, + forceCompanyId?: string, ): Promise<{ items: Booking[]; total: number }> { const page = filter.page ?? 1; const pageSize = filter.pageSize ?? 20; - const [items, total] = await this.bookingsRepository.findAndCount({ - where: { - ...(filter.status ? { status: filter.status } : {}), - ...(filter.customerId ? { customerId: filter.customerId } : {}), - }, - skip: (page - 1) * pageSize, - take: pageSize, - order: { createdAt: "DESC" }, + const statusFilter = this.parseStatusFilter(filter); + const schedulingStatusFilter = this.parseSchedulingStatusFilter(filter); + + return this.bookingsRepository.findAllPaginated({ + page, + pageSize, + ...statusFilter, + ...schedulingStatusFilter, + assignedToSchedule: filter.assignedToSchedule, + // A forced company scope (portal/customer) overrides any caller-provided + // companyId so a customer can only ever see their own company's bookings. + companyId: forceCompanyId ?? filter.companyId, + contractType: filter.contractType, + serviceTypeId: filter.serviceTypeId, + cargoTypeId: filter.cargoTypeId, + freightType: filter.freightType, + tradeDirection: filter.tradeDirection, + paymentCurrency: filter.paymentCurrency, + paymentStatus: filter.paymentStatus, + allowConsolidation: filter.allowConsolidation, + consolidationPaired: filter.consolidationPaired, + sortBy: filter.sortBy, + sortOrder: filter.sortOrder, }); - return { items, total }; } - /** Get a single booking by ID, throwing if not found. */ + /** Booking statuses at which a customer can pay (mirrors booking-payment.service). */ + private static readonly PAYABLE_STATUSES = [ + 'FULLY_EXECUTED', + 'SELECTED_FOR_BATCH', + 'AWAITING_PAYMENT', + ]; + + /** + * List the current customer's bookings that are ready for payment: + * payable status AND not yet PAID. Company scope is derived from the + * authenticated user and cannot be widened by the caller. + */ + async findMyPayable( + userId: string, + filter: FilterBookingDto, + ): Promise<{ items: Booking[]; total: number }> { + const { company } = await this.companiesService.getCompanyInfoByUserId(userId); + + return this.bookingsRepository.findAllPaginated({ + page: filter.page ?? 1, + pageSize: filter.pageSize ?? 20, + statuses: BookingsService.PAYABLE_STATUSES, + excludePaymentStatus: 'PAID', + companyId: company.id, + sortBy: filter.sortBy, + sortOrder: filter.sortOrder, + }); + } + + /** + * Resolve the company a customer user belongs to, for scoping their own + * bookings. Returns null when no profile/company is linked yet. + */ + async resolveCustomerCompanyId(userId: string): Promise { + try { + const { company } = + await this.companiesService.getCompanyInfoByUserId(userId); + return company?.id ?? null; + } catch { + return null; + } + } + + /** + * Authorize a customer's access to a single booking. Staff are scoped at the + * controller (they pass `isStaff`); for a customer, the booking must belong + * to the company the authenticated user is linked to — otherwise it is hidden + * behind a NotFound so booking IDs can't be probed. + */ + async assertCustomerCanAccessBooking( + userId: string | undefined, + booking: Booking, + ): Promise { + if (!userId) { + throw new ForbiddenException('Authentication required'); + } + const companyId = await this.resolveCustomerCompanyId(userId); + if (!companyId || booking.companyId !== companyId) { + // Don't reveal that the booking exists for another company. + throw new NotFoundException(`Booking ${booking.id} not found`); + } + } + + /** + * Build the customer-facing shipment tracking payload for a booking from the + * train schedule it is assigned to and the live checkpoint log. The caller is + * responsible for authorizing access to the booking first. + * + * When the booking has not been assigned to a train yet, returns a valid + * "no schedule" payload so the UI can show a pre-dispatch state. + */ + async getBookingTracking( + bookingId: string, + ): Promise { + const booking = await this.findById(bookingId); + + const empty: Freight.IBookingTracking = { + bookingId: booking.id, + bookingReference: booking.reference, + hasSchedule: false, + scheduleId: null, + trainNumber: null, + scheduleStatus: null, + direction: null, + origin: null, + destination: null, + stations: [], + checkpoints: [], + currentSequenceNo: -1, + actualDepartureAt: null, + actualArrivalAt: null, + scheduledDepartureAt: null, + scheduledArrivalAt: null, + }; + + if (!booking.trainScheduleId) { + return empty; + } + + // Pull the live corridor + checkpoints for the assigned schedule. If the + // schedule was removed, fall back to the pre-dispatch state rather than 500. + let track: Awaited< + ReturnType + >; + try { + track = await this.trainSchedulingService.getScheduleCheckpoints( + booking.trainScheduleId, + ); + } catch { + return empty; + } + + return { + bookingId: booking.id, + bookingReference: booking.reference, + hasSchedule: true, + scheduleId: track.scheduleId, + trainNumber: track.trainNumber, + scheduleStatus: track.status as Freight.TrainScheduleStatus, + direction: track.direction, + origin: track.origin, + destination: track.destination, + stations: track.stations, + checkpoints: track.checkpoints as Freight.ITrackingCheckpoint[], + currentSequenceNo: track.currentSequenceNo, + actualDepartureAt: track.actualDepartureAt, + actualArrivalAt: track.actualArrivalAt, + scheduledDepartureAt: track.scheduledDepartureAt, + scheduledArrivalAt: track.scheduledArrivalAt, + }; + } + + /** Aggregate metrics and tab counts for the backoffice booking list. */ + async getListSummary(filter: FilterBookingDto): Promise { + const page = filter.page ?? 1; + const pageSize = filter.pageSize ?? 20; + const statusFilter = this.parseStatusFilter(filter); + const listFilter = { + ...statusFilter, + companyId: filter.companyId, + contractType: filter.contractType, + serviceTypeId: filter.serviceTypeId, + cargoTypeId: filter.cargoTypeId, + freightType: filter.freightType, + tradeDirection: filter.tradeDirection, + paymentCurrency: filter.paymentCurrency, + paymentStatus: filter.paymentStatus, + allowConsolidation: filter.allowConsolidation, + consolidationPaired: filter.consolidationPaired, + }; + + const [statusCounts, metrics] = await Promise.all([ + this.bookingsRepository.getStatusCounts(), + this.bookingsRepository.getListSummaryMetrics({ + ...listFilter, + page, + pageSize, + needsActionStatuses: NEEDS_ACTION_STATUSES, + urgentPriorityThreshold: URGENT_PRIORITY_THRESHOLD, + }), + ]); + + return { + metrics, + tabs: mapStatusCountsToTabs(statusCounts), + }; + } + + /** Get a single booking by ID with files. */ async findById(id: string): Promise { - const booking = await this.bookingsRepository.findById(id); + const booking = await this.bookingsRepository.findByIdWithFiles(id); if (!booking) { throw new NotFoundException(`Booking ${id} not found`); } + + if (booking.files && booking.files.length > 0) { + booking.files = await Promise.all( + booking.files.map(async (file: FileRecord) => { + const objectName = this.minioService.getObjectNameFromUrl(file.url); + const signedUrl = await this.minioService.getSignedUrl(objectName, 300); + return { ...file, signedUrl }; + }), + ); + } + return booking; } - /** Soft-delete a booking. */ + async findByReference(reference: string): Promise { + const booking = await this.bookingsRepository.findByReferenceWithFiles(reference); + if (!booking) { + throw new NotFoundException(`Booking with reference "${reference}" not found`); + } + return this.findById(booking.id); + } + + /** Upload documents for a DRAFT booking. */ + async uploadDocuments( + id: string, + files: Express.Multer.File[], + ): Promise { + const booking = await this.findById(id); + if (booking.status !== 'DRAFT') { + throw new BadRequestException( + 'Documents can only be uploaded for DRAFT bookings', + ); + } + await this.filesService.uploadMany(id, 'bookings', files); + return this.findById(id); + } + async remove(id: string): Promise { - await this.findById(id); + const booking = await this.findById(id); + if (booking.status !== 'DRAFT') { + throw new BadRequestException('Only DRAFT bookings can be deleted'); + } await this.bookingsRepository.softDelete(id); } + + async findQueue( + queue: string, + filter: FilterBookingDto, + options?: { excludeBulk?: boolean }, + ): Promise<{ items: Booking[]; total: number }> { + const statusMap: Record = { + intake: 'SUBMITTED', + approval: ['PENDING_APPROVAL', 'APPROVED_PENDING_SIGNATURE'], + signatures: ['APPROVED_PENDING_SIGNATURE', 'PENDING_APPROVAL'], + contract: ['APPROVED', 'CONTRACT_READY', 'SIGNED_CUSTOMER', 'FULLY_EXECUTED'], + marketing: 'SIGNED_CUSTOMER', + finance: 'FULLY_EXECUTED', + }; + + const status = statusMap[queue]; + if (!status) { + throw new BadRequestException(`Unknown queue: ${queue}`); + } + + return this.bookingsRepository.findQueue({ + status, + page: filter.page, + pageSize: filter.pageSize, + excludeBulk: options?.excludeBulk ?? queue === 'approval', + sortBy: filter.sortBy, + sortOrder: filter.sortOrder, + }); + } + + async requestConsolidation(id: string): Promise<{ + booking: Booking; + partner: Booking | null; + paired: boolean; + message: string; + }> { + const booking = await this.findById(id); + + if (!booking.allowConsolidation) { + throw new BadRequestException('Booking is not eligible for consolidation'); + } + + const needs = await this.consolidationService.needsConsolidationFromBooking( + booking, + ); + if (!needs) { + throw new BadRequestException( + 'Booking already fills whole wagon(s) for all container lines; consolidation is not required', + ); + } + + if (booking.consolidationPartnerId) { + throw new ConflictException('Booking is already paired for consolidation'); + } + + const result = await this.tryAutoConsolidate(booking); + const partner = result.booking.consolidationPartnerId + ? await this.findById(result.booking.consolidationPartnerId) + : null; + + return { + booking: result.booking, + partner, + paired: partner !== null, + message: result.messages[0] ?? '', + }; + } + + async removeConsolidation(id: string): Promise<{ booking: Booking; partner: Booking }> { + const booking = await this.findById(id); + if (!booking.consolidationPartnerId) { + throw new BadRequestException('Booking has no consolidation partner'); + } + + const partnerId = booking.consolidationPartnerId; + await this.bookingsRepository.unpairConsolidation(id, partnerId); + + return { + booking: await this.findById(id), + partner: await this.findById(partnerId), + }; + } + + async getConsolidationDetails(id: string): Promise<{ + booking: Booking; + partner: Booking | null; + splitBilling: { bookingShare: number; partnerShare: number } | null; + wagonSlots: Awaited>; + statusMessage: string; + }> { + const booking = await this.findById(id); + const wagonSlots = await this.consolidationService.slotsFromBooking(booking); + + if (!booking.consolidationPartnerId) { + const statusMessage = + booking.status === 'PENDING_CONSOLIDATION' + ? this.consolidationService.describePending(booking, wagonSlots) + : wagonSlots.length > 0 + ? 'Consolidation may be required; no partner paired yet.' + : 'No wagon consolidation needed.'; + return { + booking, + partner: null, + splitBilling: null, + wagonSlots, + statusMessage, + }; + } + + const partner = await this.findById(booking.consolidationPartnerId); + return { + booking, + partner, + splitBilling: { + bookingShare: Number(booking.totalAmount), + partnerShare: Number(partner.totalAmount), + }, + wagonSlots, + statusMessage: this.consolidationService.describePaired( + partner.reference, + wagonSlots, + ), + }; + } + + private pricingRelevantFieldsChanged( + existing: Booking, + dto: UpdateBookingDto, + freightType: FreightType, + cargoTypeId: string | null | undefined, + allowConsolidation: boolean, + containers: CreateBookingContainerDto[], + ): boolean { + if (dto.freightType !== undefined && dto.freightType !== existing.freightType) { + return true; + } + if (dto.tradeDirection !== undefined && dto.tradeDirection !== existing.tradeDirection) { + return true; + } + if (dto.paymentCurrency !== undefined && dto.paymentCurrency !== existing.paymentCurrency) { + return true; + } + if (dto.isHazardous !== undefined && dto.isHazardous !== existing.isHazardous) { + return true; + } + if ( + dto.allowConsolidation !== undefined && + dto.allowConsolidation !== existing.allowConsolidation + ) { + return true; + } + if (dto.shippingLineId !== undefined && dto.shippingLineId !== existing.shippingLineId) { + return true; + } + if (dto.cargoTypeId !== undefined && dto.cargoTypeId !== existing.cargoTypeId) { + return true; + } + if (dto.containers !== undefined) { + const existingContainers = (existing.bookingContainers ?? []) + .filter((bc): bc is typeof bc & { containerTypeId: string } => bc.containerTypeId != null) + .map((bc) => ({ + containerTypeId: bc.containerTypeId, + quantity: bc.quantity, + vgmPerUnitTons: Number(bc.vgmPerUnitTons), + })); + if (JSON.stringify(existingContainers) !== JSON.stringify(containers)) { + return true; + } + } + if ( + freightType !== existing.freightType || + (cargoTypeId ?? null) !== (existing.cargoTypeId ?? null) || + allowConsolidation !== existing.allowConsolidation + ) { + return true; + } + return false; + } + + /** Staff expedite: mark a government booking PAID and ready for scheduling (no commercial hold). */ + async governmentExpedite(id: string, staffUserId: string): Promise { + const booking = await this.findById(id); + if (!booking.isGovernment) { + throw new BadRequestException('Only government bookings can be expedited'); + } + const blocked = ['PAID', 'IN_TRANSIT', 'COMPLETED', 'CANCELLED', 'REJECTED']; + if (blocked.includes(booking.status)) { + throw new BadRequestException(`Cannot expedite booking in status ${booking.status}`); + } + + await this.bookingsRepository.update(id, { + status: 'PAID', + paymentStatus: 'PAID', + schedulingStatus: SchedulingStatus.Eligible, + holdStartedAt: null, + holdExpiresAt: null, + }); + await this.bookingsRepository.createReviewNote( + id, + `Government booking expedited to PAID by staff (${staffUserId})`, + 'STAFF_NOTE', + staffUserId, + ); + + return this.findById(id); + } } diff --git a/apps/edr-freight-api/src/modules/bookings/consolidation.service.ts b/apps/edr-freight-api/src/modules/bookings/consolidation.service.ts new file mode 100644 index 000000000..541d5d09f --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/consolidation.service.ts @@ -0,0 +1,124 @@ +import { Injectable } from '@nestjs/common'; + +import { ContainerTypesService } from '../rule-engine/services/container-types.service'; +import { Booking } from './entities/booking.entity'; + +export interface ConsolidationSlot { + containerTypeId: string; + containerTypeCode: string; + quantity: number; + containersPerWagon: number; + remainder: number; + slotsNeeded: number; +} + +export interface ConsolidationAttemptResult { + booking: Booking; + partner: Booking | null; + paired: boolean; + messages: string[]; +} + +/** Containers that fit on one wagon for a given container type (inverse of wagons_per_unit). */ +export function containersPerWagon(wagonsPerUnit: number): number { + const wpu = Number(wagonsPerUnit); + if (!wpu || wpu <= 0) return 1; + return Math.max(1, Math.round(1 / wpu)); +} + +export function wagonRemainder(quantity: number, perWagon: number): number { + const r = quantity % perWagon; + return r; +} + +export function slotsNeededToFillWagon(quantity: number, perWagon: number): number { + const remainder = wagonRemainder(quantity, perWagon); + if (remainder === 0) return 0; + return perWagon - remainder; +} + +/** Two bookings' quantities for the same type complete whole wagon(s). */ +export function quantitiesComplementWagon( + q1: number, + q2: number, + perWagon: number, +): boolean { + return ( + wagonRemainder(q1, perWagon) > 0 && + wagonRemainder(q2, perWagon) > 0 && + (q1 + q2) % perWagon === 0 + ); +} + +@Injectable() +export class ConsolidationService { + constructor(private readonly containerTypesService: ContainerTypesService) {} + + async slotsFromContainerLines( + lines: Array<{ containerTypeId: string; quantity: number }>, + ): Promise { + const slots: ConsolidationSlot[] = []; + for (const line of lines) { + const ct = await this.containerTypesService.findById(line.containerTypeId); + const perWagon = containersPerWagon(Number(ct.wagonsPerUnit)); + const remainder = wagonRemainder(line.quantity, perWagon); + if (remainder === 0) continue; + slots.push({ + containerTypeId: line.containerTypeId, + containerTypeCode: ct.code, + quantity: line.quantity, + containersPerWagon: perWagon, + remainder, + slotsNeeded: perWagon - remainder, + }); + } + return slots; + } + + async slotsFromBooking(booking: Booking): Promise { + const lines = (booking.bookingContainers ?? []) + .filter((bc): bc is typeof bc & { containerTypeId: string } => bc.containerTypeId != null) + .map((bc) => ({ + containerTypeId: bc.containerTypeId, + quantity: bc.quantity, + })); + return this.slotsFromContainerLines(lines); + } + + async needsConsolidation( + lines: Array<{ containerTypeId: string; quantity: number }>, + ): Promise { + const slots = await this.slotsFromContainerLines(lines); + return slots.length > 0; + } + + async needsConsolidationFromBooking(booking: Booking): Promise { + const slots = await this.slotsFromBooking(booking); + return slots.length > 0; + } + + describePending(_booking: Booking, slots: ConsolidationSlot[]): string { + if (slots.length === 0) { + return 'Booking does not require wagon consolidation.'; + } + const parts = slots.map( + (s) => + `${s.slotsNeeded} more × ${s.containerTypeCode} (${s.containersPerWagon} per wagon; you have ${s.quantity})`, + ); + return ( + `No compatible partner found yet. Your booking is queued (PENDING_CONSOLIDATION). ` + + `Waiting for: ${parts.join('; ')}. You will be notified when another customer fills the wagon.` + ); + } + + describePaired(partnerReference: string, slots: ConsolidationSlot[]): string { + const parts = slots.map( + (s) => + `${s.containerTypeCode}: ${s.quantity} + partner fills wagon (${s.containersPerWagon} per wagon)`, + ); + return ( + `Consolidation partner found (${partnerReference}). ` + + `Shared wagon confirmed: ${parts.join('; ')}.` + ); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/dto/booking-list-summary.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/booking-list-summary.dto.ts new file mode 100644 index 000000000..30ca4bdb7 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/dto/booking-list-summary.dto.ts @@ -0,0 +1,34 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class BookingListSummaryMetricsDto { + @ApiProperty({ example: 42 }) + inQueue!: number; + + @ApiProperty({ example: 10 }) + onThisPage!: number; + + @ApiProperty({ example: 8 }) + needsAction!: number; + + @ApiProperty({ example: 3 }) + urgent!: number; +} + +export class BookingListSummaryTabsDto { + @ApiProperty() all!: number; + @ApiProperty() intake!: number; + @ApiProperty() in_approval!: number; + @ApiProperty() approved_contract!: number; + @ApiProperty() payment!: number; + @ApiProperty() operations!: number; + @ApiProperty() completed!: number; + @ApiProperty() closed!: number; +} + +export class BookingListSummaryDto { + @ApiProperty({ type: BookingListSummaryMetricsDto }) + metrics!: BookingListSummaryMetricsDto; + + @ApiProperty({ type: BookingListSummaryTabsDto }) + tabs!: BookingListSummaryTabsDto; +} diff --git a/apps/edr-freight-api/src/modules/bookings/dto/booking-reference-data.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/booking-reference-data.dto.ts new file mode 100644 index 000000000..0dc2bd255 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/dto/booking-reference-data.dto.ts @@ -0,0 +1,107 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class BookingReferenceYardDto { + @ApiProperty({ format: 'uuid' }) + id!: string; + + @ApiProperty({ example: 'Mojo Dry Port' }) + name!: string; + + @ApiProperty({ example: 'MOJO' }) + code!: string; + + @ApiProperty({ example: 'Ethiopia' }) + country!: string; +} + +export class BookingReferenceContainerTypeDto { + @ApiProperty({ format: 'uuid' }) + id!: string; + + @ApiProperty({ example: 'Dry' }) + name!: string; + + @ApiProperty({ example: '20GP' }) + code!: string; + + @ApiProperty() + is_reefer!: boolean; + + @ApiProperty({ example: 0.5, description: 'Wagon fraction per container' }) + wagons_per_unit!: number; +} + +export class BookingReferenceContainerSizeGroupDto { + @ApiProperty({ example: '20ft' }) + size!: string; + + @ApiProperty({ type: [BookingReferenceContainerTypeDto] }) + types!: BookingReferenceContainerTypeDto[]; +} + +export class BookingReferenceServiceDto { + @ApiProperty({ format: 'uuid' }) + id!: string; + + @ApiProperty({ example: 'Rail Transport Only' }) + name!: string; + + @ApiProperty({ example: 'RAIL' }) + code!: string; +} + +export class BookingReferenceShippingLineDto { + @ApiProperty({ format: 'uuid' }) + id!: string; + + @ApiProperty({ example: 'MSC' }) + name!: string; + + @ApiProperty({ example: 'MSC' }) + code!: string; +} + +export class BookingReferenceCargoTypeChildDto { + @ApiProperty({ format: 'uuid' }) + id!: string; + + @ApiProperty({ example: 'Coffee' }) + name!: string; + + @ApiProperty({ example: 'BULK_COFFEE' }) + code!: string; + + @ApiProperty() + show_free_text_box!: boolean; +} + +export class BookingReferenceCargoTypeGroupDto { + @ApiProperty({ format: 'uuid' }) + id!: string; + + @ApiProperty({ example: 'Bulk Cargo' }) + name!: string; + + @ApiProperty({ example: 'BULK' }) + code!: string; + + @ApiPropertyOptional({ type: [BookingReferenceCargoTypeChildDto] }) + children?: BookingReferenceCargoTypeChildDto[]; +} + +export class BookingReferenceDataDto { + @ApiProperty({ type: [BookingReferenceYardDto] }) + yard!: BookingReferenceYardDto[]; + + @ApiProperty({ type: [BookingReferenceContainerSizeGroupDto] }) + containers!: BookingReferenceContainerSizeGroupDto[]; + + @ApiProperty({ type: [BookingReferenceServiceDto] }) + service!: BookingReferenceServiceDto[]; + + @ApiProperty({ type: [BookingReferenceShippingLineDto] }) + shipping_line!: BookingReferenceShippingLineDto[]; + + @ApiProperty({ type: [BookingReferenceCargoTypeGroupDto] }) + cargo_type!: BookingReferenceCargoTypeGroupDto[]; +} diff --git a/apps/edr-freight-api/src/modules/bookings/dto/contract-view.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/contract-view.dto.ts new file mode 100644 index 000000000..919652af6 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/dto/contract-view.dto.ts @@ -0,0 +1,61 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class ContractSignatureDto { + @ApiProperty({ enum: ['CUSTOMER', 'STAFF'] }) + role!: string; + + @ApiProperty() + signerDisplayName!: string; + + @ApiProperty() + signedAt!: string; + + @ApiPropertyOptional() + signatureImageUrl?: string | null; +} + +export class SavedSignatureViewDto { + @ApiProperty() + signerDisplayName!: string; + + @ApiPropertyOptional() + signatureImageUrl?: string | null; +} + +export class ContractViewDto { + @ApiProperty() + bookingId!: string; + + @ApiProperty() + reference!: string; + + @ApiProperty() + status!: string; + + @ApiProperty() + templateKey!: string; + + @ApiProperty() + title!: string; + + @ApiProperty({ description: 'Full HTML document for in-browser display' }) + html!: string; + + @ApiProperty() + canSignCustomer!: boolean; + + @ApiProperty() + canSignStaff!: boolean; + + @ApiProperty() + hasContractDocument!: boolean; + + @ApiProperty({ type: [ContractSignatureDto] }) + signatures!: ContractSignatureDto[]; + + @ApiPropertyOptional({ type: SavedSignatureViewDto }) + savedSignature?: SavedSignatureViewDto; + + @ApiPropertyOptional() + pricingSchedule?: Record; +} diff --git a/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts index c96e4fd62..fa3bb6f4d 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts @@ -1,33 +1,227 @@ -import { Freight } from "@edr/types"; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Transform, Type } from 'class-transformer'; import { + ArrayMinSize, + IsArray, + IsBoolean, IsDateString, - IsEnum, + IsIn, + IsInt, IsNumber, IsOptional, IsString, IsUUID, Min, -} from "class-validator"; + MinLength, + Validate, + ValidateIf, + ValidateNested, +} from 'class-validator'; +import { BOOKING_STATUSES, FREIGHT_TYPES } from '../entities/booking.entity'; +import { BookingFreightShapeConstraint } from './validators/booking-freight.validator'; + +const CONTRACT_TYPES = ['NEW', 'RENEWAL'] as const; +const EQUIPMENT_RETURNS = ['WITH_RETURN', 'WITHOUT_RETURN', 'NA'] as const; +const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'DOMESTIC'] as const; +const PAYMENT_CURRENCIES = ['ETB', 'USD'] as const; + +export { + BOOKING_STATUSES, + CONTRACT_TYPES, + EQUIPMENT_RETURNS, + FREIGHT_TYPES, + TRADE_DIRECTIONS, + PAYMENT_CURRENCIES, +}; + +export class CreateBookingContainerDto { + @ApiProperty({ format: 'uuid', description: 'FK to container_types.id' }) + @IsUUID() + containerTypeId!: string; + + @ApiProperty({ description: 'Quantity of containers', minimum: 1 }) + @IsInt() + @Min(1) + @Transform(({ value }) => Number(value)) + quantity!: number; + + @ApiProperty({ description: 'VGM per container in tons', minimum: 0 }) + @IsNumber() + @Min(0) + @Transform(({ value }) => Number(value)) + vgmPerUnitTons!: number; +} export class CreateBookingDto { + /** Class-level freight shape check (not a request field). */ + @Validate(BookingFreightShapeConstraint) + freightShapeValidation?: boolean; + @ApiPropertyOptional({ description: 'Unique booking reference (auto-generated if omitted)' }) + @IsOptional() @IsString() - reference!: string; + @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value)) + reference?: string; + // @ApiPropertyOptional({ format: 'uuid', description: 'Admin only: target customer (legacy)' }) + // @IsOptional() + // @IsUUID() + // customerId?: string; + + @ApiPropertyOptional({ description: 'Staff only: government booking flag' }) + @IsOptional() + @IsBoolean() + @Transform(({ value }) => value === 'true' || value === true) + isGovernment?: boolean; + + @ApiPropertyOptional({ description: 'Required when isGovernment is true' }) + @ValidateIf((o) => o.isGovernment === true) + @IsString() + @MinLength(2) + @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value)) + governmentInstitution?: string; + + @ApiPropertyOptional({ format: 'uuid', description: 'Admin only: target company' }) + @ValidateIf((o) => o.isGovernment !== true) + @IsOptional() @IsUUID() - customerId!: string; + companyId?: string; + @ApiPropertyOptional({ format: 'uuid' }) @IsOptional() @IsUUID() trainId?: string; + /** + * Staff-only manual pin to a specific train. Customers omit this — they pick a + * DAY via {@link scheduledDate} and the batch engine assigns a train within + * that (route, day) pool. When provided, the schedule must be OPEN and on the + * booking route. + */ + @ApiPropertyOptional({ + format: 'uuid', + description: 'Staff only: pin to a specific train schedule. Customers omit this.', + }) + @IsOptional() + @IsUUID() + trainScheduleId?: string; + + /** The day the customer wants to ship (the pool day key). */ + @ApiProperty({ example: '2026-06-15T00:00:00.000Z' }) @IsDateString() scheduledDate!: string; + @ApiProperty({ enum: CONTRACT_TYPES }) + @IsIn([...CONTRACT_TYPES]) + contractType!: string; + + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + @Transform(({ value }) => (value === '' || value == null ? undefined : value)) + previousContractId?: string; + + @ApiProperty({ format: 'uuid', description: 'FK to service_types.id' }) + @IsUUID() + serviceTypeId!: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + firstMilePickupAddress?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + lastMileDeliveryAddress?: string; + + @ApiProperty({ enum: EQUIPMENT_RETURNS }) + @IsIn([...EQUIPMENT_RETURNS]) + equipmentReturn!: string; + + @ApiProperty({ format: 'uuid', description: 'FK to yards.id (origin)' }) + @IsUUID() + originYardId!: string; + + @ApiProperty({ format: 'uuid', description: 'FK to yards.id (destination)' }) + @IsUUID() + destinationYardId!: string; + + @ApiProperty({ enum: TRADE_DIRECTIONS }) + @IsIn([...TRADE_DIRECTIONS]) + tradeDirection!: string; + + @ApiProperty({ enum: FREIGHT_TYPES, description: 'CONTAINER or BULK (mutually exclusive cargo shape)' }) + @IsIn([...FREIGHT_TYPES]) + freightType!: string; + + @ApiPropertyOptional({ + format: 'uuid', + description: 'Required for BULK; must be omitted for CONTAINER', + }) + @ValidateIf((o) => o.freightType === 'BULK') + @IsUUID() + cargoTypeId?: string; + + @ApiPropertyOptional({ maxLength: 200 }) + @IsOptional() + @IsString() + cargoFreeText?: string; + + @ApiPropertyOptional({ format: 'uuid', description: 'FK to shipping_lines.id' }) + @IsOptional() + @IsUUID() + shippingLineId?: string; + + @ApiProperty({ description: 'Total cargo weight VGM in tons', minimum: 0 }) @IsNumber() @Min(0) - totalAmount!: number; + @Transform(({ value }) => Number(value)) + cargoTotalWeightVgm!: number; + @ApiPropertyOptional({ default: false }) @IsOptional() - @IsEnum(Freight.BookingStatus) - status?: Freight.BookingStatus; + @IsBoolean() + @Transform(({ value }) => value === 'true' || value === true) + isHazardous?: boolean; + + @ApiProperty({ enum: PAYMENT_CURRENCIES }) + @IsIn([...PAYMENT_CURRENCIES]) + paymentCurrency!: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + pnrCode?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsDateString() + startDate?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsDateString() + endDate?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + financialTerms?: string; + + @ApiPropertyOptional({ + type: [CreateBookingContainerDto], + description: 'Required for CONTAINER (min 1 line); must be empty for BULK', + }) + @ValidateIf((o) => o.freightType === 'CONTAINER') + @IsArray() + @ArrayMinSize(1) + @ValidateNested({ each: true }) + @Type(() => CreateBookingContainerDto) + containers?: CreateBookingContainerDto[]; + + @ApiPropertyOptional({ default: false }) + @IsOptional() + @IsBoolean() + @Transform(({ value }) => value === 'true' || value === true) + allowConsolidation?: boolean; } diff --git a/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts index e4064b1e4..9ce90d2b9 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts @@ -1,25 +1,118 @@ -import { Freight } from "@edr/types"; -import { Type } from "class-transformer"; -import { IsEnum, IsInt, IsOptional, IsUUID, Min } from "class-validator"; +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { Transform } from 'class-transformer'; +import { IsIn, IsOptional, IsUUID } from 'class-validator'; +import { + BOOKING_STATUSES, + FREIGHT_TYPES, + PAYMENT_CURRENCIES, + TRADE_DIRECTIONS, +} from './create-booking.dto'; +import { PAYMENT_STATUSES } from '../entities/booking.entity'; export class FilterBookingDto { + @ApiPropertyOptional({ enum: BOOKING_STATUSES }) @IsOptional() - @IsEnum(Freight.BookingStatus) - status?: Freight.BookingStatus; + @IsIn([...BOOKING_STATUSES]) + status?: string; + @ApiPropertyOptional({ + description: + 'Filter by statuses: comma-separated (PENDING_APPROVAL,APPROVED) or repeated query params. Overrides status when set.', + }) + @IsOptional() + @Transform(({ value }) => { + if (value === undefined || value === null || value === '') return undefined; + if (Array.isArray(value)) return value.map(String).join(','); + return String(value); + }) + statuses?: string; + + // @ApiPropertyOptional({ format: 'uuid' }) + // @IsOptional() + // @IsUUID() + // customerId?: string; + + @ApiPropertyOptional({ format: 'uuid' }) @IsOptional() @IsUUID() - customerId?: string; + companyId?: string; + @ApiPropertyOptional() @IsOptional() - @Type(() => Number) - @IsInt() - @Min(1) - page?: number = 1; + contractType?: string; + @ApiPropertyOptional({ format: 'uuid' }) @IsOptional() - @Type(() => Number) - @IsInt() - @Min(1) - pageSize?: number = 20; + @IsUUID() + serviceTypeId?: string; + + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + cargoTypeId?: string; + + @ApiPropertyOptional({ enum: FREIGHT_TYPES }) + @IsOptional() + @IsIn([...FREIGHT_TYPES]) + freightType?: string; + + @ApiPropertyOptional({ enum: TRADE_DIRECTIONS }) + @IsOptional() + @IsIn([...TRADE_DIRECTIONS]) + tradeDirection?: string; + + @ApiPropertyOptional({ enum: PAYMENT_CURRENCIES }) + @IsOptional() + @IsIn([...PAYMENT_CURRENCIES]) + paymentCurrency?: string; + + @ApiPropertyOptional({ enum: PAYMENT_STATUSES }) + @IsOptional() + @IsIn([...PAYMENT_STATUSES]) + paymentStatus?: string; + + @ApiPropertyOptional() + @IsOptional() + @Transform(({ value }) => value === 'true' || value === true) + allowConsolidation?: boolean; + + @ApiPropertyOptional({ description: 'true | false — filter paired consolidation' }) + @IsOptional() + consolidationPaired?: string; + + @ApiPropertyOptional({ default: 1 }) + @IsOptional() + @Transform(({ value }) => (value ? parseInt(value, 10) : 1)) + page?: number; + + @ApiPropertyOptional({ default: 20 }) + @IsOptional() + @Transform(({ value }) => (value ? parseInt(value, 10) : 20)) + pageSize?: number; + + @ApiPropertyOptional({ + description: 'Comma-separated scheduling statuses (NOT_SCHEDULED,HOLDING,ELIGIBLE,SCHEDULED)', + }) + @IsOptional() + @Transform(({ value }) => { + if (value === undefined || value === null || value === '') return undefined; + if (Array.isArray(value)) return value.map(String).join(','); + return String(value); + }) + schedulingStatuses?: string; + + @ApiPropertyOptional({ enum: ['true', 'false'], description: 'Filter by train schedule assignment' }) + @IsOptional() + @IsIn(['true', 'false']) + assignedToSchedule?: 'true' | 'false'; + + @ApiPropertyOptional({ default: 'createdAt' }) + @IsOptional() + @IsIn(['createdAt', 'priorityScore', 'scheduledDate', 'isGovernment']) + sortBy?: string; + + @ApiPropertyOptional({ enum: ['ASC', 'DESC'], default: 'DESC' }) + @IsOptional() + @IsIn(['ASC', 'DESC']) + sortOrder?: 'ASC' | 'DESC'; } diff --git a/apps/edr-freight-api/src/modules/bookings/dto/generate-price-response.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/generate-price-response.dto.ts new file mode 100644 index 000000000..3474bec74 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/dto/generate-price-response.dto.ts @@ -0,0 +1,32 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class PriceLineItemDto { + @ApiProperty() + code!: string; + + @ApiProperty() + description!: string; + + @ApiProperty() + amount!: number; + + @ApiProperty() + currency!: string; +} + +export class GeneratePriceResponseDto { + @ApiProperty() + bookingId!: string; + + @ApiProperty() + totalAmount!: number; + + @ApiProperty() + currency!: string; + + @ApiProperty({ type: [PriceLineItemDto] }) + lineItems!: PriceLineItemDto[]; + + @ApiProperty({ type: [String] }) + warnings!: string[]; +} diff --git a/apps/edr-freight-api/src/modules/bookings/dto/pay-booking.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/pay-booking.dto.ts new file mode 100644 index 000000000..50db3864b --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/dto/pay-booking.dto.ts @@ -0,0 +1,26 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class InAppPaymentReceiptDto { + @ApiProperty({ example: true }) + success!: boolean; + + @ApiProperty({ example: 'TELEBIRR' }) + provider!: string; + + @ApiProperty({ example: 'TB-BK-2026-000123-1717584000000' }) + providerRef!: string; + + @ApiProperty({ example: 15000 }) + amount!: number; + + @ApiProperty({ example: 'ETB' }) + currency!: string; + + @ApiProperty({ example: '2026-06-05T12:00:00.000Z' }) + paidAt!: string; +} + +export class PayBookingResponseDto { + @ApiProperty({ type: InAppPaymentReceiptDto }) + paymentReceipt!: InAppPaymentReceiptDto; +} diff --git a/apps/edr-freight-api/src/modules/bookings/dto/request-changes.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/request-changes.dto.ts new file mode 100644 index 000000000..99855d49f --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/dto/request-changes.dto.ts @@ -0,0 +1,36 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsString, MinLength } from 'class-validator'; + +export class RequestChangesDto { + @ApiProperty({ description: 'Staff note explaining what the customer must fix' }) + @IsString() + @MinLength(1) + note!: string; +} + +export class StaffRejectDto { + @ApiProperty() + @IsString() + @MinLength(1) + reason!: string; +} + +export class ApproveStepDto { + @ApiProperty({ description: 'LINE_STAFF | DIRECTOR | CEO' }) + @IsString() + requiredRole!: string; +} + +export class RejectStepDto { + @ApiProperty() + @IsString() + @MinLength(1) + reason!: string; +} + +export class CancelBookingDto { + @ApiProperty() + @IsString() + @MinLength(1) + reason!: string; +} diff --git a/apps/edr-freight-api/src/modules/bookings/dto/sign-contract.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/sign-contract.dto.ts new file mode 100644 index 000000000..0b176ebd5 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/dto/sign-contract.dto.ts @@ -0,0 +1,23 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsIn, IsOptional, IsString, MinLength } from 'class-validator'; + +export class SignContractDto { + @ApiProperty({ enum: ['CUSTOMER', 'STAFF'] }) + @IsIn(['CUSTOMER', 'STAFF']) + role!: 'CUSTOMER' | 'STAFF'; + + @ApiProperty({ description: 'PNG signature image as base64 (with or without data URL prefix)' }) + @IsString() + @MinLength(20) + signatureImageBase64!: string; + + @ApiProperty() + @IsString() + @MinLength(1) + signerDisplayName!: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + consentText?: string; +} diff --git a/apps/edr-freight-api/src/modules/bookings/dto/submit-booking-response.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/submit-booking-response.dto.ts new file mode 100644 index 000000000..2828f0237 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/dto/submit-booking-response.dto.ts @@ -0,0 +1,29 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +import { PriceLineItemDto } from './generate-price-response.dto'; + +export class SubmitBookingResponseDto { + @ApiProperty() + bookingId!: string; + + @ApiProperty() + status!: string; + + @ApiProperty() + priceChanged!: boolean; + + @ApiPropertyOptional() + previousTotalAmount?: number; + + @ApiProperty() + totalAmount!: number; + + @ApiProperty() + currency!: string; + + @ApiPropertyOptional({ type: [PriceLineItemDto] }) + lineItems?: PriceLineItemDto[]; + + @ApiPropertyOptional() + message?: string; +} diff --git a/apps/edr-freight-api/src/modules/bookings/dto/update-booking.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/update-booking.dto.ts new file mode 100644 index 000000000..328e71180 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/dto/update-booking.dto.ts @@ -0,0 +1,10 @@ +import { PartialType } from '@nestjs/mapped-types'; +import { Validate } from 'class-validator'; + +import { CreateBookingDto } from './create-booking.dto'; +import { BookingFreightShapeConstraint } from './validators/booking-freight.validator'; + +export class UpdateBookingDto extends PartialType(CreateBookingDto) { + @Validate(BookingFreightShapeConstraint) + freightShapeValidation?: boolean; +} diff --git a/apps/edr-freight-api/src/modules/bookings/dto/validators/booking-freight.validator.ts b/apps/edr-freight-api/src/modules/bookings/dto/validators/booking-freight.validator.ts new file mode 100644 index 000000000..1365158b1 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/dto/validators/booking-freight.validator.ts @@ -0,0 +1,60 @@ +import { + ValidationArguments, + ValidatorConstraint, + ValidatorConstraintInterface, +} from 'class-validator'; + +import { FREIGHT_TYPES, FreightType } from '../../entities/booking.entity'; + +export interface BookingFreightShapeInput { + freightType?: string; + cargoTypeId?: string | null; + containers?: Array<{ containerTypeId?: string }> | null; +} + +@ValidatorConstraint({ name: 'BookingFreightShape', async: false }) +export class BookingFreightShapeConstraint implements ValidatorConstraintInterface { + validate(_value: unknown, args: ValidationArguments): boolean { + const dto = args.object as BookingFreightShapeInput; + if (!dto.freightType || !FREIGHT_TYPES.includes(dto.freightType as FreightType)) { + return true; + } + + const containers = dto.containers ?? []; + const hasContainers = containers.length > 0; + const hasCargoType = + dto.cargoTypeId !== undefined && + dto.cargoTypeId !== null && + String(dto.cargoTypeId).trim() !== ''; + + if (dto.freightType === 'BULK') { + if (hasContainers) return false; + if (!hasCargoType) return false; + return true; + } + + if (dto.freightType === 'CONTAINER') { + if (hasCargoType) return false; + if (!hasContainers) return false; + return containers.every( + (c) => + c.containerTypeId !== undefined && + c.containerTypeId !== null && + String(c.containerTypeId).trim() !== '', + ); + } + + return true; + } + + defaultMessage(args: ValidationArguments): string { + const dto = args.object as BookingFreightShapeInput; + if (dto.freightType === 'BULK') { + return 'BULK freight requires cargoTypeId and must not include container lines'; + } + if (dto.freightType === 'CONTAINER') { + return 'CONTAINER freight requires at least one container line with containerTypeId and must not include cargoTypeId'; + } + return 'Invalid freight type shape'; + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking-approval-step.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking-approval-step.entity.ts new file mode 100644 index 000000000..68018e883 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking-approval-step.entity.ts @@ -0,0 +1,48 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; +import { ApprovalRule } from '../../rule-engine/entities/approval-rule.entity'; +import { Booking } from './booking.entity'; + +export const APPROVAL_STEP_STATUSES = ['PENDING', 'APPROVED', 'REJECTED', 'SKIPPED'] as const; +export type ApprovalStepStatus = typeof APPROVAL_STEP_STATUSES[number]; + +@Entity({ schema: 'freight', name: 'booking_approval_step' }) +@Index(['bookingId']) +@Index(['status']) +@Index(['bookingId', 'stepOrder']) +export class BookingApprovalStep extends BaseEntity { + @Column({ name: 'booking_id', type: 'uuid' }) + bookingId!: string; + + @ManyToOne(() => Booking, (b) => b.approvalSteps, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'booking_id' }) + booking?: Booking; + + @Column({ name: 'approval_rule_id', type: 'uuid' }) + approvalRuleId!: string; + + @ManyToOne(() => ApprovalRule) + @JoinColumn({ name: 'approval_rule_id' }) + approvalRule?: ApprovalRule; + + @Column({ name: 'step_order', type: 'smallint' }) + stepOrder!: number; + + @Column({ name: 'required_role', type: 'varchar', length: 30 }) + requiredRole!: string; + + @Column({ name: 'blocks_role', type: 'varchar', length: 30, nullable: true }) + blocksRole?: string | null; + + @Column({ name: 'status', type: 'varchar', length: 20, default: 'PENDING' }) + status!: ApprovalStepStatus; + + @Column({ name: 'actioned_by_staff_id', type: 'uuid', nullable: true }) + actionedByStaffId?: string | null; + + @Column({ name: 'actioned_at', type: 'timestamptz', nullable: true }) + actionedAt?: Date | null; + + @Column({ name: 'remarks', type: 'text', nullable: true }) + remarks?: string | null; +} diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking-cargo-modifier.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking-cargo-modifier.entity.ts new file mode 100644 index 000000000..5933abae2 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking-cargo-modifier.entity.ts @@ -0,0 +1,37 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; +import { SurchargeType } from '../../rule-engine/entities/surcharge-type.entity'; +import { Booking } from './booking.entity'; +import { BookingRateSnapshot } from './booking-rate-snapshot.entity'; + +@Entity({ schema: 'freight', name: 'booking_cargo_modifier' }) +@Index(['bookingId']) +@Index(['surchargeTypeId']) +export class BookingCargoModifier extends BaseEntity { + @Column({ name: 'booking_id', type: 'uuid' }) + bookingId!: string; + + @ManyToOne(() => Booking, (b) => b.cargoModifiers, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'booking_id' }) + booking?: Booking; + + @Column({ name: 'surcharge_type_id', type: 'uuid' }) + surchargeTypeId!: string; + + @ManyToOne(() => SurchargeType) + @JoinColumn({ name: 'surcharge_type_id' }) + surchargeType?: SurchargeType; + + @Column({ name: 'trigger_value', type: 'numeric', precision: 14, scale: 4, nullable: true }) + triggerValue?: number | null; + + @Column({ name: 'calculated_amount', type: 'numeric', precision: 14, scale: 2 }) + calculatedAmount!: number; + + @Column({ name: 'rate_snapshot_id', type: 'uuid' }) + rateSnapshotId!: string; + + @ManyToOne(() => BookingRateSnapshot) + @JoinColumn({ name: 'rate_snapshot_id' }) + rateSnapshot?: BookingRateSnapshot; +} diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking-container.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking-container.entity.ts new file mode 100644 index 000000000..8a09245ea --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking-container.entity.ts @@ -0,0 +1,52 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; +import { ContainerType } from '../../rule-engine/entities/container-type.entity'; +import { WeightLimitRule } from '../../rule-engine/entities/weight-limit-rule.entity'; +import { Booking } from './booking.entity'; + +@Entity({ schema: 'freight', name: 'booking_container' }) +@Index(['bookingId']) +@Index(['isOverweight']) +export class BookingContainer extends BaseEntity { + @Column({ name: 'booking_id', type: 'uuid' }) + bookingId!: string; + + @ManyToOne(() => Booking, (b) => b.bookingContainers, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'booking_id' }) + booking?: Booking; + + @Column({ name: 'container_type_id', type: 'uuid', nullable: true }) + containerTypeId?: string | null; + + @ManyToOne(() => ContainerType, { nullable: true }) + @JoinColumn({ name: 'container_type_id' }) + containerType?: ContainerType | null; + + @Column({ name: 'container_number', type: 'varchar', length: 64, nullable: true }) + containerNumber?: string | null; + + @Column({ name: 'quantity', type: 'smallint' }) + quantity!: number; + + @Column({ name: 'vgm_per_unit_tons', type: 'numeric', precision: 10, scale: 3 }) + vgmPerUnitTons!: number; + + @Column({ name: 'total_vgm_tons', type: 'numeric', precision: 12, scale: 3 }) + totalVgmTons!: number; + + @Column({ name: 'wagons_required', type: 'numeric', precision: 6, scale: 2 }) + wagonsRequired!: number; + + @Column({ name: 'weight_limit_rule_id', type: 'uuid', nullable: true }) + weightLimitRuleId?: string | null; + + @ManyToOne(() => WeightLimitRule, { nullable: true }) + @JoinColumn({ name: 'weight_limit_rule_id' }) + weightLimitRule?: WeightLimitRule | null; + + @Column({ name: 'is_overweight', type: 'boolean', default: false }) + isOverweight!: boolean; + + @Column({ name: 'overweight_excess_tons', type: 'numeric', precision: 10, scale: 3, nullable: true }) + overweightExcessTons?: number | null; +} diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking-contract-signature.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking-contract-signature.entity.ts new file mode 100644 index 000000000..6370c97c2 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking-contract-signature.entity.ts @@ -0,0 +1,44 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne, Unique } from 'typeorm'; +import { FileRecord } from '../../files/entities/file.entity'; +import { Booking } from './booking.entity'; + +export const CONTRACT_SIGNER_ROLES = ['CUSTOMER', 'STAFF'] as const; +export type ContractSignerRole = (typeof CONTRACT_SIGNER_ROLES)[number]; + +@Entity({ schema: 'freight', name: 'booking_contract_signatures' }) +@Unique(['bookingId', 'signerRole']) +@Index(['bookingId']) +export class BookingContractSignature extends BaseEntity { + @Column({ name: 'booking_id', type: 'uuid' }) + bookingId!: string; + + @ManyToOne(() => Booking, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'booking_id' }) + booking?: Booking; + + @Column({ name: 'signer_role', type: 'varchar', length: 20 }) + signerRole!: ContractSignerRole; + + @Column({ name: 'signer_user_id', type: 'uuid', nullable: true }) + signerUserId?: string | null; + + @Column({ name: 'signer_display_name', type: 'varchar', length: 200 }) + signerDisplayName!: string; + + @Column({ name: 'signed_at', type: 'timestamptz' }) + signedAt!: Date; + + @Column({ name: 'signature_file_id', type: 'uuid', nullable: true }) + signatureFileId?: string | null; + + @ManyToOne(() => FileRecord, { nullable: true }) + @JoinColumn({ name: 'signature_file_id' }) + signatureFile?: FileRecord | null; + + @Column({ name: 'consent_text', type: 'text', nullable: true }) + consentText?: string | null; + + @Column({ name: 'ip_address', type: 'varchar', length: 64, nullable: true }) + ipAddress?: string | null; +} diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking-rate-snapshot.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking-rate-snapshot.entity.ts new file mode 100644 index 000000000..ab5b086df --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking-rate-snapshot.entity.ts @@ -0,0 +1,39 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; +import { Rate } from '../../rule-engine/entities/rate.entity'; +import { Booking } from './booking.entity'; + +@Entity({ schema: 'freight', name: 'booking_rate_snapshot' }) +@Index(['bookingId']) +@Index(['rateId']) +@Index(['rateType']) +export class BookingRateSnapshot extends BaseEntity { + @Column({ name: 'booking_id', type: 'uuid' }) + bookingId!: string; + + @ManyToOne(() => Booking, (b) => b.rateSnapshots, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'booking_id' }) + booking?: Booking; + + @Column({ name: 'rate_id', type: 'uuid' }) + rateId!: string; + + @ManyToOne(() => Rate) + @JoinColumn({ name: 'rate_id' }) + rate?: Rate; + + @Column({ name: 'rate_type', type: 'varchar', length: 50 }) + rateType!: string; + + @Column({ name: 'rate_value', type: 'numeric', precision: 14, scale: 4 }) + rateValue!: number; + + @Column({ name: 'rate_unit', type: 'varchar', length: 30 }) + rateUnit!: string; + + @Column({ name: 'currency', type: 'varchar', length: 5 }) + currency!: string; + + @Column({ name: 'snapshotted_at', type: 'timestamptz' }) + snapshottedAt!: Date; +} diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking-review-note.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking-review-note.entity.ts new file mode 100644 index 000000000..91171a793 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking-review-note.entity.ts @@ -0,0 +1,26 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; +import { Booking } from './booking.entity'; + +export const REVIEW_NOTE_TYPES = ['CHANGES_REQUESTED', 'REJECTION', 'STAFF_NOTE'] as const; +export type ReviewNoteType = (typeof REVIEW_NOTE_TYPES)[number]; + +@Entity({ schema: 'freight', name: 'booking_review_note' }) +@Index(['bookingId']) +export class BookingReviewNote extends BaseEntity { + @Column({ name: 'booking_id', type: 'uuid' }) + bookingId!: string; + + @ManyToOne(() => Booking, (b) => b.reviewNotes, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'booking_id' }) + booking?: Booking; + + @Column({ name: 'author_id', type: 'uuid', nullable: true }) + authorId?: string | null; + + @Column({ name: 'note', type: 'text' }) + note!: string; + + @Column({ name: 'type', type: 'varchar', length: 30 }) + type!: ReviewNoteType; +} diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts index 0d848c516..c5dac1736 100644 --- a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts @@ -1,43 +1,322 @@ -import { BaseEntity } from "@edr/api-common"; -import { Freight } from "@edr/types"; -import { Column, Entity } from "typeorm"; +import { BaseEntity } from '@edr/api-common'; +import { SchedulingStatus } from '@edr/types'; +import { Column, Entity, JoinColumn, ManyToOne, OneToMany } from 'typeorm'; +// import { Customer } from '../../customers/entities/customer.entity'; +import { Company } from '../../companies/entities/company.entity'; +import { CargoType } from '../../rule-engine/entities/cargo-type.entity'; +import { ServiceType } from '../../rule-engine/entities/service-type.entity'; +import { ShippingLine } from '../../rule-engine/entities/shipping-line.entity'; +import { Yard } from '../../rule-engine/entities/yard.entity'; +import { Train } from '../../trains/entities/train.entity'; +import { FileRecord } from '../../files/entities/file.entity'; +import { BookingApprovalStep } from './booking-approval-step.entity'; +import { BookingCargoModifier } from './booking-cargo-modifier.entity'; +import { BookingContainer } from './booking-container.entity'; +import { BookingRateSnapshot } from './booking-rate-snapshot.entity'; +import { BookingReviewNote } from './booking-review-note.entity'; -@Entity({ name: "bookings" }) +export const BOOKING_STATUSES = [ + 'DRAFT', + 'SUBMITTED', + 'PRICE_CHANGED_PENDING_CONFIRM', + 'CHANGES_REQUESTED', + 'PENDING_APPROVAL', + 'APPROVED_PENDING_SIGNATURE', + 'APPROVED', + 'READY_FOR_ASSIGNMENT', + 'WAGON_ASSIGNED', + 'INVOICED', + 'CONTRACT_READY', + 'SIGNED_CUSTOMER', + 'FULLY_EXECUTED', + 'SELECTED_FOR_BATCH', + 'EXPIRED', + 'PNR_GENERATED', + 'PAYMENT_VERIFICATION_IN_PROGRESS', + 'PAID', + 'IN_TRANSIT', + 'COMPLETED', + 'REJECTED', + 'CANCELLED', + 'PENDING_CONSOLIDATION', + 'CONSOLIDATED', +] as const; + +export type BookingStatus = (typeof BOOKING_STATUSES)[number]; + +export const PAYMENT_STATUSES = [ + 'PENDING', + 'PNR_GENERATED', + 'VERIFICATION_IN_PROGRESS', + 'PAID', + 'FAILED', +] as const; + +export type PaymentStatus = (typeof PAYMENT_STATUSES)[number]; + +export const FREIGHT_TYPES = ['CONTAINER', 'BULK'] as const; +export type FreightType = (typeof FREIGHT_TYPES)[number]; + +export const SCHEDULING_STATUSES = [ + SchedulingStatus.NotScheduled, + SchedulingStatus.Holding, + SchedulingStatus.Eligible, + SchedulingStatus.Scheduled, + SchedulingStatus.Dispatched, +] as const; + +export type BookingSchedulingStatus = (typeof SCHEDULING_STATUSES)[number]; + +/** Statuses where the customer may edit booking fields. */ +export const CUSTOMER_EDITABLE_STATUSES: BookingStatus[] = [ + 'DRAFT', + 'CHANGES_REQUESTED', +]; + +@Entity({ schema: 'freight', name: 'bookings' }) export class Booking extends BaseEntity { - @Column({ name: "reference", type: "varchar", length: 64, unique: true }) + @Column({ name: 'reference', type: 'varchar', length: 64, unique: true }) reference!: string; - @Column({ name: "customer_id", type: "uuid" }) - customerId!: string; + // Legacy — superseded by companyId (column kept in DB) + // @Column({ name: 'customer_id', type: 'uuid' }) + // customerId!: string; + // @ManyToOne(() => Customer) + // @JoinColumn({ name: 'customer_id' }) + // customer?: Customer; - @Column({ name: "train_id", type: "uuid", nullable: true }) + @Column({ name: 'company_id', type: 'uuid', nullable: true }) + companyId?: string | null; + + @ManyToOne(() => Company, { nullable: true }) + @JoinColumn({ name: 'company_id' }) + company?: Company | null; + + @Column({ name: 'is_government', type: 'boolean', default: false }) + isGovernment!: boolean; + + @Column({ name: 'government_institution', type: 'varchar', length: 255, nullable: true }) + governmentInstitution?: string | null; + + /** @deprecated Fleet master data link — scheduling uses train_schedule_bookings instead. */ + @Column({ name: 'train_id', type: 'uuid', nullable: true }) trainId?: string | null; - @Column({ - name: "status", - type: "enum", - enum: Freight.BookingStatus, - default: Freight.BookingStatus.Draft, - }) - status!: Freight.BookingStatus; + /** @deprecated Use train_schedule_bookings for operational scheduling. */ + @ManyToOne(() => Train, { nullable: true }) + @JoinColumn({ name: 'train_id' }) + train?: Train | null; - @Column({ name: "scheduled_date", type: "timestamptz" }) + @Column({ name: 'status', type: 'varchar', length: 40, default: 'DRAFT' }) + status!: string; + + @Column({ name: 'scheduled_date', type: 'timestamptz' }) scheduledDate!: Date; - @Column({ - name: "total_amount", - type: "numeric", - precision: 14, - scale: 2, - default: 0, - }) + @Column({ name: 'total_amount', type: 'numeric', precision: 14, scale: 2, default: 0 }) totalAmount!: number; - @Column({ - name: "payment_status", - type: "enum", - enum: Freight.PaymentStatus, - default: Freight.PaymentStatus.Pending, + @Column({ name: 'payment_status', type: 'varchar', length: 20, default: 'PENDING' }) + paymentStatus!: string; + + @Column({ name: 'contract_type', type: 'varchar', length: 20 }) + contractType!: string; + + @Column({ name: 'previous_contract_id', type: 'uuid', nullable: true }) + previousContractId?: string | null; + + @ManyToOne(() => Booking, { nullable: true }) + @JoinColumn({ name: 'previous_contract_id' }) + previousContract?: Booking | null; + + @Column({ name: 'service_type_id', type: 'uuid' }) + serviceTypeId!: string; + + @ManyToOne(() => ServiceType) + @JoinColumn({ name: 'service_type_id' }) + serviceType?: ServiceType; + + @Column({ name: 'first_mile_pickup_address', type: 'text', nullable: true }) + firstMilePickupAddress?: string | null; + + @Column({ name: 'last_mile_delivery_address', type: 'text', nullable: true }) + lastMileDeliveryAddress?: string | null; + + @Column({ name: 'equipment_return', type: 'varchar', length: 20 }) + equipmentReturn!: string; + + @Column({ name: 'origin_yard_id', type: 'uuid' }) + originYardId!: string; + + @ManyToOne(() => Yard) + @JoinColumn({ name: 'origin_yard_id' }) + originYard?: Yard; + + @Column({ name: 'destination_yard_id', type: 'uuid' }) + destinationYardId!: string; + + @ManyToOne(() => Yard) + @JoinColumn({ name: 'destination_yard_id' }) + destinationYard?: Yard; + + @Column({ name: 'trade_direction', type: 'varchar', length: 10 }) + tradeDirection!: string; + + @Column({ name: 'freight_type', type: 'varchar', length: 20 }) + freightType!: string; + + @Column({ name: 'cargo_type_id', type: 'uuid', nullable: true }) + cargoTypeId?: string | null; + + @ManyToOne(() => CargoType) + @JoinColumn({ name: 'cargo_type_id' }) + cargoType?: CargoType; + + @Column({ name: 'cargo_free_text', type: 'varchar', length: 200, nullable: true }) + cargoFreeText?: string | null; + + @Column({ name: 'shipping_line_id', type: 'uuid', nullable: true }) + shippingLineId?: string | null; + + @ManyToOne(() => ShippingLine, { nullable: true }) + @JoinColumn({ name: 'shipping_line_id' }) + shippingLine?: ShippingLine | null; + + @Column({ name: 'cargo_total_weight_vgm', type: 'numeric', precision: 12, scale: 3 }) + cargoTotalWeightVgm!: number; + + @Column({ name: 'is_hazardous', type: 'boolean', default: false }) + isHazardous!: boolean; + + @Column({ name: 'payment_currency', type: 'varchar', length: 5 }) + paymentCurrency!: string; + + @Column({ name: 'pnr_code', type: 'varchar', length: 50, nullable: true }) + pnrCode?: string | null; + + @Column({ name: 'start_date', type: 'date', nullable: true }) + startDate?: Date | null; + + @Column({ name: 'end_date', type: 'date', nullable: true }) + endDate?: Date | null; + + @Column({ name: 'financial_terms', type: 'text', nullable: true }) + financialTerms?: string | null; + + @Column({ name: 'version_number', type: 'int', default: 1 }) + versionNumber!: number; + + @Column({ name: 'approved_by_staff_id', type: 'uuid', nullable: true }) + approvedByStaffId?: string | null; + + @Column({ name: 'approved_by_staff_at', type: 'timestamptz', nullable: true }) + approvedByStaffAt?: Date | null; + + @Column({ name: 'signed_by_director_id', type: 'uuid', nullable: true }) + signedByDirectorId?: string | null; + + @Column({ name: 'signed_by_director_at', type: 'timestamptz', nullable: true }) + signedByDirectorAt?: Date | null; + + @Column({ name: 'signed_by_ceo_id', type: 'uuid', nullable: true }) + signedByCeoId?: string | null; + + @Column({ name: 'signed_by_ceo_at', type: 'timestamptz', nullable: true }) + signedByCeoAt?: Date | null; + + @Column({ name: 'customer_signed_at', type: 'timestamptz', nullable: true }) + customerSignedAt?: Date | null; + + @Column({ name: 'fully_executed_at', type: 'timestamptz', nullable: true }) + fullyExecutedAt?: Date | null; + + @Column({ name: 'marketing_approved_by_id', type: 'uuid', nullable: true }) + marketingApprovedById?: string | null; + + @Column({ name: 'marketing_approved_at', type: 'timestamptz', nullable: true }) + marketingApprovedAt?: Date | null; + + @Column({ name: 'contract_summary', type: 'text', nullable: true }) + contractSummary?: string | null; + + @Column({ name: 'contract_template_key', type: 'varchar', length: 80, nullable: true }) + contractTemplateKey?: string | null; + + @Column({ name: 'contract_generated_at', type: 'timestamptz', nullable: true }) + contractGeneratedAt?: Date | null; + + @Column({ name: 'pricing_breakdown', type: 'jsonb', nullable: true }) + pricingBreakdown?: Record | null; + + @Column({ name: 'locked_at', type: 'timestamptz', nullable: true }) + lockedAt?: Date | null; + + @Column({ name: 'priority_score', type: 'int', default: 0 }) + priorityScore!: number; + + @Column({ name: 'allow_consolidation', type: 'boolean', default: false }) + allowConsolidation!: boolean; + + @Column({ name: 'consolidation_partner_id', type: 'uuid', nullable: true }) + consolidationPartnerId?: string | null; + + @ManyToOne(() => Booking, { nullable: true }) + @JoinColumn({ name: 'consolidation_partner_id' }) + consolidationPartner?: Booking | null; + + @Column({ name: 'wagons_required', type: 'numeric', precision: 6, scale: 2, nullable: true }) + wagonsRequired?: number | null; + + @Column({ name: 'scheduling_status', type: 'varchar', length: 30, default: 'NOT_SCHEDULED' }) + schedulingStatus!: string; + + @Column({ name: 'hold_started_at', type: 'timestamptz', nullable: true }) + holdStartedAt?: Date | null; + + @Column({ name: 'hold_expires_at', type: 'timestamptz', nullable: true }) + holdExpiresAt?: Date | null; + + + @Column({ name: 'scheduled_at', type: 'timestamptz', nullable: true }) + scheduledAt?: Date | null; + + /** + * The train this booking is assigned to. FK to train_schedules. + * + * Day-level pooling: customers no longer pick a train — they pick a DAY, and + * this stays null at creation. The batch engine sets it when it assigns the + * booking to a specific train within its (route, day) pool; staff may also + * pin it manually. The day-level pool is keyed on + * (origin_yard_id, destination_yard_id, day of scheduled_date), not this column. + */ + @Column({ name: 'train_schedule_id', type: 'uuid', nullable: true }) + trainScheduleId?: string | null; + + /** End of the pay window once the booking is SELECTED_FOR_BATCH. */ + @Column({ name: 'payment_deadline', type: 'timestamptz', nullable: true }) + paymentDeadline?: Date | null; + + /** When the batch engine picked this booking and opened the pay window. */ + @Column({ name: 'selected_for_batch_at', type: 'timestamptz', nullable: true }) + selectedForBatchAt?: Date | null; + + @OneToMany(() => BookingContainer, (bc) => bc.booking) + bookingContainers?: BookingContainer[]; + + @OneToMany(() => BookingCargoModifier, (m) => m.booking) + cargoModifiers?: BookingCargoModifier[]; + + @OneToMany(() => BookingApprovalStep, (s) => s.booking) + approvalSteps?: BookingApprovalStep[]; + + @OneToMany(() => BookingRateSnapshot, (s) => s.booking) + rateSnapshots?: BookingRateSnapshot[]; + + @OneToMany(() => BookingReviewNote, (n) => n.booking) + reviewNotes?: BookingReviewNote[]; + + @OneToMany(() => FileRecord, (file) => file.resourceId, { + createForeignKeyConstraints: false, }) - paymentStatus!: Freight.PaymentStatus; + files?: FileRecord[]; } diff --git a/apps/edr-freight-api/src/modules/bookings/pay.controller.ts b/apps/edr-freight-api/src/modules/bookings/pay.controller.ts new file mode 100644 index 000000000..25f1927d3 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/pay.controller.ts @@ -0,0 +1,27 @@ +import { Controller, Param, ParseUUIDPipe, Post } from '@nestjs/common'; +import { ApiBearerAuth, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { BookingPaymentService } from './booking-payment.service'; +// import { BookingTransitionService } from './booking-transition.service'; +// import { InAppPaymentReceiptDto } from './dto/pay-booking.dto'; +// import { Booking } from './entities/booking.entity'; +// import { BookingNextStep } from './booking-next-step.util'; + +@ApiTags('payments') +@ApiBearerAuth() +@Controller('bookings') +export class PayController { + constructor( + private readonly paymentService: BookingPaymentService, + // private readonly transitionService: BookingTransitionService, + ) { } + + @Post(':id/payment/pay') + @ApiOperation({ summary: 'Complete in-app payment (mock)' }) + @ApiOkResponse({ description: 'Enriched booking with ephemeral payment receipt' }) + async pay(@Param('id', ParseUUIDPipe) id: string) { + return await this.paymentService.pay(id); + // const abstract = await this.transitionService.enrichBookingResponse(booking); + // return { ...abstract, paymentReceipt: receipt }; + } +} diff --git a/apps/edr-freight-api/src/modules/cargoes/cargoes.controller.ts b/apps/edr-freight-api/src/modules/cargoes/cargoes.controller.ts new file mode 100644 index 000000000..7f3f06ec2 --- /dev/null +++ b/apps/edr-freight-api/src/modules/cargoes/cargoes.controller.ts @@ -0,0 +1,79 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + ParseUUIDPipe, + Patch, + Post, + Query, +} from '@nestjs/common'; +import { ApiOperation, ApiTags } from '@nestjs/swagger'; +import { FleetManage, FleetView } from '../../common/booking-guards'; +import { CreateCargoDto } from './dto/create-cargo.dto'; +import { UpdateCargoDto } from './dto/update-cargo.dto'; +import { LoadCargoDto } from './dto/load-cargo.dto'; +import { DeliverCargoDto } from './dto/deliver-cargo.dto'; +import { CargoesService } from './cargoes.service'; + +@ApiTags('cargoes') +@Controller('cargoes') +@FleetView() +export class CargoesController { + constructor(private readonly cargoesService: CargoesService) {} + + @Post() + @FleetManage() + @ApiOperation({ summary: 'Create a new cargo' }) + create(@Body() dto: CreateCargoDto) { + return this.cargoesService.create(dto); + } + + @Get() + @ApiOperation({ summary: 'List all cargoes' }) + findAll(@Query() query: Record) { + return this.cargoesService.findAll(query); + } + + @Get(':id') + @ApiOperation({ summary: 'Get a cargo by ID' }) + findOne(@Param('id', ParseUUIDPipe) id: string) { + return this.cargoesService.findById(id); + } + + @Patch(':id') + @FleetManage() + @ApiOperation({ summary: 'Update a cargo' }) + update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateCargoDto) { + return this.cargoesService.update(id, dto); + } + + @Delete(':id') + @FleetManage() + @ApiOperation({ summary: 'Delete a cargo' }) + remove(@Param('id', ParseUUIDPipe) id: string) { + return this.cargoesService.remove(id); + } + + @Post(':id/load') + @FleetManage() + @ApiOperation({ summary: 'Load cargo into a container' }) + load(@Param('id', ParseUUIDPipe) id: string, @Body() dto: LoadCargoDto) { + return this.cargoesService.loadCargo(id, dto); + } + + @Post(':id/unload') + @FleetManage() + @ApiOperation({ summary: 'Unload cargo from container' }) + unload(@Param('id', ParseUUIDPipe) id: string) { + return this.cargoesService.unloadCargo(id); + } + + @Post(':id/deliver') + @FleetManage() + @ApiOperation({ summary: 'Mark cargo as delivered' }) + deliver(@Param('id', ParseUUIDPipe) id: string, @Body() dto?: DeliverCargoDto) { + return this.cargoesService.deliverCargo(id, dto); + } +} diff --git a/apps/edr-freight-api/src/modules/cargoes/cargoes.module.ts b/apps/edr-freight-api/src/modules/cargoes/cargoes.module.ts new file mode 100644 index 000000000..d1c03c9d8 --- /dev/null +++ b/apps/edr-freight-api/src/modules/cargoes/cargoes.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Cargo } from './entities/cargoes.entity'; +import { Container } from '../container-management/entities/container.entity'; +import { CargoType } from '../rule-engine/entities/cargo-type.entity'; +import { CargoesController } from './cargoes.controller'; +import { CargoesService } from './cargoes.service'; + +@Module({ + imports: [TypeOrmModule.forFeature([Cargo, Container, CargoType])], + controllers: [CargoesController], + providers: [CargoesService], + exports: [CargoesService], +}) +export class CargoesModule {} diff --git a/apps/edr-freight-api/src/modules/cargoes/cargoes.repository.ts b/apps/edr-freight-api/src/modules/cargoes/cargoes.repository.ts new file mode 100644 index 000000000..cedb217da --- /dev/null +++ b/apps/edr-freight-api/src/modules/cargoes/cargoes.repository.ts @@ -0,0 +1,15 @@ +import { BaseRepository } from '@edr/api-common'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Cargo } from './entities/cargoes.entity'; + +@Injectable() +export class CargoesRepository extends BaseRepository { + constructor( + @InjectRepository(Cargo) + repository: Repository, + ) { + super(repository); + } +} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/cargoes/cargoes.service.ts b/apps/edr-freight-api/src/modules/cargoes/cargoes.service.ts new file mode 100644 index 000000000..6f73035b4 --- /dev/null +++ b/apps/edr-freight-api/src/modules/cargoes/cargoes.service.ts @@ -0,0 +1,177 @@ +import { Injectable, NotFoundException, ConflictException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { FindOptionsOrder, FindOptionsWhere, ILike, Repository } from 'typeorm'; +import { CreateCargoDto } from './dto/create-cargo.dto'; +import { UpdateCargoDto } from './dto/update-cargo.dto'; +import { LoadCargoDto } from './dto/load-cargo.dto'; +import { DeliverCargoDto } from './dto/deliver-cargo.dto'; +import { Cargo } from './entities/cargoes.entity'; +import { Container } from '../container-management/entities/container.entity'; +import { CargoType } from '../rule-engine/entities/cargo-type.entity'; + +@Injectable() +export class CargoesService { + constructor( + @InjectRepository(Cargo) + private readonly cargoRepo: Repository, + @InjectRepository(Container) + private readonly containerRepo: Repository, + @InjectRepository(CargoType) + private readonly cargoTypeRepo: Repository, + ) {} + + async create(dto: CreateCargoDto): Promise { + const existing = await this.cargoRepo.findOne({ + where: { cargoReference: dto.cargoReference }, + }); + if (existing) { + throw new ConflictException(`Cargo reference "${dto.cargoReference}" already exists`); + } + + const container = await this.containerRepo.findOne({ where: { id: dto.containerId } }); + if (!container) { + throw new NotFoundException(`Container ${dto.containerId} not found`); + } + + if (dto.cargoTypeId) { + const cargoType = await this.cargoTypeRepo.findOne({ + where: { id: dto.cargoTypeId, isActive: true }, + }); + if (!cargoType) throw new NotFoundException(`Cargo type ${dto.cargoTypeId} not found`); + } + + const cargo = this.cargoRepo.create(dto); + return this.cargoRepo.save(cargo); + } + + async findAll(query: Record = {}): Promise { + const where: FindOptionsWhere[] | FindOptionsWhere = []; + const search = query.search?.trim(); + const status = query.status?.trim(); + const containerId = query.containerId?.trim(); + + if (search) { + where.push({ + cargoReference: ILike(`%${search}%`), + ...(status ? { status } : {}), + ...(containerId ? { containerId } : {}), + }); + where.push({ + description: ILike(`%${search}%`), + ...(status ? { status } : {}), + ...(containerId ? { containerId } : {}), + }); + } + + const sortBy = ['cargoReference', 'quantity', 'weight', 'volume', 'status'].includes(query.sortBy ?? '') + ? (query.sortBy as keyof Cargo) + : 'cargoReference'; + const sortOrder = query.sortOrder?.toUpperCase() === 'DESC' ? 'DESC' : 'ASC'; + + return this.cargoRepo.find({ + where: search ? where : { ...(status ? { status } : {}), ...(containerId ? { containerId } : {}) }, + order: { [sortBy]: sortOrder } as FindOptionsOrder, + skip: query.page && query.limit ? (Number(query.page) - 1) * Number(query.limit) : undefined, + take: query.limit ? Number(query.limit) : undefined, + }); + } + + async findById(id: string): Promise { + const cargo = await this.cargoRepo.findOne({ where: { id } }); + if (!cargo) throw new NotFoundException(`Cargo ${id} not found`); + return cargo; + } + + async update(id: string, dto: UpdateCargoDto): Promise { + const cargo = await this.findById(id); + if (dto.cargoReference && dto.cargoReference !== cargo.cargoReference) { + const existing = await this.cargoRepo.findOne({ + where: { cargoReference: dto.cargoReference }, + }); + if (existing) { + throw new ConflictException(`Cargo reference "${dto.cargoReference}" already exists`); + } + } + if (dto.containerId) { + const container = await this.containerRepo.findOne({ where: { id: dto.containerId } }); + if (!container) throw new NotFoundException(`Container ${dto.containerId} not found`); + } + if (dto.cargoTypeId) { + const cargoType = await this.cargoTypeRepo.findOne({ + where: { id: dto.cargoTypeId, isActive: true }, + }); + if (!cargoType) throw new NotFoundException(`Cargo type ${dto.cargoTypeId} not found`); + } + Object.assign(cargo, dto); + return this.cargoRepo.save(cargo); + } + + async remove(id: string): Promise { + const cargo = await this.findById(id); + await this.cargoRepo.remove(cargo); + } + + async loadCargo(id: string, dto: LoadCargoDto): Promise { + const cargo = await this.cargoRepo.findOne({ + where: { id }, + relations: { container: true }, // ✅ fixed + }); + if (!cargo) throw new NotFoundException('Cargo not found'); + if (cargo.status !== 'PENDING') { + throw new ConflictException('Cargo already loaded or delivered'); + } + + cargo.status = 'LOADED'; + cargo.loadedAt = new Date(); + cargo.quantity = dto.quantity; + cargo.weight = dto.weight; + cargo.volume = dto.volume ?? null; + if (dto.description) cargo.description = dto.description; + + if (cargo.container) { + cargo.container.status = 'LOADED'; + await this.containerRepo.save(cargo.container); + } + + return this.cargoRepo.save(cargo); + } + + async unloadCargo(id: string): Promise { + const cargo = await this.findById(id); + if (cargo.status !== 'LOADED') { + throw new ConflictException('Cargo is not loaded'); + } + cargo.status = 'UNLOADED'; + cargo.unloadedAt = new Date(); + return this.cargoRepo.save(cargo); + } + + async deliverCargo(id: string, dto?: DeliverCargoDto): Promise { + const cargo = await this.cargoRepo.findOne({ + where: { id }, + relations: { container: true }, // ✅ fixed + }); + if (!cargo) throw new NotFoundException('Cargo not found'); + if (cargo.status !== 'LOADED') { + throw new ConflictException('Only loaded cargo can be delivered'); + } + + cargo.status = 'DELIVERED'; + cargo.deliveredAt = dto?.pickupDate ? new Date(dto.pickupDate) : new Date(); + if (dto?.receiverName) cargo.receiverName = dto.receiverName; + if (dto?.deliveryRemarks) cargo.deliveryRemarks = dto.deliveryRemarks; + + const remaining = + cargo.containerId != null + ? await this.cargoRepo.count({ + where: { containerId: cargo.containerId, status: 'LOADED' }, + }) + : 0; + if (remaining === 0 && cargo.container) { + cargo.container.status = 'AVAILABLE'; + await this.containerRepo.save(cargo.container); + } + + return this.cargoRepo.save(cargo); + } +} diff --git a/apps/edr-freight-api/src/modules/cargoes/dto/create-cargo.dto.ts b/apps/edr-freight-api/src/modules/cargoes/dto/create-cargo.dto.ts new file mode 100644 index 000000000..8373f5a4b --- /dev/null +++ b/apps/edr-freight-api/src/modules/cargoes/dto/create-cargo.dto.ts @@ -0,0 +1,45 @@ +import { IsString, IsUUID, IsOptional, IsNumber, Min, IsIn, IsDateString } from 'class-validator'; + +export class CreateCargoDto { + @IsString() + cargoReference!: string; + + @IsUUID() + shipmentId!: string; + + @IsUUID() + containerId!: string; + + @IsOptional() + @IsUUID() + cargoTypeId?: string; + + @IsOptional() + @IsString() + description?: string; + + @IsNumber() + @Min(0.001) + quantity!: number; + + @IsNumber() + @Min(0) + weight!: number; + + @IsOptional() + @IsNumber() + @Min(0) + volume?: number; + + @IsOptional() + @IsIn(['PENDING', 'LOADED', 'IN_TRANSIT', 'DELIVERED', 'UNLOADED']) + status?: string; + + @IsOptional() + @IsDateString() + loadedAt?: string; + + @IsOptional() + @IsDateString() + unloadedAt?: string; +} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/cargoes/dto/deliver-cargo.dto.ts b/apps/edr-freight-api/src/modules/cargoes/dto/deliver-cargo.dto.ts new file mode 100644 index 000000000..de402a33e --- /dev/null +++ b/apps/edr-freight-api/src/modules/cargoes/dto/deliver-cargo.dto.ts @@ -0,0 +1,17 @@ +import { IsDateString, IsOptional, IsString } from 'class-validator'; + +export class DeliverCargoDto { + /** Name of the person who received / picked up the cargo (Proof of Delivery). */ + @IsOptional() + @IsString() + receiverName?: string; + + /** When the cargo was picked up / delivered. Defaults to now. */ + @IsOptional() + @IsDateString() + pickupDate?: string; + + @IsOptional() + @IsString() + deliveryRemarks?: string; +} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/cargoes/dto/load-cargo.dto.ts b/apps/edr-freight-api/src/modules/cargoes/dto/load-cargo.dto.ts new file mode 100644 index 000000000..9e8573751 --- /dev/null +++ b/apps/edr-freight-api/src/modules/cargoes/dto/load-cargo.dto.ts @@ -0,0 +1,20 @@ +import { IsNumber, Min, IsOptional, IsString } from 'class-validator'; + +export class LoadCargoDto { + @IsNumber() + @Min(0.001) + quantity!: number; + + @IsNumber() + @Min(0) + weight!: number; + + @IsOptional() + @IsNumber() + @Min(0) + volume?: number; + + @IsOptional() + @IsString() + description?: string; +} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/cargoes/dto/unload-cargo.dto.ts b/apps/edr-freight-api/src/modules/cargoes/dto/unload-cargo.dto.ts new file mode 100644 index 000000000..e69de29bb diff --git a/apps/edr-freight-api/src/modules/cargoes/dto/update-cargo.dto.ts b/apps/edr-freight-api/src/modules/cargoes/dto/update-cargo.dto.ts new file mode 100644 index 000000000..7596b7dbe --- /dev/null +++ b/apps/edr-freight-api/src/modules/cargoes/dto/update-cargo.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/swagger'; +import { CreateCargoDto } from './create-cargo.dto'; + +export class UpdateCargoDto extends PartialType(CreateCargoDto) {} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/cargoes/entities/cargoes.entity.ts b/apps/edr-freight-api/src/modules/cargoes/entities/cargoes.entity.ts new file mode 100644 index 000000000..685f9b659 --- /dev/null +++ b/apps/edr-freight-api/src/modules/cargoes/entities/cargoes.entity.ts @@ -0,0 +1,74 @@ +// apps/edr-freight-api/src/modules/cargoes/entities/cargo.entity.ts +import { Entity, Column, ManyToOne, JoinColumn } from 'typeorm'; +import { BaseEntity } from '@edr/api-common'; +import { Booking } from '../../bookings/entities/booking.entity'; +import { Container } from '../../container-management/entities/container.entity'; +import { WagonBookingAllocation } from '../../train-schedules/entities/wagon-booking-allocation.entity'; + +@Entity({ name: 'cargoes', schema: 'freight' }) +export class Cargo extends BaseEntity { + @Column({ unique: true, name: 'cargo_reference' }) + cargoReference!: string; + + @Column({ name: 'shipment_id', type: 'uuid' }) + shipmentId!: string; + + @Column({ name: 'container_id', type: 'uuid', nullable: true }) + containerId!: string | null; + + @Column({ name: 'cargo_type_id', type: 'uuid', nullable: true }) + cargoTypeId!: string | null; // optional link to cargo_types table + + @Column({ type: 'text', nullable: true }) + description!: string | null; + + @Column({ type: 'decimal', precision: 12, scale: 3 }) + quantity!: number; + + @Column({ type: 'decimal', precision: 10, scale: 2 }) + weight!: number; // kg + + @Column({ type: 'decimal', precision: 10, scale: 2, nullable: true }) + volume!: number | null; // m³ + + @Column({ type: 'varchar', default: 'PENDING' }) + status!: string; // PENDING, LOADED, IN_TRANSIT, DELIVERED, UNLOADED + + @Column({ name: 'loaded_at', type: 'timestamp', nullable: true }) + loadedAt!: Date | null; + + @Column({ name: 'unloaded_at', type: 'timestamp', nullable: true }) + unloadedAt!: Date | null; + + // Proof of Delivery (customer pickup) capture. + @Column({ name: 'receiver_name', type: 'varchar', nullable: true }) + receiverName!: string | null; + + @Column({ name: 'delivered_at', type: 'timestamp', nullable: true }) + deliveredAt!: Date | null; + + @Column({ name: 'delivery_remarks', type: 'text', nullable: true }) + deliveryRemarks!: string | null; + + @Column({ name: 'wagon_booking_allocation_id', type: 'uuid', nullable: true }) + wagonBookingAllocationId!: string | null; + + @ManyToOne(() => WagonBookingAllocation, { nullable: true, onDelete: 'SET NULL' }) + @JoinColumn({ name: 'wagon_booking_allocation_id' }) + wagonBookingAllocation?: WagonBookingAllocation | null; + + @Column({ name: 'booking_id', type: 'uuid', nullable: true }) + bookingId!: string | null; + + @ManyToOne(() => Booking, { nullable: true, onDelete: 'SET NULL' }) + @JoinColumn({ name: 'booking_id' }) + booking?: Booking | null; + + @Column({ name: 'load_type', type: 'varchar', length: 20, nullable: true }) + loadType!: string | null; + + // Relationship to Container + @ManyToOne(() => Container, (container) => container.cargoes, { onDelete: 'RESTRICT', nullable: true }) + @JoinColumn({ name: 'container_id' }) + container!: Container | null; +} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/cbe-exchange/cbe-exchange.service.ts b/apps/edr-freight-api/src/modules/cbe-exchange/cbe-exchange.service.ts new file mode 100644 index 000000000..27e89896b --- /dev/null +++ b/apps/edr-freight-api/src/modules/cbe-exchange/cbe-exchange.service.ts @@ -0,0 +1,106 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +const DEFAULT_SCRAPE_URL = 'https://ethio.forex/bank/CBET'; + +/** Matches USD buying/selling embedded in ethio.forex CBET page HTML (after entity unescape). */ +const USD_RATE_REGEX = + /currency_code":\[0,"USD"\],"currency_name":\[0,"US DOLLAR"\],"buying":\[0,([\d.]+)\],"selling":\[0,([\d.]+)\]/; + +@Injectable() +export class CbeExchangeService { + private readonly logger = new Logger(CbeExchangeService.name); + private cachedRate: number | null = null; + private cacheExpiresAt = 0; + + constructor(private readonly configService: ConfigService) {} + + /** + * Returns the current CBE USD→ETB **selling** rate scraped from ethio.forex. + * Cached for CBE_EXCHANGE_CACHE_TTL_MS; falls back to CBE_EXCHANGE_FALLBACK_RATE on failure. + */ + async getUsdToEtbRate(): Promise { + const now = Date.now(); + + if (this.cachedRate !== null && now < this.cacheExpiresAt) { + return this.cachedRate; + } + + const scrapeUrl = this.getScrapeUrl(); + const fallbackRate = + this.configService.get('app.cbeExchange.fallbackRate') ?? 130; + const cacheTtlMs = + this.configService.get('app.cbeExchange.cacheTtlMs') ?? 3_600_000; + + try { + const response = await fetch(scrapeUrl, { + signal: AbortSignal.timeout(8_000), + headers: { 'User-Agent': 'Mozilla/5.0' }, + }); + + if (!response.ok) { + throw new Error(`CBE scrape responded with status ${response.status}`); + } + + const html = await response.text(); + const rates = this.parseScrapedRates(html); + + if (!rates) { + throw new Error('USD rate not found in ethio.forex page HTML'); + } + + const rate = rates.selling; + if (!Number.isFinite(rate) || rate <= 0) { + throw new Error(`Invalid selling rate parsed: ${rate}`); + } + + this.cachedRate = rate; + this.cacheExpiresAt = now + cacheTtlMs; + this.logger.log( + `CBE USD→ETB rate refreshed from ethio.forex — buying=${rates.buying} selling=${rate}`, + ); + return rate; + } catch (err) { + this.logger.error( + `Failed to scrape CBE exchange rate — using fallback ${fallbackRate} ETB/USD. Error: ${(err as Error).message}`, + ); + + if (this.cachedRate !== null) { + this.logger.warn(`Using previously cached CBE rate: ${this.cachedRate}`); + return this.cachedRate; + } + + return fallbackRate; + } + } + + private getScrapeUrl(): string { + const configured = + this.configService.get('app.cbeExchange.scrapeUrl') ?? + this.configService.get('app.cbeExchange.apiUrl'); + return configured?.trim() || DEFAULT_SCRAPE_URL; + } + + private parseScrapedRates( + html: string, + ): { buying: number; selling: number } | null { + const decoded = this.unescapeHtml(html); + const match = USD_RATE_REGEX.exec(decoded); + if (!match) return null; + + const buying = Number(match[1]); + const selling = Number(match[2]); + if (!Number.isFinite(buying) || !Number.isFinite(selling)) return null; + + return { buying, selling }; + } + + private unescapeHtml(html: string): string { + return html + .replace(/"/g, '"') + .replace(/"/g, '"') + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>'); + } +} diff --git a/apps/edr-freight-api/src/modules/companies/companies.controller.ts b/apps/edr-freight-api/src/modules/companies/companies.controller.ts new file mode 100644 index 000000000..ac2868ec3 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/companies.controller.ts @@ -0,0 +1,241 @@ +import { + Controller, + Get, + Post, + Patch, + Delete, + Body, + Param, + Query, + ParseUUIDPipe, + HttpCode, + HttpStatus, + UseInterceptors, + UploadedFiles, +} from "@nestjs/common"; +import { AnyFilesInterceptor } from "@nestjs/platform-express"; +import { ApiOperation, ApiTags, ApiConsumes } from "@nestjs/swagger"; +import { CurrentUser } from "@edr/api-common"; +import { FreightAdmin } from "../../common/booking-guards"; +import { FilesService } from "../files/files.service"; +import { CompaniesService } from "./companies.service"; +import { CreateCompanyDto } from "./dto/create-company.dto"; +import { UpdateCompanyDto } from "./dto/update-company.dto"; +import { CreateExternalProfileDto } from "./dto/create-external-profile.dto"; +import { CreateCompanyWithProfileDto } from "./dto/create-company-with-profile.dto"; +import { AddCompanyProfilesDto } from "./dto/add-company-profiles.dto"; +import { + ResponseCompanyDto, + ResponseCompanyProfileDto, +} from "./dto/response-company.dto"; +import { ResponseExternalProfileDto } from "./dto/response-external-profile.dto"; +import { CompanyInfoResponseDto } from "./dto/company-info-response.dto"; +import { UpdateProfileDto } from "./dto/update-profile.dto"; +import { ProfileResponseDto } from "./dto/profile-response.dto"; +import { DashboardSummaryResponseDto } from "./dto/dashboard-summary-response.dto"; + +interface CurrentIamUser { + id: string; + name?: { en: string; am: string }; + email?: string; + phoneNumber?: string; +} + +@ApiTags("Companies") +@Controller("companies") +export class CompaniesController { + constructor( + private readonly companiesService: CompaniesService, + private readonly filesService: FilesService, + ) { } + + @Get("getInfo") + @ApiOperation({ summary: "Get company info for the current user" }) + async getInfo( + @CurrentUser() user: CurrentIamUser, + ): Promise { + const { profile, company } = + await this.companiesService.getCompanyInfoByUserId(user.id); + return new CompanyInfoResponseDto(profile, company); + } + + @Get("profile") + @ApiOperation({ summary: "Get flattened profile for the settings page" }) + async getProfile( + @CurrentUser() user: CurrentIamUser, + ): Promise { + const { profile, company } = + await this.companiesService.getCompanyInfoByUserId(user.id); + return new ProfileResponseDto(profile, company); + } + + @Get("dashboard") + @ApiOperation({ + summary: + "Get portal dashboard KPIs (delivered, spend, freight volume) for the current user", + }) + async getDashboard( + @CurrentUser() user: CurrentIamUser, + ): Promise { + return this.companiesService.getDashboardSummary(user.id); + } + + @Patch("profile") + @ApiOperation({ summary: "Update profile (flattened settings page)" }) + async updateProfile( + @CurrentUser() user: CurrentIamUser, + @Body() dto: UpdateProfileDto, + ): Promise { + return this.companiesService.updateProfile(user.id, dto); + } + + @Post("company-profiles") + @ApiOperation({ + summary: + "Add operational profile(s) (importer/exporter/forwarder) to the current user's company", + }) + async addCompanyProfiles( + @CurrentUser() user: CurrentIamUser, + @Body() dto: AddCompanyProfilesDto, + ): Promise { + const profiles = await this.companiesService.addCompanyProfilesForUser( + user.id, + dto.types, + ); + return profiles.map((p) => new ResponseCompanyProfileDto(p)); + } + + // Used by portal + @Post("create") + @ApiOperation({ + summary: + "Create a company with its associated external profile (onboarding)", + }) + async createWithProfile( + @CurrentUser() user: CurrentIamUser, + @Body() dto: CreateCompanyWithProfileDto, + ): Promise { + const nameParts = (user.name?.en ?? "").split(" "); + const { profile, company } = + await this.companiesService.createCompanyWithProfile( + { + userId: user.id, + firstName: nameParts[0] || "", + lastName: nameParts.slice(-1)[0] || "", + email: user.email ?? "", + phone: user.phoneNumber ?? "", + }, + dto, + ); + return new CompanyInfoResponseDto(profile, company); + } + + // Used by backoffice + @Post() + @FreightAdmin() + @ApiOperation({ + summary: + "Create a new company (customer, freight_forwarder, dj_freight_forwarder, transporter)", + }) + async create(@Body() dto: CreateCompanyDto): Promise { + const company = await this.companiesService.createCompany(dto); + return new ResponseCompanyDto(company); + } + + @Get() + @ApiOperation({ summary: "List all companies" }) + async findAll(): Promise { + const companies = await this.companiesService.findAllCompanies(); + return companies.map((c) => new ResponseCompanyDto(c)); + } + + @Get("type/:type") + @ApiOperation({ summary: "Find companies by type" }) + async findByType(@Param("type") type: string): Promise { + const companies = await this.companiesService.findAllCompanies(); + return companies + .filter((c) => c.type === type) + .map((c) => new ResponseCompanyDto(c)); + } + + @Get("search") + @ApiOperation({ summary: "Search companies by name" }) + async search(@Query("name") name: string): Promise { + const companies = await this.companiesService.findAllCompanies(); + return companies + .filter((c) => c.name.toLowerCase().includes(name.toLowerCase())) + .map((c) => new ResponseCompanyDto(c)); + } + + @Get(":id") + @ApiOperation({ summary: "Get company by ID" }) + async findById( + @Param("id", ParseUUIDPipe) id: string, + ): Promise { + const company = await this.companiesService.findCompanyById(id); + return new ResponseCompanyDto(company); + } + + @Patch(":id") + @FreightAdmin() + @ApiOperation({ summary: "Update a company" }) + async update( + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: UpdateCompanyDto, + ): Promise { + const company = await this.companiesService.updateCompany(id, dto); + return new ResponseCompanyDto(company); + } + + @Delete(":id") + @FreightAdmin() + @ApiOperation({ summary: "Soft-delete a company" }) + @HttpCode(HttpStatus.NO_CONTENT) + async remove(@Param("id", ParseUUIDPipe) id: string): Promise { + await this.companiesService.deleteCompany(id); + } + + @Post(":companyId/documents") + @UseInterceptors(AnyFilesInterceptor()) + @ApiConsumes("multipart/form-data") + @ApiOperation({ summary: "Upload documents for a company (onboarding)" }) + async uploadDocuments( + @Param("companyId", ParseUUIDPipe) companyId: string, + @UploadedFiles() files: Array, + ) { + return this.filesService.uploadMany(companyId, "companies", files); + } + + @Post(":companyId/profiles") + @FreightAdmin() + @ApiOperation({ summary: "Add a profile (employee) to a company" }) + async createProfile( + @Param("companyId", ParseUUIDPipe) companyId: string, + @Body() dto: CreateExternalProfileDto, + ): Promise { + const profile = await this.companiesService.createProfile({ + ...dto, + companyId, + }); + return new ResponseExternalProfileDto(profile); + } + + @Get(":companyId/profiles") + @ApiOperation({ summary: "List profiles for a company" }) + async listProfiles( + @Param("companyId", ParseUUIDPipe) companyId: string, + ): Promise { + const profiles = + await this.companiesService.findProfilesByCompany(companyId); + return profiles.map((p) => new ResponseExternalProfileDto(p)); + } + + @Get("profile/user/:userId") + @ApiOperation({ summary: "Get profile by IAM user ID" }) + async findProfileByUser( + @Param("userId", ParseUUIDPipe) userId: string, + ): Promise { + const profile = await this.companiesService.findProfileByUserId(userId); + return new ResponseExternalProfileDto(profile); + } +} diff --git a/apps/edr-freight-api/src/modules/companies/companies.module.ts b/apps/edr-freight-api/src/modules/companies/companies.module.ts new file mode 100644 index 000000000..53d3de4c8 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/companies.module.ts @@ -0,0 +1,30 @@ +import { Module } from "@nestjs/common"; +import { TypeOrmModule } from "@nestjs/typeorm"; +import { FilesModule } from "../files/files.module"; +import { CompaniesController } from "./companies.controller"; +import { CompaniesService } from "./companies.service"; +import { CompaniesRepository } from "./companies.repository"; +import { ExternalProfileRepository } from "./external-profile.repository"; +import { CompanyDashboardRepository } from "./company-dashboard.repository"; +import { Company } from "./entities/company.entity"; +import { ExternalProfile } from "./entities/external-profile.entity"; +import { CompanyProfile } from "./entities/company-profile.entity"; +import { Booking } from "../bookings/entities/booking.entity"; +import { CompanyProfileRepository } from "./company-profile.repository"; + +@Module({ + imports: [ + TypeOrmModule.forFeature([Company, ExternalProfile, CompanyProfile, Booking]), + FilesModule, + ], + controllers: [CompaniesController], + providers: [ + CompaniesService, + CompaniesRepository, + ExternalProfileRepository, + CompanyProfileRepository, + CompanyDashboardRepository, + ], + exports: [CompaniesService], +}) +export class CompaniesModule { } diff --git a/apps/edr-freight-api/src/modules/companies/companies.repository.ts b/apps/edr-freight-api/src/modules/companies/companies.repository.ts new file mode 100644 index 000000000..1156823f7 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/companies.repository.ts @@ -0,0 +1,35 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { BaseRepository } from '@edr/api-common'; +import { Company } from './entities/company.entity'; + +@Injectable() +export class CompaniesRepository extends BaseRepository { + constructor( + @InjectRepository(Company) + repo: Repository, + ) { + super(repo); + } + + async findByTin(tin: string): Promise { + return this.repository.findOne({ where: { tin } as any }); + } + + async findByType(type: string): Promise { + return this.repository.find({ where: { type } as any, order: { name: 'ASC' } }); + } + + async findByName(name: string): Promise { + return this.repository + .createQueryBuilder('company') + .where('company.name ILIKE :name', { name: `%${name}%` }) + .getMany(); + } + + async existsByTin(tin: string): Promise { + const count = await this.repository.count({ where: { tin } as any }); + return count > 0; + } +} diff --git a/apps/edr-freight-api/src/modules/companies/companies.service.ts b/apps/edr-freight-api/src/modules/companies/companies.service.ts new file mode 100644 index 000000000..fe1bc5598 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -0,0 +1,508 @@ +import { + Injectable, + NotFoundException, + ConflictException, + BadRequestException, +} from "@nestjs/common"; +import { CompaniesRepository } from "./companies.repository"; +import { CompanyProfileRepository } from "./company-profile.repository"; +import { ExternalProfileRepository } from "./external-profile.repository"; +import { CompanyDashboardRepository } from "./company-dashboard.repository"; +import { CreateCompanyDto } from "./dto/create-company.dto"; +import { UpdateCompanyDto } from "./dto/update-company.dto"; +import { CreateExternalProfileDto } from "./dto/create-external-profile.dto"; +import { CreateCompanyWithProfileDto } from "./dto/create-company-with-profile.dto"; +import { UpdateProfileDto } from "./dto/update-profile.dto"; +import { ProfileResponseDto } from "./dto/profile-response.dto"; +import { DashboardSummaryResponseDto } from "./dto/dashboard-summary-response.dto"; +import { Company } from "./entities/company.entity"; +import { ExternalProfile } from "./entities/external-profile.entity"; +import { + CompanyProfile, + ProfileType, + ProfileStatus, +} from "./entities/company-profile.entity"; + +export interface UserIdentity { + userId: string; + firstName: string; + lastName: string; + email: string; + phone: string; +} + +@Injectable() +export class CompaniesService { + constructor( + private readonly companiesRepo: CompaniesRepository, + private readonly companyProfilesRepo: CompanyProfileRepository, + private readonly profilesRepo: ExternalProfileRepository, + private readonly dashboardRepo: CompanyDashboardRepository, + ) { } + + async createCompany(dto: CreateCompanyDto): Promise { + const exists = await this.companiesRepo.existsByTin(dto.tin); + if (exists) { + throw new ConflictException(`Company with TIN ${dto.tin} already exists`); + } + return this.companiesRepo.create(dto); + } + + async createCompanyWithProfile( + identity: UserIdentity, + dto: CreateCompanyWithProfileDto, + ): Promise<{ company: Company; profile: ExternalProfile }> { + if (dto.tin) { + const exists = await this.companiesRepo.existsByTin(dto.tin); + if (exists) { + throw new ConflictException( + `Company with TIN ${dto.tin} already exists`, + ); + } + } + + const existingProfile = await this.profilesRepo.findByEmail(identity.email); + if (existingProfile) { + throw new ConflictException( + `Profile with email ${identity.email} already exists`, + ); + } + + const company = await this.companiesRepo.create({ + name: dto.companyName, + type: dto.companyType, + tin: dto.tin ?? "", + vatNumber: dto.vatNumber ?? null, + fanNumber: dto.fanNumber ?? null, + country: dto.companyLocation ?? "Ethiopia", + address: dto.companyAddress ?? null, + phone: dto.companyPhone ?? null, + email: dto.companyEmail ?? null, + attributes: dto.attributes ?? null, + }); + + const profile = await this.profilesRepo.create({ + userId: identity.userId, + companyId: company.id, + firstName: identity.firstName, + lastName: identity.lastName, + email: identity.email, + phone: identity.phone, + jobTitle: dto.jobTitle ?? null, + isPrimaryContact: dto.isPrimaryContact ?? true, + }); + + // Persist the operational role(s) chosen during onboarding. Types are + // already constrained to the company type on the client; any that don't + // match are skipped defensively rather than failing the whole signup. + if (dto.companyProfiles?.length) { + const allowedTypes = this.getProfileTypeForCompanyType(company.type); + for (const input of dto.companyProfiles) { + if (!allowedTypes.includes(input.type)) continue; + const existing = await this.companyProfilesRepo.findByType( + company.id, + input.type, + ); + if (existing) continue; + const reference = await this.companyProfilesRepo.generateReference( + input.type, + ); + await this.companyProfilesRepo.create({ + companyId: company.id, + type: input.type, + reference, + businessLicense: input.businessLicense ?? null, + status: ProfileStatus.Active, + }); + } + company.companyProfiles = await this.companyProfilesRepo.findByCompanyId( + company.id, + ); + } + + return { company, profile }; + } + + async findAllCompanies(): Promise { + return this.companiesRepo.findAll({ order: { name: "ASC" } }); + } + + async findCompanyById(id: string): Promise { + const company = await this.companiesRepo.findById(id); + if (!company) throw new NotFoundException(`Company ${id} not found`); + return company; + } + + async getCompanyInfoByUserId( + userId: string, + ): Promise<{ profile: ExternalProfile; company: Company }> { + const profile = await this.profilesRepo.findByUserId(userId); + if (!profile) + throw new NotFoundException(`Profile for user ${userId} not found`); + + const company = profile.company; + if (!company) + throw new NotFoundException( + `Company for profile ${profile.id} not found`, + ); + + company.companyProfiles = + await this.companyProfilesRepo.findByCompanyId(company.id); + + return { profile, company }; + } + + /** + * Dashboard KPIs for the portal home (MyPortalPage), aggregated from the + * current user's company bookings. All figures are scoped to that company. + * + * Note: delivered/spend/volume all derive from the bookings table — there is + * no separate data source for them. On-time delivery rate is replaced by + * completion rate (delivered ÷ committed): the schema has no ETA / + * promised-delivery date, so on-time cannot be computed. + * + * Period attribution uses booking.created_at: there is no delivery-date + * column, so "delivered YTD" counts bookings created this year that reached a + * delivered/completed status. + */ + async getDashboardSummary( + userId: string, + ): Promise { + // A user without a company profile has no bookings — return an empty summary + // rather than 404, so the portal home still renders. + const profile = await this.profilesRepo.findByUserId(userId); + const companyId = profile?.company?.id ?? profile?.companyId ?? null; + if (!companyId) return this.emptyDashboardSummary(); + + const now = new Date(); + const yearStart = new Date(now.getFullYear(), 0, 1); + const prevYearStart = new Date(now.getFullYear() - 1, 0, 1); + // Same point in the previous year, so YoY compares like-for-like windows. + const prevYearToDate = new Date( + prevYearStart.getTime() + (now.getTime() - yearStart.getTime()), + ); + + const [ + deliveredThis, + committedThis, + spendThisByCcy, + spendPrevByCcy, + tonnageThis, + tonnagePrev, + monthlyRows, + ] = await Promise.all([ + this.dashboardRepo.countDelivered(companyId, yearStart, now), + this.dashboardRepo.countCommitted(companyId, yearStart, now), + this.dashboardRepo.sumPaidSpendByCurrency(companyId, yearStart, now), + this.dashboardRepo.sumPaidSpendByCurrency( + companyId, + prevYearStart, + prevYearToDate, + ), + this.dashboardRepo.sumCommittedTonnage(companyId, yearStart, now), + this.dashboardRepo.sumCommittedTonnage( + companyId, + prevYearStart, + prevYearToDate, + ), + this.dashboardRepo.monthlyCommittedTonnage( + companyId, + this.monthsAgo(now, 5), + now, + ), + ]); + + // Spend can span currencies; report the dominant one (prefer ETB on ties). + const spend = this.pickCurrencyTotal(spendThisByCcy); + const spendPrev = + spendPrevByCcy.find((c) => c.currency === spend.currency)?.total ?? 0; + + return { + deliveredCount: deliveredThis, + // Share of committed bookings that reached delivered/completed. + completionRate: + committedThis > 0 + ? Math.round((deliveredThis / committedThis) * 100) + : 0, + spendYtd: spend.total, + spendCurrency: spend.currency, + spendYtdChangePct: this.changePct(spend.total, spendPrev), + freightVolume: { + totalTonnes: Math.round(tonnageThis), + totalValue: spend.total, + currency: spend.currency, + ytdChangePct: this.changePct(tonnageThis, tonnagePrev), + monthly: this.buildMonthlySeries(now, monthlyRows), + }, + }; + } + + private emptyDashboardSummary(): DashboardSummaryResponseDto { + const now = new Date(); + return { + deliveredCount: 0, + completionRate: 0, + spendYtd: 0, + spendCurrency: "ETB", + spendYtdChangePct: 0, + freightVolume: { + totalTonnes: 0, + totalValue: 0, + currency: "ETB", + ytdChangePct: 0, + monthly: this.buildMonthlySeries(now, []), + }, + }; + } + + /** First day of the month `n` months before `from`. */ + private monthsAgo(from: Date, n: number): Date { + return new Date(from.getFullYear(), from.getMonth() - n, 1); + } + + /** Pick the currency with the largest total, preferring ETB on ties / when empty. */ + private pickCurrencyTotal(totals: { currency: string; total: number }[]): { + currency: string; + total: number; + } { + if (totals.length === 0) return { currency: "ETB", total: 0 }; + return totals.reduce((best, cur) => (cur.total > best.total ? cur : best)); + } + + /** Percentage change vs a prior value, rounded; 0 when there is no prior base. */ + private changePct(current: number, previous: number): number { + if (previous <= 0) return 0; + return Math.round(((current - previous) / previous) * 100); + } + + /** Build a fixed 6-month tonnage series ending on `now`, zero-filling gaps. */ + private buildMonthlySeries( + now: Date, + rows: { year: number; month: number; tonnes: number }[], + ): { month: string; tonnes: number }[] { + const labels = [ + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec", + ]; + const byKey = new Map(rows.map((r) => [`${r.year}-${r.month}`, r.tonnes])); + const series: { month: string; tonnes: number }[] = []; + for (let i = 5; i >= 0; i--) { + const d = new Date(now.getFullYear(), now.getMonth() - i, 1); + const key = `${d.getFullYear()}-${d.getMonth() + 1}`; + series.push({ + month: labels[d.getMonth()], + tonnes: Math.round(byKey.get(key) ?? 0), + }); + } + return series; + } + + async updateCompany(id: string, dto: UpdateCompanyDto): Promise { + await this.findCompanyById(id); + const updated = await this.companiesRepo.update(id, dto); + if (!updated) throw new NotFoundException(`Company ${id} not found`); + return updated; + } + + async updateProfile( + userId: string, + dto: UpdateProfileDto, + ): Promise { + const { profile, company } = await this.getCompanyInfoByUserId(userId); + + const companyUpdates: Record = {}; + const attrUpdates: Record = { ...(company.attributes ?? {}) }; + + if (dto.companyName !== undefined) companyUpdates.name = dto.companyName; + if (dto.companyEmail !== undefined) companyUpdates.email = dto.companyEmail; + if (dto.companyPhone !== undefined) companyUpdates.phone = dto.companyPhone; + if (dto.companyLocation !== undefined) + companyUpdates.country = dto.companyLocation; + if (dto.companyAddress !== undefined) + companyUpdates.address = dto.companyAddress; + if (dto.tin !== undefined) companyUpdates.tin = dto.tin; + if (dto.vatNumber !== undefined) companyUpdates.vatNumber = dto.vatNumber; + if (dto.fanNumber !== undefined) { + companyUpdates.fanNumber = dto.fanNumber; + } + + if (dto.contactPersonName !== undefined) + attrUpdates.contactPersonName = dto.contactPersonName; + if (dto.contactPersonPhone !== undefined) + attrUpdates.contactPersonPhone = dto.contactPersonPhone; + if (dto.generalManagerName !== undefined) + attrUpdates.generalManagerName = dto.generalManagerName; + if (dto.generalManagerEmail !== undefined) + attrUpdates.generalManagerEmail = dto.generalManagerEmail; + if (dto.generalManagerPhone !== undefined) + attrUpdates.generalManagerPhone = dto.generalManagerPhone; + if (dto.poaName !== undefined) attrUpdates.poaName = dto.poaName; + if (dto.poaPhone !== undefined) attrUpdates.poaPhone = dto.poaPhone; + if (dto.poaEmail !== undefined) attrUpdates.poaEmail = dto.poaEmail; + if (dto.poaLocation !== undefined) + attrUpdates.poaLocation = dto.poaLocation; + if (dto.poaAddress !== undefined) attrUpdates.poaAddress = dto.poaAddress; + + companyUpdates.attributes = attrUpdates; + + const updated = await this.companiesRepo.update(company.id, companyUpdates); + if (!updated) + throw new NotFoundException(`Company ${company.id} not found`); + return new ProfileResponseDto(profile, updated); + } + + async deleteCompany(id: string): Promise { + await this.findCompanyById(id); + await this.companiesRepo.softDelete(id); + } + + async createProfile(dto: CreateExternalProfileDto): Promise { + await this.findCompanyById(dto.companyId); + + const existing = await this.profilesRepo.findByEmail(dto.email); + if (existing) { + throw new ConflictException( + `Profile with email ${dto.email} already exists`, + ); + } + + return this.profilesRepo.create(dto); + } + + async findProfileByUserId(userId: string): Promise { + const profile = await this.profilesRepo.findByUserId(userId); + if (!profile) + throw new NotFoundException(`Profile for user ${userId} not found`); + return profile; + } + + async findProfilesByCompany(companyId: string): Promise { + return this.profilesRepo.findByCompanyId(companyId); + } + + private getProfileTypeForCompanyType(companyType: string): ProfileType[] { + switch (companyType) { + case "customer": + return [ProfileType.importer, ProfileType.exporter]; + case "freight_forwarder": + return [ProfileType.freightForwarder]; + case "dj_freight_forwarder": + return [ProfileType.djFreightForwarder]; + case "transporter": + return [ProfileType.transporter]; + default: + return []; + } + } + + async createCompanyProfile( + companyId: string, + profileType?: ProfileType, + ): Promise { + const company = await this.findCompanyById(companyId); + + const allowedTypes = this.getProfileTypeForCompanyType(company.type); + + const type = profileType ?? allowedTypes[0]; + if (!allowedTypes.includes(type)) { + throw new BadRequestException( + `Profile type "${type}" is not allowed for company type "${company.type}"`, + ); + } + + const existing = await this.companyProfilesRepo.findByType(companyId, type); + if (existing) { + throw new ConflictException( + `Company already has a ${type} profile (${existing.reference})`, + ); + } + + const reference = await this.companyProfilesRepo.generateReference(type); + + return this.companyProfilesRepo.create({ + companyId, + type, + reference, + status: ProfileStatus.Active, + }); + } + + async createDefaultProfilesForCompany( + companyId: string, + ): Promise { + const company = await this.findCompanyById(companyId); + const types = this.getProfileTypeForCompanyType(company.type); + + const profiles: CompanyProfile[] = []; + for (const type of types) { + const existing = await this.companyProfilesRepo.findByType( + companyId, + type, + ); + if (!existing) { + profiles.push(await this.createCompanyProfile(companyId, type)); + } + } + + if (profiles.length === 0) { + throw new BadRequestException( + `Company of type "${company.type}" must have at least one operational profile`, + ); + } + + return profiles; + } + + /** + * Add operational profile(s) to the current user's company (portal settings). + * Add-only and idempotent: each requested type must be allowed for the + * company's type, profiles that already exist are skipped (not re-created or + * rejected), and the full updated list is returned. + */ + async addCompanyProfilesForUser( + userId: string, + types: ProfileType[], + ): Promise { + const profile = await this.profilesRepo.findByUserId(userId); + if (!profile) + throw new NotFoundException(`Profile for user ${userId} not found`); + + const companyId = profile.company?.id ?? profile.companyId; + const company = await this.findCompanyById(companyId); + const allowedTypes = this.getProfileTypeForCompanyType(company.type); + + for (const type of types) { + if (!allowedTypes.includes(type)) { + throw new BadRequestException( + `Profile type "${type}" is not allowed for company type "${company.type}"`, + ); + } + + const existing = await this.companyProfilesRepo.findByType( + companyId, + type, + ); + if (existing) continue; + + const reference = await this.companyProfilesRepo.generateReference(type); + await this.companyProfilesRepo.create({ + companyId, + type, + reference, + status: ProfileStatus.Active, + }); + } + + return this.companyProfilesRepo.findByCompanyId(companyId); + } +} diff --git a/apps/edr-freight-api/src/modules/companies/company-dashboard.repository.ts b/apps/edr-freight-api/src/modules/companies/company-dashboard.repository.ts new file mode 100644 index 000000000..365cf1daa --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/company-dashboard.repository.ts @@ -0,0 +1,126 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { Booking } from '../bookings/entities/booking.entity'; + +/** Booking statuses that represent a delivered/finished shipment. */ +const DELIVERED_STATUSES = ['DELIVERED', 'COMPLETED'] as const; + +/** + * Statuses that represent real, committed freight (excludes drafts and dead + * bookings) — used for tonnage so cancelled/expired drafts don't inflate volume. + */ +const COMMITTED_STATUSES = [ + 'APPROVED', + 'CONTRACT_READY', + 'SIGNED_CUSTOMER', + 'FULLY_EXECUTED', + 'SELECTED_FOR_BATCH', + 'PNR_GENERATED', + 'PAYMENT_VERIFICATION_IN_PROGRESS', + 'PAID', + 'IN_TRANSIT', + 'COMPLETED', + 'DELIVERED', + 'CONSOLIDATED', +] as const; + +export interface CurrencyTotal { + currency: string; + total: number; +} + +export interface MonthlyTonnage { + year: number; + month: number; // 1-12 + tonnes: number; +} + +/** + * Read-only aggregation queries against the bookings table, scoped to a + * company, that back the portal dashboard. Lives in the companies module so it + * can be exposed via `companies.controller` without a circular dependency on + * BookingsModule (which already imports CompaniesModule). + */ +@Injectable() +export class CompanyDashboardRepository { + constructor( + @InjectRepository(Booking) + private readonly bookings: Repository, + ) {} + + /** Count of delivered/completed bookings for a company within [from, to). */ + async countDelivered(companyId: string, from: Date, to: Date): Promise { + return this.bookings + .createQueryBuilder('b') + .where('b.company_id = :companyId', { companyId }) + .andWhere('b.deleted_at IS NULL') + .andWhere('b.status IN (:...statuses)', { statuses: [...DELIVERED_STATUSES] }) + .andWhere('b.created_at >= :from AND b.created_at < :to', { from, to }) + .getCount(); + } + + /** Count of committed (non-draft, non-dead) bookings for a company within [from, to). */ + async countCommitted(companyId: string, from: Date, to: Date): Promise { + return this.bookings + .createQueryBuilder('b') + .where('b.company_id = :companyId', { companyId }) + .andWhere('b.deleted_at IS NULL') + .andWhere('b.status IN (:...statuses)', { statuses: [...COMMITTED_STATUSES] }) + .andWhere('b.created_at >= :from AND b.created_at < :to', { from, to }) + .getCount(); + } + + /** Sum of paid booking totals, grouped by currency, within [from, to). */ + async sumPaidSpendByCurrency(companyId: string, from: Date, to: Date): Promise { + const rows = await this.bookings + .createQueryBuilder('b') + .select('b.payment_currency', 'currency') + .addSelect('COALESCE(SUM(b.total_amount), 0)', 'total') + .where('b.company_id = :companyId', { companyId }) + .andWhere('b.deleted_at IS NULL') + .andWhere("b.payment_status = 'PAID'") + .andWhere('b.created_at >= :from AND b.created_at < :to', { from, to }) + .groupBy('b.payment_currency') + .getRawMany<{ currency: string; total: string }>(); + + return rows.map((r) => ({ currency: r.currency ?? 'ETB', total: Number(r.total) })); + } + + /** Total committed tonnage (cargo VGM) for a company within [from, to). */ + async sumCommittedTonnage(companyId: string, from: Date, to: Date): Promise { + const row = await this.bookings + .createQueryBuilder('b') + .select('COALESCE(SUM(b.cargo_total_weight_vgm), 0)', 'total') + .where('b.company_id = :companyId', { companyId }) + .andWhere('b.deleted_at IS NULL') + .andWhere('b.status IN (:...statuses)', { statuses: [...COMMITTED_STATUSES] }) + .andWhere('b.created_at >= :from AND b.created_at < :to', { from, to }) + .getRawOne<{ total: string }>(); + + return Number(row?.total ?? 0); + } + + /** Committed tonnage grouped by calendar month within [from, to). */ + async monthlyCommittedTonnage(companyId: string, from: Date, to: Date): Promise { + const rows = await this.bookings + .createQueryBuilder('b') + .select('EXTRACT(YEAR FROM b.created_at)', 'year') + .addSelect('EXTRACT(MONTH FROM b.created_at)', 'month') + .addSelect('COALESCE(SUM(b.cargo_total_weight_vgm), 0)', 'total') + .where('b.company_id = :companyId', { companyId }) + .andWhere('b.deleted_at IS NULL') + .andWhere('b.status IN (:...statuses)', { statuses: [...COMMITTED_STATUSES] }) + .andWhere('b.created_at >= :from AND b.created_at < :to', { from, to }) + .groupBy('year') + .addGroupBy('month') + .getRawMany<{ year: string; month: string; total: string }>(); + + return rows.map((r) => ({ + year: Number(r.year), + month: Number(r.month), + tonnes: Number(r.total), + })); + } +} diff --git a/apps/edr-freight-api/src/modules/companies/company-profile.repository.ts b/apps/edr-freight-api/src/modules/companies/company-profile.repository.ts new file mode 100644 index 000000000..db7427112 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/company-profile.repository.ts @@ -0,0 +1,61 @@ +import { Injectable } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { Repository } from "typeorm"; +import { BaseRepository } from "@edr/api-common"; +import { CompanyProfile, ProfileType } from "./entities/company-profile.entity"; + +const SEQUENCE_MAP: Record = { + [ProfileType.exporter]: "seq_company_profile_ex", + [ProfileType.importer]: "seq_company_profile_im", + [ProfileType.freightForwarder]: "seq_company_profile_ffe", + [ProfileType.djFreightForwarder]: "seq_company_profile_fwj", + [ProfileType.transporter]: "seq_company_profile_tr", +}; + +const PREFIX_MAP: Record = { + [ProfileType.exporter]: "EX", + [ProfileType.importer]: "IM", + [ProfileType.freightForwarder]: "FFE", + [ProfileType.djFreightForwarder]: "FWJ", + [ProfileType.transporter]: "TR", +}; + +@Injectable() +export class CompanyProfileRepository extends BaseRepository { + constructor( + @InjectRepository(CompanyProfile) + repo: Repository, + ) { + super(repo); + } + + async generateReference(type: ProfileType): Promise { + const seqName = SEQUENCE_MAP[type]; + const result = await this.repository.query( + `SELECT nextval('${seqName}') AS next_id`, + ); + const nextId = result[0].next_id as number; + const prefix = PREFIX_MAP[type]; + return `${prefix}-${String(nextId).padStart(5, "0")}`; + } + + async findByCompanyId(companyId: string): Promise { + return this.repository.find({ + where: { companyId }, + relations: ["company"], + }); + } + + async findByType( + companyId: string, + type: ProfileType, + ): Promise { + return this.repository.findOne({ + where: { companyId, type }, + }); + } + + async findByReference(reference: string): Promise { + return this.repository.findOne({ where: { reference } }); + } +} diff --git a/apps/edr-freight-api/src/modules/companies/dto/add-company-profiles.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/add-company-profiles.dto.ts new file mode 100644 index 000000000..838c42111 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/dto/add-company-profiles.dto.ts @@ -0,0 +1,9 @@ +import { IsArray, IsEnum, ArrayMinSize } from "class-validator"; +import { ProfileType } from "../entities/company-profile.entity"; + +export class AddCompanyProfilesDto { + @IsArray() + @ArrayMinSize(1) + @IsEnum(ProfileType, { each: true }) + types!: ProfileType[]; +} diff --git a/apps/edr-freight-api/src/modules/companies/dto/company-info-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/company-info-response.dto.ts new file mode 100644 index 000000000..f6ffb8296 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/dto/company-info-response.dto.ts @@ -0,0 +1,14 @@ +import { Company } from '../entities/company.entity'; +import { ExternalProfile } from '../entities/external-profile.entity'; +import { ResponseCompanyDto } from './response-company.dto'; +import { ResponseExternalProfileDto } from './response-external-profile.dto'; + +export class CompanyInfoResponseDto { + profile: ResponseExternalProfileDto; + company: ResponseCompanyDto; + + constructor(profile: ExternalProfile, company: Company) { + this.profile = new ResponseExternalProfileDto(profile); + this.company = new ResponseCompanyDto(company); + } +} diff --git a/apps/edr-freight-api/src/modules/companies/dto/create-company-with-profile.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/create-company-with-profile.dto.ts new file mode 100644 index 000000000..aa0bb72a2 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/dto/create-company-with-profile.dto.ts @@ -0,0 +1,77 @@ +import { IsString, IsNotEmpty, IsOptional, IsEmail, MaxLength, IsBoolean, IsEnum, IsArray, ValidateNested, ArrayMinSize } from 'class-validator'; +import { Type } from 'class-transformer'; +import { CompanyType } from '../entities/company.entity'; +import { ProfileType } from '../entities/company-profile.entity'; + +export class CompanyProfileInputDto { + @IsEnum(ProfileType) + type!: ProfileType; + + @IsOptional() + @IsString() + @MaxLength(100) + businessLicense?: string; +} + +export class CreateCompanyWithProfileDto { + @IsEnum(CompanyType) + companyType!: CompanyType; + + @IsString() + @IsNotEmpty() + @MaxLength(200) + companyName!: string; + + @IsOptional() + @IsEmail() + @MaxLength(150) + companyEmail?: string; + + @IsOptional() + @IsString() + @MaxLength(20) + companyPhone?: string; + + @IsOptional() + @IsString() + @MaxLength(32) + companyLocation?: string; + + @IsOptional() + @IsString() + companyAddress?: string; + + @IsOptional() + @IsString() + @MaxLength(10) + tin?: string; + + @IsOptional() + @IsString() + @MaxLength(50) + vatNumber?: string; + + @IsOptional() + @IsString() + @MaxLength(16) + fanNumber?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + jobTitle?: string; + + @IsOptional() + @IsBoolean() + isPrimaryContact?: boolean; + + @IsOptional() + attributes?: Record; + + @IsOptional() + @IsArray() + @ArrayMinSize(1) + @ValidateNested({ each: true }) + @Type(() => CompanyProfileInputDto) + companyProfiles?: CompanyProfileInputDto[]; +} diff --git a/apps/edr-freight-api/src/modules/companies/dto/create-company.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/create-company.dto.ts new file mode 100644 index 000000000..5718f541e --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/dto/create-company.dto.ts @@ -0,0 +1,54 @@ +import { IsString, IsNotEmpty, IsOptional, IsEnum, MaxLength, Length, Matches, IsEmail } from 'class-validator'; +import { CompanyType, CompanyStatus } from '../entities/company.entity'; + +export class CreateCompanyDto { + @IsString() + @IsNotEmpty() + @MaxLength(200) + name!: string; + + @IsEnum(CompanyType) + type!: CompanyType; + + @IsOptional() + @IsEnum(CompanyStatus) + status?: CompanyStatus; + + @IsString() + @IsNotEmpty() + @Length(10, 10) + @Matches(/^\d+$/, { message: 'TIN must contain only digits' }) + tin!: string; + + @IsOptional() + @IsString() + @MaxLength(50) + vatNumber?: string; + + @IsOptional() + @IsString() + @MaxLength(32) + country?: string; + + @IsOptional() + @IsString() + address?: string; + + @IsOptional() + @IsString() + @MaxLength(20) + phone?: string; + + @IsOptional() + @IsEmail() + @MaxLength(150) + email?: string; + + @IsOptional() + @IsString() + @MaxLength(200) + website?: string; + + @IsOptional() + attributes?: Record; +} diff --git a/apps/edr-freight-api/src/modules/companies/dto/create-external-profile.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/create-external-profile.dto.ts new file mode 100644 index 000000000..c694a50e0 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/dto/create-external-profile.dto.ts @@ -0,0 +1,44 @@ +import { IsString, IsNotEmpty, IsOptional, IsEmail, MaxLength, IsBoolean, IsUUID } from 'class-validator'; + +export class CreateExternalProfileDto { + @IsUUID() + @IsNotEmpty() + userId!: string; + + @IsUUID() + @IsNotEmpty() + companyId!: string; + + @IsString() + @IsNotEmpty() + @MaxLength(100) + firstName!: string; + + @IsString() + @IsNotEmpty() + @MaxLength(100) + lastName!: string; + + @IsEmail() + @IsNotEmpty() + email!: string; + + @IsOptional() + @IsString() + @MaxLength(20) + phone?: string; + + @IsOptional() + @IsString() + @MaxLength(50) + nationalId?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + jobTitle?: string; + + @IsOptional() + @IsBoolean() + isPrimaryContact?: boolean; +} diff --git a/apps/edr-freight-api/src/modules/companies/dto/dashboard-summary-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/dashboard-summary-response.dto.ts new file mode 100644 index 000000000..c0d99643c --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/dto/dashboard-summary-response.dto.ts @@ -0,0 +1,59 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class FreightVolumePointDto { + @ApiProperty({ example: 'May', description: 'Short month label' }) + month!: string; + + @ApiProperty({ example: 940, description: 'Tonnage shipped in the month' }) + tonnes!: number; +} + +export class FreightVolumeDto { + @ApiProperty({ example: 4180, description: 'Total tonnage shipped year-to-date' }) + totalTonnes!: number; + + @ApiProperty({ example: 1240000, description: 'Total committed freight value year-to-date' }) + totalValue!: number; + + @ApiProperty({ example: 'ETB' }) + currency!: string; + + @ApiProperty({ example: 16, description: 'Tonnage change vs same period last year, in percent' }) + ytdChangePct!: number; + + @ApiProperty({ type: [FreightVolumePointDto], description: 'Monthly tonnage series (oldest first, last 6 months)' }) + monthly!: FreightVolumePointDto[]; +} + +/** + * KPIs for the portal dashboard (MyPortalPage), aggregated from the current + * user's company bookings. All figures are scoped to that company. + * + * Note: every metric here derives from the bookings table — there is no + * separate "non-booking" data source for delivered/spend/volume. On-time + * delivery rate is replaced by completion rate: no ETA / promised-delivery + * column exists in the schema, so on-time cannot be computed, whereas + * completion rate (delivered ÷ committed) can. + */ +export class DashboardSummaryResponseDto { + @ApiProperty({ example: 12, description: 'Bookings delivered/completed year-to-date' }) + deliveredCount!: number; + + @ApiProperty({ + example: 92, + description: 'Share of committed bookings that have been delivered/completed (YTD), in percent', + }) + completionRate!: number; + + @ApiProperty({ example: 1240000, description: 'Total paid spend year-to-date' }) + spendYtd!: number; + + @ApiProperty({ example: 'ETB' }) + spendCurrency!: string; + + @ApiProperty({ example: 16, description: 'Spend change vs same period last year, in percent' }) + spendYtdChangePct!: number; + + @ApiProperty({ type: FreightVolumeDto }) + freightVolume!: FreightVolumeDto; +} diff --git a/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts new file mode 100644 index 000000000..d6744e75f --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts @@ -0,0 +1,61 @@ +import { Company } from '../entities/company.entity'; +import { ExternalProfile } from '../entities/external-profile.entity'; +import { ResponseCompanyProfileDto } from './response-company.dto'; + +export class ProfileResponseDto { + companyId: string; + companyName: string; + companyType: string; + companyEmail: string | null; + companyPhone: string | null; + companyLocation: string; + companyAddress: string | null; + tinNumber: string; + vatNumber: string | null; + fanNumber: string | null; + + companyProfiles: ResponseCompanyProfileDto[]; + + contactPersonName: string | null; + contactPersonPhone: string | null; + generalManagerName: string | null; + generalManagerEmail: string | null; + generalManagerPhone: string | null; + + poaName: string | null; + poaPhone: string | null; + poaEmail: string | null; + poaLocation: string | null; + poaAddress: string | null; + + profileId: string; + + constructor(profile: ExternalProfile, company: Company) { + this.companyId = company.id; + this.companyName = company.name; + this.companyType = company.type; + this.companyProfiles = + company.companyProfiles?.map((p) => new ResponseCompanyProfileDto(p)) ?? + []; + this.companyEmail = company.email ?? null; + this.companyPhone = company.phone ?? null; + this.companyLocation = company.country; + this.companyAddress = company.address ?? null; + this.tinNumber = company.tin; + this.vatNumber = company.vatNumber ?? null; + this.fanNumber = company.fanNumber ?? null; + this.profileId = profile.id; + + const attrs = company.attributes ?? {}; + this.contactPersonName = attrs.contactPersonName ?? null; + this.contactPersonPhone = attrs.contactPersonPhone ?? null; + this.generalManagerName = attrs.generalManagerName ?? null; + this.generalManagerEmail = attrs.generalManagerEmail ?? null; + this.generalManagerPhone = attrs.generalManagerPhone ?? null; + this.poaName = attrs.poaName ?? null; + this.poaPhone = attrs.poaPhone ?? null; + this.poaEmail = attrs.poaEmail ?? null; + this.poaLocation = attrs.poaLocation ?? null; + this.poaAddress = attrs.poaAddress ?? null; + } +} diff --git a/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts new file mode 100644 index 000000000..cb7777e8b --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts @@ -0,0 +1,65 @@ +import { Company, CompanyType, CompanyStatus } from '../entities/company.entity'; +import { CompanyProfile } from '../entities/company-profile.entity'; +import { ResponseExternalProfileDto } from './response-external-profile.dto'; + +export class ResponseCompanyProfileDto { + id: string; + type: string; + reference: string; + status: string; + businessLicense?: string | null; + attributes?: Record | null; + createdAt: Date; + updatedAt: Date; + + constructor(profile: CompanyProfile) { + this.id = profile.id; + this.type = profile.type; + this.reference = profile.reference; + this.status = profile.status; + this.businessLicense = profile.businessLicense; + this.attributes = profile.attributes; + this.createdAt = profile.createdAt; + this.updatedAt = profile.updatedAt; + } +} + +export class ResponseCompanyDto { + id: string; + name: string; + type: CompanyType; + status: CompanyStatus; + tin: string; + vatNumber?: string | null; + fanNumber?: string | null; + country: string; + address?: string | null; + phone?: string | null; + email?: string | null; + website?: string | null; + attributes?: Record | null; + profiles?: ResponseExternalProfileDto[]; + companyProfiles?: ResponseCompanyProfileDto[]; + createdAt: Date; + updatedAt: Date; + + constructor(company: Company) { + this.id = company.id; + this.name = company.name; + this.type = company.type; + this.status = company.status; + this.tin = company.tin; + this.vatNumber = company.vatNumber; + this.fanNumber = company.fanNumber; + this.country = company.country; + this.address = company.address; + this.phone = company.phone; + this.email = company.email; + this.website = company.website; + this.attributes = company.attributes; + this.profiles = company.profiles?.map((p) => new ResponseExternalProfileDto(p)); + this.companyProfiles = company.companyProfiles?.map((p) => new ResponseCompanyProfileDto(p)); + this.createdAt = company.createdAt; + this.updatedAt = company.updatedAt; + } +} diff --git a/apps/edr-freight-api/src/modules/companies/dto/response-external-profile.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/response-external-profile.dto.ts new file mode 100644 index 000000000..a33585845 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/dto/response-external-profile.dto.ts @@ -0,0 +1,31 @@ +import { ExternalProfile } from '../entities/external-profile.entity'; + +export class ResponseExternalProfileDto { + id: string; + userId: string; + companyId: string; + firstName: string; + lastName: string; + email: string; + phone?: string | null; + nationalId?: string | null; + jobTitle?: string | null; + isPrimaryContact: boolean; + createdAt: Date; + updatedAt: Date; + + constructor(profile: ExternalProfile) { + this.id = profile.id; + this.userId = profile.userId; + this.companyId = profile.companyId; + this.firstName = profile.firstName; + this.lastName = profile.lastName; + this.email = profile.email; + this.phone = profile.phone; + this.nationalId = profile.nationalId; + this.jobTitle = profile.jobTitle; + this.isPrimaryContact = profile.isPrimaryContact; + this.createdAt = profile.createdAt; + this.updatedAt = profile.updatedAt; + } +} diff --git a/apps/edr-freight-api/src/modules/companies/dto/update-company.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/update-company.dto.ts new file mode 100644 index 000000000..71c3c0739 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/dto/update-company.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/mapped-types'; +import { CreateCompanyDto } from './create-company.dto'; + +export class UpdateCompanyDto extends PartialType(CreateCompanyDto) {} diff --git a/apps/edr-freight-api/src/modules/companies/dto/update-external-profile.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/update-external-profile.dto.ts new file mode 100644 index 000000000..3546e5c10 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/dto/update-external-profile.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/mapped-types'; +import { CreateExternalProfileDto } from './create-external-profile.dto'; + +export class UpdateExternalProfileDto extends PartialType(CreateExternalProfileDto) {} diff --git a/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts new file mode 100644 index 000000000..0acdf60a1 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts @@ -0,0 +1,83 @@ +import { IsString, IsOptional, IsEmail, MaxLength, Length, Matches } from 'class-validator'; + +export class UpdateProfileDto { + @IsOptional() + @IsString() + @MaxLength(200) + companyName?: string; + + @IsOptional() + @IsEmail() + @MaxLength(150) + companyEmail?: string; + + @IsOptional() + @IsString() + @MaxLength(20) + companyPhone?: string; + + @IsOptional() + @IsString() + @MaxLength(32) + companyLocation?: string; + + @IsOptional() + @IsString() + companyAddress?: string; + + @IsOptional() + @IsString() + @Length(10, 10) + @Matches(/^\d+$/, { message: 'TIN must contain only digits' }) + tin?: string; + + @IsOptional() + @IsString() + @MaxLength(50) + vatNumber?: string; + + @IsOptional() + @IsString() + @MaxLength(16) + fanNumber?: string; + + @IsOptional() + @IsString() + contactPersonName?: string; + + @IsOptional() + @IsString() + contactPersonPhone?: string; + + @IsOptional() + @IsString() + generalManagerName?: string; + + @IsOptional() + @IsEmail() + generalManagerEmail?: string; + + @IsOptional() + @IsString() + generalManagerPhone?: string; + + @IsOptional() + @IsString() + poaName?: string; + + @IsOptional() + @IsString() + poaPhone?: string; + + @IsOptional() + @IsEmail() + poaEmail?: string; + + @IsOptional() + @IsString() + poaLocation?: string; + + @IsOptional() + @IsString() + poaAddress?: string; +} diff --git a/apps/edr-freight-api/src/modules/companies/entities/company-profile.entity.ts b/apps/edr-freight-api/src/modules/companies/entities/company-profile.entity.ts new file mode 100644 index 000000000..84da76135 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/entities/company-profile.entity.ts @@ -0,0 +1,62 @@ +import { BaseEntity } from "@edr/api-common"; +import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm"; +import { Company } from "./company.entity"; + +export enum ProfileType { + importer = "importer", + exporter = "exporter", + freightForwarder = "freight_forwarder", + djFreightForwarder = "dj_freight_forwarder", + transporter = "transporter", +} + +export enum ProfileStatus { + Active = "active", + Pending = "pending", + Suspended = "suspended", + Blacklisted = "blacklisted", +} + +@Entity({ schema: "freight", name: "company_profiles" }) +@Index(["reference"], { unique: true }) +@Index(["type"]) +@Index(["companyId"]) +export class CompanyProfile extends BaseEntity { + @Column({ name: "company_id", type: "uuid" }) + companyId!: string; + + @ManyToOne(() => Company, (company) => company.companyProfiles) + @JoinColumn({ name: "company_id" }) + company!: Company; + + @Column({ name: "type", type: "varchar", length: 32, enum: ProfileType }) + type!: ProfileType; + + @Column({ + name: "reference", + type: "varchar", + length: 20, + nullable: false, + unique: true, + }) + reference!: string; + + @Column({ + name: "status", + type: "varchar", + length: 32, + default: ProfileStatus.Active, + }) + status!: ProfileStatus; + + @Column({ + name: "business_license", + type: "varchar", + length: 100, + nullable: true, + }) + businessLicense?: string | null; + + @Column({ name: "attributes", type: "jsonb", nullable: true }) + attributes?: Record | null; +} diff --git a/apps/edr-freight-api/src/modules/companies/entities/company.entity.ts b/apps/edr-freight-api/src/modules/companies/entities/company.entity.ts new file mode 100644 index 000000000..ec578a3b7 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/entities/company.entity.ts @@ -0,0 +1,110 @@ +import { BaseEntity } from "@edr/api-common"; +import { Column, Entity, Index, OneToMany } from "typeorm"; +import { ExternalProfile } from "./external-profile.entity"; +import { CompanyProfile } from "./company-profile.entity"; + +export enum CompanyType { + Customer = "customer", + FreightForwarder = "freight_forwarder", + DJFreightForwarder = "dj_freight_forwarder", + Transporter = "transporter", +} + +export enum CompanyStatus { + Active = "active", + Pending = "pending", + Suspended = "suspended", + Blacklisted = "blacklisted", +} + +@Entity({ schema: "freight", name: "companies" }) +@Index(["tin"]) +@Index(["type"]) +export class Company extends BaseEntity { + @Column({ name: "name", type: "varchar", length: 200 }) + name!: string; + + @Column({ name: "type", type: "varchar", length: 32, enum: CompanyType }) + type!: CompanyType; + + @Column({ + name: "status", + type: "varchar", + length: 32, + default: CompanyStatus.Pending, + }) + status!: CompanyStatus; + + @Column({ name: "tin", type: "varchar", length: 10, unique: true }) + tin!: string; + + @Column({ name: "vat_number", type: "varchar", length: 50, nullable: true }) + vatNumber?: string | null; + + @Column({ name: "fan_number", type: "varchar", length: 16, nullable: true }) + fanNumber?: string | null; + + @Column({ name: "country", type: "varchar", length: 32, default: "Ethiopia" }) + country!: string; + + @Column({ name: "address", type: "text", nullable: true }) + address?: string | null; + + @Column({ name: "phone", type: "varchar", length: 20, nullable: true }) + phone?: string | null; + + @Column({ name: "email", type: "varchar", length: 150, nullable: true }) + email?: string | null; + + @Column({ + name: "contact_person_name", + type: "varchar", + length: 100, + nullable: true, + }) + contactPersonName?: string | null; + + @Column({ + name: "contact_person_phone", + type: "varchar", + length: 20, + nullable: true, + }) + contactPersonPhone?: string | null; + + @Column({ + name: "general_manager_name", + type: "varchar", + length: 100, + nullable: true, + }) + generalManagerName?: string | null; + + @Column({ + name: "general_manager_email", + type: "varchar", + length: 150, + nullable: true, + }) + generalManagerEmail?: string | null; + + @Column({ + name: "general_manager_phone", + type: "varchar", + length: 20, + nullable: true, + }) + generalManagerPhone?: string | null; + + @Column({ name: "website", type: "varchar", length: 200, nullable: true }) + website?: string | null; + + @Column({ name: "attributes", type: "jsonb", nullable: true }) + attributes?: Record | null; + + @OneToMany(() => ExternalProfile, (profile) => profile.company) + profiles?: ExternalProfile[]; + + @OneToMany(() => CompanyProfile, (profile) => profile.company) + companyProfiles?: CompanyProfile[]; +} diff --git a/apps/edr-freight-api/src/modules/companies/entities/external-profile.entity.ts b/apps/edr-freight-api/src/modules/companies/entities/external-profile.entity.ts new file mode 100644 index 000000000..91a014f10 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/entities/external-profile.entity.ts @@ -0,0 +1,39 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, ManyToOne, JoinColumn } from 'typeorm'; +import { Company } from './company.entity'; + +@Entity({ schema: 'freight', name: 'external_profiles' }) +@Index(['userId']) +@Index(['companyId']) +export class ExternalProfile extends BaseEntity { + @Column({ name: 'user_id', type: 'uuid' }) + userId!: string; + + @Column({ name: 'company_id', type: 'uuid' }) + companyId!: string; + + @ManyToOne(() => Company, (company) => company.profiles) + @JoinColumn({ name: 'company_id' }) + company!: Company; + + @Column({ name: 'first_name', type: 'varchar', length: 100 }) + firstName!: string; + + @Column({ name: 'last_name', type: 'varchar', length: 100 }) + lastName!: string; + + @Column({ name: 'email', type: 'varchar', length: 150, unique: true }) + email!: string; + + @Column({ name: 'phone', type: 'varchar', length: 20, nullable: true }) + phone?: string | null; + + @Column({ name: 'national_id', type: 'varchar', length: 50, nullable: true }) + nationalId?: string | null; + + @Column({ name: 'job_title', type: 'varchar', length: 100, nullable: true }) + jobTitle?: string | null; + + @Column({ name: 'is_primary_contact', type: 'boolean', default: false }) + isPrimaryContact!: boolean; +} diff --git a/apps/edr-freight-api/src/modules/companies/external-profile.repository.ts b/apps/edr-freight-api/src/modules/companies/external-profile.repository.ts new file mode 100644 index 000000000..581dfd72b --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/external-profile.repository.ts @@ -0,0 +1,30 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { BaseRepository } from '@edr/api-common'; +import { ExternalProfile } from './entities/external-profile.entity'; + +@Injectable() +export class ExternalProfileRepository extends BaseRepository { + constructor( + @InjectRepository(ExternalProfile) + repo: Repository, + ) { + super(repo); + } + + async findByUserId(userId: string): Promise { + return this.repository.findOne({ + where: { userId } as any, + relations: ['company'], + }); + } + + async findByCompanyId(companyId: string): Promise { + return this.repository.find({ where: { companyId } as any }); + } + + async findByEmail(email: string): Promise { + return this.repository.findOne({ where: { email } as any }); + } +} diff --git a/apps/edr-freight-api/src/modules/consignments/consignments.controller.ts b/apps/edr-freight-api/src/modules/consignments/consignments.controller.ts index 44220f52f..b107e8935 100644 --- a/apps/edr-freight-api/src/modules/consignments/consignments.controller.ts +++ b/apps/edr-freight-api/src/modules/consignments/consignments.controller.ts @@ -9,17 +9,19 @@ import { } from "@nestjs/common"; import { ApiOperation, ApiTags } from "@nestjs/swagger"; +import { FleetManage, FleetView } from "../../common/booking-guards"; import { ConsignmentsService } from "./consignments.service"; import { CreateConsignmentDto } from "./dto/create-consignment.dto"; import { FilterConsignmentDto } from "./dto/filter-consignment.dto"; @ApiTags("consignments") -// @UseGuards(JwtAuthGuard) — TODO: integrate @edr/auth @Controller("consignments") +@FleetView() export class ConsignmentsController { constructor(private readonly consignmentsService: ConsignmentsService) {} @Post() + @FleetManage() @ApiOperation({ summary: "Create a new consignment" }) create(@Body() dto: CreateConsignmentDto) { return this.consignmentsService.create(dto); diff --git a/apps/edr-freight-api/src/modules/consignments/entities/consignment.entity.ts b/apps/edr-freight-api/src/modules/consignments/entities/consignment.entity.ts index 6224db4cc..3b7ee222b 100644 --- a/apps/edr-freight-api/src/modules/consignments/entities/consignment.entity.ts +++ b/apps/edr-freight-api/src/modules/consignments/entities/consignment.entity.ts @@ -2,7 +2,7 @@ import { BaseEntity } from "@edr/api-common"; import { Freight } from "@edr/types"; import { Column, Entity } from "typeorm"; -@Entity({ name: "consignments" }) +@Entity({schema:"freight", name: "consignments" }) export class Consignment extends BaseEntity { @Column({ name: "booking_id", type: "uuid" }) bookingId!: string; diff --git a/apps/edr-freight-api/src/modules/container-management/containers.controller.ts b/apps/edr-freight-api/src/modules/container-management/containers.controller.ts new file mode 100644 index 000000000..1a0cdb14f --- /dev/null +++ b/apps/edr-freight-api/src/modules/container-management/containers.controller.ts @@ -0,0 +1,71 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + ParseUUIDPipe, + Patch, + Post, + Query, +} from '@nestjs/common'; +import { ApiOperation, ApiTags } from '@nestjs/swagger'; +import { FleetManage, FleetView } from '../../common/booking-guards'; +import { CreateContainerDto } from './dto/create-container.dto'; +import { UpdateContainerDto } from './dto/update-container.dto'; +import { AssignContainerToWagonDto } from './dto/assign-container-to-wagon.dto'; +import { ContainersService } from './containers.service'; + +@ApiTags('containers') +@Controller('containers') +@FleetView() +export class ContainersController { + constructor(private readonly containersService: ContainersService) {} + + @Post() + @FleetManage() + @ApiOperation({ summary: 'Create a new container' }) + create(@Body() dto: CreateContainerDto) { + return this.containersService.create(dto); + } + + @Get() + @ApiOperation({ summary: 'List all containers' }) + findAll(@Query() query: Record) { + return this.containersService.findAll(query); + } + + @Get(':id') + @ApiOperation({ summary: 'Get a container by ID' }) + findOne(@Param('id', ParseUUIDPipe) id: string) { + return this.containersService.findById(id); + } + + @Patch(':id') + @FleetManage() + @ApiOperation({ summary: 'Update a container' }) + update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateContainerDto) { + return this.containersService.update(id, dto); + } + + @Delete(':id') + @FleetManage() + @ApiOperation({ summary: 'Delete a container' }) + remove(@Param('id', ParseUUIDPipe) id: string) { + return this.containersService.remove(id); + } + + @Post(':id/assign-wagon') + @FleetManage() + @ApiOperation({ summary: 'Assign container to a wagon' }) + assignToWagon(@Param('id', ParseUUIDPipe) id: string, @Body() dto: AssignContainerToWagonDto) { + return this.containersService.assignToWagon(id, dto); + } + + @Post(':id/unassign-wagon') + @FleetManage() + @ApiOperation({ summary: 'Unassign container from wagon' }) + unassignFromWagon(@Param('id', ParseUUIDPipe) id: string) { + return this.containersService.unassignFromWagon(id); + } +} diff --git a/apps/edr-freight-api/src/modules/container-management/containers.module.ts b/apps/edr-freight-api/src/modules/container-management/containers.module.ts new file mode 100644 index 000000000..b048b5af8 --- /dev/null +++ b/apps/edr-freight-api/src/modules/container-management/containers.module.ts @@ -0,0 +1,15 @@ +// apps/edr-freight-api/src/modules/container-management/containers.module.ts +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Container } from './entities/container.entity'; +import { Wagon } from '../wagons/entities/wagon.entity'; +import { ContainerType } from '../rule-engine/entities/container-type.entity'; +import { ContainersController } from './containers.controller'; +import { ContainersService } from './containers.service'; + +@Module({ + imports: [TypeOrmModule.forFeature([Container, Wagon, ContainerType])], + controllers: [ContainersController], + providers: [ContainersService], +}) +export class ContainersModule {} diff --git a/apps/edr-freight-api/src/modules/container-management/containers.repository.ts b/apps/edr-freight-api/src/modules/container-management/containers.repository.ts new file mode 100644 index 000000000..c6842cdea --- /dev/null +++ b/apps/edr-freight-api/src/modules/container-management/containers.repository.ts @@ -0,0 +1,15 @@ +import { BaseRepository } from '@edr/api-common'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Container } from './entities/container.entity'; + +@Injectable() +export class ContainersRepository extends BaseRepository { + constructor( + @InjectRepository(Container) + repository: Repository, + ) { + super(repository); + } +} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/container-management/containers.service copy.ts b/apps/edr-freight-api/src/modules/container-management/containers.service copy.ts new file mode 100644 index 000000000..f6094a497 --- /dev/null +++ b/apps/edr-freight-api/src/modules/container-management/containers.service copy.ts @@ -0,0 +1,86 @@ +import { Injectable, NotFoundException, ConflictException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { CreateContainerDto } from './dto/create-container.dto'; +import { UpdateContainerDto } from './dto/update-container.dto'; +import { AssignContainerToWagonDto } from './dto/assign-container-to-wagon.dto'; +import { Container } from './entities/container.entity'; +//import { ContainersRepository } from './containers.repository'; +import { WagonsRepository } from '../wagons/wagons.repository'; + +@Injectable() +export class ContainersService { + constructor( + @InjectRepository(Container) + private readonly containerRepo: Repository, + private readonly wagonsRepository: WagonsRepository, + ) {} + + async create(dto: CreateContainerDto): Promise { + const container = this.containerRepo.create(dto); + // Convert undefined to null for optional fields + if (dto.wagonId === undefined) container.wagonId = null; + if (dto.position === undefined) container.position = null; + return this.containerRepo.save(container); + } + + async findAll(): Promise { + return this.containerRepo.find({ order: { containerNumber: 'ASC' } }); + } + + async findById(id: string): Promise { + const container = await this.containerRepo.findOne({ where: { id } }); + if (!container) throw new NotFoundException(`Container ${id} not found`); + return container; + } + + async update(id: string, dto: UpdateContainerDto): Promise { + const container = await this.findById(id); + Object.assign(container, dto); + // Convert undefined to null for nullable fields + if (dto.wagonId === undefined) container.wagonId = null; + if (dto.position === undefined) container.position = null; + return this.containerRepo.save(container); + } + + async remove(id: string): Promise { + const container = await this.findById(id); + await this.containerRepo.remove(container); + } + + async assignToWagon(containerId: string, dto: AssignContainerToWagonDto): Promise { + const container = await this.findById(containerId); + if (container.status === 'LOADED') { + throw new ConflictException('Cannot reassign a loaded container'); + } + + const wagon = await this.wagonsRepository.findById(dto.wagonId); + if (!wagon) throw new NotFoundException('Wagon not found'); + + let position: number | null = dto.position ?? null; // convert undefined to null + if (position === null) { + const maxPos = await this.containerRepo + .createQueryBuilder('c') + .select('MAX(c.position)', 'max') + .where('c.wagonId = :wagonId', { wagonId: wagon.id }) + .getRawOne(); + position = (maxPos?.max ?? 0) + 1; + } + + container.wagonId = wagon.id; + container.position = position; // now position is number | null, safe + container.status = 'AVAILABLE'; + return this.containerRepo.save(container); + } + + async unassignFromWagon(containerId: string): Promise { + const container = await this.findById(containerId); + if (container.status === 'LOADED') { + throw new ConflictException('Cannot unassign a loaded container'); + } + container.wagonId = null; + container.position = null; + container.status = 'AVAILABLE'; + return this.containerRepo.save(container); + } +} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/container-management/containers.service.ts b/apps/edr-freight-api/src/modules/container-management/containers.service.ts new file mode 100644 index 000000000..bf7c966aa --- /dev/null +++ b/apps/edr-freight-api/src/modules/container-management/containers.service.ts @@ -0,0 +1,148 @@ +// apps/edr-freight-api/src/modules/container-management/containers.service.ts +import { Injectable, NotFoundException, ConflictException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { FindOptionsOrder, FindOptionsWhere, ILike, Repository } from 'typeorm'; +import { CreateContainerDto } from './dto/create-container.dto'; +import { UpdateContainerDto } from './dto/update-container.dto'; +import { AssignContainerToWagonDto } from './dto/assign-container-to-wagon.dto'; +import { Container } from './entities/container.entity'; +import { Wagon } from '../wagons/entities/wagon.entity'; +import { ContainerType } from '../rule-engine/entities/container-type.entity'; + +@Injectable() +export class ContainersService { + constructor( + @InjectRepository(Container) + private readonly containerRepo: Repository, + @InjectRepository(Wagon) + private readonly wagonRepo: Repository, // ✅ use raw repository + @InjectRepository(ContainerType) + private readonly containerTypeRepo: Repository, + ) {} + + async create(dto: CreateContainerDto): Promise { + const existing = await this.containerRepo.findOne({ + where: { containerNumber: dto.containerNumber }, + }); + if (existing) { + throw new ConflictException(`Container number "${dto.containerNumber}" already exists`); + } + + const containerType = await this.containerTypeRepo.findOne({ + where: { id: dto.containerTypeId, isActive: true }, + }); + if (!containerType) { + throw new NotFoundException(`Container type ${dto.containerTypeId} not found`); + } + + if (dto.wagonId) { + const wagon = await this.wagonRepo.findOne({ where: { id: dto.wagonId } }); + if (!wagon) throw new NotFoundException(`Wagon ${dto.wagonId} not found`); + } + + const container = this.containerRepo.create(dto); + if (dto.wagonId === undefined) container.wagonId = null; + if (dto.position === undefined) container.position = null; + return this.containerRepo.save(container); + } + + async findAll(query: Record = {}): Promise { + const where: FindOptionsWhere[] | FindOptionsWhere = []; + const search = query.search?.trim(); + const status = query.status?.trim(); + const wagonId = query.wagonId?.trim(); + + if (search) { + where.push({ + containerNumber: ILike(`%${search}%`), + ...(status ? { status } : {}), + ...(wagonId ? { wagonId } : {}), + }); + } + + const sortBy = ['containerNumber', 'tareWeight', 'maxGrossWeight', 'status', 'position'].includes(query.sortBy ?? '') + ? (query.sortBy as keyof Container) + : 'containerNumber'; + const sortOrder = query.sortOrder?.toUpperCase() === 'DESC' ? 'DESC' : 'ASC'; + + return this.containerRepo.find({ + where: search ? where : { ...(status ? { status } : {}), ...(wagonId ? { wagonId } : {}) }, + order: { [sortBy]: sortOrder } as FindOptionsOrder, + skip: query.page && query.limit ? (Number(query.page) - 1) * Number(query.limit) : undefined, + take: query.limit ? Number(query.limit) : undefined, + }); + } + + async findById(id: string): Promise { + const container = await this.containerRepo.findOne({ where: { id } }); + if (!container) throw new NotFoundException(`Container ${id} not found`); + return container; + } + + async update(id: string, dto: UpdateContainerDto): Promise { + const container = await this.findById(id); + if (dto.containerNumber && dto.containerNumber !== container.containerNumber) { + const existing = await this.containerRepo.findOne({ + where: { containerNumber: dto.containerNumber }, + }); + if (existing) { + throw new ConflictException(`Container number "${dto.containerNumber}" already exists`); + } + } + if (dto.containerTypeId) { + const containerType = await this.containerTypeRepo.findOne({ + where: { id: dto.containerTypeId, isActive: true }, + }); + if (!containerType) { + throw new NotFoundException(`Container type ${dto.containerTypeId} not found`); + } + } + if (dto.wagonId) { + const wagon = await this.wagonRepo.findOne({ where: { id: dto.wagonId } }); + if (!wagon) throw new NotFoundException(`Wagon ${dto.wagonId} not found`); + } + Object.assign(container, dto); + return this.containerRepo.save(container); + } + + async remove(id: string): Promise { + const container = await this.findById(id); + await this.containerRepo.remove(container); + } + + async assignToWagon(containerId: string, dto: AssignContainerToWagonDto): Promise { + const container = await this.findById(containerId); + if (container.status === 'LOADED') { + throw new ConflictException('Cannot reassign a loaded container'); + } + + const wagon = await this.wagonRepo.findOne({ where: { id: dto.wagonId } }); + if (!wagon) throw new NotFoundException(`Wagon ${dto.wagonId} not found`); + + let position: number | null = dto.position ?? null; + if (position === null) { + const maxPos = await this.containerRepo + .createQueryBuilder('c') + .select('MAX(c.position)', 'max') + .where('c.wagonId = :wagonId', { wagonId: wagon.id }) + .getRawOne(); + position = (maxPos?.max ?? 0) + 1; + } + + container.wagonId = wagon.id; + container.position = position; + container.status = 'AVAILABLE'; + return this.containerRepo.save(container); + } + + async unassignFromWagon(containerId: string): Promise { + const container = await this.findById(containerId); + if (container.status === 'LOADED') { + throw new ConflictException('Cannot unassign a loaded container'); + } + container.wagonId = null; + container.position = null; + container.status = 'AVAILABLE'; + return this.containerRepo.save(container); + } +} diff --git a/apps/edr-freight-api/src/modules/container-management/dto/assign-container-to-wagon.dto.ts b/apps/edr-freight-api/src/modules/container-management/dto/assign-container-to-wagon.dto.ts new file mode 100644 index 000000000..3b7be1d9e --- /dev/null +++ b/apps/edr-freight-api/src/modules/container-management/dto/assign-container-to-wagon.dto.ts @@ -0,0 +1,11 @@ +import { IsUUID, IsOptional, IsInt, Min } from 'class-validator'; + +export class AssignContainerToWagonDto { + @IsUUID() + wagonId!: string; + + @IsOptional() + @IsInt() + @Min(1) + position?: number; +} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/container-management/dto/create-container.dto.ts b/apps/edr-freight-api/src/modules/container-management/dto/create-container.dto.ts new file mode 100644 index 000000000..1efed1cc9 --- /dev/null +++ b/apps/edr-freight-api/src/modules/container-management/dto/create-container.dto.ts @@ -0,0 +1,34 @@ +import { IsString, IsUUID, IsOptional, IsInt, Min, IsNumber, IsIn } from 'class-validator'; + +export class CreateContainerDto { + @IsString() + containerNumber!: string; + + @IsUUID() + containerTypeId!: string; + + @IsOptional() + @IsUUID() + wagonId?: string; + + @IsOptional() + @IsInt() + @Min(1) + position?: number; + + @IsNumber() + @Min(0) + tareWeight!: number; + + @IsNumber() + @Min(0) + maxGrossWeight!: number; + + @IsOptional() + @IsString() + sealNumber?: string; + + @IsOptional() + @IsIn(['AVAILABLE', 'LOADED', 'IN_TRANSIT', 'MAINTENANCE', 'DAMAGED']) + status?: string; +} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/container-management/dto/update-container.dto.ts b/apps/edr-freight-api/src/modules/container-management/dto/update-container.dto.ts new file mode 100644 index 000000000..7391bc642 --- /dev/null +++ b/apps/edr-freight-api/src/modules/container-management/dto/update-container.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/swagger'; +import { CreateContainerDto } from './create-container.dto'; + +export class UpdateContainerDto extends PartialType(CreateContainerDto) {} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/container-management/entities/container.entity.ts b/apps/edr-freight-api/src/modules/container-management/entities/container.entity.ts new file mode 100644 index 000000000..e6fdd47ee --- /dev/null +++ b/apps/edr-freight-api/src/modules/container-management/entities/container.entity.ts @@ -0,0 +1,68 @@ +// apps/edr-freight-api/src/modules/container-management/entities/container.entity.ts +import { Entity, Column, ManyToOne, OneToMany, JoinColumn } from 'typeorm'; +import { BaseEntity } from '@edr/api-common'; +import { Booking } from '../../bookings/entities/booking.entity'; +import { BookingContainer } from '../../bookings/entities/booking-container.entity'; +import { WagonBookingAllocation } from '../../train-schedules/entities/wagon-booking-allocation.entity'; +import { Wagon } from '../../wagons/entities/wagon.entity'; +import { Cargo } from '../../cargoes/entities/cargoes.entity'; + +@Entity({ name: 'containers', schema: 'freight' }) +export class Container extends BaseEntity { + @Column({ unique: true, name: 'container_number' }) + containerNumber!: string; + + @Column({ name: 'container_type_id', type: 'uuid' }) + containerTypeId!: string; + + @Column({ name: 'wagon_id', type: 'uuid', nullable: true }) + wagonId!: string | null; + + @Column({ type: 'int', nullable: true }) + position!: number | null; // position on the wagon (1..N) + + @Column({ name: 'tare_weight', type: 'decimal', precision: 10, scale: 2 }) + tareWeight!: number; + + @Column({ name: 'max_gross_weight', type: 'decimal', precision: 10, scale: 2 }) + maxGrossWeight!: number; + + @Column({ + name: 'seal_number', + type: 'varchar', + nullable: true, +}) +sealNumber!: string | null; + + @Column({ type: 'varchar', default: 'AVAILABLE' }) + status!: string; // AVAILABLE, LOADED, IN_TRANSIT, MAINTENANCE, DAMAGED + + @Column({ name: 'booking_id', type: 'uuid', nullable: true }) + bookingId!: string | null; + + @ManyToOne(() => Booking, { nullable: true, onDelete: 'SET NULL' }) + @JoinColumn({ name: 'booking_id' }) + booking?: Booking | null; + + @Column({ name: 'wagon_booking_allocation_id', type: 'uuid', nullable: true }) + wagonBookingAllocationId!: string | null; + + @ManyToOne(() => WagonBookingAllocation, { nullable: true, onDelete: 'SET NULL' }) + @JoinColumn({ name: 'wagon_booking_allocation_id' }) + wagonBookingAllocation?: WagonBookingAllocation | null; + + @Column({ name: 'booking_container_id', type: 'uuid', nullable: true }) + bookingContainerId!: string | null; + + @ManyToOne(() => BookingContainer, { nullable: true, onDelete: 'SET NULL' }) + @JoinColumn({ name: 'booking_container_id' }) + bookingContainer?: BookingContainer | null; + + @ManyToOne(() => Wagon, (wagon) => wagon.containers, { onDelete: 'SET NULL' }) + @JoinColumn({ name: 'wagon_id' }) + wagon!: Wagon | null; + + // Relationship to Cargo + @OneToMany(() => Cargo, (cargo) => cargo.container) + cargoes!: Cargo[]; +} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/customers/customers.controller.ts b/apps/edr-freight-api/src/modules/customers/customers.controller.ts deleted file mode 100644 index 65c4f42de..000000000 --- a/apps/edr-freight-api/src/modules/customers/customers.controller.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { - Body, - Controller, - Get, - Param, - ParseUUIDPipe, - Post, -} from "@nestjs/common"; -import { ApiOperation, ApiTags } from "@nestjs/swagger"; - -import { CustomersService } from "./customers.service"; -import { CreateCustomerDto } from "./dto/create-customer.dto"; - -@ApiTags("customers") -// @UseGuards(JwtAuthGuard) — TODO: integrate @edr/auth -@Controller("customers") -export class CustomersController { - constructor(private readonly customersService: CustomersService) {} - - @Post() - @ApiOperation({ summary: "Create a new customer" }) - create(@Body() dto: CreateCustomerDto) { - return this.customersService.create(dto); - } - - @Get() - @ApiOperation({ summary: "List all customers" }) - findAll() { - return this.customersService.findAll(); - } - - @Get(":id") - @ApiOperation({ summary: "Get a customer by ID" }) - findOne(@Param("id", ParseUUIDPipe) id: string) { - return this.customersService.findById(id); - } -} diff --git a/apps/edr-freight-api/src/modules/customers/customers.module.ts b/apps/edr-freight-api/src/modules/customers/customers.module.ts deleted file mode 100644 index 28c6b7c89..000000000 --- a/apps/edr-freight-api/src/modules/customers/customers.module.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { Module } from "@nestjs/common"; -import { TypeOrmModule } from "@nestjs/typeorm"; - -import { CustomersController } from "./customers.controller"; -import { CustomersRepository } from "./customers.repository"; -import { CustomersService } from "./customers.service"; -import { Customer } from "./entities/customer.entity"; - -@Module({ - imports: [TypeOrmModule.forFeature([Customer])], - controllers: [CustomersController], - providers: [CustomersService, CustomersRepository], - exports: [CustomersService], -}) -export class CustomersModule {} diff --git a/apps/edr-freight-api/src/modules/customers/customers.repository.ts b/apps/edr-freight-api/src/modules/customers/customers.repository.ts deleted file mode 100644 index c6cb72fcf..000000000 --- a/apps/edr-freight-api/src/modules/customers/customers.repository.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { BaseRepository } from "@edr/api-common"; -import { Injectable } from "@nestjs/common"; -import { InjectRepository } from "@nestjs/typeorm"; -import { Repository } from "typeorm"; - -import { Customer } from "./entities/customer.entity"; - -@Injectable() -export class CustomersRepository extends BaseRepository { - constructor( - @InjectRepository(Customer) - repository: Repository, - ) { - super(repository); - } - - /** Find a customer by their unique email. */ - findByEmail(email: string): Promise { - return this.repository.findOne({ where: { email } }); - } -} diff --git a/apps/edr-freight-api/src/modules/customers/customers.service.ts b/apps/edr-freight-api/src/modules/customers/customers.service.ts deleted file mode 100644 index d3be75800..000000000 --- a/apps/edr-freight-api/src/modules/customers/customers.service.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { Injectable, NotFoundException } from "@nestjs/common"; - -import { CustomersRepository } from "./customers.repository"; -import { CreateCustomerDto } from "./dto/create-customer.dto"; -import { Customer } from "./entities/customer.entity"; - -@Injectable() -export class CustomersService { - constructor(private readonly customersRepository: CustomersRepository) {} - - /** Create a new freight customer. */ - create(dto: CreateCustomerDto): Promise { - return this.customersRepository.create(dto); - } - - /** List every customer (alphabetical). */ - findAll(): Promise { - return this.customersRepository.findAll({ order: { name: "ASC" } }); - } - - /** Get a single customer by ID. */ - async findById(id: string): Promise { - const customer = await this.customersRepository.findById(id); - if (!customer) { - throw new NotFoundException(`Customer ${id} not found`); - } - return customer; - } -} diff --git a/apps/edr-freight-api/src/modules/customers/dto/create-customer.dto.ts b/apps/edr-freight-api/src/modules/customers/dto/create-customer.dto.ts deleted file mode 100644 index bcaa13e78..000000000 --- a/apps/edr-freight-api/src/modules/customers/dto/create-customer.dto.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { IsEmail, IsOptional, IsString } from "class-validator"; - -export class CreateCustomerDto { - @IsString() - name!: string; - - @IsEmail() - email!: string; - - @IsString() - phone!: string; - - @IsOptional() - @IsString() - address?: string; - - @IsOptional() - @IsString() - taxId?: string; -} diff --git a/apps/edr-freight-api/src/modules/customers/entities/customer.entity.ts b/apps/edr-freight-api/src/modules/customers/entities/customer.entity.ts deleted file mode 100644 index 28f79f4c8..000000000 --- a/apps/edr-freight-api/src/modules/customers/entities/customer.entity.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { BaseEntity } from "@edr/api-common"; -import { Column, Entity } from "typeorm"; - -@Entity({ name: "customers" }) -export class Customer extends BaseEntity { - @Column({ name: "name", type: "varchar", length: 256 }) - name!: string; - - @Column({ name: "email", type: "varchar", length: 256, unique: true }) - email!: string; - - @Column({ name: "phone", type: "varchar", length: 32 }) - phone!: string; - - @Column({ name: "address", type: "text", nullable: true }) - address?: string | null; - - @Column({ name: "tax_id", type: "varchar", length: 64, nullable: true }) - taxId?: string | null; -} diff --git a/apps/edr-freight-api/src/modules/demo-permissions/demo-permissions.controller.ts b/apps/edr-freight-api/src/modules/demo-permissions/demo-permissions.controller.ts new file mode 100644 index 000000000..6f425e1bb --- /dev/null +++ b/apps/edr-freight-api/src/modules/demo-permissions/demo-permissions.controller.ts @@ -0,0 +1,22 @@ +import { Controller, Get, UseGuards } from "@nestjs/common"; +import { ApiOperation, ApiTags } from "@nestjs/swagger"; + +import { PermissionGuard } from "@tria-plc/api-common/modules/auth/services/permission.guard"; + +@ApiTags("demo-permissions") +@Controller() +export class DemoPermissionsController { + @Get("test_user1") + @ApiOperation({ summary: "Permission demo (can:demo:user1)" }) + @UseGuards(PermissionGuard(["can:demo:user1"])) + testUser1() { + return { ok: true, permission: "can:demo:user1" }; + } + + @Get("test_user2") + @ApiOperation({ summary: "Permission demo (can:demo:user2)" }) + @UseGuards(PermissionGuard(["can:demo:user2"])) + testUser2() { + return { ok: true, permission: "can:demo:user2" }; + } +} diff --git a/apps/edr-freight-api/src/modules/demo-permissions/demo-permissions.module.ts b/apps/edr-freight-api/src/modules/demo-permissions/demo-permissions.module.ts new file mode 100644 index 000000000..db73ed728 --- /dev/null +++ b/apps/edr-freight-api/src/modules/demo-permissions/demo-permissions.module.ts @@ -0,0 +1,8 @@ +import { Module } from "@nestjs/common"; + +import { DemoPermissionsController } from "./demo-permissions.controller"; + +@Module({ + controllers: [DemoPermissionsController], +}) +export class DemoPermissionsModule {} diff --git a/apps/edr-freight-api/src/modules/dropdown-settings/dropdown-settings.controller.ts b/apps/edr-freight-api/src/modules/dropdown-settings/dropdown-settings.controller.ts new file mode 100644 index 000000000..7a63964d8 --- /dev/null +++ b/apps/edr-freight-api/src/modules/dropdown-settings/dropdown-settings.controller.ts @@ -0,0 +1,113 @@ +import { + Body, + Controller, + Delete, + Get, + HttpCode, + HttpStatus, + Param, + ParseUUIDPipe, + Patch, + Post, + Put, +} from "@nestjs/common"; +import { ApiOperation, ApiTags } from "@nestjs/swagger"; + +import { FreightAdmin } from "../../common/booking-guards"; +import { CreateDropdownOptionDto } from "./dto/create-dropdown-option.dto"; +import { CreateDropdownSettingDto } from "./dto/create-dropdown-setting.dto"; +import { UpdateDropdownOptionDto } from "./dto/update-dropdown-option.dto"; +import { UpdateDropdownSettingDto } from "./dto/update-dropdown-setting.dto"; +import { DropdownSettingsService } from "./dropdown-settings.service"; + +@ApiTags("dropdown-settings") +@Controller("dropdown-settings") +export class DropdownSettingsController { + constructor(private readonly service: DropdownSettingsService) {} + + // Reads stay open: the customer portal fetches these to render dynamic + // dropdowns (by-code). Only writes are admin-guarded. + + @Get() + @ApiOperation({ summary: "List all dropdown settings" }) + list() { + return this.service.list(); + } + + @Get(":id") + @ApiOperation({ summary: "Get a dropdown setting by ID" }) + getById(@Param("id", ParseUUIDPipe) id: string) { + return this.service.getById(id); + } + + @Get("by-code/:code") + @ApiOperation({ summary: "Get a dropdown setting by its stable code" }) + getByCode(@Param("code") code: string) { + return this.service.getByCode(code); + } + + @Post() + @FreightAdmin() + @ApiOperation({ summary: "Create a new dropdown setting" }) + create(@Body() dto: CreateDropdownSettingDto) { + return this.service.create(dto); + } + + @Patch(":id") + @FreightAdmin() + @ApiOperation({ summary: "Update a dropdown setting's metadata" }) + update( + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: UpdateDropdownSettingDto, + ) { + return this.service.update(id, dto); + } + + @Delete(":id") + @FreightAdmin() + @ApiOperation({ summary: "Soft-delete a dropdown setting" }) + @HttpCode(HttpStatus.NO_CONTENT) + remove(@Param("id", ParseUUIDPipe) id: string) { + return this.service.remove(id); + } + + /* ------------------------- option routes ------------------------- */ + + @Put(":id/options") + @FreightAdmin() + @ApiOperation({ summary: "Replace the full option list for a setting" }) + replaceOptions( + @Param("id", ParseUUIDPipe) id: string, + @Body() options: CreateDropdownOptionDto[], + ) { + return this.service.replaceOptions(id, options); + } + + @Post(":id/options") + @FreightAdmin() + @ApiOperation({ summary: "Append a single option to a setting" }) + addOption( + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: CreateDropdownOptionDto, + ) { + return this.service.addOption(id, dto); + } + + @Patch("options/:optionId") + @FreightAdmin() + @ApiOperation({ summary: "Update a single option" }) + updateOption( + @Param("optionId", ParseUUIDPipe) optionId: string, + @Body() dto: UpdateDropdownOptionDto, + ) { + return this.service.updateOption(optionId, dto); + } + + @Delete("options/:optionId") + @FreightAdmin() + @ApiOperation({ summary: "Soft-delete a single option" }) + @HttpCode(HttpStatus.NO_CONTENT) + removeOption(@Param("optionId", ParseUUIDPipe) optionId: string) { + return this.service.removeOption(optionId); + } +} diff --git a/apps/edr-freight-api/src/modules/dropdown-settings/dropdown-settings.module.ts b/apps/edr-freight-api/src/modules/dropdown-settings/dropdown-settings.module.ts new file mode 100644 index 000000000..59fc3ebbb --- /dev/null +++ b/apps/edr-freight-api/src/modules/dropdown-settings/dropdown-settings.module.ts @@ -0,0 +1,24 @@ +import { Module } from "@nestjs/common"; +import { TypeOrmModule } from "@nestjs/typeorm"; + +import { DropdownOption } from "./entities/dropdown-option.entity"; +import { DropdownSetting } from "./entities/dropdown-setting.entity"; +import { DropdownSettingsController } from "./dropdown-settings.controller"; +import { DropdownSettingsRepository } from "./dropdown-settings.repository"; +import { DropdownSettingsService } from "./dropdown-settings.service"; +import { DROPDOWN_SETTINGS_REPOSITORY } from "./interfaces/dropdown-settings.repository.interface"; + +@Module({ + imports: [TypeOrmModule.forFeature([DropdownSetting, DropdownOption])], + controllers: [DropdownSettingsController], + providers: [ + DropdownSettingsRepository, + { + provide: DROPDOWN_SETTINGS_REPOSITORY, + useExisting: DropdownSettingsRepository, + }, + DropdownSettingsService, + ], + exports: [DropdownSettingsService], +}) +export class DropdownSettingsModule {} diff --git a/apps/edr-freight-api/src/modules/dropdown-settings/dropdown-settings.repository.ts b/apps/edr-freight-api/src/modules/dropdown-settings/dropdown-settings.repository.ts new file mode 100644 index 000000000..a4f851e53 --- /dev/null +++ b/apps/edr-freight-api/src/modules/dropdown-settings/dropdown-settings.repository.ts @@ -0,0 +1,82 @@ +import { BaseRepository } from "@edr/api-common"; +import { Injectable } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { Repository } from "typeorm"; + +import { DropdownOption } from "./entities/dropdown-option.entity"; +import { DropdownSetting } from "./entities/dropdown-setting.entity"; +import type { IDropdownSettingsRepository } from "./interfaces/dropdown-settings.repository.interface"; + +@Injectable() +export class DropdownSettingsRepository + extends BaseRepository + implements IDropdownSettingsRepository +{ + constructor( + @InjectRepository(DropdownSetting) + repository: Repository, + @InjectRepository(DropdownOption) + private readonly optionsRepository: Repository, + ) { + super(repository); + } + + findByCode(code: string): Promise { + return this.repository.findOne({ + where: { code }, + relations: { children: true }, + order: { children: { order: "ASC" } }, + }); + } + + override findById(id: string): Promise { + return this.repository.findOne({ + where: { id }, + relations: { children: true }, + order: { children: { order: "ASC" } }, + }); + } + + override findAll(): Promise { + return this.repository.find({ + order: { label: "ASC", children: { order: "ASC" } }, + relations: { children: true }, + }); + } + + async replaceOptions( + settingId: string, + options: Array>, + ): Promise { + await this.optionsRepository.delete({ settingId }); + if (options.length === 0) return []; + const entities = options.map((o, idx) => + this.optionsRepository.create({ + ...o, + settingId, + order: o.order ?? idx + 1, + }), + ); + return this.optionsRepository.save(entities); + } + + async addOption( + settingId: string, + option: Partial, + ): Promise { + const entity = this.optionsRepository.create({ ...option, settingId }); + return this.optionsRepository.save(entity); + } + + async updateOption( + optionId: string, + data: Partial, + ): Promise { + await this.optionsRepository.update(optionId, data as never); + return this.optionsRepository.findOne({ where: { id: optionId } }); + } + + async removeOption(optionId: string): Promise { + await this.optionsRepository.softDelete(optionId); + } +} diff --git a/apps/edr-freight-api/src/modules/dropdown-settings/dropdown-settings.service.ts b/apps/edr-freight-api/src/modules/dropdown-settings/dropdown-settings.service.ts new file mode 100644 index 000000000..530cfded4 --- /dev/null +++ b/apps/edr-freight-api/src/modules/dropdown-settings/dropdown-settings.service.ts @@ -0,0 +1,203 @@ +import { + ConflictException, + Inject, + Injectable, + NotFoundException, +} from "@nestjs/common"; + +import { CreateDropdownOptionDto } from "./dto/create-dropdown-option.dto"; +import { CreateDropdownSettingDto } from "./dto/create-dropdown-setting.dto"; +import { UpdateDropdownOptionDto } from "./dto/update-dropdown-option.dto"; +import { UpdateDropdownSettingDto } from "./dto/update-dropdown-setting.dto"; +import { DropdownOption } from "./entities/dropdown-option.entity"; +import { DropdownSetting } from "./entities/dropdown-setting.entity"; +import { + DROPDOWN_SETTINGS_REPOSITORY, + IDropdownSettingsRepository, +} from "./interfaces/dropdown-settings.repository.interface"; + +const STATIONS_TER_CODE = "stations_ter"; + +const DEFAULT_STATION_OPTIONS: CreateDropdownOptionDto[] = [ + { + value: "inside_addis_ababa", + label: "Addis Ababa", + note: "Inside country", + order: 1, + }, + { + value: "inside_adama", + label: "Adama", + note: "Inside country", + order: 2, + }, + { + value: "inside_mojo", + label: "Mojo", + note: "Inside country", + order: 3, + }, + { + value: "inside_awash", + label: "Awash", + note: "Inside country", + order: 4, + }, + { + value: "inside_mieso", + label: "Mieso", + note: "Inside country", + order: 5, + }, + { + value: "inside_dire_dawa", + label: "Dire Dawa", + note: "Inside country", + order: 6, + }, + { + value: "outside_ali_sabieh", + label: "Ali Sabieh", + note: "Outside country", + order: 7, + }, + { + value: "outside_holhol", + label: "Holhol", + note: "Outside country", + order: 8, + }, + { + value: "outside_djibouti_city", + label: "Djibouti City", + note: "Outside country", + order: 9, + }, + { + value: "outside_doraleh_terminal", + label: "Doraleh Terminal", + note: "Outside country", + order: 10, + }, +]; + +@Injectable() +export class DropdownSettingsService { + constructor( + @Inject(DROPDOWN_SETTINGS_REPOSITORY) + private readonly repository: IDropdownSettingsRepository, + ) {} + + list(): Promise { + return this.repository.findAll(); + } + + async getById(id: string): Promise { + const setting = await this.repository.findById(id); + if (!setting) throw new NotFoundException(`Setting ${id} not found`); + return setting; + } + + async getByCode(code: string): Promise { + const setting = await this.repository.findByCode(code); + if (!setting) throw new NotFoundException(`Setting "${code}" not found`); + return setting; + } + + async create(dto: CreateDropdownSettingDto): Promise { + const existing = await this.repository.findByCode(dto.code); + if (existing) { + throw new ConflictException( + `Dropdown setting with code "${dto.code}" already exists`, + ); + } + + const setting = await this.repository.create({ + code: dto.code, + label: dto.label, + description: dto.description ?? null, + multiple: dto.multiple ?? false, + meta: dto.meta ?? null, + }); + + if (dto.children && dto.children.length > 0) { + await this.repository.replaceOptions(setting.id, dto.children); + } + + return this.getById(setting.id); + } + + async seedDefaultStations(): Promise { + const existing = await this.repository.findByCode(STATIONS_TER_CODE); + + if (!existing) { + await this.create({ + code: STATIONS_TER_CODE, + label: "Stations TER", + description: + "Temporary freight station list used by booking origin and destination yards.", + multiple: false, + meta: { + searchable: true, + clearable: true, + version: "temporary", + }, + children: DEFAULT_STATION_OPTIONS, + }); + return; + } + + if ((existing.children?.length ?? 0) === 0) { + await this.repository.replaceOptions( + existing.id, + DEFAULT_STATION_OPTIONS, + ); + } + } + + async update( + id: string, + dto: UpdateDropdownSettingDto, + ): Promise { + await this.getById(id); + const updated = await this.repository.update(id, dto); + if (!updated) throw new NotFoundException(`Setting ${id} not found`); + return this.getById(id); + } + + async remove(id: string): Promise { + await this.getById(id); + await this.repository.softDelete(id); + } + + /* ------------------------ option operations ------------------------ */ + + async replaceOptions( + settingId: string, + options: CreateDropdownOptionDto[], + ): Promise { + await this.getById(settingId); + return this.repository.replaceOptions(settingId, options); + } + + async addOption( + settingId: string, + dto: CreateDropdownOptionDto, + ): Promise { + await this.getById(settingId); + return this.repository.addOption(settingId, dto); + } + + async updateOption( + optionId: string, + dto: UpdateDropdownOptionDto, + ): Promise { + const updated = await this.repository.updateOption(optionId, dto); + if (!updated) throw new NotFoundException(`Option ${optionId} not found`); + return updated; + } + + async removeOption(optionId: string): Promise { + await this.repository.removeOption(optionId); + } +} diff --git a/apps/edr-freight-api/src/modules/dropdown-settings/dto/create-dropdown-option.dto.ts b/apps/edr-freight-api/src/modules/dropdown-settings/dto/create-dropdown-option.dto.ts new file mode 100644 index 000000000..675c0794f --- /dev/null +++ b/apps/edr-freight-api/src/modules/dropdown-settings/dto/create-dropdown-option.dto.ts @@ -0,0 +1,40 @@ +import { Type } from "class-transformer"; +import { + IsBoolean, + IsInt, + IsOptional, + IsString, + MaxLength, + Min, + ValidateNested, +} from "class-validator"; + +import { DropdownOptionMetaDto } from "./dropdown-option-meta.dto"; + +export class CreateDropdownOptionDto { + @IsString() + @MaxLength(256) + value!: string; + + @IsString() + @MaxLength(256) + label!: string; + + @IsOptional() + @IsString() + note?: string; + + @IsOptional() + @IsBoolean() + disabled?: boolean; + + @IsOptional() + @IsInt() + @Min(0) + order?: number; + + @IsOptional() + @ValidateNested() + @Type(() => DropdownOptionMetaDto) + meta?: DropdownOptionMetaDto; +} diff --git a/apps/edr-freight-api/src/modules/dropdown-settings/dto/create-dropdown-setting.dto.ts b/apps/edr-freight-api/src/modules/dropdown-settings/dto/create-dropdown-setting.dto.ts new file mode 100644 index 000000000..a4cedb1df --- /dev/null +++ b/apps/edr-freight-api/src/modules/dropdown-settings/dto/create-dropdown-setting.dto.ts @@ -0,0 +1,45 @@ +import { Type } from "class-transformer"; +import { + IsArray, + IsBoolean, + IsOptional, + IsString, + Matches, + MaxLength, + ValidateNested, +} from "class-validator"; + +import { CreateDropdownOptionDto } from "./create-dropdown-option.dto"; +import { DropdownSettingMetaDto } from "./dropdown-setting-meta.dto"; + +export class CreateDropdownSettingDto { + @IsString() + @MaxLength(128) + @Matches(/^[a-z][a-z0-9_]*$/i, { + message: "code must be snake_case-friendly (letters, digits, underscores)", + }) + code!: string; + + @IsString() + @MaxLength(256) + label!: string; + + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @IsBoolean() + multiple?: boolean; + + @IsOptional() + @ValidateNested() + @Type(() => DropdownSettingMetaDto) + meta?: DropdownSettingMetaDto; + + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => CreateDropdownOptionDto) + children?: CreateDropdownOptionDto[]; +} diff --git a/apps/edr-freight-api/src/modules/dropdown-settings/dto/dropdown-option-meta.dto.ts b/apps/edr-freight-api/src/modules/dropdown-settings/dto/dropdown-option-meta.dto.ts new file mode 100644 index 000000000..9d7381fb0 --- /dev/null +++ b/apps/edr-freight-api/src/modules/dropdown-settings/dto/dropdown-option-meta.dto.ts @@ -0,0 +1,18 @@ +import { IsOptional, IsString, MaxLength } from "class-validator"; + +export class DropdownOptionMetaDto { + @IsOptional() + @IsString() + @MaxLength(64) + icon?: string; + + @IsOptional() + @IsString() + @MaxLength(32) + color?: string; + + @IsOptional() + @IsString() + @MaxLength(64) + badge?: string; +} diff --git a/apps/edr-freight-api/src/modules/dropdown-settings/dto/dropdown-setting-meta.dto.ts b/apps/edr-freight-api/src/modules/dropdown-settings/dto/dropdown-setting-meta.dto.ts new file mode 100644 index 000000000..7eb16aa33 --- /dev/null +++ b/apps/edr-freight-api/src/modules/dropdown-settings/dto/dropdown-setting-meta.dto.ts @@ -0,0 +1,43 @@ +import { Transform } from "class-transformer"; +import { + IsArray, + IsBoolean, + IsOptional, + IsString, + MaxLength, +} from "class-validator"; + +export class DropdownSettingMetaDto { + @IsOptional() + @IsString() + @MaxLength(64) + icon?: string; + + @IsOptional() + @IsString() + @MaxLength(32) + color?: string; + + @IsOptional() + @IsBoolean() + searchable?: boolean; + + @IsOptional() + @IsBoolean() + clearable?: boolean; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + @Transform(({ value }) => + Array.isArray(value) + ? (value as string[]).map((s) => s.trim()).filter(Boolean) + : value, + ) + permissions?: string[]; + + @IsOptional() + @IsString() + @MaxLength(32) + version?: string; +} diff --git a/apps/edr-freight-api/src/modules/dropdown-settings/dto/update-dropdown-option.dto.ts b/apps/edr-freight-api/src/modules/dropdown-settings/dto/update-dropdown-option.dto.ts new file mode 100644 index 000000000..bbe0a3ecc --- /dev/null +++ b/apps/edr-freight-api/src/modules/dropdown-settings/dto/update-dropdown-option.dto.ts @@ -0,0 +1,7 @@ +import { PartialType } from "@nestjs/mapped-types"; + +import { CreateDropdownOptionDto } from "./create-dropdown-option.dto"; + +export class UpdateDropdownOptionDto extends PartialType( + CreateDropdownOptionDto, +) {} diff --git a/apps/edr-freight-api/src/modules/dropdown-settings/dto/update-dropdown-setting.dto.ts b/apps/edr-freight-api/src/modules/dropdown-settings/dto/update-dropdown-setting.dto.ts new file mode 100644 index 000000000..35908c419 --- /dev/null +++ b/apps/edr-freight-api/src/modules/dropdown-settings/dto/update-dropdown-setting.dto.ts @@ -0,0 +1,7 @@ +import { OmitType, PartialType } from "@nestjs/mapped-types"; + +import { CreateDropdownSettingDto } from "./create-dropdown-setting.dto"; + +export class UpdateDropdownSettingDto extends PartialType( + OmitType(CreateDropdownSettingDto, ["children"] as const), +) {} diff --git a/apps/edr-freight-api/src/modules/dropdown-settings/entities/dropdown-option.entity.ts b/apps/edr-freight-api/src/modules/dropdown-settings/entities/dropdown-option.entity.ts new file mode 100644 index 000000000..619031672 --- /dev/null +++ b/apps/edr-freight-api/src/modules/dropdown-settings/entities/dropdown-option.entity.ts @@ -0,0 +1,47 @@ +import { BaseEntity } from "@edr/api-common"; +import { + Column, + Entity, + Index, + JoinColumn, + ManyToOne, +} from "typeorm"; + +import { DropdownSetting } from "./dropdown-setting.entity"; + +export interface DropdownOptionMeta { + icon?: string; + color?: string; + badge?: string; +} + +@Entity({schema:"freight", name: "dropdown_options" }) +@Index(["settingId", "value"], { unique: true }) +export class DropdownOption extends BaseEntity { + @ManyToOne(() => DropdownSetting, (setting) => setting.children, { + onDelete: "CASCADE", + }) + @JoinColumn({ name: "setting_id" }) + setting!: DropdownSetting; + + @Column({ name: "setting_id", type: "uuid" }) + settingId!: string; + + @Column({ name: "value", type: "varchar", length: 256 }) + value!: string; + + @Column({ name: "label", type: "varchar", length: 256 }) + label!: string; + + @Column({ name: "note", type: "text", nullable: true }) + note?: string | null; + + @Column({ name: "is_disabled", type: "boolean", default: false }) + disabled!: boolean; + + @Column({ name: "display_order", type: "integer", default: 0 }) + order!: number; + + @Column({ name: "meta", type: "jsonb", nullable: true }) + meta?: DropdownOptionMeta | null; +} diff --git a/apps/edr-freight-api/src/modules/dropdown-settings/entities/dropdown-setting.entity.ts b/apps/edr-freight-api/src/modules/dropdown-settings/entities/dropdown-setting.entity.ts new file mode 100644 index 000000000..5c87c8125 --- /dev/null +++ b/apps/edr-freight-api/src/modules/dropdown-settings/entities/dropdown-setting.entity.ts @@ -0,0 +1,37 @@ +import { BaseEntity } from "@edr/api-common"; +import { Column, Entity, Index, OneToMany } from "typeorm"; + +import { DropdownOption } from "./dropdown-option.entity"; + +export interface DropdownSettingMeta { + icon?: string; + color?: string; + searchable?: boolean; + clearable?: boolean; + permissions?: string[]; + version?: string; +} + +@Entity({schema:"freight", name: "dropdown_settings" }) +@Index(["code"], { unique: true }) +export class DropdownSetting extends BaseEntity { + @Column({ name: "code", type: "varchar", length: 128, unique: true }) + code!: string; + + @Column({ name: "label", type: "varchar", length: 256 }) + label!: string; + + @Column({ name: "description", type: "text", nullable: true }) + description?: string | null; + + @Column({ name: "multiple", type: "boolean", default: false }) + multiple!: boolean; + + @Column({ name: "meta", type: "jsonb", nullable: true }) + meta?: DropdownSettingMeta | null; + + @OneToMany(() => DropdownOption, (option) => option.setting, { + cascade: true, + }) + children!: DropdownOption[]; +} diff --git a/apps/edr-freight-api/src/modules/dropdown-settings/interfaces/dropdown-settings.repository.interface.ts b/apps/edr-freight-api/src/modules/dropdown-settings/interfaces/dropdown-settings.repository.interface.ts new file mode 100644 index 000000000..7b004a066 --- /dev/null +++ b/apps/edr-freight-api/src/modules/dropdown-settings/interfaces/dropdown-settings.repository.interface.ts @@ -0,0 +1,38 @@ +import { DropdownOption } from "../entities/dropdown-option.entity"; +import { DropdownSetting } from "../entities/dropdown-setting.entity"; + +/** + * Contract every DropdownSettings repository must satisfy. Lets services + * depend on the abstraction and lets tests swap in an in-memory fake. + */ +export const DROPDOWN_SETTINGS_REPOSITORY = Symbol( + "DROPDOWN_SETTINGS_REPOSITORY", +); + +export interface IDropdownSettingsRepository { + findAll(): Promise; + findById(id: string): Promise; + findByCode(code: string): Promise; + + create(data: Partial): Promise; + update( + id: string, + data: Partial, + ): Promise; + softDelete(id: string): Promise; + + /* Option-level helpers */ + replaceOptions( + settingId: string, + options: Array>, + ): Promise; + addOption( + settingId: string, + option: Partial, + ): Promise; + updateOption( + optionId: string, + data: Partial, + ): Promise; + removeOption(optionId: string): Promise; +} diff --git a/apps/edr-freight-api/src/modules/facilities/dto/create-facility.dto.ts b/apps/edr-freight-api/src/modules/facilities/dto/create-facility.dto.ts new file mode 100644 index 000000000..6a8776312 --- /dev/null +++ b/apps/edr-freight-api/src/modules/facilities/dto/create-facility.dto.ts @@ -0,0 +1,18 @@ +import type { FacilityStatus, FacilityType } from '../entities/facility.entity'; + +export class CreateFacilityDto { + code!: string; + name!: string; + description?: string; + facilityType!: FacilityType; + facilityStatus?: FacilityStatus; + locationName?: string; + country?: string; + city?: string; + address?: string; + latitude?: number; + longitude?: number; + capacity?: number; + isActive?: boolean; + notes?: string; +} diff --git a/apps/edr-freight-api/src/modules/facilities/dto/update-facility.dto.ts b/apps/edr-freight-api/src/modules/facilities/dto/update-facility.dto.ts new file mode 100644 index 000000000..060aad95e --- /dev/null +++ b/apps/edr-freight-api/src/modules/facilities/dto/update-facility.dto.ts @@ -0,0 +1,18 @@ +import type { FacilityStatus, FacilityType } from '../entities/facility.entity'; + +export class UpdateFacilityDto { + code?: string; + name?: string; + description?: string; + facilityType?: FacilityType; + facilityStatus?: FacilityStatus; + locationName?: string; + country?: string; + city?: string; + address?: string; + latitude?: number; + longitude?: number; + capacity?: number; + isActive?: boolean; + notes?: string; +} diff --git a/apps/edr-freight-api/src/modules/facilities/entities/facility.entity.ts b/apps/edr-freight-api/src/modules/facilities/entities/facility.entity.ts new file mode 100644 index 000000000..a3d28c200 --- /dev/null +++ b/apps/edr-freight-api/src/modules/facilities/entities/facility.entity.ts @@ -0,0 +1,60 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, OneToMany } from 'typeorm'; + +import { Warehouse } from '../../warehouses/entities/warehouse.entity'; + +export const FACILITY_TYPES = ['PORT', 'DRY_PORT', 'TERMINAL', 'RAIL_YARD', 'WAREHOUSE_COMPLEX'] as const; +export type FacilityType = (typeof FACILITY_TYPES)[number]; + +export const FACILITY_STATUSES = ['ACTIVE', 'INACTIVE', 'UNDER_MAINTENANCE'] as const; +export type FacilityStatus = (typeof FACILITY_STATUSES)[number]; + +@Entity({ schema: 'freight', name: 'facilities' }) +@Index(['code'], { unique: true }) +@Index(['facilityStatus']) +export class Facility extends BaseEntity { + @Column({ name: 'code', type: 'varchar', length: 40, unique: true }) + code!: string; + + @Column({ name: 'name', type: 'varchar', length: 160 }) + name!: string; + + @Column({ name: 'description', type: 'text', nullable: true }) + description?: string | null; + + @Column({ name: 'facility_type', type: 'varchar', length: 32 }) + facilityType!: FacilityType; + + @Column({ name: 'facility_status', type: 'varchar', length: 32, default: 'ACTIVE' }) + facilityStatus!: FacilityStatus; + + @Column({ name: 'location_name', type: 'varchar', length: 200, nullable: true }) + locationName?: string | null; + + @Column({ name: 'country', type: 'varchar', length: 100, nullable: true }) + country?: string | null; + + @Column({ name: 'city', type: 'varchar', length: 100, nullable: true }) + city?: string | null; + + @Column({ name: 'address', type: 'text', nullable: true }) + address?: string | null; + + @Column({ name: 'latitude', type: 'numeric', precision: 10, scale: 8, nullable: true }) + latitude?: number | null; + + @Column({ name: 'longitude', type: 'numeric', precision: 11, scale: 8, nullable: true }) + longitude?: number | null; + + @Column({ name: 'capacity', type: 'numeric', precision: 14, scale: 3, nullable: true }) + capacity?: number | null; + + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive!: boolean; + + @Column({ name: 'notes', type: 'text', nullable: true }) + notes?: string | null; + + @OneToMany(() => Warehouse, (warehouse) => warehouse.facility) + warehouses?: Warehouse[]; +} diff --git a/apps/edr-freight-api/src/modules/facilities/facilities.controller.ts b/apps/edr-freight-api/src/modules/facilities/facilities.controller.ts new file mode 100644 index 000000000..25fbbc365 --- /dev/null +++ b/apps/edr-freight-api/src/modules/facilities/facilities.controller.ts @@ -0,0 +1,44 @@ +import { Body, Controller, Delete, Get, HttpCode, Param, Patch, Post } from '@nestjs/common'; +import { ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { CreateFacilityDto } from './dto/create-facility.dto'; +import { UpdateFacilityDto } from './dto/update-facility.dto'; +import { Facility } from './entities/facility.entity'; +import { FacilitiesService } from './facilities.service'; + +@ApiTags('Facilities') +@Controller('facilities') +export class FacilitiesController { + constructor(private readonly facilitiesService: FacilitiesService) {} + + @Post() + @ApiOperation({ summary: 'Create a new facility' }) + async create(@Body() createFacilityDto: CreateFacilityDto): Promise { + return this.facilitiesService.create(createFacilityDto); + } + + @Get() + @ApiOperation({ summary: 'List all facilities' }) + async findAll(): Promise { + return this.facilitiesService.findAll(); + } + + @Get(':id') + @ApiOperation({ summary: 'Get a facility by ID' }) + async findOne(@Param('id') id: string): Promise { + return this.facilitiesService.findOne(id); + } + + @Patch(':id') + @ApiOperation({ summary: 'Update a facility' }) + async update(@Param('id') id: string, @Body() updateFacilityDto: UpdateFacilityDto): Promise { + return this.facilitiesService.update(id, updateFacilityDto); + } + + @Delete(':id') + @HttpCode(204) + @ApiOperation({ summary: 'Delete a facility (soft delete)' }) + async remove(@Param('id') id: string): Promise { + return this.facilitiesService.remove(id); + } +} diff --git a/apps/edr-freight-api/src/modules/facilities/facilities.module.ts b/apps/edr-freight-api/src/modules/facilities/facilities.module.ts new file mode 100644 index 000000000..cf9cd2b9f --- /dev/null +++ b/apps/edr-freight-api/src/modules/facilities/facilities.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { Facility } from './entities/facility.entity'; +import { FacilitiesController } from './facilities.controller'; +import { FacilitiesRepository } from './facilities.repository'; +import { FacilitiesService } from './facilities.service'; + +@Module({ + imports: [TypeOrmModule.forFeature([Facility])], + controllers: [FacilitiesController], + providers: [FacilitiesService, FacilitiesRepository], + exports: [FacilitiesService, FacilitiesRepository], +}) +export class FacilitiesModule {} diff --git a/apps/edr-freight-api/src/modules/facilities/facilities.repository.ts b/apps/edr-freight-api/src/modules/facilities/facilities.repository.ts new file mode 100644 index 000000000..32b2df232 --- /dev/null +++ b/apps/edr-freight-api/src/modules/facilities/facilities.repository.ts @@ -0,0 +1,13 @@ +import { BaseRepository } from '@edr/api-common'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { Facility } from './entities/facility.entity'; + +@Injectable() +export class FacilitiesRepository extends BaseRepository { + constructor(@InjectRepository(Facility) repository: Repository) { + super(repository); + } +} diff --git a/apps/edr-freight-api/src/modules/facilities/facilities.service.ts b/apps/edr-freight-api/src/modules/facilities/facilities.service.ts new file mode 100644 index 000000000..5cc0d62d7 --- /dev/null +++ b/apps/edr-freight-api/src/modules/facilities/facilities.service.ts @@ -0,0 +1,31 @@ +import { Injectable } from '@nestjs/common'; + +import { CreateFacilityDto } from './dto/create-facility.dto'; +import { UpdateFacilityDto } from './dto/update-facility.dto'; +import { Facility } from './entities/facility.entity'; +import { FacilitiesRepository } from './facilities.repository'; + +@Injectable() +export class FacilitiesService { + constructor(private readonly facilitiesRepository: FacilitiesRepository) {} + + async create(createFacilityDto: CreateFacilityDto): Promise { + return this.facilitiesRepository.create(createFacilityDto); + } + + async findAll(): Promise { + return this.facilitiesRepository.findAll({ relations: ['warehouses'] }); + } + + async findOne(id: string): Promise { + return this.facilitiesRepository.findById(id); + } + + async update(id: string, updateFacilityDto: UpdateFacilityDto): Promise { + return this.facilitiesRepository.update(id, updateFacilityDto); + } + + async remove(id: string): Promise { + return this.facilitiesRepository.softDelete(id); + } +} diff --git a/apps/edr-freight-api/src/modules/file-upload-settings/dto/create-file-upload-field.dto.ts b/apps/edr-freight-api/src/modules/file-upload-settings/dto/create-file-upload-field.dto.ts new file mode 100644 index 000000000..6d670dae3 --- /dev/null +++ b/apps/edr-freight-api/src/modules/file-upload-settings/dto/create-file-upload-field.dto.ts @@ -0,0 +1,51 @@ +import { + ArrayNotEmpty, + IsArray, + IsBoolean, + IsInt, + IsOptional, + IsString, + Max, + MaxLength, + Min, +} from "class-validator"; + +export class CreateFileUploadFieldDto { + @IsString() + @MaxLength(128) + fileKey!: string; + + @IsString() + @MaxLength(256) + fileLabel!: string; + + @IsOptional() + @IsString() + helpText?: string; + + @IsBoolean() + isRequired!: boolean; + + @IsBoolean() + isMultiple!: boolean; + + @IsInt() + @Min(1) + @Max(50) + maxFiles!: number; + + @IsArray() + @ArrayNotEmpty() + @IsString({ each: true }) + allowedExtensions!: string[]; + + @IsInt() + @Min(1) + @Max(500) + maxSizeMb!: number; + + @IsOptional() + @IsInt() + @Min(0) + order?: number; +} diff --git a/apps/edr-freight-api/src/modules/file-upload-settings/dto/create-file-upload-setting.dto.ts b/apps/edr-freight-api/src/modules/file-upload-settings/dto/create-file-upload-setting.dto.ts new file mode 100644 index 000000000..6a4d62f9e --- /dev/null +++ b/apps/edr-freight-api/src/modules/file-upload-settings/dto/create-file-upload-setting.dto.ts @@ -0,0 +1,44 @@ +import { Type } from "class-transformer"; +import { + IsArray, + IsEnum, + IsOptional, + IsString, + MaxLength, + ValidateNested, +} from "class-validator"; + +import { CreateFileUploadFieldDto } from "./create-file-upload-field.dto"; + +export enum FileUploadEntityDto { + Customer = "customer", + Booking = "booking", + Consignment = "consignment", + Shipment = "shipment", + Invoice = "invoice", + Train = "train", + Other = "other", +} + +export class CreateFileUploadSettingDto { + @IsString() + @MaxLength(128) + code!: string; + + @IsString() + @MaxLength(256) + label!: string; + + @IsOptional() + @IsString() + description?: string; + + @IsEnum(FileUploadEntityDto) + entity!: FileUploadEntityDto; + + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => CreateFileUploadFieldDto) + fields?: CreateFileUploadFieldDto[]; +} diff --git a/apps/edr-freight-api/src/modules/file-upload-settings/dto/update-file-upload-field.dto.ts b/apps/edr-freight-api/src/modules/file-upload-settings/dto/update-file-upload-field.dto.ts new file mode 100644 index 000000000..abef2b5b7 --- /dev/null +++ b/apps/edr-freight-api/src/modules/file-upload-settings/dto/update-file-upload-field.dto.ts @@ -0,0 +1,7 @@ +import { PartialType } from "@nestjs/mapped-types"; + +import { CreateFileUploadFieldDto } from "./create-file-upload-field.dto"; + +export class UpdateFileUploadFieldDto extends PartialType( + CreateFileUploadFieldDto, +) {} diff --git a/apps/edr-freight-api/src/modules/file-upload-settings/dto/update-file-upload-setting.dto.ts b/apps/edr-freight-api/src/modules/file-upload-settings/dto/update-file-upload-setting.dto.ts new file mode 100644 index 000000000..f055f4ebf --- /dev/null +++ b/apps/edr-freight-api/src/modules/file-upload-settings/dto/update-file-upload-setting.dto.ts @@ -0,0 +1,7 @@ +import { OmitType, PartialType } from "@nestjs/mapped-types"; + +import { CreateFileUploadSettingDto } from "./create-file-upload-setting.dto"; + +export class UpdateFileUploadSettingDto extends PartialType( + OmitType(CreateFileUploadSettingDto, ["fields"] as const), +) {} diff --git a/apps/edr-freight-api/src/modules/file-upload-settings/entities/file-upload-field.entity.ts b/apps/edr-freight-api/src/modules/file-upload-settings/entities/file-upload-field.entity.ts new file mode 100644 index 000000000..e76c5716b --- /dev/null +++ b/apps/edr-freight-api/src/modules/file-upload-settings/entities/file-upload-field.entity.ts @@ -0,0 +1,58 @@ +import { BaseEntity } from "@edr/api-common"; +import { + Check, + Column, + Entity, + Index, + JoinColumn, + ManyToOne, +} from "typeorm"; + +import { FileUploadSetting } from "./file-upload-setting.entity"; + +@Entity({schema:"freight", name: "file_upload_fields" }) +@Index(["settingId", "fileKey"], { unique: true }) +@Check(`"max_files" > 0`) +@Check(`"max_size_mb" > 0`) +export class FileUploadField extends BaseEntity { + @ManyToOne(() => FileUploadSetting, (setting) => setting.fields, { + onDelete: "CASCADE", + }) + @JoinColumn({ name: "setting_id" }) + setting!: FileUploadSetting; + + @Column({ name: "setting_id", type: "uuid" }) + settingId!: string; + + @Column({ name: "file_key", type: "varchar", length: 128 }) + fileKey!: string; + + @Column({ name: "file_label", type: "varchar", length: 256 }) + fileLabel!: string; + + @Column({ name: "help_text", type: "text", nullable: true }) + helpText?: string | null; + + @Column({ name: "is_required", type: "boolean", default: false }) + isRequired!: boolean; + + @Column({ name: "is_multiple", type: "boolean", default: false }) + isMultiple!: boolean; + + @Column({ name: "max_files", type: "integer", default: 1 }) + maxFiles!: number; + + @Column({ + name: "allowed_extensions", + type: "text", + array: true, + default: () => "'{}'::text[]", + }) + allowedExtensions!: string[]; + + @Column({ name: "max_size_mb", type: "integer", default: 10 }) + maxSizeMb!: number; + + @Column({ name: "display_order", type: "integer", default: 0 }) + displayOrder!: number; +} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/file-upload-settings/entities/file-upload-setting.entity.ts b/apps/edr-freight-api/src/modules/file-upload-settings/entities/file-upload-setting.entity.ts new file mode 100644 index 000000000..2318078c1 --- /dev/null +++ b/apps/edr-freight-api/src/modules/file-upload-settings/entities/file-upload-setting.entity.ts @@ -0,0 +1,52 @@ +import { BaseEntity } from "@edr/api-common"; +import { + Column, + Entity, + Index, + OneToMany, +} from "typeorm"; + +import { FileUploadField } from "./file-upload-field.entity"; + +@Entity({ schema:"freight",name: "file_upload_settings" }) +@Index(["code"], { unique: true }) +export class FileUploadSetting extends BaseEntity { + @Column({ + name: "code", + type: "varchar", + length: 128, + unique: true, + }) + code!: string; + + @Column({ + name: "label", + type: "varchar", + length: 256, + }) + label!: string; + + @Column({ + name: "description", + type: "text", + nullable: true, + }) + description?: string | null; + + @Column({ + name: "entity", + type: "varchar", + length: 32, + default: "other", + }) + entity!: string; + + @OneToMany( + () => FileUploadField, + (field) => field.setting, + { + cascade: true, + } + ) + fields!: FileUploadField[]; +} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/file-upload-settings/file-upload-settings.controller.ts b/apps/edr-freight-api/src/modules/file-upload-settings/file-upload-settings.controller.ts new file mode 100644 index 000000000..661339902 --- /dev/null +++ b/apps/edr-freight-api/src/modules/file-upload-settings/file-upload-settings.controller.ts @@ -0,0 +1,119 @@ +import { + Body, + Controller, + Delete, + Get, + HttpCode, + HttpStatus, + Param, + ParseUUIDPipe, + Patch, + Post, + Put, +} from "@nestjs/common"; +import { ApiOperation, ApiTags } from "@nestjs/swagger"; + +import { FreightAdmin } from "../../common/booking-guards"; +import { CreateFileUploadFieldDto } from "./dto/create-file-upload-field.dto"; +import { CreateFileUploadSettingDto } from "./dto/create-file-upload-setting.dto"; +import { UpdateFileUploadFieldDto } from "./dto/update-file-upload-field.dto"; +import { UpdateFileUploadSettingDto } from "./dto/update-file-upload-setting.dto"; +import { FileUploadSettingsService } from "./file-upload-settings.service"; + +@ApiTags("file-upload-settings") +@Controller("file-upload-settings") +export class FileUploadSettingsController { + constructor(private readonly service: FileUploadSettingsService) {} + + // Reads stay open: the customer portal fetches these to render dynamic + // upload forms (by-code / by-entity). Only writes are admin-guarded. + + @Get() + @ApiOperation({ summary: "List all file upload settings" }) + list() { + return this.service.list(); + } + + @Get(":id") + @ApiOperation({ summary: "Get a file upload setting by ID" }) + getById(@Param("id", ParseUUIDPipe) id: string) { + return this.service.getById(id); + } + + @Get("by-code/:code") + @ApiOperation({ summary: "Get a file upload setting by its stable code" }) + getByCode(@Param("code") code: string) { + return this.service.getByCode(code); + } + + @Get("by-entity/:entity") + @ApiOperation({ summary: "Get all file upload settings for an entity type (customer, booking, etc.)" }) + getByEntity(@Param("entity") entity: string) { + return this.service.getByEntity(entity); + } + + @Post() + @FreightAdmin() + @ApiOperation({ summary: "Create a new file upload setting" }) + create(@Body() dto: CreateFileUploadSettingDto) { + return this.service.create(dto); + } + + @Patch(":id") + @FreightAdmin() + @ApiOperation({ summary: "Update a file upload setting's metadata" }) + update( + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: UpdateFileUploadSettingDto, + ) { + return this.service.update(id, dto); + } + + @Delete(":id") + @FreightAdmin() + @ApiOperation({ summary: "Soft-delete a file upload setting" }) + @HttpCode(HttpStatus.NO_CONTENT) + remove(@Param("id", ParseUUIDPipe) id: string) { + return this.service.remove(id); + } + + /* ------------------------- field routes ------------------------- */ + + @Put(":id/fields") + @FreightAdmin() + @ApiOperation({ summary: "Replace the full field list for a setting" }) + replaceFields( + @Param("id", ParseUUIDPipe) id: string, + @Body() fields: CreateFileUploadFieldDto[], + ) { + return this.service.replaceFields(id, fields); + } + + @Post(":id/fields") + @FreightAdmin() + @ApiOperation({ summary: "Append a single field to a setting" }) + addField( + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: CreateFileUploadFieldDto, + ) { + return this.service.addField(id, dto); + } + + @Patch("fields/:fieldId") + @FreightAdmin() + @ApiOperation({ summary: "Update a single field" }) + updateField( + @Param("fieldId", ParseUUIDPipe) fieldId: string, + @Body() dto: UpdateFileUploadFieldDto, + ) { + return this.service.updateField(fieldId, dto); + } + + @Delete("fields/:fieldId") + @FreightAdmin() + @ApiOperation({ summary: "Soft-delete a single field" }) + @HttpCode(HttpStatus.NO_CONTENT) + removeField(@Param("fieldId", ParseUUIDPipe) fieldId: string) { + return this.service.removeField(fieldId); + } +} diff --git a/apps/edr-freight-api/src/modules/file-upload-settings/file-upload-settings.module.ts b/apps/edr-freight-api/src/modules/file-upload-settings/file-upload-settings.module.ts new file mode 100644 index 000000000..e32762394 --- /dev/null +++ b/apps/edr-freight-api/src/modules/file-upload-settings/file-upload-settings.module.ts @@ -0,0 +1,26 @@ +import { Module } from "@nestjs/common"; +import { TypeOrmModule } from "@nestjs/typeorm"; + +import { FileUploadField } from "./entities/file-upload-field.entity"; +import { FileUploadSetting } from "./entities/file-upload-setting.entity"; +import { FileUploadSettingsController } from "./file-upload-settings.controller"; +import { FileUploadSettingsRepository } from "./file-upload-settings.repository"; +import { FileUploadSettingsService } from "./file-upload-settings.service"; +import { FILE_UPLOAD_SETTINGS_REPOSITORY } from "./interfaces/file-upload-settings.repository.interface"; + +@Module({ + imports: [TypeOrmModule.forFeature([FileUploadSetting, FileUploadField])], + controllers: [FileUploadSettingsController], + providers: [ + FileUploadSettingsRepository, + { + // Bind the interface token to the concrete TypeORM repository so the + // service can inject the abstraction (handy for tests / swap-out). + provide: FILE_UPLOAD_SETTINGS_REPOSITORY, + useExisting: FileUploadSettingsRepository, + }, + FileUploadSettingsService, + ], + exports: [FileUploadSettingsService], +}) +export class FileUploadSettingsModule {} diff --git a/apps/edr-freight-api/src/modules/file-upload-settings/file-upload-settings.repository.ts b/apps/edr-freight-api/src/modules/file-upload-settings/file-upload-settings.repository.ts new file mode 100644 index 000000000..c14b30052 --- /dev/null +++ b/apps/edr-freight-api/src/modules/file-upload-settings/file-upload-settings.repository.ts @@ -0,0 +1,84 @@ +import { BaseRepository } from "@edr/api-common"; +import { Injectable } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { Repository } from "typeorm"; + +import { FileUploadField } from "./entities/file-upload-field.entity"; +import { FileUploadSetting } from "./entities/file-upload-setting.entity"; +import type { IFileUploadSettingsRepository } from "./interfaces/file-upload-settings.repository.interface"; + +@Injectable() +export class FileUploadSettingsRepository + extends BaseRepository + implements IFileUploadSettingsRepository +{ + constructor( + @InjectRepository(FileUploadSetting) + repository: Repository, + @InjectRepository(FileUploadField) + private readonly fieldsRepository: Repository, + ) { + super(repository); + } + + /** Look up all settings for a given entity (e.g. "customer", "booking"). */ + findByEntity(entity: string): Promise { + return this.repository.find({ + where: { entity }, + order: { label: "ASC" }, + relations: { fields: true }, + }); + } + + /** Look up a setting by its stable code. */ + findByCode(code: string): Promise { + return this.repository.findOne({ + where: { code }, + relations: { fields: true }, + }); + } + + override findAll(): Promise { + return this.repository.find({ + order: { label: "ASC" }, + relations: { fields: true }, + }); + } + + /** Replace the whole field list for a setting. Returns the saved rows. */ + async replaceFields( + settingId: string, + fields: Array>, + ): Promise { + await this.fieldsRepository.delete({ settingId }); + if (fields.length === 0) return []; + const entities = fields.map((f, idx) => + this.fieldsRepository.create({ + ...f, + settingId, + displayOrder: f.displayOrder ?? idx + 1, + }), + ); + return this.fieldsRepository.save(entities); + } + + async addField( + settingId: string, + field: Partial, + ): Promise { + const entity = this.fieldsRepository.create({ ...field, settingId }); + return this.fieldsRepository.save(entity); + } + + async updateField( + fieldId: string, + data: Partial, + ): Promise { + await this.fieldsRepository.update(fieldId, data as never); + return this.fieldsRepository.findOne({ where: { id: fieldId } }); + } + + async removeField(fieldId: string): Promise { + await this.fieldsRepository.softDelete(fieldId); + } +} diff --git a/apps/edr-freight-api/src/modules/file-upload-settings/file-upload-settings.service.ts b/apps/edr-freight-api/src/modules/file-upload-settings/file-upload-settings.service.ts new file mode 100644 index 000000000..947bb5ffb --- /dev/null +++ b/apps/edr-freight-api/src/modules/file-upload-settings/file-upload-settings.service.ts @@ -0,0 +1,113 @@ +import { + ConflictException, + Inject, + Injectable, + NotFoundException, +} from "@nestjs/common"; + +import { CreateFileUploadFieldDto } from "./dto/create-file-upload-field.dto"; +import { CreateFileUploadSettingDto } from "./dto/create-file-upload-setting.dto"; +import { UpdateFileUploadFieldDto } from "./dto/update-file-upload-field.dto"; +import { UpdateFileUploadSettingDto } from "./dto/update-file-upload-setting.dto"; +import { FileUploadField } from "./entities/file-upload-field.entity"; +import { FileUploadSetting } from "./entities/file-upload-setting.entity"; +import { + FILE_UPLOAD_SETTINGS_REPOSITORY, + IFileUploadSettingsRepository, +} from "./interfaces/file-upload-settings.repository.interface"; + +@Injectable() +export class FileUploadSettingsService { + constructor( + @Inject(FILE_UPLOAD_SETTINGS_REPOSITORY) + private readonly repository: IFileUploadSettingsRepository, + ) {} + + list(): Promise { + return this.repository.findAll(); + } + + async getById(id: string): Promise { + const setting = await this.repository.findById(id); + if (!setting) throw new NotFoundException(`Setting ${id} not found`); + return setting; + } + + getByEntity(entity: string): Promise { + return this.repository.findByEntity(entity); + } + + async getByCode(code: string): Promise { + const setting = await this.repository.findByCode(code); + if (!setting) throw new NotFoundException(`Setting "${code}" not found`); + return setting; + } + + async create(dto: CreateFileUploadSettingDto): Promise { + const existing = await this.repository.findByCode(dto.code); + if (existing) { + throw new ConflictException( + `File upload setting with code "${dto.code}" already exists`, + ); + } + + const setting = await this.repository.create({ + code: dto.code, + label: dto.label, + description: dto.description ?? null, + entity: dto.entity, + }); + + if (dto.fields && dto.fields.length > 0) { + await this.repository.replaceFields(setting.id, dto.fields); + } + + return this.getById(setting.id); + } + + async update( + id: string, + dto: UpdateFileUploadSettingDto, + ): Promise { + await this.getById(id); // existence check + const updated = await this.repository.update(id, dto); + if (!updated) throw new NotFoundException(`Setting ${id} not found`); + return updated; + } + + async remove(id: string): Promise { + await this.getById(id); + await this.repository.softDelete(id); + } + + /* ------------------------ field operations ------------------------ */ + + async replaceFields( + settingId: string, + fields: CreateFileUploadFieldDto[], + ): Promise { + await this.getById(settingId); + return this.repository.replaceFields(settingId, fields); + } + + async addField( + settingId: string, + dto: CreateFileUploadFieldDto, + ): Promise { + await this.getById(settingId); + return this.repository.addField(settingId, dto); + } + + async updateField( + fieldId: string, + dto: UpdateFileUploadFieldDto, + ): Promise { + const updated = await this.repository.updateField(fieldId, dto); + if (!updated) throw new NotFoundException(`Field ${fieldId} not found`); + return updated; + } + + async removeField(fieldId: string): Promise { + await this.repository.removeField(fieldId); + } +} diff --git a/apps/edr-freight-api/src/modules/file-upload-settings/interfaces/file-upload-settings.repository.interface.ts b/apps/edr-freight-api/src/modules/file-upload-settings/interfaces/file-upload-settings.repository.interface.ts new file mode 100644 index 000000000..a0aa01d06 --- /dev/null +++ b/apps/edr-freight-api/src/modules/file-upload-settings/interfaces/file-upload-settings.repository.interface.ts @@ -0,0 +1,39 @@ +import { FileUploadField } from "../entities/file-upload-field.entity"; +import { FileUploadSetting } from "../entities/file-upload-setting.entity"; + +/** + * Contract every FileUploadSettings repository must satisfy. Lets services + * depend on the abstraction and lets tests swap in an in-memory fake. + */ +export const FILE_UPLOAD_SETTINGS_REPOSITORY = Symbol( + "FILE_UPLOAD_SETTINGS_REPOSITORY", +); + +export interface IFileUploadSettingsRepository { + findAll(): Promise; + findById(id: string): Promise; + findByCode(code: string): Promise; + findByEntity(entity: string): Promise; + + create(data: Partial): Promise; + update( + id: string, + data: Partial, + ): Promise; + softDelete(id: string): Promise; + + /* Field-level helpers */ + replaceFields( + settingId: string, + fields: Array>, + ): Promise; + addField( + settingId: string, + field: Partial, + ): Promise; + updateField( + fieldId: string, + data: Partial, + ): Promise; + removeField(fieldId: string): Promise; +} diff --git a/apps/edr-freight-api/src/modules/files/entities/file.entity.ts b/apps/edr-freight-api/src/modules/files/entities/file.entity.ts new file mode 100644 index 000000000..221b7c29b --- /dev/null +++ b/apps/edr-freight-api/src/modules/files/entities/file.entity.ts @@ -0,0 +1,26 @@ +import { BaseEntity } from "@edr/api-common"; +import { Column, Entity } from "typeorm"; + +@Entity({ schema: "freight", name: "files" }) +export class FileRecord extends BaseEntity { + @Column({ name: "resource_id", type: "uuid" }) + resourceId!: string; + + @Column({ name: "resource", type: "varchar", length: 100 }) + resource!: string; + + @Column({ name: "code", type: "varchar", length: 100 }) + code!: string; + + @Column({ name: "name", type: "varchar", length: 500 }) + name!: string; + + @Column({ name: "url", type: "text" }) + url!: string; + + @Column({ name: "size", type: "integer" }) + size!: number; + + @Column({ name: "mime_type", type: "varchar", length: 255 }) + mimeType!: string; +} diff --git a/apps/edr-freight-api/src/modules/files/files.controller.ts b/apps/edr-freight-api/src/modules/files/files.controller.ts new file mode 100644 index 000000000..acf274ff0 --- /dev/null +++ b/apps/edr-freight-api/src/modules/files/files.controller.ts @@ -0,0 +1,28 @@ +import { Controller, Get, Param, ParseUUIDPipe, Res } from "@nestjs/common"; +import { ApiOperation, ApiTags } from "@nestjs/swagger"; +import { Response } from "express"; + +import { FilesService } from "./files.service"; + +@ApiTags("files") +@Controller("files") +export class FilesController { + constructor(private readonly filesService: FilesService) {} + + @Get(":fileId") + @ApiOperation({ + summary: "Download a file by ID", + description: + "Global endpoint — streams any uploaded file directly from MinIO by its UUID. " + + "No resource context (e.g. booking ID) required.", + }) + async download( + @Param("fileId", ParseUUIDPipe) fileId: string, + @Res() res: Response, + ) { + const { stream, record } = await this.filesService.streamById(fileId); + res.setHeader("Content-Type", record.mimeType); + res.setHeader("Content-Disposition", `attachment; filename="${record.name}"`); + stream.pipe(res); + } +} diff --git a/apps/edr-freight-api/src/modules/files/files.module.ts b/apps/edr-freight-api/src/modules/files/files.module.ts new file mode 100644 index 000000000..fa04c9fa7 --- /dev/null +++ b/apps/edr-freight-api/src/modules/files/files.module.ts @@ -0,0 +1,16 @@ +import { Module } from "@nestjs/common"; +import { TypeOrmModule } from "@nestjs/typeorm"; + +import { MinioModule } from "../minio/minio.module"; +import { FilesController } from "./files.controller"; +import { FilesRepository } from "./files.repository"; +import { FilesService } from "./files.service"; +import { FileRecord } from "./entities/file.entity"; + +@Module({ + imports: [TypeOrmModule.forFeature([FileRecord]), MinioModule], + controllers: [FilesController], + providers: [FilesService, FilesRepository], + exports: [FilesService], +}) +export class FilesModule {} diff --git a/apps/edr-freight-api/src/modules/files/files.repository.ts b/apps/edr-freight-api/src/modules/files/files.repository.ts new file mode 100644 index 000000000..ea4bfd19e --- /dev/null +++ b/apps/edr-freight-api/src/modules/files/files.repository.ts @@ -0,0 +1,36 @@ +import { BaseRepository } from "@edr/api-common"; +import { Injectable } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { Repository } from "typeorm"; + +import { FileRecord } from "./entities/file.entity"; + +@Injectable() +export class FilesRepository extends BaseRepository { + constructor( + @InjectRepository(FileRecord) + repository: Repository, + ) { + super(repository); + } + + findByResource(resourceId: string, resource: string): Promise { + return this.repository.find({ where: { resourceId, resource } }); + } + + findByCode( + resourceId: string, + resource: string, + code: string, + ): Promise { + return this.repository.findOne({ where: { resourceId, resource, code } }); + } + + async deleteByCode( + resourceId: string, + resource: string, + code: string, + ): Promise { + await this.repository.delete({ resourceId, resource, code }); + } +} diff --git a/apps/edr-freight-api/src/modules/files/files.service.ts b/apps/edr-freight-api/src/modules/files/files.service.ts new file mode 100644 index 000000000..97a5e9e34 --- /dev/null +++ b/apps/edr-freight-api/src/modules/files/files.service.ts @@ -0,0 +1,86 @@ +import { Injectable, NotFoundException } from "@nestjs/common"; +import { Readable } from "stream"; + +import { MinioService } from "../minio/minio.service"; +import { FilesRepository } from "./files.repository"; +import { FileRecord } from "./entities/file.entity"; + +export interface CreateFileInput { + resourceId: string; + resource: string; + code: string; + file: Express.Multer.File; +} + +@Injectable() +export class FilesService { + constructor( + private readonly filesRepository: FilesRepository, + private readonly minioService: MinioService, + ) {} + + async upload(input: CreateFileInput): Promise { + const { resourceId, resource, code, file } = input; + const objectName = `${resource}/${resourceId}/${Date.now()}_${file.originalname}`; + const url = await this.minioService.uploadFile(objectName, file.buffer, file.mimetype); + + return this.filesRepository.create({ + resourceId, + resource, + code, + name: file.originalname, + url, + size: file.size, + mimeType: file.mimetype, + }); + } + + /** Replace existing file row for the same resource + code (e.g. contract PDF). */ + async upsertByCode(input: CreateFileInput): Promise { + const { resourceId, resource, code } = input; + await this.filesRepository.deleteByCode(resourceId, resource, code); + return this.upload(input); + } + + async uploadMany( + resourceId: string, + resource: string, + files: Express.Multer.File[], + ): Promise { + return Promise.all( + files.map((file) => + this.upload({ resourceId, resource, code: file.fieldname, file }), + ), + ); + } + + async findById(id: string): Promise { + const record = await this.filesRepository.findById(id); + if (!record) throw new NotFoundException(`File ${id} not found`); + return record; + } + + findByResource(resourceId: string, resource: string): Promise { + return this.filesRepository.findByResource(resourceId, resource); + } + + async findByCode( + resourceId: string, + resource: string, + code: string, + ): Promise { + const record = await this.filesRepository.findByCode(resourceId, resource, code); + if (!record) + throw new NotFoundException( + `File with code "${code}" not found for ${resource} ${resourceId}`, + ); + return record; + } + + async streamById(id: string): Promise<{ stream: Readable; record: FileRecord }> { + const record = await this.findById(id); + const objectName = this.minioService.getObjectNameFromUrl(record.url); + const stream = await this.minioService.getFileStream(objectName); + return { stream, record }; + } +} diff --git a/apps/edr-freight-api/src/modules/locomotives/dto/create-locomotive.dto.ts b/apps/edr-freight-api/src/modules/locomotives/dto/create-locomotive.dto.ts new file mode 100644 index 000000000..5745d3037 --- /dev/null +++ b/apps/edr-freight-api/src/modules/locomotives/dto/create-locomotive.dto.ts @@ -0,0 +1,67 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Transform } from 'class-transformer'; +import { IsIn, IsNumber, IsOptional, IsString, MaxLength, Min, IsUUID } from 'class-validator'; + +import { + LOCOMOTIVE_STATUSES, + LOCOMOTIVE_TYPES, +} from '../entities/locomotive.entity'; + +export class CreateLocomotiveDto { + @ApiProperty({ example: 'LOCO-001' }) + @IsString() + @MaxLength(32) + code!: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + @MaxLength(100) + name?: string; + + @ApiProperty({ enum: LOCOMOTIVE_TYPES }) + @IsIn([...LOCOMOTIVE_TYPES]) + locomotiveType!: string; + + @ApiProperty({ enum: LOCOMOTIVE_STATUSES }) + @IsIn([...LOCOMOTIVE_STATUSES]) + status!: string; + + @ApiPropertyOptional({ description: 'Current yard location' }) + @IsOptional() + @IsUUID() + currentYardId?: string; + + @ApiProperty({ example: 3500 }) + @Transform(({ value }) => Number(value)) + @IsNumber() + @Min(0) + maxPullWeightTons!: number; + + @ApiProperty({ example: 760 }) + @Transform(({ value }) => Number(value)) + @IsNumber() + @Min(0) + maxTrainLengthMeters!: number; + + @ApiPropertyOptional({ example: 4200 }) + @IsOptional() + @Transform(({ value }) => (value === '' || value == null ? undefined : Number(value))) + @IsNumber() + @Min(0) + powerKw?: number; + + @ApiPropertyOptional({ example: 300 }) + @IsOptional() + @Transform(({ value }) => (value === '' || value == null ? undefined : Number(value))) + @IsNumber() + @Min(0) + tractionForceKn?: number; + + @ApiPropertyOptional({ example: 120 }) + @IsOptional() + @Transform(({ value }) => (value === '' || value == null ? undefined : Number(value))) + @IsNumber() + @Min(0) + maxSpeedKmh?: number; +} diff --git a/apps/edr-freight-api/src/modules/locomotives/dto/filter-locomotives.dto.ts b/apps/edr-freight-api/src/modules/locomotives/dto/filter-locomotives.dto.ts new file mode 100644 index 000000000..c634d5efb --- /dev/null +++ b/apps/edr-freight-api/src/modules/locomotives/dto/filter-locomotives.dto.ts @@ -0,0 +1,24 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsIn, IsOptional, IsUUID } from 'class-validator'; + +import { + LOCOMOTIVE_STATUSES, + LOCOMOTIVE_TYPES, +} from '../entities/locomotive.entity'; + +export class FilterLocomotivesDto { + @ApiPropertyOptional({ enum: LOCOMOTIVE_STATUSES }) + @IsOptional() + @IsIn([...LOCOMOTIVE_STATUSES]) + status?: string; + + @ApiPropertyOptional({ enum: LOCOMOTIVE_TYPES }) + @IsOptional() + @IsIn([...LOCOMOTIVE_TYPES]) + locomotiveType?: string; + + @ApiPropertyOptional({ description: 'Filter by current yard' }) + @IsOptional() + @IsUUID() + currentYardId?: string; +} diff --git a/apps/edr-freight-api/src/modules/locomotives/dto/update-locomotive.dto.ts b/apps/edr-freight-api/src/modules/locomotives/dto/update-locomotive.dto.ts new file mode 100644 index 000000000..0f5cd2761 --- /dev/null +++ b/apps/edr-freight-api/src/modules/locomotives/dto/update-locomotive.dto.ts @@ -0,0 +1,5 @@ +import { PartialType } from '@nestjs/swagger'; + +import { CreateLocomotiveDto } from './create-locomotive.dto'; + +export class UpdateLocomotiveDto extends PartialType(CreateLocomotiveDto) {} diff --git a/apps/edr-freight-api/src/modules/locomotives/entities/locomotive.entity.ts b/apps/edr-freight-api/src/modules/locomotives/entities/locomotive.entity.ts new file mode 100644 index 000000000..6dcd9ad3e --- /dev/null +++ b/apps/edr-freight-api/src/modules/locomotives/entities/locomotive.entity.ts @@ -0,0 +1,63 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, OneToMany, ManyToOne, JoinColumn } from 'typeorm'; + +import { TrainSet } from '../../train-sets/entities/train-set.entity'; +import { Yard } from '../../rule-engine/entities/yard.entity'; + +export const LOCOMOTIVE_STATUSES = [ + 'AVAILABLE', + 'UNAVAILABLE', + 'IMPORT_READY', + 'EXPORT_READY', + 'ASSIGNED', + 'MAINTENANCE', + 'OUT_OF_SERVICE', +] as const; + +export const LOCOMOTIVE_TYPES = ['DIESEL', 'ELECTRIC'] as const; + +export type LocomotiveStatus = (typeof LOCOMOTIVE_STATUSES)[number]; +export type LocomotiveType = (typeof LOCOMOTIVE_TYPES)[number]; + +@Entity({ schema: 'freight', name: 'locomotives' }) +@Index(['code']) +@Index(['status']) +@Index(['currentYardId']) +export class Locomotive extends BaseEntity { + @Column({ name: 'code', type: 'varchar', length: 32, unique: true }) + code!: string; + + @Column({ name: 'name', type: 'varchar', length: 100, nullable: true }) + name?: string | null; + + @Column({ name: 'locomotive_type', type: 'varchar', length: 20, default: 'DIESEL' }) + locomotiveType!: LocomotiveType; + + @Column({ name: 'max_pull_weight_tons', type: 'numeric', precision: 10, scale: 3 }) + maxPullWeightTons!: number; + + @Column({ name: 'max_train_length_meters', type: 'numeric', precision: 10, scale: 3, default: 760 }) + maxTrainLengthMeters!: number; + + @Column({ name: 'status', type: 'varchar', length: 20, default: 'AVAILABLE' }) + status!: LocomotiveStatus; + + @Column({ name: 'current_yard_id', type: 'uuid', nullable: true }) + currentYardId!: string | null; + + @ManyToOne(() => Yard, { nullable: true, onDelete: 'SET NULL' }) + @JoinColumn({ name: 'current_yard_id' }) + currentYard?: Yard | null; + + @Column({ name: 'power_kw', type: 'numeric', precision: 10, scale: 3, nullable: true }) + powerKw?: number | null; + + @Column({ name: 'traction_force_kn', type: 'numeric', precision: 10, scale: 3, nullable: true }) + tractionForceKn?: number | null; + + @Column({ name: 'max_speed_kmh', type: 'numeric', precision: 10, scale: 3, nullable: true }) + maxSpeedKmh?: number | null; + + @OneToMany(() => TrainSet, (trainSet) => trainSet.locomotive) + trainSets?: TrainSet[]; +} diff --git a/apps/edr-freight-api/src/modules/locomotives/locomotives.controller.ts b/apps/edr-freight-api/src/modules/locomotives/locomotives.controller.ts new file mode 100644 index 000000000..c907af717 --- /dev/null +++ b/apps/edr-freight-api/src/modules/locomotives/locomotives.controller.ts @@ -0,0 +1,49 @@ +import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { FleetManage, FleetView } from '../../common/booking-guards'; +import { CreateLocomotiveDto } from './dto/create-locomotive.dto'; +import { FilterLocomotivesDto } from './dto/filter-locomotives.dto'; +import { UpdateLocomotiveDto } from './dto/update-locomotive.dto'; +import { LocomotivesService } from './locomotives.service'; + +@ApiTags('locomotives') +@ApiBearerAuth() +@Controller('locomotives') +@FleetView() +export class LocomotivesController { + constructor(private readonly locomotivesService: LocomotivesService) {} + + @Get() + @ApiOperation({ summary: 'List locomotives' }) + findAll(@Query() filter: FilterLocomotivesDto) { + return this.locomotivesService.findAll(filter); + } + + @Get(':id') + @ApiOperation({ summary: 'Get a locomotive by ID' }) + findOne(@Param('id', ParseUUIDPipe) id: string) { + return this.locomotivesService.findById(id); + } + + @Post() + @FleetManage() + @ApiOperation({ summary: 'Create a locomotive' }) + create(@Body() dto: CreateLocomotiveDto) { + return this.locomotivesService.create(dto); + } + + @Patch(':id') + @FleetManage() + @ApiOperation({ summary: 'Update a locomotive' }) + update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateLocomotiveDto) { + return this.locomotivesService.update(id, dto); + } + + @Post(':id/decommission') + @FleetManage() + @ApiOperation({ summary: 'Decommission a locomotive' }) + decommission(@Param('id', ParseUUIDPipe) id: string) { + return this.locomotivesService.decommission(id); + } +} diff --git a/apps/edr-freight-api/src/modules/locomotives/locomotives.module.ts b/apps/edr-freight-api/src/modules/locomotives/locomotives.module.ts new file mode 100644 index 000000000..264b9cb44 --- /dev/null +++ b/apps/edr-freight-api/src/modules/locomotives/locomotives.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { LocomotivesController } from './locomotives.controller'; +import { Locomotive } from './entities/locomotive.entity'; +import { LocomotivesRepository } from './locomotives.repository'; +import { LocomotivesService } from './locomotives.service'; + +@Module({ + imports: [TypeOrmModule.forFeature([Locomotive])], + controllers: [LocomotivesController], + providers: [LocomotivesRepository, LocomotivesService], + exports: [LocomotivesRepository, LocomotivesService], +}) +export class LocomotivesModule {} diff --git a/apps/edr-freight-api/src/modules/locomotives/locomotives.repository.ts b/apps/edr-freight-api/src/modules/locomotives/locomotives.repository.ts new file mode 100644 index 000000000..af2a40f50 --- /dev/null +++ b/apps/edr-freight-api/src/modules/locomotives/locomotives.repository.ts @@ -0,0 +1,16 @@ +import { BaseRepository } from '@edr/api-common'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { Locomotive } from './entities/locomotive.entity'; + +@Injectable() +export class LocomotivesRepository extends BaseRepository { + constructor( + @InjectRepository(Locomotive) + repository: Repository, + ) { + super(repository); + } +} diff --git a/apps/edr-freight-api/src/modules/locomotives/locomotives.service.ts b/apps/edr-freight-api/src/modules/locomotives/locomotives.service.ts new file mode 100644 index 000000000..ebcb09bce --- /dev/null +++ b/apps/edr-freight-api/src/modules/locomotives/locomotives.service.ts @@ -0,0 +1,112 @@ +import { ConflictException, Injectable, NotFoundException } from '@nestjs/common'; + +import { CreateLocomotiveDto } from './dto/create-locomotive.dto'; +import { FilterLocomotivesDto } from './dto/filter-locomotives.dto'; +import { UpdateLocomotiveDto } from './dto/update-locomotive.dto'; + +import { + Locomotive, + type LocomotiveStatus, + type LocomotiveType, +} from './entities/locomotive.entity'; +import { LocomotivesRepository } from './locomotives.repository'; + +@Injectable() +export class LocomotivesService { + constructor(private readonly locomotivesRepository: LocomotivesRepository) {} + + findAll(filter: FilterLocomotivesDto): Promise { + return this.locomotivesRepository.findAll({ + where: { + ...(filter.status ? { status: filter.status as LocomotiveStatus } : {}), + ...(filter.locomotiveType + ? { locomotiveType: filter.locomotiveType as LocomotiveType } + : {}), + ...(filter.currentYardId ? { currentYardId: filter.currentYardId } : {}), + }, + relations: { currentYard: true }, + order: { code: 'ASC' }, + }); + } + + async create(dto: CreateLocomotiveDto): Promise { + const [existing] = await this.locomotivesRepository.findAll({ where: { code: dto.code } }); + + if (existing) { + throw new ConflictException(`Locomotive code ${dto.code} already exists`); + } + + return this.locomotivesRepository.create({ + code: dto.code, + name: dto.name?.trim() || null, + locomotiveType: dto.locomotiveType as LocomotiveType, + status: dto.status as LocomotiveStatus, + currentYardId: dto.currentYardId ?? null, + maxPullWeightTons: dto.maxPullWeightTons, + maxTrainLengthMeters: dto.maxTrainLengthMeters, + powerKw: dto.powerKw ?? null, + tractionForceKn: dto.tractionForceKn ?? null, + maxSpeedKmh: dto.maxSpeedKmh ?? null, + }); + } + + async findById(id: string): Promise { + const locomotive = await this.locomotivesRepository.findById(id, { + relations: { currentYard: true }, + }); + + if (!locomotive) { + throw new NotFoundException(`Locomotive ${id} not found`); + } + + return locomotive; + } + + async update(id: string, dto: UpdateLocomotiveDto): Promise { + const locomotive = await this.findById(id); + + if (dto.code && dto.code !== locomotive.code) { + const [existing] = await this.locomotivesRepository.findAll({ where: { code: dto.code } }); + if (existing && existing.id !== id) { + throw new ConflictException(`Locomotive code ${dto.code} already exists`); + } + } + + const updated = await this.locomotivesRepository.update(id, { + ...dto, + locomotiveType: + dto.locomotiveType === undefined ? locomotive.locomotiveType : dto.locomotiveType as LocomotiveType, + status: dto.status === undefined ? locomotive.status : dto.status as LocomotiveStatus, + currentYardId: + dto.currentYardId === undefined + ? locomotive.currentYardId + : (dto.currentYardId ?? null), + name: dto.name === undefined ? locomotive.name : dto.name?.trim() || null, + powerKw: dto.powerKw === undefined ? locomotive.powerKw : dto.powerKw ?? null, + tractionForceKn: + dto.tractionForceKn === undefined ? locomotive.tractionForceKn : dto.tractionForceKn ?? null, + maxSpeedKmh: + dto.maxSpeedKmh === undefined ? locomotive.maxSpeedKmh : dto.maxSpeedKmh ?? null, + }); + + if (!updated) { + throw new NotFoundException(`Locomotive ${id} not found`); + } + + return updated; + } + + async decommission(id: string): Promise { + await this.findById(id); + + const updated = await this.locomotivesRepository.update(id, { + status: 'OUT_OF_SERVICE', + }); + + if (!updated) { + throw new NotFoundException(`Locomotive ${id} not found`); + } + + return updated; + } +} diff --git a/apps/edr-freight-api/src/modules/minio/index.ts b/apps/edr-freight-api/src/modules/minio/index.ts new file mode 100644 index 000000000..c5891e495 --- /dev/null +++ b/apps/edr-freight-api/src/modules/minio/index.ts @@ -0,0 +1,2 @@ +export * from "./minio.module"; +export * from "./minio.service"; diff --git a/apps/edr-freight-api/src/modules/minio/minio.config.ts b/apps/edr-freight-api/src/modules/minio/minio.config.ts new file mode 100644 index 000000000..10482a325 --- /dev/null +++ b/apps/edr-freight-api/src/modules/minio/minio.config.ts @@ -0,0 +1,10 @@ +import { registerAs } from "@nestjs/config"; + +export const minioConfig = registerAs("minio", () => ({ + endPoint: process.env.MINIO_ENDPOINT || "minio-dev.smart.aaca.gov.et", + port: parseInt(process.env.MINIO_PORT || "443", 10), + useSSL: process.env.MINIO_USE_SSL !== "false", + accessKey: process.env.MINIO_ACCESS_KEY || "", + secretKey: process.env.MINIO_SECRET_KEY || "", + bucket: process.env.MINIO_BUCKET || "fhc", +})); diff --git a/apps/edr-freight-api/src/modules/minio/minio.module.ts b/apps/edr-freight-api/src/modules/minio/minio.module.ts new file mode 100644 index 000000000..d4745beaf --- /dev/null +++ b/apps/edr-freight-api/src/modules/minio/minio.module.ts @@ -0,0 +1,11 @@ +import { Module } from "@nestjs/common"; +import { ConfigModule } from "@nestjs/config"; +import { minioConfig } from "./minio.config"; +import { MinioService } from "./minio.service"; + +@Module({ + imports: [ConfigModule.forFeature(minioConfig)], + providers: [MinioService], + exports: [MinioService], +}) +export class MinioModule {} diff --git a/apps/edr-freight-api/src/modules/minio/minio.service.ts b/apps/edr-freight-api/src/modules/minio/minio.service.ts new file mode 100644 index 000000000..9f7a5e65d --- /dev/null +++ b/apps/edr-freight-api/src/modules/minio/minio.service.ts @@ -0,0 +1,108 @@ +import { Inject, Injectable, Logger, NotFoundException } from "@nestjs/common"; +import { ConfigType } from "@nestjs/config"; +import { Client } from "minio"; +import { Readable } from "stream"; +import { minioConfig } from "./minio.config"; + +@Injectable() +export class MinioService { + private readonly client: Client; + private readonly logger = new Logger(MinioService.name); + private readonly bucket: string; + + constructor( + @Inject(minioConfig.KEY) + private readonly config: ConfigType, + ) { + console.log('[MinioService] Configuration loaded:', { + endPoint: config.endPoint, + port: config.port, + useSSL: config.useSSL, + accessKey: config.accessKey, + secretKey: config.secretKey ? '***HIDDEN***' : 'EMPTY', + bucket: config.bucket, + }); + this.bucket = config.bucket; + this.client = new Client({ + endPoint: config.endPoint, + port: config.port, + useSSL: config.useSSL, + accessKey: config.accessKey, + secretKey: config.secretKey, + }); + } + + async uploadFile( + objectName: string, + buffer: Buffer, + contentType: string, + ): Promise { + try { + await this.client.putObject(this.bucket, objectName, buffer, buffer.length, { + "Content-Type": contentType, + }); + this.logger.log(`File uploaded successfully: ${objectName}`); + return this.getPublicUrl(objectName); + } catch (error) { + this.logger.error(`Failed to upload file ${objectName}:`, error); + throw error; + } + } + + getPublicUrl(objectName: string): string { + const protocol = this.config.useSSL ? "https" : "http"; + return `${protocol}://${this.config.endPoint}:${this.config.port}/${this.bucket}/${objectName}`; + } + + getObjectNameFromUrl(value: string): string { + const trimmed = value.trim(); + if (!trimmed) { + throw new NotFoundException("File object path is empty"); + } + + if (!/^https?:\/\//i.test(trimmed)) { + return trimmed.replace(/^\/+/, ""); + } + + const url = new URL(trimmed); + const parts = url.pathname.split("/").filter(Boolean); + if (parts[0] === this.bucket) { + parts.shift(); + } + + const objectName = parts.join("/"); + if (!objectName) { + throw new NotFoundException("File object path is empty"); + } + + return objectName; + } + + async deleteFile(objectName: string): Promise { + try { + await this.client.removeObject(this.bucket, objectName); + this.logger.log(`File deleted successfully: ${objectName}`); + } catch (error) { + this.logger.error(`Failed to delete file ${objectName}:`, error); + throw error; + } + } + + async getFileStream(objectName: string): Promise { + try { + return this.client.getObject(this.bucket, objectName); + } catch (error) { + this.logger.error(`Failed to get file ${objectName}:`, error); + throw error; + } + } + + async getSignedUrl(objectName: string, expirySeconds: number = 300): Promise { + try { + return await this.client.presignedGetObject(this.bucket, objectName, expirySeconds); + } catch (error) { + this.logger.error(`Failed to generate signed URL for ${objectName}:`, error); + throw error; + } + } +} diff --git a/apps/edr-freight-api/src/modules/notifications/notifications.module.ts b/apps/edr-freight-api/src/modules/notifications/notifications.module.ts index 39fe1b5c7..2ff2f9727 100644 --- a/apps/edr-freight-api/src/modules/notifications/notifications.module.ts +++ b/apps/edr-freight-api/src/modules/notifications/notifications.module.ts @@ -1,9 +1,14 @@ import { Module } from "@nestjs/common"; import { NotificationsService } from "./notifications.service"; +import { EmailNotificationStrategy } from "./strategies/notification.email.strategy"; +import { SmsNotificationStrategy } from "./strategies/notification.sms.strategy"; +import { HttpModule } from "@nestjs/axios"; @Module({ - providers: [NotificationsService], + imports: [HttpModule], + controllers: [], + providers: [EmailNotificationStrategy, SmsNotificationStrategy, NotificationsService], exports: [NotificationsService], }) -export class NotificationsModule {} +export class NotificationsModule { } diff --git a/apps/edr-freight-api/src/modules/notifications/notifications.service.ts b/apps/edr-freight-api/src/modules/notifications/notifications.service.ts index c17250264..35e8ff07d 100644 --- a/apps/edr-freight-api/src/modules/notifications/notifications.service.ts +++ b/apps/edr-freight-api/src/modules/notifications/notifications.service.ts @@ -1,14 +1,35 @@ -import { Injectable, Logger } from "@nestjs/common"; +import { Injectable, Logger, NotFoundException } from "@nestjs/common"; +import { EmailNotificationStrategy } from "./strategies/notification.email.strategy"; +import { NotificationStrategy } from "./strategies/notification.strategy"; +import { SmsNotificationStrategy } from "./strategies/notification.sms.strategy"; + + +type StrategyMethod = "sms" | "email" @Injectable() export class NotificationsService { private readonly logger = new Logger(NotificationsService.name); + private readonly strategies: Map + constructor(private readonly email: EmailNotificationStrategy, private readonly sms: SmsNotificationStrategy) { + this.strategies = new Map([ + ["sms", this.sms as NotificationStrategy], + ["email", this.email as NotificationStrategy] + ]) + } /** * Dispatch a notification to an operator or customer. * TODO: wire to email/SMS provider (SendGrid, SMS API, etc.) via a mailer service. */ - async send(recipient: string, subject: string, body: string): Promise { - this.logger.log(`[notify] ${recipient} :: ${subject} :: ${body}`); + + async directSend(method: StrategyMethod, recipient: string, message: string) { + const strategy = this.strategies.get(method); + if (!strategy) { + throw new NotFoundException(); + } + const sent = await strategy.send(recipient, message) + this.logger.log(`is sent - ${sent}`) } + + } diff --git a/apps/edr-freight-api/src/modules/notifications/strategies/notification.email.strategy.ts b/apps/edr-freight-api/src/modules/notifications/strategies/notification.email.strategy.ts new file mode 100644 index 000000000..57873639b --- /dev/null +++ b/apps/edr-freight-api/src/modules/notifications/strategies/notification.email.strategy.ts @@ -0,0 +1,12 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { NotificationStrategy } from "./notification.strategy"; + +@Injectable() +export class EmailNotificationStrategy implements NotificationStrategy { + private readonly logger = new Logger(EmailNotificationStrategy.name); + constructor() { } + async send(recipient: string, message: string): Promise { + this.logger.log(`${recipient}, ${message}`) + return false; + } +} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/notifications/strategies/notification.sms.strategy.ts b/apps/edr-freight-api/src/modules/notifications/strategies/notification.sms.strategy.ts new file mode 100644 index 000000000..2f8916845 --- /dev/null +++ b/apps/edr-freight-api/src/modules/notifications/strategies/notification.sms.strategy.ts @@ -0,0 +1,25 @@ +import { Injectable} from "@nestjs/common"; +import { NotificationStrategy } from "./notification.strategy"; +import { HttpService } from '@nestjs/axios'; +import { ConfigService } from "@nestjs/config"; +import { firstValueFrom } from 'rxjs'; + +@Injectable() +export class SmsNotificationStrategy implements NotificationStrategy { + constructor(private readonly httpService: HttpService, private readonly configService: ConfigService) { } + async send(recipient: string, message: string) { + const url = this.configService.get("OZIKING_SMS_URL") + const body = { + to: recipient, + text: message + } + const response = await firstValueFrom( + this.httpService.post( + url, + body, + ), + ); + + return response.status === 201; + } +} diff --git a/apps/edr-freight-api/src/modules/notifications/strategies/notification.strategy.ts b/apps/edr-freight-api/src/modules/notifications/strategies/notification.strategy.ts new file mode 100644 index 000000000..2bc53120c --- /dev/null +++ b/apps/edr-freight-api/src/modules/notifications/strategies/notification.strategy.ts @@ -0,0 +1,6 @@ +import { Injectable } from "@nestjs/common"; + +@Injectable() +export abstract class NotificationStrategy { + abstract send(recipient: string, message: string): Promise +} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/otp/otp.controller.spec.ts b/apps/edr-freight-api/src/modules/otp/otp.controller.spec.ts new file mode 100644 index 000000000..cac5fdba0 --- /dev/null +++ b/apps/edr-freight-api/src/modules/otp/otp.controller.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { OtpController } from './otp.controller'; + +describe('OtpController', () => { + let controller: OtpController; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [OtpController], + }).compile(); + + controller = module.get(OtpController); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); +}); diff --git a/apps/edr-freight-api/src/modules/otp/otp.controller.ts b/apps/edr-freight-api/src/modules/otp/otp.controller.ts new file mode 100644 index 000000000..9866ca570 --- /dev/null +++ b/apps/edr-freight-api/src/modules/otp/otp.controller.ts @@ -0,0 +1,53 @@ +// otp.controller.ts + +import { + Body, + Controller, + Post, +} from "@nestjs/common"; + + +import { OtpService } from "./otp.service"; +import { Public } from "@edr/api-common"; + +@Controller("otp") +@Public() +export class OtpController { + constructor( + private readonly otpService: OtpService + ) {} + + // --------------------------------------------------------------------------- + // Send OTP + // --------------------------------------------------------------------------- + + @Post("send") + async sendOtp( + @Body("phone") + phone: string, + @Body("otp") + otp: string + ) { + return this.otpService.sendOtp( + phone,otp + ); + } + + // --------------------------------------------------------------------------- + // Verify OTP + // --------------------------------------------------------------------------- + + @Post("verify") + async verifyOtp( + @Body("phone") + phone: string, + + @Body("otp") + otp: string + ) { + return this.otpService.verifyOtp( + phone, + otp + ); + } +} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/otp/otp.entity.ts b/apps/edr-freight-api/src/modules/otp/otp.entity.ts new file mode 100644 index 000000000..f5900f6b8 --- /dev/null +++ b/apps/edr-freight-api/src/modules/otp/otp.entity.ts @@ -0,0 +1,25 @@ +// otp.entity.ts + +import { + Column, + Entity, +} from "typeorm"; +import { BaseEntity } from "@edr/api-common"; + +@Entity({ + name: "otp_verifications", +}) +export class OtpVerification extends BaseEntity{ + @Column({ + unique: true, + }) + phone!: string; + + @Column() + otp!: string; + + @Column({ + default: false, + }) + verified!: boolean; +} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/otp/otp.module.ts b/apps/edr-freight-api/src/modules/otp/otp.module.ts new file mode 100644 index 000000000..7a6d1faa6 --- /dev/null +++ b/apps/edr-freight-api/src/modules/otp/otp.module.ts @@ -0,0 +1,33 @@ +// otp.module.ts + +import { Module } from "@nestjs/common"; + +import { TypeOrmModule } from "@nestjs/typeorm"; + +import { OtpVerification } from "./otp.entity"; + +import { OtpController } from "./otp.controller"; + +import { OtpService } from "./otp.service"; + +import { OtpRepository } from "./otp.repository"; + +@Module({ + imports: [ + TypeOrmModule.forFeature([ + OtpVerification, + ]), + ], + + controllers: [OtpController], + + providers: [ + OtpService, + OtpRepository, + ], + + exports: [ + OtpRepository, + ], +}) +export class OtpModule {} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/otp/otp.repository.ts b/apps/edr-freight-api/src/modules/otp/otp.repository.ts new file mode 100644 index 000000000..8aa69dcd6 --- /dev/null +++ b/apps/edr-freight-api/src/modules/otp/otp.repository.ts @@ -0,0 +1,86 @@ +// otp.repository.ts + +import { Injectable } from "@nestjs/common"; + +import { InjectRepository } from "@nestjs/typeorm"; + +import { Repository } from "typeorm"; + +import { OtpVerification } from "./otp.entity"; + +@Injectable() +export class OtpRepository { + constructor( + @InjectRepository( + OtpVerification + ) + private readonly repository: Repository + ) {} + + // --------------------------------------------------------------------------- + // Find By Phone + // --------------------------------------------------------------------------- + + async findByPhone( + phone: string + ) { + return this.repository.findOne({ + where: { + phone, + }, + }); + } + + // --------------------------------------------------------------------------- + // Create OTP + // --------------------------------------------------------------------------- + + async createOtp( + phone: string, + otp: string + ) { + const entity = + this.repository.create({ + phone, + otp, + verified: false, + }); + + return this.repository.save( + entity + ); + } + + // --------------------------------------------------------------------------- + // Update OTP + // --------------------------------------------------------------------------- + + async updateOtp( + otpVerification: OtpVerification, + otp: string + ) { + otpVerification.otp = otp; + + otpVerification.verified = + false; + + return this.repository.save( + otpVerification + ); + } + + // --------------------------------------------------------------------------- + // Verify Phone + // --------------------------------------------------------------------------- + + async verifyPhone( + otpVerification: OtpVerification + ) { + otpVerification.verified = + true; + + return this.repository.save( + otpVerification + ); + } +} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/otp/otp.service.spec.ts b/apps/edr-freight-api/src/modules/otp/otp.service.spec.ts new file mode 100644 index 000000000..28e2afc26 --- /dev/null +++ b/apps/edr-freight-api/src/modules/otp/otp.service.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { OtpService } from './otp.service'; + +describe('OtpService', () => { + let service: OtpService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [OtpService], + }).compile(); + + service = module.get(OtpService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/apps/edr-freight-api/src/modules/otp/otp.service.ts b/apps/edr-freight-api/src/modules/otp/otp.service.ts new file mode 100644 index 000000000..4e16be20a --- /dev/null +++ b/apps/edr-freight-api/src/modules/otp/otp.service.ts @@ -0,0 +1,141 @@ +// otp.service.ts + +import { + BadRequestException, + Injectable, +} from "@nestjs/common"; + +import axios from "axios"; + +import { OtpRepository } from "./otp.repository"; + +@Injectable() +export class OtpService { + constructor( + private readonly otpRepository: OtpRepository + ) {} + + // --------------------------------------------------------------------------- + // Generate OTP + // --------------------------------------------------------------------------- + + generateOtp(): string { + return Math.floor( + 100000 + Math.random() * 900000 + ).toString(); + } + + // --------------------------------------------------------------------------- + // Send OTP + // --------------------------------------------------------------------------- + + async sendOtp(phone: string, otp: string) { + try { + // generate otp + // const otp = + // this.generateOtp(); + + // find existing phone + const existingPhone = + await this.otpRepository.findByPhone( + phone + ); + + // update existing otp + if (existingPhone) { + await this.otpRepository.updateOtp( + existingPhone, + otp + ); + } else { + // create new otp + await this.otpRepository.createOtp( + phone, + otp + ); + } + + // send sms + await axios.post( + "https://notification-dev.license.aafda.gov.et/api/sms-services/ozeking/sms", + { + to: phone, + + sourceId: "EDR", + + sourceName: + "EDR Freight", + + appKey: + "YOUR_APP_KEY", + + text: `Your verification code is ${otp}`, + + callbackUrl: "", + }, + { + headers: { + accept: "*/*", + + "Content-Type": + "application/json", + }, + } + ); + + return { + success: true, + + message: + "OTP sent successfully", + }; + } catch (error) { + console.log(error); + + throw new BadRequestException( + "Failed to send OTP" + ); + } + } + + // --------------------------------------------------------------------------- + // Verify OTP + // --------------------------------------------------------------------------- + + async verifyOtp( + phone: string, + otp: string + ) { + // find phone + const otpData = + await this.otpRepository.findByPhone( + phone + ); + + // phone not found + if (!otpData) { + throw new BadRequestException( + "Phone number not found" + ); + } + + // invalid otp + if (otpData.otp !== otp) { + throw new BadRequestException( + "Invalid OTP" + ); + } + + // verify phone + await this.otpRepository.verifyPhone( + otpData + ); + + return { + success: true, + + message: + "Phone verified successfully", + }; + } +} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/overview/dto/overview-query.dto.ts b/apps/edr-freight-api/src/modules/overview/dto/overview-query.dto.ts new file mode 100644 index 000000000..591fb6b6a --- /dev/null +++ b/apps/edr-freight-api/src/modules/overview/dto/overview-query.dto.ts @@ -0,0 +1,17 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsIn, IsOptional } from 'class-validator'; + +const OVERVIEW_RANGES = ['7d', '30d', '90d'] as const; + +export type OverviewRangeQuery = (typeof OVERVIEW_RANGES)[number]; + +export class OverviewQueryDto { + @ApiPropertyOptional({ + enum: OVERVIEW_RANGES, + default: '30d', + description: 'Time range for trend charts', + }) + @IsOptional() + @IsIn(OVERVIEW_RANGES) + range?: OverviewRangeQuery = '30d'; +} diff --git a/apps/edr-freight-api/src/modules/overview/dto/overview-response.dto.ts b/apps/edr-freight-api/src/modules/overview/dto/overview-response.dto.ts new file mode 100644 index 000000000..767a217a6 --- /dev/null +++ b/apps/edr-freight-api/src/modules/overview/dto/overview-response.dto.ts @@ -0,0 +1,104 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class OverviewBookingKpisDto { + @ApiProperty() totalActive!: number; + @ApiProperty() needsAction!: number; + @ApiProperty() urgent!: number; + @ApiProperty() inApproval!: number; + @ApiProperty() submittedToday!: number; +} + +export class OverviewOperationsKpisDto { + @ApiProperty() trainsActive!: number; + @ApiProperty() wagonsAvailable!: number; + @ApiProperty() containersInTransit!: number; + @ApiProperty() cargoesLoaded!: number; +} + +export class OverviewCustomerKpisDto { + @ApiProperty() totalCustomers!: number; + @ApiProperty() newCustomersThisMonth!: number; +} + +export class OverviewBillingKpisDto { + @ApiProperty() revenueMtdEtb!: number; + @ApiProperty() revenueMtdUsd!: number; + @ApiProperty() pendingPayments!: number; + @ApiProperty() successfulPaymentsMtd!: number; +} + +export class OverviewStaffKpisDto { + @ApiProperty() activeEmployees!: number; + @ApiProperty() activeUsers!: number; +} + +export class OverviewKpisDto { + @ApiProperty({ type: OverviewBookingKpisDto }) + bookings!: OverviewBookingKpisDto; + + @ApiProperty({ type: OverviewOperationsKpisDto }) + operations!: OverviewOperationsKpisDto; + + @ApiProperty({ type: OverviewCustomerKpisDto }) + customers!: OverviewCustomerKpisDto; + + @ApiProperty({ type: OverviewBillingKpisDto }) + billing!: OverviewBillingKpisDto; + + @ApiProperty({ type: OverviewStaffKpisDto }) + staff!: OverviewStaffKpisDto; +} + +export class OverviewTrendPointDto { + @ApiProperty({ example: '2026-06-01' }) date!: string; + @ApiProperty() count!: number; +} + +export class OverviewStatusCountDto { + @ApiProperty() status!: string; + @ApiProperty() count!: number; +} + +export class OverviewPipelineCountDto { + @ApiProperty() stage!: string; + @ApiProperty() count!: number; +} + +export class OverviewPaymentTrendPointDto { + @ApiProperty({ example: '2026-06-01' }) date!: string; + @ApiProperty() amountEtb!: number; + @ApiProperty() amountUsd!: number; +} + +export class OverviewRecentBookingDto { + @ApiProperty() id!: string; + @ApiProperty() reference!: string; + @ApiProperty() customerLabel!: string; + @ApiProperty() status!: string; + @ApiProperty() priorityScore!: number; + @ApiProperty({ nullable: true }) totalAmount!: number | null; + @ApiProperty({ nullable: true }) paymentCurrency!: string | null; + @ApiProperty() createdAt!: string; +} + +export class OverviewResponseDto { + @ApiProperty({ type: OverviewKpisDto }) + kpis!: OverviewKpisDto; + + @ApiProperty({ type: [OverviewTrendPointDto] }) + bookingTrend!: OverviewTrendPointDto[]; + + @ApiProperty({ type: [OverviewStatusCountDto] }) + bookingsByStatus!: OverviewStatusCountDto[]; + + @ApiProperty({ type: [OverviewPipelineCountDto] }) + bookingsByPipeline!: OverviewPipelineCountDto[]; + + @ApiProperty({ type: [OverviewPaymentTrendPointDto] }) + paymentTrend!: OverviewPaymentTrendPointDto[]; + + @ApiProperty({ type: [OverviewRecentBookingDto] }) + recentBookings!: OverviewRecentBookingDto[]; + + @ApiProperty() generatedAt!: string; +} diff --git a/apps/edr-freight-api/src/modules/overview/dto/overview-tab-response.dto.ts b/apps/edr-freight-api/src/modules/overview/dto/overview-tab-response.dto.ts new file mode 100644 index 000000000..c19a8baee --- /dev/null +++ b/apps/edr-freight-api/src/modules/overview/dto/overview-tab-response.dto.ts @@ -0,0 +1,131 @@ +import { ApiProperty } from '@nestjs/swagger'; + +import { + OverviewBillingKpisDto, + OverviewBookingKpisDto, + OverviewCustomerKpisDto, + OverviewOperationsKpisDto, + OverviewPaymentTrendPointDto, + OverviewPipelineCountDto, + OverviewRecentBookingDto, + OverviewStaffKpisDto, + OverviewStatusCountDto, + OverviewTrendPointDto, +} from './overview-response.dto'; + +export class OverviewLabelCountDto { + @ApiProperty() label!: string; + @ApiProperty() count!: number; +} + +export class OverviewPaymentMethodDto { + @ApiProperty() method!: string; + @ApiProperty() count!: number; + @ApiProperty() amountEtb!: number; + @ApiProperty() amountUsd!: number; +} + +export class OverviewCurrencyAmountDto { + @ApiProperty() currency!: string; + @ApiProperty() amount!: number; +} + +export class OverviewBookingsTabDto { + @ApiProperty({ type: OverviewBookingKpisDto }) + kpis!: OverviewBookingKpisDto; + + @ApiProperty({ type: [OverviewTrendPointDto] }) + bookingTrend!: OverviewTrendPointDto[]; + + @ApiProperty({ type: [OverviewStatusCountDto] }) + bookingsByStatus!: OverviewStatusCountDto[]; + + @ApiProperty({ type: [OverviewPipelineCountDto] }) + bookingsByPipeline!: OverviewPipelineCountDto[]; + + @ApiProperty({ type: [OverviewLabelCountDto] }) + bookingsByFreightType!: OverviewLabelCountDto[]; + + @ApiProperty({ type: [OverviewLabelCountDto] }) + bookingsByCurrency!: OverviewLabelCountDto[]; + + @ApiProperty({ type: [OverviewRecentBookingDto] }) + recentBookings!: OverviewRecentBookingDto[]; + + @ApiProperty() + generatedAt!: string; +} + +export class OverviewBillingTabDto { + @ApiProperty({ type: OverviewBillingKpisDto }) + kpis!: OverviewBillingKpisDto; + + @ApiProperty({ type: [OverviewPaymentTrendPointDto] }) + paymentTrend!: OverviewPaymentTrendPointDto[]; + + @ApiProperty({ type: [OverviewStatusCountDto] }) + paymentsByStatus!: OverviewStatusCountDto[]; + + @ApiProperty({ type: [OverviewPaymentMethodDto] }) + paymentsByMethod!: OverviewPaymentMethodDto[]; + + @ApiProperty({ type: [OverviewCurrencyAmountDto] }) + revenueByCurrency!: OverviewCurrencyAmountDto[]; + + @ApiProperty() + generatedAt!: string; +} + +export class OverviewOperationsTabDto { + @ApiProperty({ type: OverviewOperationsKpisDto }) + kpis!: OverviewOperationsKpisDto; + + @ApiProperty({ type: [OverviewStatusCountDto] }) + trainStatusBreakdown!: OverviewStatusCountDto[]; + + @ApiProperty({ type: [OverviewStatusCountDto] }) + wagonStatusBreakdown!: OverviewStatusCountDto[]; + + @ApiProperty({ type: [OverviewStatusCountDto] }) + containerStatusBreakdown!: OverviewStatusCountDto[]; + + @ApiProperty({ type: [OverviewStatusCountDto] }) + cargoStatusBreakdown!: OverviewStatusCountDto[]; + + @ApiProperty() + generatedAt!: string; +} + +export class OverviewCustomersTabDto { + @ApiProperty({ type: OverviewCustomerKpisDto }) + kpis!: OverviewCustomerKpisDto; + + @ApiProperty({ type: [OverviewTrendPointDto] }) + customerGrowthTrend!: OverviewTrendPointDto[]; + + @ApiProperty({ type: [OverviewLabelCountDto] }) + customersByType!: OverviewLabelCountDto[]; + + @ApiProperty({ type: [OverviewLabelCountDto] }) + topCustomersByBookings!: OverviewLabelCountDto[]; + + @ApiProperty() + generatedAt!: string; +} + +export class OverviewStaffTabDto { + @ApiProperty({ type: OverviewStaffKpisDto }) + kpis!: OverviewStaffKpisDto; + + @ApiProperty({ type: [OverviewStatusCountDto] }) + usersByStatus!: OverviewStatusCountDto[]; + + @ApiProperty({ type: [OverviewTrendPointDto] }) + employeeGrowthTrend!: OverviewTrendPointDto[]; + + @ApiProperty({ type: [OverviewLabelCountDto] }) + activeUsersBreakdown!: OverviewLabelCountDto[]; + + @ApiProperty() + generatedAt!: string; +} diff --git a/apps/edr-freight-api/src/modules/overview/overview.constants.ts b/apps/edr-freight-api/src/modules/overview/overview.constants.ts new file mode 100644 index 000000000..fed9a76c7 --- /dev/null +++ b/apps/edr-freight-api/src/modules/overview/overview.constants.ts @@ -0,0 +1,26 @@ +export const OVERVIEW_URGENT_PRIORITY_THRESHOLD = 1000; + +export const OVERVIEW_NEEDS_ACTION_STATUSES = [ + 'SUBMITTED', + 'PENDING_APPROVAL', + 'APPROVED_PENDING_SIGNATURE', +] as const; + +export const OVERVIEW_IN_APPROVAL_STATUSES = [ + 'PENDING_APPROVAL', + 'APPROVED_PENDING_SIGNATURE', +] as const; + +export const OVERVIEW_CLOSED_STATUSES = [ + 'REJECTED', + 'CANCELLED', + 'COMPLETED', +] as const; + +export const OVERVIEW_RANGE_DAYS = { + '7d': 7, + '30d': 30, + '90d': 90, +} as const; + +export type OverviewRange = keyof typeof OVERVIEW_RANGE_DAYS; diff --git a/apps/edr-freight-api/src/modules/overview/overview.controller.ts b/apps/edr-freight-api/src/modules/overview/overview.controller.ts new file mode 100644 index 000000000..fe545b452 --- /dev/null +++ b/apps/edr-freight-api/src/modules/overview/overview.controller.ts @@ -0,0 +1,74 @@ +import { Controller, Get, Query } from '@nestjs/common'; +import { + ApiBearerAuth, + ApiOkResponse, + ApiOperation, + ApiTags, +} from '@nestjs/swagger'; + +import { BookingView } from '../../common/booking-guards'; +import { OverviewQueryDto } from './dto/overview-query.dto'; +import { OverviewResponseDto } from './dto/overview-response.dto'; +import { + OverviewBillingTabDto, + OverviewBookingsTabDto, + OverviewCustomersTabDto, + OverviewOperationsTabDto, + OverviewStaffTabDto, +} from './dto/overview-tab-response.dto'; +import { OverviewService } from './overview.service'; + +@ApiTags('Overview') +@ApiBearerAuth() +@Controller('overview') +export class OverviewController { + constructor(private readonly overviewService: OverviewService) {} + + @Get() + @BookingView() + @ApiOperation({ summary: 'Aggregated dashboard summary for backoffice overview' }) + @ApiOkResponse({ type: OverviewResponseDto }) + getDashboard(@Query() query: OverviewQueryDto): Promise { + return this.overviewService.getDashboard(query.range ?? '30d'); + } + + @Get('bookings') + @BookingView() + @ApiOperation({ summary: 'Bookings tab metrics and charts' }) + @ApiOkResponse({ type: OverviewBookingsTabDto }) + getBookingsTab(@Query() query: OverviewQueryDto): Promise { + return this.overviewService.getBookingsTab(query.range ?? '30d'); + } + + @Get('billing') + @BookingView() + @ApiOperation({ summary: 'Billing tab metrics and charts' }) + @ApiOkResponse({ type: OverviewBillingTabDto }) + getBillingTab(@Query() query: OverviewQueryDto): Promise { + return this.overviewService.getBillingTab(query.range ?? '30d'); + } + + @Get('operations') + @BookingView() + @ApiOperation({ summary: 'Operations tab metrics and charts' }) + @ApiOkResponse({ type: OverviewOperationsTabDto }) + getOperationsTab(): Promise { + return this.overviewService.getOperationsTab(); + } + + @Get('customers') + @BookingView() + @ApiOperation({ summary: 'Customers tab metrics and charts' }) + @ApiOkResponse({ type: OverviewCustomersTabDto }) + getCustomersTab(@Query() query: OverviewQueryDto): Promise { + return this.overviewService.getCustomersTab(query.range ?? '30d'); + } + + @Get('staff') + @BookingView() + @ApiOperation({ summary: 'Staff tab metrics and charts' }) + @ApiOkResponse({ type: OverviewStaffTabDto }) + getStaffTab(@Query() query: OverviewQueryDto): Promise { + return this.overviewService.getStaffTab(query.range ?? '30d'); + } +} diff --git a/apps/edr-freight-api/src/modules/overview/overview.module.ts b/apps/edr-freight-api/src/modules/overview/overview.module.ts new file mode 100644 index 000000000..87cdc62d5 --- /dev/null +++ b/apps/edr-freight-api/src/modules/overview/overview.module.ts @@ -0,0 +1,34 @@ +import { Module } from "@nestjs/common"; +import { TypeOrmModule } from "@nestjs/typeorm"; +import { Employee } from "@tria-plc/iamapi-common"; +import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity"; + +import { Booking } from "../bookings/entities/booking.entity"; +import { Cargo } from "../cargoes/entities/cargoes.entity"; +import { Container } from "../container-management/entities/container.entity"; +import { Company } from "../companies/entities/company.entity"; +import { PaymentEntity } from "../payment/entities/payment.entity"; +import { Train } from "../trains/entities/train.entity"; +import { Wagon } from "../wagons/entities/wagon.entity"; +import { OverviewController } from "./overview.controller"; +import { OverviewRepository } from "./overview.repository"; +import { OverviewService } from "./overview.service"; + +@Module({ + imports: [ + TypeOrmModule.forFeature([ + Booking, + PaymentEntity, + Company, + Train, + Wagon, + Container, + Cargo, + Employee, + User, + ]), + ], + controllers: [OverviewController], + providers: [OverviewService, OverviewRepository], +}) +export class OverviewModule { } diff --git a/apps/edr-freight-api/src/modules/overview/overview.repository.ts b/apps/edr-freight-api/src/modules/overview/overview.repository.ts new file mode 100644 index 000000000..a57ba24db --- /dev/null +++ b/apps/edr-freight-api/src/modules/overview/overview.repository.ts @@ -0,0 +1,595 @@ +import { Injectable } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { EUserStatus } from "@tria-plc/api-common/utils/enums/user.enum"; +import { Employee } from "@tria-plc/iamapi-common"; +import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity"; +import { Freight } from "@edr/types"; +import { Repository, ObjectLiteral } from "typeorm"; + +import { Booking } from "../bookings/entities/booking.entity"; +import { Cargo } from "../cargoes/entities/cargoes.entity"; +import { Container } from "../container-management/entities/container.entity"; +import { PaymentEntity } from "../payment/entities/payment.entity"; +import { Train } from "../trains/entities/train.entity"; +import { Wagon } from "../wagons/entities/wagon.entity"; +import { + OVERVIEW_CLOSED_STATUSES, + OVERVIEW_IN_APPROVAL_STATUSES, + OVERVIEW_NEEDS_ACTION_STATUSES, + OVERVIEW_URGENT_PRIORITY_THRESHOLD, +} from "./overview.constants"; +import { Company } from "../companies/entities/company.entity"; + +export type OverviewBookingKpisRow = { + totalActive: number; + needsAction: number; + urgent: number; + inApproval: number; + submittedToday: number; +}; + +export type OverviewRecentBookingRow = { + id: string; + reference: string; + customerLabel: string; + status: string; + priorityScore: number; + totalAmount: number | null; + paymentCurrency: string | null; + createdAt: Date; +}; + +@Injectable() +export class OverviewRepository { + constructor( + @InjectRepository(Booking) + private readonly bookingRepository: Repository, + @InjectRepository(PaymentEntity) + private readonly paymentRepository: Repository, + @InjectRepository(Company) + private readonly companyRepository: Repository, + @InjectRepository(Train) + private readonly trainRepository: Repository, + @InjectRepository(Wagon) + private readonly wagonRepository: Repository, + @InjectRepository(Container) + private readonly containerRepository: Repository, + @InjectRepository(Cargo) + private readonly cargoRepository: Repository, + @InjectRepository(Employee) + private readonly employeeRepository: Repository, + @InjectRepository(User) + private readonly userRepository: Repository, + ) { } + + async getBookingKpis(): Promise { + const row = await this.bookingRepository + .createQueryBuilder("booking") + .select( + `COUNT(*) FILTER (WHERE booking.status NOT IN (:...closedStatuses) AND booking.status != 'DRAFT')::int`, + "totalActive", + ) + .addSelect( + `COUNT(*) FILTER (WHERE booking.status IN (:...needsActionStatuses))::int`, + "needsAction", + ) + .addSelect( + `COUNT(*) FILTER (WHERE booking.priority_score >= :urgentThreshold)::int`, + "urgent", + ) + .addSelect( + `COUNT(*) FILTER (WHERE booking.status IN (:...inApprovalStatuses))::int`, + "inApproval", + ) + .addSelect( + `COUNT(*) FILTER (WHERE booking.created_at >= CURRENT_DATE AND booking.status != 'DRAFT')::int`, + "submittedToday", + ) + .where("booking.deleted_at IS NULL") + .setParameters({ + closedStatuses: [...OVERVIEW_CLOSED_STATUSES], + needsActionStatuses: [...OVERVIEW_NEEDS_ACTION_STATUSES], + inApprovalStatuses: [...OVERVIEW_IN_APPROVAL_STATUSES], + urgentThreshold: OVERVIEW_URGENT_PRIORITY_THRESHOLD, + }) + .getRawOne>(); + + return { + totalActive: Number(row?.totalActive ?? 0), + needsAction: Number(row?.needsAction ?? 0), + urgent: Number(row?.urgent ?? 0), + inApproval: Number(row?.inApproval ?? 0), + submittedToday: Number(row?.submittedToday ?? 0), + }; + } + + async getOperationsKpis(): Promise<{ + trainsActive: number; + wagonsAvailable: number; + containersInTransit: number; + cargoesLoaded: number; + }> { + const [trainsActive, wagonsAvailable, containersInTransit, cargoesLoaded] = + await Promise.all([ + this.trainRepository + .createQueryBuilder("train") + .where("train.deleted_at IS NULL") + .andWhere("train.status IN (:...statuses)", { + statuses: [ + Freight.TrainStatus.InService, + Freight.TrainStatus.Scheduled, + ], + }) + .getCount(), + this.wagonRepository + .createQueryBuilder("wagon") + .where("wagon.deleted_at IS NULL") + .andWhere("wagon.status = :status", { + status: Freight.WagonStatus.Available, + }) + .getCount(), + this.containerRepository + .createQueryBuilder("container") + .where("container.deleted_at IS NULL") + .andWhere("container.status = :status", { status: "IN_TRANSIT" }) + .getCount(), + this.cargoRepository + .createQueryBuilder("cargo") + .where("cargo.deleted_at IS NULL") + .andWhere("cargo.status IN (:...statuses)", { + statuses: ["LOADED", "IN_TRANSIT"], + }) + .getCount(), + ]); + + return { + trainsActive, + wagonsAvailable, + containersInTransit, + cargoesLoaded, + }; + } + + async getCustomerKpis(): Promise<{ + totalCustomers: number; + newCustomersThisMonth: number; + }> { + const row = await this.companyRepository + .createQueryBuilder("customer") + .select("COUNT(*)::int", "totalCustomers") + .addSelect( + `COUNT(*) FILTER (WHERE customer.created_at >= date_trunc('month', CURRENT_DATE))::int`, + "newCustomersThisMonth", + ) + .where("customer.deleted_at IS NULL") + .getRawOne>(); + + return { + totalCustomers: Number(row?.totalCustomers ?? 0), + newCustomersThisMonth: Number(row?.newCustomersThisMonth ?? 0), + }; + } + + async getBillingKpis(): Promise<{ + revenueMtdEtb: number; + revenueMtdUsd: number; + pendingPayments: number; + successfulPaymentsMtd: number; + }> { + const revenueRow = await this.paymentRepository + .createQueryBuilder("payment") + .select( + `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'ETB'), 0)`, + "revenueMtdEtb", + ) + .addSelect( + `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`, + "revenueMtdUsd", + ) + .addSelect(`COUNT(*)::int`, "successfulPaymentsMtd") + .where("payment.status = :status", { status: "success" }) + .andWhere( + `COALESCE(payment.paid_at, payment.created_at) >= date_trunc('month', CURRENT_DATE)`, + ) + .getRawOne>(); + + const pendingPayments = await this.paymentRepository + .createQueryBuilder("payment") + .where("payment.status IN (:...statuses)", { + statuses: ["action-required", "processing"], + }) + .getCount(); + + return { + revenueMtdEtb: Number(revenueRow?.revenueMtdEtb ?? 0), + revenueMtdUsd: Number(revenueRow?.revenueMtdUsd ?? 0), + pendingPayments, + successfulPaymentsMtd: Number(revenueRow?.successfulPaymentsMtd ?? 0), + }; + } + + async getStaffKpis(): Promise<{ + activeEmployees: number; + activeUsers: number; + }> { + const [activeEmployees, activeUsers] = await Promise.all([ + this.employeeRepository.count({ + where: { isCurrent: true }, + }), + this.userRepository.count({ + where: { + isActive: true, + status: EUserStatus.ACCEPTED, + }, + }), + ]); + + return { activeEmployees, activeUsers }; + } + + async getBookingTrend( + days: number, + ): Promise<{ date: string; count: number }[]> { + const rows = await this.bookingRepository + .createQueryBuilder("booking") + .select(`to_char(booking.created_at::date, 'YYYY-MM-DD')`, "date") + .addSelect("COUNT(*)::int", "count") + .where("booking.deleted_at IS NULL") + .andWhere(`booking.created_at >= CURRENT_DATE - :days::int + 1`, { days }) + .groupBy("booking.created_at::date") + .orderBy("booking.created_at::date", "ASC") + .getRawMany<{ date: string; count: string }>(); + + return rows.map((row) => ({ + date: row.date, + count: Number(row.count), + })); + } + + async getStatusCounts(): Promise> { + const rows = await this.bookingRepository + .createQueryBuilder("booking") + .select("booking.status", "status") + .addSelect("COUNT(*)::int", "count") + .where("booking.deleted_at IS NULL") + .groupBy("booking.status") + .getRawMany<{ status: string; count: string }>(); + + return Object.fromEntries( + rows.map((row) => [row.status, Number(row.count)]), + ); + } + + async getPaymentTrend( + days: number, + ): Promise<{ date: string; amountEtb: number; amountUsd: number }[]> { + const rows = await this.paymentRepository + .createQueryBuilder("payment") + .select( + `to_char(COALESCE(payment.paid_at, payment.created_at)::date, 'YYYY-MM-DD')`, + "date", + ) + .addSelect( + `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'ETB'), 0)`, + "amountEtb", + ) + .addSelect( + `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`, + "amountUsd", + ) + .where("payment.status = :status", { status: "success" }) + .andWhere( + `COALESCE(payment.paid_at, payment.created_at) >= CURRENT_DATE - :days::int + 1`, + { days }, + ) + .groupBy(`COALESCE(payment.paid_at, payment.created_at)::date`) + .orderBy(`COALESCE(payment.paid_at, payment.created_at)::date`, "ASC") + .getRawMany<{ date: string; amountEtb: string; amountUsd: string }>(); + + return rows.map((row) => ({ + date: row.date, + amountEtb: Number(row.amountEtb), + amountUsd: Number(row.amountUsd), + })); + } + + async getRecentBookings(limit: number): Promise { + const rows = await this.bookingRepository + .createQueryBuilder("booking") + .leftJoin("booking.company", "company") + .select("booking.id", "id") + .addSelect("booking.reference", "reference") + .addSelect("COALESCE(company.name, '—')", "customerLabel") + .addSelect("booking.status", "status") + .addSelect("booking.priority_score", "priorityScore") + .addSelect("booking.total_amount", "totalAmount") + .addSelect("booking.payment_currency", "paymentCurrency") + .addSelect("booking.created_at", "createdAt") + .where("booking.deleted_at IS NULL") + .orderBy("booking.created_at", "DESC") + .limit(limit) + .getRawMany<{ + id: string; + reference: string; + customerLabel: string; + status: string; + priorityScore: string; + totalAmount: string | null; + paymentCurrency: string | null; + createdAt: Date; + }>(); + + return rows.map((row) => ({ + id: row.id, + reference: row.reference, + customerLabel: row.customerLabel, + status: row.status, + priorityScore: Number(row.priorityScore), + totalAmount: row.totalAmount != null ? Number(row.totalAmount) : null, + paymentCurrency: row.paymentCurrency, + createdAt: row.createdAt, + })); + } + + async getBookingsByFreightType(): Promise< + { label: string; count: number }[] + > { + const rows = await this.bookingRepository + .createQueryBuilder("booking") + .select("booking.freight_type", "label") + .addSelect("COUNT(*)::int", "count") + .where("booking.deleted_at IS NULL") + .andWhere("booking.status != 'DRAFT'") + .groupBy("booking.freight_type") + .orderBy("count", "DESC") + .getRawMany<{ label: string; count: string }>(); + + return rows.map((row) => ({ + label: row.label, + count: Number(row.count), + })); + } + + async getBookingsByCurrency(): Promise<{ label: string; count: number }[]> { + const rows = await this.bookingRepository + .createQueryBuilder("booking") + .select("booking.payment_currency", "label") + .addSelect("COUNT(*)::int", "count") + .where("booking.deleted_at IS NULL") + .andWhere("booking.status != 'DRAFT'") + .groupBy("booking.payment_currency") + .orderBy("count", "DESC") + .getRawMany<{ label: string; count: string }>(); + + return rows.map((row) => ({ + label: row.label, + count: Number(row.count), + })); + } + + async getPaymentsByStatus(): Promise<{ status: string; count: number }[]> { + const rows = await this.paymentRepository + .createQueryBuilder("payment") + .select("payment.status", "status") + .addSelect("COUNT(*)::int", "count") + .groupBy("payment.status") + .orderBy("count", "DESC") + .getRawMany<{ status: string; count: string }>(); + + return rows.map((row) => ({ + status: row.status, + count: Number(row.count), + })); + } + + async getPaymentsByMethod(): Promise< + { method: string; count: number; amountEtb: number; amountUsd: number }[] + > { + const rows = await this.paymentRepository + .createQueryBuilder("payment") + .select("payment.method", "method") + .addSelect("COUNT(*)::int", "count") + .addSelect( + `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'ETB' AND payment.status = 'success'), 0)`, + "amountEtb", + ) + .addSelect( + `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD' AND payment.status = 'success'), 0)`, + "amountUsd", + ) + .groupBy("payment.method") + .orderBy("count", "DESC") + .getRawMany<{ + method: string; + count: string; + amountEtb: string; + amountUsd: string; + }>(); + + return rows.map((row) => ({ + method: row.method, + count: Number(row.count), + amountEtb: Number(row.amountEtb), + amountUsd: Number(row.amountUsd), + })); + } + + async getRevenueByCurrency(): Promise< + { currency: string; amount: number }[] + > { + const rows = await this.paymentRepository + .createQueryBuilder("payment") + .select("payment.currency", "currency") + .addSelect("COALESCE(SUM(payment.amount), 0)", "amount") + .where("payment.status = :status", { status: "success" }) + .andWhere( + `COALESCE(payment.paid_at, payment.created_at) >= date_trunc('month', CURRENT_DATE)`, + ) + .groupBy("payment.currency") + .getRawMany<{ currency: string; amount: string }>(); + + return rows.map((row) => ({ + currency: row.currency, + amount: Number(row.amount), + })); + } + + async getTrainStatusBreakdown(): Promise< + { status: string; count: number }[] + > { + return this.statusBreakdown(this.trainRepository, "train"); + } + + async getWagonStatusBreakdown(): Promise< + { status: string; count: number }[] + > { + return this.statusBreakdown(this.wagonRepository, "wagon"); + } + + async getContainerStatusBreakdown(): Promise< + { status: string; count: number }[] + > { + return this.statusBreakdown(this.containerRepository, "container"); + } + + async getCargoStatusBreakdown(): Promise< + { status: string; count: number }[] + > { + return this.statusBreakdown(this.cargoRepository, "cargo"); + } + + private async statusBreakdown( + repository: Repository, + alias: string, + ): Promise<{ status: string; count: number }[]> { + const rows = await repository + .createQueryBuilder(alias) + .select(`${alias}.status`, "status") + .addSelect("COUNT(*)::int", "count") + .where(`${alias}.deleted_at IS NULL`) + .groupBy(`${alias}.status`) + .orderBy("count", "DESC") + .getRawMany<{ status: string; count: string }>(); + + return rows.map((row) => ({ + status: row.status, + count: Number(row.count), + })); + } + + async getCustomerGrowthTrend( + days: number, + ): Promise<{ date: string; count: number }[]> { + const rows = await this.companyRepository + .createQueryBuilder("customer") + .select(`to_char(customer.created_at::date, 'YYYY-MM-DD')`, "date") + .addSelect("COUNT(*)::int", "count") + .where("customer.deleted_at IS NULL") + .andWhere(`customer.created_at >= CURRENT_DATE - :days::int + 1`, { + days, + }) + .groupBy("customer.created_at::date") + .orderBy("customer.created_at::date", "ASC") + .getRawMany<{ date: string; count: string }>(); + + return rows.map((row) => ({ + date: row.date, + count: Number(row.count), + })); + } + + async getCustomersByType(): Promise<{ label: string; count: number }[]> { + const rows = await this.companyRepository + .createQueryBuilder("customer") + .select( + `COALESCE(NULLIF(customer.type, ''), 'Unknown')`, + "label", + ) + .addSelect("COUNT(*)::int", "count") + .where("customer.deleted_at IS NULL") + .groupBy("customer.type") + .orderBy("count", "DESC") + .getRawMany<{ label: string; count: string }>(); + + return rows.map((row) => ({ + label: row.label, + count: Number(row.count), + })); + } + + async getTopCustomersByBookings( + limit: number, + ): Promise<{ label: string; count: number }[]> { + const rows = await this.bookingRepository + .createQueryBuilder("booking") + .leftJoin("booking.company", "company") + .select(`COALESCE(company.name, 'Unknown')`, "label") + .addSelect("COUNT(*)::int", "count") + .where("booking.deleted_at IS NULL") + .andWhere("booking.status != 'DRAFT'") + .groupBy("company.name") + .orderBy("count", "DESC") + .limit(limit) + .getRawMany<{ label: string; count: string }>(); + + return rows.map((row) => ({ + label: row.label, + count: Number(row.count), + })); + } + + async getUsersByStatus(): Promise<{ status: string; count: number }[]> { + const rows = await this.userRepository + .createQueryBuilder("user") + .select("user.status", "status") + .addSelect("COUNT(*)::int", "count") + .groupBy("user.status") + .orderBy("count", "DESC") + .getRawMany<{ status: string; count: string }>(); + + return rows.map((row) => ({ + status: row.status, + count: Number(row.count), + })); + } + + async getEmployeeGrowthTrend( + days: number, + ): Promise<{ date: string; count: number }[]> { + const rows = await this.employeeRepository + .createQueryBuilder("employee") + .select(`to_char(employee.created_at::date, 'YYYY-MM-DD')`, "date") + .addSelect("COUNT(*)::int", "count") + .where("employee.is_current = true") + .andWhere(`employee.created_at >= CURRENT_DATE - :days::int + 1`, { + days, + }) + .groupBy("employee.created_at::date") + .orderBy("employee.created_at::date", "ASC") + .getRawMany<{ date: string; count: string }>(); + + return rows.map((row) => ({ + date: row.date, + count: Number(row.count), + })); + } + + async getActiveUsersBreakdown(): Promise<{ label: string; count: number }[]> { + const [active, inactive] = await Promise.all([ + this.userRepository.count({ + where: { isActive: true, status: EUserStatus.ACCEPTED }, + }), + this.userRepository + .createQueryBuilder("user") + .where("user.is_active = false OR user.status != :status", { + status: EUserStatus.ACCEPTED, + }) + .getCount(), + ]); + + return [ + { label: "Active", count: active }, + { label: "Inactive", count: inactive }, + ]; + } +} diff --git a/apps/edr-freight-api/src/modules/overview/overview.service.ts b/apps/edr-freight-api/src/modules/overview/overview.service.ts new file mode 100644 index 000000000..feadf2409 --- /dev/null +++ b/apps/edr-freight-api/src/modules/overview/overview.service.ts @@ -0,0 +1,210 @@ +import { Injectable } from '@nestjs/common'; + +import { + BOOKING_LIST_TABS, + mapStatusCountsToTabs, +} from '../bookings/booking-list-tabs.config'; +import type { OverviewRangeQuery } from './dto/overview-query.dto'; +import type { OverviewResponseDto } from './dto/overview-response.dto'; +import type { + OverviewBillingTabDto, + OverviewBookingsTabDto, + OverviewCustomersTabDto, + OverviewOperationsTabDto, + OverviewStaffTabDto, +} from './dto/overview-tab-response.dto'; +import { OVERVIEW_RANGE_DAYS } from './overview.constants'; +import { OverviewRepository } from './overview.repository'; + +@Injectable() +export class OverviewService { + constructor(private readonly overviewRepository: OverviewRepository) {} + + private mapStatusCounts(statusCounts: Record) { + const pipelineTabs = mapStatusCountsToTabs(statusCounts); + const bookingsByPipeline = BOOKING_LIST_TABS.filter( + (tab) => tab.key !== 'all', + ).map((tab) => ({ + stage: tab.key, + count: pipelineTabs[tab.key], + })); + + const bookingsByStatus = Object.entries(statusCounts) + .map(([status, count]) => ({ status, count })) + .sort((a, b) => b.count - a.count); + + return { bookingsByPipeline, bookingsByStatus }; + } + + async getDashboard(range: OverviewRangeQuery = '30d'): Promise { + const days = OVERVIEW_RANGE_DAYS[range]; + + const [ + bookingKpis, + operationsKpis, + customerKpis, + billingKpis, + staffKpis, + bookingTrend, + statusCounts, + paymentTrend, + recentBookings, + ] = await Promise.all([ + this.overviewRepository.getBookingKpis(), + this.overviewRepository.getOperationsKpis(), + this.overviewRepository.getCustomerKpis(), + this.overviewRepository.getBillingKpis(), + this.overviewRepository.getStaffKpis(), + this.overviewRepository.getBookingTrend(days), + this.overviewRepository.getStatusCounts(), + this.overviewRepository.getPaymentTrend(days), + this.overviewRepository.getRecentBookings(8), + ]); + + const { bookingsByPipeline, bookingsByStatus } = + this.mapStatusCounts(statusCounts); + + return { + kpis: { + bookings: bookingKpis, + operations: operationsKpis, + customers: customerKpis, + billing: billingKpis, + staff: staffKpis, + }, + bookingTrend, + bookingsByStatus, + bookingsByPipeline, + paymentTrend, + recentBookings: recentBookings.map((row) => ({ + ...row, + createdAt: row.createdAt.toISOString(), + })), + generatedAt: new Date().toISOString(), + }; + } + + async getBookingsTab(range: OverviewRangeQuery = '30d'): Promise { + const days = OVERVIEW_RANGE_DAYS[range]; + + const [ + kpis, + bookingTrend, + statusCounts, + bookingsByFreightType, + bookingsByCurrency, + recentBookings, + ] = await Promise.all([ + this.overviewRepository.getBookingKpis(), + this.overviewRepository.getBookingTrend(days), + this.overviewRepository.getStatusCounts(), + this.overviewRepository.getBookingsByFreightType(), + this.overviewRepository.getBookingsByCurrency(), + this.overviewRepository.getRecentBookings(8), + ]); + + const { bookingsByPipeline, bookingsByStatus } = + this.mapStatusCounts(statusCounts); + + return { + kpis, + bookingTrend, + bookingsByStatus, + bookingsByPipeline, + bookingsByFreightType, + bookingsByCurrency, + recentBookings: recentBookings.map((row) => ({ + ...row, + createdAt: row.createdAt.toISOString(), + })), + generatedAt: new Date().toISOString(), + }; + } + + async getBillingTab(range: OverviewRangeQuery = '30d'): Promise { + const days = OVERVIEW_RANGE_DAYS[range]; + + const [kpis, paymentTrend, paymentsByStatus, paymentsByMethod, revenueByCurrency] = + await Promise.all([ + this.overviewRepository.getBillingKpis(), + this.overviewRepository.getPaymentTrend(days), + this.overviewRepository.getPaymentsByStatus(), + this.overviewRepository.getPaymentsByMethod(), + this.overviewRepository.getRevenueByCurrency(), + ]); + + return { + kpis, + paymentTrend, + paymentsByStatus, + paymentsByMethod, + revenueByCurrency, + generatedAt: new Date().toISOString(), + }; + } + + async getOperationsTab(): Promise { + const [ + kpis, + trainStatusBreakdown, + wagonStatusBreakdown, + containerStatusBreakdown, + cargoStatusBreakdown, + ] = await Promise.all([ + this.overviewRepository.getOperationsKpis(), + this.overviewRepository.getTrainStatusBreakdown(), + this.overviewRepository.getWagonStatusBreakdown(), + this.overviewRepository.getContainerStatusBreakdown(), + this.overviewRepository.getCargoStatusBreakdown(), + ]); + + return { + kpis, + trainStatusBreakdown, + wagonStatusBreakdown, + containerStatusBreakdown, + cargoStatusBreakdown, + generatedAt: new Date().toISOString(), + }; + } + + async getCustomersTab(range: OverviewRangeQuery = '30d'): Promise { + const days = OVERVIEW_RANGE_DAYS[range]; + + const [kpis, customerGrowthTrend, customersByType, topCustomersByBookings] = + await Promise.all([ + this.overviewRepository.getCustomerKpis(), + this.overviewRepository.getCustomerGrowthTrend(days), + this.overviewRepository.getCustomersByType(), + this.overviewRepository.getTopCustomersByBookings(8), + ]); + + return { + kpis, + customerGrowthTrend, + customersByType, + topCustomersByBookings, + generatedAt: new Date().toISOString(), + }; + } + + async getStaffTab(range: OverviewRangeQuery = '30d'): Promise { + const days = OVERVIEW_RANGE_DAYS[range]; + + const [kpis, usersByStatus, employeeGrowthTrend, activeUsersBreakdown] = + await Promise.all([ + this.overviewRepository.getStaffKpis(), + this.overviewRepository.getUsersByStatus(), + this.overviewRepository.getEmployeeGrowthTrend(days), + this.overviewRepository.getActiveUsersBreakdown(), + ]); + + return { + kpis, + usersByStatus, + employeeGrowthTrend, + activeUsersBreakdown, + generatedAt: new Date().toISOString(), + }; + } +} diff --git a/apps/edr-freight-api/src/modules/payment/dto/update-payment-status.dto.ts b/apps/edr-freight-api/src/modules/payment/dto/update-payment-status.dto.ts new file mode 100644 index 000000000..de72c8ba7 --- /dev/null +++ b/apps/edr-freight-api/src/modules/payment/dto/update-payment-status.dto.ts @@ -0,0 +1,23 @@ +import { IsEnum, IsOptional, IsString } from "class-validator"; + +export enum PaymentStatus { + REQUIRES_ACTION, + PROCESSING, + SUCCEEDED, + FAILED, + CANCELLED, + REFUNDED, + +} +export class UpdatePaymentStatusDto { + @IsString() + orderId!: string; + + + @IsEnum(PaymentStatus) + status!: PaymentStatus + + @IsOptional() + @IsString() + failureMessage?: string +} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/payment/entities/payment-refund.entity.ts b/apps/edr-freight-api/src/modules/payment/entities/payment-refund.entity.ts new file mode 100644 index 000000000..e0fed2cb4 --- /dev/null +++ b/apps/edr-freight-api/src/modules/payment/entities/payment-refund.entity.ts @@ -0,0 +1,37 @@ +import { + Column, + CreateDateColumn, + Entity, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, +} from "typeorm"; +import { PaymentEntity } from "./payment.entity"; + +@Entity({ schema: "freight", name: "payment_refunds" }) +export class PaymentRefundEntity { + @PrimaryGeneratedColumn("uuid") + id!: string; + + @Column({ type: "uuid", name: "payment_id" }) + paymentId!: string; + + @Column({ type: "int", name: "amount_minor" }) + amountMinor!: number; + + @Column({ type: "varchar", length: 255, nullable: true }) + reason?: string; + + @Column({ type: "varchar", length: 255, nullable: true, name: "provider_refund_id" }) + providerRefundId?: string; + + @Column({ type: "varchar", length: 50 }) + status!: string; + + @CreateDateColumn({ name: "created_at" }) + createdAt!: Date; + + @ManyToOne(() => PaymentEntity, (payment) => payment.refunds, { onDelete: "RESTRICT" }) + @JoinColumn({ name: "payment_id" }) + payment!: PaymentEntity; +} diff --git a/apps/edr-freight-api/src/modules/payment/entities/payment-webhook-event.entity.ts b/apps/edr-freight-api/src/modules/payment/entities/payment-webhook-event.entity.ts new file mode 100644 index 000000000..294a30188 --- /dev/null +++ b/apps/edr-freight-api/src/modules/payment/entities/payment-webhook-event.entity.ts @@ -0,0 +1,48 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + PrimaryGeneratedColumn, + Unique, +} from "typeorm"; + +export type WebhookPaymentMethod = "telebirr" | "cbe-birr" | "ebirr"; + +@Entity({ schema: "freight", name: "payment_webhook_events" }) +@Unique(["provider", "externalEventId"]) +@Index(["merchantOrderId"]) +export class PaymentWebhookEventEntity { + @PrimaryGeneratedColumn("uuid") + id!: string; + + @Column({ type: "enum", enum: ["telebirr", "cbe-birr", "ebirr"] }) + provider!: WebhookPaymentMethod; + + @Column({ type: "varchar", length: 255, name: "external_event_id" }) + externalEventId!: string; + + @Column({ type: "varchar", length: 255, nullable: true, name: "merchant_order_id" }) + merchantOrderId?: string; + + @Column({ type: "varchar", length: 255, nullable: true, name: "provider_txn_id" }) + providerTxnId?: string; + + @Column({ type: "boolean", name: "signature_valid" }) + signatureValid!: boolean; + + @Column({ type: "varchar", length: 100 }) + status!: string; + + @Column({ type: "jsonb" }) + payload!: Record; + + @CreateDateColumn({ name: "received_at" }) + receivedAt!: Date; + + @Column({ type: "timestamp", nullable: true, name: "processed_at" }) + processedAt?: Date; + + @Column({ type: "text", nullable: true, name: "processing_error" }) + processingError?: string; +} diff --git a/apps/edr-freight-api/src/modules/payment/entities/payment.entity.ts b/apps/edr-freight-api/src/modules/payment/entities/payment.entity.ts new file mode 100644 index 000000000..2bb81a331 --- /dev/null +++ b/apps/edr-freight-api/src/modules/payment/entities/payment.entity.ts @@ -0,0 +1,69 @@ +import { BaseEntity, Column, CreateDateColumn, Entity, OneToMany, PrimaryGeneratedColumn } from "typeorm"; +import { PaymentRefundEntity } from "./payment-refund.entity"; + + +type PaymentType = "booking" +type PaymentMethod = "telebirr" | "cbe-birr" | "ebirr" | "waafi" | "card" | "dmoney" | "cac-bank" +type Currency = "ETB" | "USD" +export type PaymentStatus = "action-required" | "processing" | "success" | "failed" | "canceled" | "refunded" + +@Entity({ schema: 'freight', name: 'payments' }) +export class PaymentEntity extends BaseEntity { + @PrimaryGeneratedColumn("uuid") + id!: string + + @Column({ type: 'varchar', length: 255, name: "ref_id" }) + refId!: string + + @Column({ type: "enum", enum: ["booking"] }) + type!: PaymentType; + + @Column({ type: "enum", enum: ["telebirr", "cbe-birr", "ebirr", "waafi", "card", "dmoney", "cac-bank"] }) + method!: PaymentMethod + + @Column({ type: "enum", enum: ["ETB", "USD"] }) + currency!: Currency + + @Column({ type: "numeric" }) + amount!: number + + @Column({ type: "varchar", length: 255, name: "reason", }) + reason?: string; + + @Column({ type: "jsonb", default: {}, name: "raw_initiation" }) + rawInitiation?: Record + + @Column({ type: "jsonb", nullable: true, name: "client_action" }) + clientAction?: Record; + + @Column({ type: "varchar", length: 255, unique: true, name: "merchant_order_id", }) + merchantOrderId!: string + + @Column({ type: "varchar", length: 255, unique: true, nullable: true, name: "transaction_id", }) + transactionId?: string + + @Column({ type: "enum", enum: ["action-required", "processing", "success", "failed", "canceled", "refunded"], default: "action-required" }) + status!: PaymentStatus + + @Column({ type: "date", nullable: true, name: "paid_at" }) + paidAt?: Date + + @Column({ type: "timestamp", nullable: true, name: "refunded_at" }) + refundedAt?: Date + + @Column({ type: "timestamp", nullable: true, name: "expires_at" }) + expiresAt?: Date + + @Column({ type: "varchar", length: 30, nullable: true, name: "failer_code" }) + failerCode?: string + + @Column({ type: "varchar", length: 255, nullable: true, name: "failer_message" }) + failureMessage?: string + + @CreateDateColumn({ name: "created_at" }) + createdAt!: Date + + @OneToMany(() => PaymentRefundEntity, (refund) => refund.payment) + refunds!: PaymentRefundEntity[]; + +} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/payment/internal-payment.controller.ts b/apps/edr-freight-api/src/modules/payment/internal-payment.controller.ts new file mode 100644 index 000000000..0db38a751 --- /dev/null +++ b/apps/edr-freight-api/src/modules/payment/internal-payment.controller.ts @@ -0,0 +1,35 @@ +import { + Body, + Controller, + HttpCode, + HttpStatus, + Post, + UseGuards, +} from "@nestjs/common"; +import { ApiOperation, ApiTags } from "@nestjs/swagger"; +import { ServiceAuthGuard } from "../../common/guards/service-auth.guard"; +import { PaymentEventDto, MarkPaidResponseDto } from "./internal-payment.dto"; +import { PaymentService } from "./payment.service"; + +/** + * Consumer side of the payment microservice's outbox relay. + * Only the payment service may call this (shared SERVICE_AUTH_TOKEN). + * Idempotent by design — the relay delivers at-least-once, so duplicates must be harmless. + * Becomes a queue consumer via PaymentEventsConsumer when RabbitMQ is available; + * this HTTP endpoint remains as a transport-agnostic fallback. + */ +@ApiTags("Internal Payments") +@UseGuards(ServiceAuthGuard) +@Controller("internal/payments") +export class InternalPaymentController { + constructor(private readonly paymentService: PaymentService) { } + + @Post("mark-paid") + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: "Apply a payment.succeeded / payment.failed event from the payment service (idempotent)", + }) + async markPaid(@Body() event: PaymentEventDto): Promise { + return this.paymentService.handlePaymentEvent(event); + } +} diff --git a/apps/edr-freight-api/src/modules/payment/internal-payment.dto.ts b/apps/edr-freight-api/src/modules/payment/internal-payment.dto.ts new file mode 100644 index 000000000..1bf8c3f82 --- /dev/null +++ b/apps/edr-freight-api/src/modules/payment/internal-payment.dto.ts @@ -0,0 +1,53 @@ +import { + IsEnum, + IsIn, + IsInt, + IsISO8601, + IsOptional, + IsPositive, + IsString, + IsUUID, +} from "class-validator"; +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { + PaymentEventType, + PaymentReferenceType, + PaymentService, + ProviderMethod, +} from "@edr/types"; + +/** + * Wire shape of the PaymentEvent envelope (@edr/types) delivered by the payment + * microservice's outbox relay. Delivery is at-least-once — the consumer is idempotent. + */ +export class PaymentEventDto { + @ApiProperty({ enum: [1] }) @IsIn([1]) version!: 1; + @ApiProperty() @IsUUID() eventId!: string; + @ApiProperty({ enum: ["payment.succeeded", "payment.failed"] }) + @IsIn(["payment.succeeded", "payment.failed"]) + eventType!: PaymentEventType; + + @ApiProperty() @IsISO8601() occurredAt!: string; + @ApiProperty({ enum: PaymentService }) @IsEnum(PaymentService) service!: string; + @ApiProperty() @IsUUID() intentId!: string; + @ApiProperty({ enum: PaymentReferenceType }) + @IsEnum(PaymentReferenceType) + referenceType!: string; + + @ApiProperty() @IsString() referenceId!: string; + @ApiProperty() @IsString() merchantOrderId!: string; + @ApiProperty({ enum: ProviderMethod }) @IsEnum(ProviderMethod) provider!: string; + @ApiProperty() @IsInt() @IsPositive() amountMinor!: number; + @ApiProperty() @IsString() currency!: string; + + @ApiPropertyOptional() @IsOptional() @IsString() providerTxnId?: string; + @ApiPropertyOptional() @IsOptional() @IsISO8601() paidAt?: string; + @ApiPropertyOptional() @IsOptional() @IsString() failureCode?: string; + @ApiPropertyOptional() @IsOptional() @IsString() failureMessage?: string; +} + +export class MarkPaidResponseDto { + @ApiProperty() processed!: boolean; + @ApiPropertyOptional() alreadyFinalized?: boolean; + @ApiPropertyOptional() reason?: string; +} diff --git a/apps/edr-freight-api/src/modules/payment/payment-client.service.ts b/apps/edr-freight-api/src/modules/payment/payment-client.service.ts new file mode 100644 index 000000000..bcfa643b6 --- /dev/null +++ b/apps/edr-freight-api/src/modules/payment/payment-client.service.ts @@ -0,0 +1,81 @@ +import { BadGatewayException, Injectable, Logger } from "@nestjs/common"; +import { HttpService } from "@nestjs/axios"; +import { AxiosError } from "axios"; +import { firstValueFrom } from "rxjs"; +import { + InitiatePaymentRequest, + PaymentIntentSnapshot, + PaymentReferenceType, + PaymentService, +} from "@edr/types"; + +/** + * Thin HTTP client for the payment microservice (apps/edr-payment-api). + * Domain validation stays in the freight API; provider calls, intents, + * and webhooks live in the payment service. + */ +@Injectable() +export class PaymentClientService { + private readonly logger = new Logger(PaymentClientService.name); + private readonly baseUrl = ( + // process.env.PAYMENT_API_URL ?? + "https://paymentcallback.triaplc.com" + ).replace(/\/$/, ""); + private readonly serviceToken = process.env.SERVICE_AUTH_TOKEN ?? ""; + + constructor(private readonly http: HttpService) { } + + /** POST /payments/initiate — idempotent per (service, referenceType, referenceId). */ + async initiate(request: InitiatePaymentRequest): Promise { + return this.call("POST", "/payments/initiate", request); + } + + /** GET /payments/intents?… — active intent by domain reference; null when none exists. */ + async getIntentByReference( + referenceType: PaymentReferenceType, + referenceId: string, + ): Promise { + const query = new URLSearchParams({ + service: PaymentService.FREIGHT, + referenceType, + referenceId, + }); + try { + return await this.call("GET", `/payments/intents?${query.toString()}`); + } catch (err) { + if (err instanceof AxiosError && err.response?.status === 404) return null; + throw err; + } + } + + private async call(method: "GET" | "POST", path: string, body?: unknown): Promise { + const url = `${this.baseUrl}${path}`; + try { + const response = await firstValueFrom( + this.http.request({ + method, + url, + data: body, + headers: this.serviceToken + ? { "x-service-token": this.serviceToken } + : {}, + }), + ); + return response.data; + } catch (err) { + if (err instanceof AxiosError && err.response) { + if (err.response.status === 404) throw err; + const detail = + (err.response.data as { message?: string | string[] })?.message ?? + err.message; + this.logger.error( + `payment service ${method} ${path} → ${err.response.status}: ${detail}`, + ); + throw new BadGatewayException(`Payment service error: ${detail}`); + } + const message = err instanceof Error && err.message ? err.message : String(err); + this.logger.error(`payment service unreachable (${method} ${path}): ${message}`); + throw new BadGatewayException("Payment service unreachable"); + } + } +} diff --git a/apps/edr-freight-api/src/modules/payment/payment-events.consumer.ts b/apps/edr-freight-api/src/modules/payment/payment-events.consumer.ts new file mode 100644 index 000000000..91237223c --- /dev/null +++ b/apps/edr-freight-api/src/modules/payment/payment-events.consumer.ts @@ -0,0 +1,49 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { Nack, RabbitSubscribe } from "@golevelup/nestjs-rabbitmq"; +import { Public } from "@edr/api-common"; +import { + PAYMENT_EVENTS_DLX, + PAYMENT_EVENTS_EXCHANGE, + PAYMENT_QUEUES, + PaymentEvent, + PaymentService, + paymentServiceBindingPattern, +} from "@edr/types"; +import { PaymentEventDto } from "./internal-payment.dto"; +import { PaymentService as PaymentSvc } from "./payment.service"; + +const FREIGHT_QUEUE = PAYMENT_QUEUES[PaymentService.FREIGHT]; + +@Injectable() +export class PaymentEventsConsumer { + private readonly logger = new Logger(PaymentEventsConsumer.name); + + constructor(private readonly paymentService: PaymentSvc) { } + + @Public() + @RabbitSubscribe({ + exchange: PAYMENT_EVENTS_EXCHANGE, + routingKey: paymentServiceBindingPattern(PaymentService.FREIGHT), + queue: FREIGHT_QUEUE.main, + queueOptions: { + durable: true, + deadLetterExchange: PAYMENT_EVENTS_DLX, + }, + }) + async handle(event: PaymentEvent): Promise { + try { + const result = await this.paymentService.handlePaymentEvent( + event as unknown as PaymentEventDto, + ); + this.logger.log( + `processed ${event.eventType} (${event.eventId}) ref=${event.referenceId}: ${JSON.stringify(result)}`, + ); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + this.logger.error( + `DEAD-LETTERING ${event.eventType} (${event.eventId}) ref=${event.referenceId}: ${message}`, + ); + return new Nack(false); + } + } +} diff --git a/apps/edr-freight-api/src/modules/payment/payment.controller.ts b/apps/edr-freight-api/src/modules/payment/payment.controller.ts new file mode 100644 index 000000000..14308883d --- /dev/null +++ b/apps/edr-freight-api/src/modules/payment/payment.controller.ts @@ -0,0 +1,219 @@ +import { + Body, + Controller, + Get, + HttpStatus, + Param, + Post, + Query, + Res, +} from "@nestjs/common"; +import { + ApiTags, + ApiOperation, + ApiQuery, + ApiOkResponse, + ApiProduces, +} from "@nestjs/swagger"; +import { Response } from "express"; +import { Public } from "@edr/api-common"; +import { BookingView, FreightAdmin } from "../../common/booking-guards"; +import { PaymentService } from "./payment.service"; +import { + InitiatePaymentDto, + InitiateResponseDto, + IntentStatusDto, + PaymentMethodTypeEnum, + PaymentPlatformDto, + RefundDto, +} from "./payments.dto"; + +@ApiTags("Payment") +@Controller("payments") +export class PaymentController { + constructor(private readonly paymentService: PaymentService) { } + + @Get("summary") + @BookingView() + @ApiOperation({ summary: "Payment count/amount summary for dashboard cards" }) + getSummary() { + return this.paymentService.getSummary(); + } + + @Get("all") + @BookingView() + @ApiOperation({ summary: "Get all payments with filters (view-only, any staff)" }) + @ApiQuery({ name: "search", required: false }) + @ApiQuery({ name: "status", required: false }) + @ApiQuery({ name: "method", required: false }) + @ApiQuery({ name: "page", required: false }) + @ApiQuery({ name: "pageSize", required: false }) + async getAll( + @Query("search") search?: string, + @Query("status") status?: string, + @Query("method") method?: string, + @Query("page") page?: string, + @Query("pageSize") pageSize?: string, + ) { + return this.paymentService.getAll({ + search, + status, + method, + page: page ? parseInt(page) : 1, + pageSize: pageSize ? parseInt(pageSize) : 10, + }); + } + + @Post("initiate") + @ApiOperation({ + summary: "Initiate payment for a freight booking", + description: `Initiates payment via the central payment microservice.\n\n**Supported methods:**\n- TELEBIRR — Ethiopian mobile money\n- CBE_BIRR — Commercial Bank of Ethiopia\n- EBIRR — Electronic payment gateway\n- WAAFI — Djibouti mobile money\n- CARD — Visa/Mastercard\n- DMONEY — Djibouti D-money\n- CAC_BANK — CAC Int Bank (OTP)`, + }) + @ApiOkResponse({ type: InitiateResponseDto }) + initiatePayment(@Body() dto: InitiatePaymentDto) { + return this.paymentService.initiatePayment(dto); + } + + @Get("intents/:bookingId") + @ApiOperation({ summary: "Get payment intent status for a booking" }) + @ApiOkResponse({ type: IntentStatusDto }) + getIntent(@Param("bookingId") bookingId: string) { + return this.paymentService.getIntentByBookingId(bookingId); + } + + @Post("refund") + @FreightAdmin() + @ApiOperation({ summary: "Refund a paid booking (staff/admin only)" }) + refund(@Body() dto: RefundDto) { + return this.paymentService.refund(dto); + } + + @Get("checkout") + @Public() + @ApiOperation({ + summary: "Browser checkout redirect", + description: + "Initiates payment and returns an HTML page that auto-redirects to the provider checkout URL. Open directly in a browser tab.", + }) + @ApiQuery({ name: "bookingId", required: true }) + @ApiQuery({ name: "method", enum: PaymentMethodTypeEnum, required: true }) + @ApiQuery({ name: "platform", enum: ["web", "mobile"], required: false }) + @ApiProduces("text/html") + async checkout( + @Query("bookingId") bookingId: string, + @Query("method") method: PaymentMethodTypeEnum, + @Query("platform") platform: PaymentPlatformDto = "web", + @Res() res: Response, + ) { + if (!bookingId) { + return res + .status(HttpStatus.BAD_REQUEST) + .type("html") + .send(this.buildErrorHtml("Missing required query parameter: bookingId")); + } + if (!method || !Object.values(PaymentMethodTypeEnum).includes(method)) { + return res + .status(HttpStatus.BAD_REQUEST) + .type("html") + .send(this.buildErrorHtml("Missing or invalid query parameter: method")); + } + + try { + const result = await this.paymentService.initiatePayment({ bookingId, method, platform }); + const url = + result.clientAction?.type === "REDIRECT" ? result.clientAction.url : undefined; + + if (url) { + return res.status(HttpStatus.OK).type("html").send(this.buildRedirectHtml(url)); + } + return res + .status(HttpStatus.OK) + .type("html") + .send(this.buildStatusHtml(result.status, result.intentId)); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : "An unexpected error occurred"; + return res.status(HttpStatus.OK).type("html").send(this.buildErrorHtml(message)); + } + } + + @Get("receipt/:orderId") + @Public() + @ApiOperation({ summary: "Generate a payment receipt HTML page" }) + @ApiProduces("text/html") + async receipt(@Param("orderId") orderId: string, @Res() res: Response) { + const html = await this.paymentService.genReceiptHtml(orderId); + return res.status(HttpStatus.OK).type("html").send(html); + } + + private buildRedirectHtml(url: string): string { + const escaped = url.replace(/\"/g, """); + return ` + + + + + Redirecting to payment… + + + +
+
+

Redirecting to payment provider…

+

Click here if you are not redirected

+
+ + +`; + } + + private buildStatusHtml(status: string, intentId: string): string { + return ` + + + + Payment status + + + +
+
${status}
+ Intent: ${intentId} +
+ +`; + } + + private buildErrorHtml(message: string): string { + return ` + + + + Payment error + + + +
+
Payment could not be initiated
+

${message}

+
+ +`; + } +} diff --git a/apps/edr-freight-api/src/modules/payment/payment.module.ts b/apps/edr-freight-api/src/modules/payment/payment.module.ts new file mode 100644 index 000000000..e21ea87b9 --- /dev/null +++ b/apps/edr-freight-api/src/modules/payment/payment.module.ts @@ -0,0 +1,63 @@ +import { Module, forwardRef } from "@nestjs/common"; +import { TypeOrmModule } from "@nestjs/typeorm"; +import { HttpModule } from "@nestjs/axios"; +import { ConfigModule, ConfigService } from "@nestjs/config"; +import { RabbitMQModule } from "@golevelup/nestjs-rabbitmq"; +import { + PAYMENT_EVENTS_DLX, + PAYMENT_EVENTS_EXCHANGE, + PAYMENT_QUEUES, + PaymentService as PaymentServiceEnum, + paymentServiceBindingPattern, +} from "@edr/types"; +import { PaymentService } from "./payment.service"; +import { PaymentClientService } from "./payment-client.service"; +import { PaymentController } from "./payment.controller"; +import { PaymentRepository } from "./payment.repository"; +import { PaymentEventsConsumer } from "./payment-events.consumer"; +import { InternalPaymentController } from "./internal-payment.controller"; +import { ServiceAuthGuard } from "../../common/guards/service-auth.guard"; +import { TrainSchedulingModule } from "../train-scheduling/train-scheduling.module"; +import { PaymentWebhookEventEntity } from "./entities/payment-webhook-event.entity"; +import { PaymentRefundEntity } from "./entities/payment-refund.entity"; + +const FREIGHT_QUEUE = PAYMENT_QUEUES[PaymentServiceEnum.FREIGHT]; + +@Module({ + imports: [ + HttpModule.register({ timeout: 10_000 }), + ConfigModule, + forwardRef(() => TrainSchedulingModule), + TypeOrmModule.forFeature([PaymentWebhookEventEntity, PaymentRefundEntity]), + RabbitMQModule.forRootAsync({ + inject: [ConfigService], + useFactory: (config: ConfigService) => ({ + uri: config.get("rabbitmq.url") as string, + exchanges: [ + { name: PAYMENT_EVENTS_EXCHANGE, type: "topic", options: { durable: true } }, + { name: PAYMENT_EVENTS_DLX, type: "topic", options: { durable: true } }, + ], + queues: [ + { + name: FREIGHT_QUEUE.dlq, + exchange: PAYMENT_EVENTS_DLX, + routingKey: paymentServiceBindingPattern(PaymentServiceEnum.FREIGHT), + options: { durable: true }, + }, + ], + prefetchCount: config.get("rabbitmq.prefetch") ?? 10, + connectionInitOptions: { wait: false }, + }), + }), + ], + providers: [ + PaymentRepository, + PaymentService, + PaymentClientService, + PaymentEventsConsumer, + ServiceAuthGuard, + ], + controllers: [PaymentController, InternalPaymentController], + exports: [PaymentService], +}) +export class PaymentModule { } diff --git a/apps/edr-freight-api/src/modules/payment/payment.repository.ts b/apps/edr-freight-api/src/modules/payment/payment.repository.ts new file mode 100644 index 000000000..8c830a20f --- /dev/null +++ b/apps/edr-freight-api/src/modules/payment/payment.repository.ts @@ -0,0 +1,64 @@ +import { Injectable } from "@nestjs/common"; +import { DataSource, FindOptionsWhere, QueryDeepPartialEntity, QueryRunner, Repository } from "typeorm"; +import { PaymentEntity } from "./entities/payment.entity"; + +@Injectable() +export class PaymentRepository { + private readonly paymentRepo: Repository; + constructor(private readonly dataSource: DataSource) { + this.paymentRepo = this.dataSource.getRepository(PaymentEntity) + } + + async createTr(qr: QueryRunner, data: Pick): Promise { + const payment = qr.manager.create(PaymentEntity, data) + return qr.manager.save(payment) + } + + async create(data: Pick): Promise { + const payment = this.paymentRepo.create(data) + return this.paymentRepo.save(payment) + } + + + + findOneBy(options: FindOptionsWhere | FindOptionsWhere[]): Promise { + return this.paymentRepo.findOneBy(options); + } + + update(where: FindOptionsWhere, data: QueryDeepPartialEntity) { + return this.paymentRepo.update(where, data) + } + + getActivePaymentByRefIdAndMethod(refId: string, method: PaymentEntity["method"]) { + return this.paymentRepo + .createQueryBuilder('payment') + .where('payment.method = :method', { method }) + .andWhere('payment.refId = :refId', { refId }) + .andWhere('payment.status IN (:...statuses)', { + statuses: ['action-required'], + }) + .andWhere('payment.expiresAt > :now', { now: new Date() }) + .getOne(); + } + + + + + + getActivePaymentByOrderIdAndMethod(orderId: string, method: PaymentEntity["method"]) { + return this.paymentRepo + .createQueryBuilder('payment') + .where('payment.method = :method', { method }) + .andWhere('payment.merchantOrderId = :orderId', { orderId }) + .andWhere('payment.status IN (:...statuses)', { + statuses: ['action-required'], + }) + .andWhere('payment.expiresAt > :now', { now: new Date() }) + .getOne(); + } + + createQueryBuilder(alias: string) { + return this.paymentRepo.createQueryBuilder(alias); + } + +} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/payment/payment.service.ts b/apps/edr-freight-api/src/modules/payment/payment.service.ts new file mode 100644 index 000000000..177b7475b --- /dev/null +++ b/apps/edr-freight-api/src/modules/payment/payment.service.ts @@ -0,0 +1,431 @@ +import { + BadRequestException, + forwardRef, + Inject, + Injectable, + InternalServerErrorException, + Logger, + NotFoundException, +} from "@nestjs/common"; +import { DataSource } from "typeorm"; +import { PaymentEntity } from "./entities/payment.entity"; +import { PaymentRepository } from "./payment.repository"; +import { PaymentClientService } from "./payment-client.service"; + +import * as fs from "fs"; +import * as path from "path"; +import * as Handlebars from "handlebars"; +import { Booking } from "../bookings/entities/booking.entity"; + +import { + ClientAction, + ProviderPaymentStatus, +} from "@edr/payment-providers"; +import { + PaymentService as PaymentServiceEnum, + PaymentReferenceType, + PaymentIntentSnapshot, + ProviderMethod, +} from "@edr/types"; +import { + InitiatePaymentDto, + InitiateResponseDto, + IntentStatusDto, + RefundDto, +} from "./payments.dto"; +import { BookingBatchService } from "../train-scheduling/booking-batch.service"; + +const STATUS_MAP: Record = { + "action-required": ProviderPaymentStatus.REQUIRES_ACTION, + "processing": ProviderPaymentStatus.PROCESSING, + "success": ProviderPaymentStatus.SUCCEEDED, + "failed": ProviderPaymentStatus.FAILED, + "canceled": ProviderPaymentStatus.CANCELLED, + "refunded": ProviderPaymentStatus.CANCELLED, +}; + +@Injectable() +export class PaymentService { + private readonly logger = new Logger(PaymentService.name); + + constructor( + private readonly datasource: DataSource, + private readonly paymentRepo: PaymentRepository, + private readonly paymentClient: PaymentClientService, + @Inject(forwardRef(() => BookingBatchService)) + private readonly bookingBatchService: BookingBatchService, + ) { } + + async getAll(filters: { + search?: string; + status?: string; + method?: string; + page?: number; + pageSize?: number; + }) { + const { search, status, method, page = 1, pageSize = 10 } = filters; + const skip = (page - 1) * pageSize; + + const qb = this.paymentRepo.createQueryBuilder("payment"); + + if (search) { + qb.andWhere( + "(payment.merchantOrderId ILIKE :search OR payment.refId ILIKE :search OR payment.transactionId ILIKE :search)", + { search: `%${search}%` }, + ); + } + if (status) { + qb.andWhere("payment.status = :status", { status }); + } + if (method) { + qb.andWhere("payment.method = :method", { method }); + } + + const [items, total] = await qb + .orderBy("payment.createdAt", "DESC") + .skip(skip) + .take(pageSize) + .getManyAndCount(); + + return { + items: items.map((p) => ({ + id: p.id, + bookingId: p.refId, + amount: p.amount, + currency: p.currency, + method: p.method, + status: p.status, + merchantOrderId: p.merchantOrderId, + paidAt: p.paidAt, + createdAt: p.createdAt, + })), + total, + page, + pageSize, + }; + } + + /** Aggregate counts across ALL payments for the dashboard summary cards. */ + async getSummary() { + const rows = await this.paymentRepo + .createQueryBuilder("payment") + .select("payment.status", "status") + .addSelect("COUNT(*)::int", "count") + .groupBy("payment.status") + .getRawMany<{ status: string; count: number }>(); + + const byStatus: Record = {}; + let total = 0; + for (const row of rows) { + byStatus[row.status] = row.count; + total += row.count; + } + + // Sum of successfully collected amounts. + const paidAgg = await this.paymentRepo + .createQueryBuilder("payment") + .select("COALESCE(SUM(payment.amount), 0)", "sum") + .where("payment.status = :status", { status: "success" }) + .getRawOne<{ sum: string }>(); + + return { + total, + success: byStatus["success"] ?? 0, + processing: + (byStatus["processing"] ?? 0) + (byStatus["action-required"] ?? 0), + failed: (byStatus["failed"] ?? 0) + (byStatus["canceled"] ?? 0), + refunded: byStatus["refunded"] ?? 0, + paidAmount: Number(paidAgg?.sum ?? 0), + }; + } + + async initiatePayment(dto: InitiatePaymentDto): Promise { + const booking = await this.datasource + .getRepository(Booking) + .findOneBy({ id: dto.bookingId }); + if (!booking) throw new NotFoundException("Booking not found"); + + const amountMinor = Math.round(Number(booking.totalAmount)); + + const snapshot = await this.paymentClient.initiate({ + service: PaymentServiceEnum.FREIGHT, + referenceType: PaymentReferenceType.SHIPMENT, + referenceId: booking.id, + orderRef: booking.reference, + amountMinor, + currency: booking.paymentCurrency, + provider: dto.method as unknown as ProviderMethod, + platform: dto.platform, + payerAccount: dto.payerAccount, + returnUrl:'https://edrfreight.triaplc.com/payment/success', + failureUrl: 'https://edrfreight.triaplc.com/payment/failure', + }); + + const intent = await this.syncIntentProjection(booking.id, booking, snapshot); + + if (snapshot.status === ProviderPaymentStatus.SUCCEEDED) { + await this.finalizePaymentSuccess({ + intentId: intent.id, + bookingId: booking.id, + providerTxnId: snapshot.providerTxnId, + paidAt: snapshot.paidAt ? new Date(snapshot.paidAt) : undefined, + }); + } + + return this.formatIntentResponse(intent); + } + + private async syncIntentProjection( + bookingId: string, + booking: Booking, + snapshot: PaymentIntentSnapshot, + ): Promise { + const existing = await this.paymentRepo.findOneBy({ refId: bookingId, type: "booking" }); + + const PROVIDER_TO_METHOD: Record = { + TELEBIRR: "telebirr", + CBE_BIRR: "cbe-birr", + EBIRR: "ebirr", + WAAFI: "waafi", + CARD: "card", + DMONEY: "dmoney", + CAC_BANK: "cac-bank", + }; + const method: PaymentEntity["method"] = + PROVIDER_TO_METHOD[snapshot.provider ?? ""] ?? "telebirr"; + const status = snapshot.status === ProviderPaymentStatus.SUCCEEDED + ? "processing" + : this.toLocalStatus(snapshot.status); + + const clientAction = (snapshot.clientAction ?? undefined) as Record | undefined; + const data = { + status, + method, + merchantOrderId: snapshot.merchantOrderId ?? existing?.merchantOrderId ?? "", + transactionId: snapshot.providerTxnId ?? existing?.transactionId, + expiresAt: snapshot.expiresAt ? new Date(snapshot.expiresAt) : existing?.expiresAt, + failerCode: snapshot.failureCode ?? undefined, + failureMessage: snapshot.failureMessage ?? undefined, + }; + + if (existing) { + await this.paymentRepo.update({ id: existing.id }, { ...data, clientAction } as any); + return { ...existing, ...data, clientAction } as PaymentEntity; + } + + return this.paymentRepo.create({ + refId: bookingId, + type: "booking", + amount: booking.totalAmount, + currency: booking.paymentCurrency, + reason: `Payment for booking ${booking.reference}`, + rawInitiation: snapshot as unknown as Record, + clientAction: clientAction ?? {}, + ...data, + } as any); + } + + async getIntentByBookingId(bookingId: string): Promise { + const local = await this.paymentRepo.findOneBy({ refId: bookingId, type: "booking" }); + + let snapshot: PaymentIntentSnapshot | null = null; + try { + snapshot = await this.paymentClient.getIntentByReference( + PaymentReferenceType.SHIPMENT, + bookingId, + ); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + this.logger.warn( + `payment service lookup failed for booking ${bookingId}: ${message}; using local intent`, + ); + } + + if (!snapshot) { + if (!local) throw new NotFoundException("PaymentIntent not found"); + return this.formatIntentStatus(local); + } + + const booking = await this.datasource + .getRepository(Booking) + .findOneBy({ id: bookingId }); + + if (!booking) throw new NotFoundException("Booking not found"); + + const intent = await this.syncIntentProjection(bookingId, booking, snapshot); + + if (snapshot.status === ProviderPaymentStatus.SUCCEEDED) { + await this.finalizePaymentSuccess({ + intentId: intent.id, + bookingId: booking.id, + providerTxnId: snapshot.providerTxnId, + paidAt: snapshot.paidAt ? new Date(snapshot.paidAt) : undefined, + }); + } + + const refreshed = await this.paymentRepo.findOneBy({ id: intent.id }); + return this.formatIntentStatus(refreshed ?? intent); + } + + async refund(dto: RefundDto) { + const intent = await this.paymentRepo.findOneBy({ refId: dto.bookingId, type: "booking" }); + if (!intent || intent.status !== "success") { + throw new BadRequestException("No successful payment to refund"); + } + + await this.datasource.transaction(async (mg) => { + await mg.update(PaymentEntity, { id: intent.id }, { status: "refunded", refundedAt: new Date() }); + await mg.update(Booking, { id: dto.bookingId }, { paymentStatus: "FAILED", status: "CANCELLED" }); + }); + + return { refunded: true, bookingId: dto.bookingId }; + } + + async finalizePaymentSuccess(input: { + intentId: string; + bookingId: string; + providerTxnId?: string; + paidAt?: Date; + }): Promise<{ alreadyFinalized: boolean }> { + const intent = await this.paymentRepo.findOneBy({ id: input.intentId }); + if (!intent) throw new NotFoundException("PaymentIntent not found"); + if (intent.status === "success") return { alreadyFinalized: true }; + + const paidAt = input.paidAt ?? new Date(); + + await this.datasource.transaction(async (mg) => { + await mg.update( + PaymentEntity, + { id: intent.id }, + { status: "success", paidAt, transactionId: input.providerTxnId ?? intent.transactionId }, + ); + await mg.update(Booking, { id: input.bookingId }, { paymentStatus: "PAID" ,status:"PAID"}); + }); + + try { + await this.bookingBatchService.ensurePaidBookingAllocated(input.bookingId); + } catch (err) { + this.logger.error( + `Error allocating booking after payment: ${err instanceof Error ? err.message : String(err)}`, + ); + } + + return { alreadyFinalized: false }; + } + + async markPaymentFailed(input: { + intentId: string; + failureCode?: string; + failureMessage?: string; + }): Promise { + const intent = await this.paymentRepo.findOneBy({ id: input.intentId }); + if (!intent) throw new NotFoundException("PaymentIntent not found"); + if (intent.status === "success" || intent.status === "canceled") return; + + await this.paymentRepo.update( + { id: intent.id }, + { status: "failed", failerCode: input.failureCode, failureMessage: input.failureMessage }, + ); + } + + async getActivePaymentByOrderIdAndMethod(orderId: string, method: PaymentEntity["method"]): Promise { + return this.paymentRepo.getActivePaymentByOrderIdAndMethod(orderId, method); + } + + async genReceiptHtml(orderId: string) { + const payment = await this.paymentRepo.findOneBy({ merchantOrderId: orderId, status: "success" }); + if (!payment) throw new BadRequestException("No successful payment found for this order"); + + const filePath = path.join(__dirname, "templates", "receipt.hbs"); + if (!fs.existsSync(filePath)) throw new InternalServerErrorException(); + + const source = fs.readFileSync(filePath, "utf8"); + const template = Handlebars.compile(source); + return template({ + vendorName: "Ethio Djibouti Railway Freight Booking", + vendorAddress: "Addis Ababa", + receiptDate: payment.paidAt, + paymentMethod: payment.method, + subtotal: payment.amount.toString(), + total: payment.amount.toString(), + currency: payment.currency, + reason: payment.reason, + }); + } + + findBookingById(id: string) { + return this.paymentRepo.findOneBy({ refId: id, type: "booking" }); + } + + formatIntentResponse(intent: PaymentEntity): InitiateResponseDto { + const clientAction = + intent.clientAction && typeof intent.clientAction === "object" + ? (intent.clientAction as unknown as ClientAction) + : undefined; + return { + intentId: intent.id, + status: STATUS_MAP[intent.status] ?? ProviderPaymentStatus.PROCESSING, + clientAction, + merchantOrderId: intent.merchantOrderId ?? undefined, + }; + } + + private formatIntentStatus(intent: PaymentEntity): IntentStatusDto { + return { + ...this.formatIntentResponse(intent), + paidAt: intent.paidAt?.toISOString(), + failureCode: intent.failerCode ?? undefined, + failureMessage: intent.failureMessage ?? undefined, + }; + } + + async handlePaymentEvent(event: { + eventType: string; + eventId: string; + referenceId: string; + intentId: string; + providerTxnId?: string; + paidAt?: string; + failureCode?: string; + failureMessage?: string; + }): Promise<{ processed: boolean; alreadyFinalized?: boolean; reason?: string }> { + if (event.eventType === "payment.succeeded") { + const intent = await this.paymentRepo.findOneBy({ refId: event.referenceId, type: "booking" }); + if (!intent) { + return { processed: false, reason: `No local intent for booking ${event.referenceId}` }; + } + const { alreadyFinalized } = await this.finalizePaymentSuccess({ + intentId: intent.id, + bookingId: event.referenceId, + providerTxnId: event.providerTxnId, + paidAt: event.paidAt ? new Date(event.paidAt) : undefined, + }); + return { processed: true, alreadyFinalized }; + } + + if (event.eventType === "payment.failed") { + const intent = await this.paymentRepo.findOneBy({ refId: event.referenceId, type: "booking" }); + if (!intent) { + return { processed: false, reason: `No local intent for booking ${event.referenceId}` }; + } + await this.markPaymentFailed({ + intentId: intent.id, + failureCode: event.failureCode, + failureMessage: event.failureMessage, + }); + return { processed: true }; + } + + return { processed: false, reason: `Unknown event type: ${event.eventType}` }; + } + + private toLocalStatus(status: ProviderPaymentStatus): PaymentEntity["status"] { + switch (status) { + case ProviderPaymentStatus.SUCCEEDED: return "success"; + case ProviderPaymentStatus.FAILED: return "failed"; + case ProviderPaymentStatus.CANCELLED: return "canceled"; + case ProviderPaymentStatus.PROCESSING: return "processing"; + default: return "action-required"; + } + } +} diff --git a/apps/edr-freight-api/src/modules/payment/payments.dto.ts b/apps/edr-freight-api/src/modules/payment/payments.dto.ts new file mode 100644 index 000000000..67ca68e87 --- /dev/null +++ b/apps/edr-freight-api/src/modules/payment/payments.dto.ts @@ -0,0 +1,108 @@ +import { ProviderPaymentStatus } from "@edr/types"; +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { IsEnum, IsIn, IsOptional, IsString } from "class-validator"; + +export type PaymentPlatformDto = "web" | "mobile"; + +export enum PaymentMethodTypeEnum { + TELEBIRR = "TELEBIRR", + CBE_BIRR = "CBE_BIRR", + EBIRR = "EBIRR", + WAAFI = "WAAFI", + CARD = "CARD", + DMONEY = "DMONEY", + CAC_BANK = "CAC_BANK", +} + +export class InitiatePaymentDto { + @ApiProperty({ example: "booking-uuid" }) + @IsString() + bookingId!: string; + + @ApiProperty({ + enum: PaymentMethodTypeEnum, + description: "Payment method: TELEBIRR/CBE_BIRR/EBIRR (Ethiopia), WAAFI (Djibouti), CARD (International), DMONEY", + example: "TELEBIRR", + }) + @IsEnum(PaymentMethodTypeEnum) + method!: PaymentMethodTypeEnum; + + @ApiPropertyOptional({ enum: ["web", "mobile"], default: "web" }) + @IsOptional() + @IsIn(["web", "mobile"]) + platform?: PaymentPlatformDto; + + @ApiPropertyOptional({ description: "Payer account / mobile number (e.g. for Waafi MWALLET)" }) + @IsOptional() + @IsString() + payerAccount?: string; + + @ApiPropertyOptional({ description: "Browser return URL after successful payment" }) + @IsOptional() + @IsString() + returnUrl?: string; + + @ApiPropertyOptional({ description: "Browser return URL after failed/cancelled payment" }) + @IsOptional() + @IsString() + failureUrl?: string; +} + +export class RefundDto { + @ApiProperty({ example: "booking-uuid" }) + @IsString() + bookingId!: string; + + @ApiPropertyOptional({ description: "Optional reason for refund" }) + @IsOptional() + @IsString() + reason?: string; +} + +export class ClientActionDto { + @ApiProperty({ enum: ["REDIRECT", "LAUNCH_APP", "COLLECT_OTP"] }) + type!: "REDIRECT" | "LAUNCH_APP" | "COLLECT_OTP"; + + @ApiPropertyOptional({ description: "Set when type=REDIRECT (web flow)" }) + url?: string; + + @ApiPropertyOptional({ description: "Set when type=LAUNCH_APP (mobile flow)" }) + appId?: string; + + @ApiPropertyOptional({ description: "Set when type=LAUNCH_APP (mobile flow)" }) + receiveCode?: string; + + @ApiPropertyOptional({ description: "Set when type=LAUNCH_APP (mobile flow)" }) + shortCode?: string; + + @ApiPropertyOptional({ description: "Set when type=COLLECT_OTP (e.g. CAC Bank)" }) + providerOrderId?: string; + + @ApiPropertyOptional({ description: "Set when type=COLLECT_OTP" }) + message?: string; +} + +export class InitiateResponseDto { + @ApiProperty() + intentId!: string; + + @ApiProperty({ enum: ProviderPaymentStatus }) + status!: ProviderPaymentStatus; + + @ApiPropertyOptional({ type: ClientActionDto }) + clientAction?: ClientActionDto; + + @ApiPropertyOptional() + merchantOrderId?: string; +} + +export class IntentStatusDto extends InitiateResponseDto { + @ApiPropertyOptional() + paidAt?: string; + + @ApiPropertyOptional() + failureCode?: string; + + @ApiPropertyOptional() + failureMessage?: string; +} diff --git a/apps/edr-freight-api/src/modules/payment/templates/payment.hbs b/apps/edr-freight-api/src/modules/payment/templates/payment.hbs new file mode 100644 index 000000000..f590764f7 --- /dev/null +++ b/apps/edr-freight-api/src/modules/payment/templates/payment.hbs @@ -0,0 +1,15 @@ + + + + + Redirecting... + + +

Redirecting...

+ + + + + \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/payment/templates/receipt.hbs b/apps/edr-freight-api/src/modules/payment/templates/receipt.hbs new file mode 100644 index 000000000..0f156f6dc --- /dev/null +++ b/apps/edr-freight-api/src/modules/payment/templates/receipt.hbs @@ -0,0 +1,191 @@ + + + + + + + Receipt - {{vendorName}} + + + + + +
+
+

{{vendorName}}

+

{{vendorAddress}}

+
+ + + + + + + + + + + + + + + + +
Date{{receiptDate}}
Payment Method + {{paymentMethod}} +
Description{{reason}}
+ +
+ + + + + + + + + + +
Subtotal + {{currency}} {{subtotal}} +
+ Total Paid + + {{currency}} {{total}} +
+
+ + + +
+ +
+
+ + + + + \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/routes/dto/create-route.dto.ts b/apps/edr-freight-api/src/modules/routes/dto/create-route.dto.ts new file mode 100644 index 000000000..45e737607 --- /dev/null +++ b/apps/edr-freight-api/src/modules/routes/dto/create-route.dto.ts @@ -0,0 +1,28 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { ArrayMinSize, IsArray, IsBoolean, IsOptional, IsString, IsUUID, MaxLength, ValidateNested } from 'class-validator'; + +export class CreateRouteMilestoneDto { + @ApiProperty({ format: 'uuid' }) + @IsUUID() + yardId!: string; +} + +export class CreateRouteDto { + @ApiProperty() + @IsString() + @MaxLength(120) + name!: string; + + @ApiProperty({ type: [CreateRouteMilestoneDto] }) + @IsArray() + @ArrayMinSize(2) + @ValidateNested({ each: true }) + @Type(() => CreateRouteMilestoneDto) + milestones!: CreateRouteMilestoneDto[]; + + @ApiPropertyOptional() + @IsOptional() + @IsBoolean() + isActive?: boolean; +} diff --git a/apps/edr-freight-api/src/modules/routes/dto/filter-routes.dto.ts b/apps/edr-freight-api/src/modules/routes/dto/filter-routes.dto.ts new file mode 100644 index 000000000..020a34cdf --- /dev/null +++ b/apps/edr-freight-api/src/modules/routes/dto/filter-routes.dto.ts @@ -0,0 +1,16 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { Transform } from 'class-transformer'; +import { IsBoolean, IsOptional, IsString } from 'class-validator'; + +export class FilterRoutesDto { + @ApiPropertyOptional() + @IsOptional() + @IsString() + search?: string; + + @ApiPropertyOptional() + @IsOptional() + @Transform(({ value }) => value === 'true' || value === true) + @IsBoolean() + isActive?: boolean; +} diff --git a/apps/edr-freight-api/src/modules/routes/dto/update-route.dto.ts b/apps/edr-freight-api/src/modules/routes/dto/update-route.dto.ts new file mode 100644 index 000000000..ccda6bd61 --- /dev/null +++ b/apps/edr-freight-api/src/modules/routes/dto/update-route.dto.ts @@ -0,0 +1,5 @@ +import { PartialType } from '@nestjs/swagger'; + +import { CreateRouteDto } from './create-route.dto'; + +export class UpdateRouteDto extends PartialType(CreateRouteDto) {} diff --git a/apps/edr-freight-api/src/modules/routes/entities/route-milestone.entity.ts b/apps/edr-freight-api/src/modules/routes/entities/route-milestone.entity.ts new file mode 100644 index 000000000..63e37b8ec --- /dev/null +++ b/apps/edr-freight-api/src/modules/routes/entities/route-milestone.entity.ts @@ -0,0 +1,26 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; + +import { Yard } from '../../rule-engine/entities/yard.entity'; +import { Route } from './route.entity'; + +@Entity({ schema: 'freight', name: 'route_milestones' }) +@Index(['routeId', 'sequenceNo'], { unique: true }) +export class RouteMilestone extends BaseEntity { + @Column({ name: 'route_id', type: 'uuid' }) + routeId!: string; + + @ManyToOne(() => Route, (route) => route.milestones, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'route_id' }) + route?: Route; + + @Column({ name: 'yard_id', type: 'uuid' }) + yardId!: string; + + @ManyToOne(() => Yard) + @JoinColumn({ name: 'yard_id' }) + yard?: Yard; + + @Column({ name: 'sequence_no', type: 'int' }) + sequenceNo!: number; +} diff --git a/apps/edr-freight-api/src/modules/routes/entities/route.entity.ts b/apps/edr-freight-api/src/modules/routes/entities/route.entity.ts new file mode 100644 index 000000000..8c6e4785e --- /dev/null +++ b/apps/edr-freight-api/src/modules/routes/entities/route.entity.ts @@ -0,0 +1,33 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm'; + +import { Yard } from '../../rule-engine/entities/yard.entity'; +import { RouteMilestone } from './route-milestone.entity'; + +@Entity({ schema: 'freight', name: 'routes' }) +@Index(['name']) +@Index(['isActive']) +export class Route extends BaseEntity { + @Column({ name: 'name', type: 'varchar', length: 120, unique: true }) + name!: string; + + @Column({ name: 'origin_yard_id', type: 'uuid' }) + originYardId!: string; + + @ManyToOne(() => Yard) + @JoinColumn({ name: 'origin_yard_id' }) + originYard?: Yard; + + @Column({ name: 'destination_yard_id', type: 'uuid' }) + destinationYardId!: string; + + @ManyToOne(() => Yard) + @JoinColumn({ name: 'destination_yard_id' }) + destinationYard?: Yard; + + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive!: boolean; + + @OneToMany(() => RouteMilestone, (milestone) => milestone.route, { cascade: false }) + milestones?: RouteMilestone[]; +} diff --git a/apps/edr-freight-api/src/modules/routes/route-milestones.repository.ts b/apps/edr-freight-api/src/modules/routes/route-milestones.repository.ts new file mode 100644 index 000000000..a0e97cd23 --- /dev/null +++ b/apps/edr-freight-api/src/modules/routes/route-milestones.repository.ts @@ -0,0 +1,13 @@ +import { BaseRepository } from '@edr/api-common'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { RouteMilestone } from './entities/route-milestone.entity'; + +@Injectable() +export class RouteMilestonesRepository extends BaseRepository { + constructor(@InjectRepository(RouteMilestone) repository: Repository) { + super(repository); + } +} diff --git a/apps/edr-freight-api/src/modules/routes/routes.controller.ts b/apps/edr-freight-api/src/modules/routes/routes.controller.ts new file mode 100644 index 000000000..8c25d67b3 --- /dev/null +++ b/apps/edr-freight-api/src/modules/routes/routes.controller.ts @@ -0,0 +1,49 @@ +import { Body, Controller, Delete, Get, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { FleetManage, FleetView } from '../../common/booking-guards'; +import { CreateRouteDto } from './dto/create-route.dto'; +import { FilterRoutesDto } from './dto/filter-routes.dto'; +import { UpdateRouteDto } from './dto/update-route.dto'; +import { RoutesService } from './routes.service'; + +@ApiTags('routes') +@ApiBearerAuth() +@Controller('routes') +@FleetView() +export class RoutesController { + constructor(private readonly routesService: RoutesService) {} + + @Get() + @ApiOperation({ summary: 'List routes' }) + findAll(@Query() filter: FilterRoutesDto) { + return this.routesService.findAll(filter); + } + + @Get(':id') + @ApiOperation({ summary: 'Get route by ID' }) + findOne(@Param('id', ParseUUIDPipe) id: string) { + return this.routesService.findById(id); + } + + @Post() + @FleetManage() + @ApiOperation({ summary: 'Create route' }) + create(@Body() dto: CreateRouteDto) { + return this.routesService.create(dto); + } + + @Patch(':id') + @FleetManage() + @ApiOperation({ summary: 'Update route' }) + update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateRouteDto) { + return this.routesService.update(id, dto); + } + + @Delete(':id') + @FleetManage() + @ApiOperation({ summary: 'Deactivate route' }) + remove(@Param('id', ParseUUIDPipe) id: string) { + return this.routesService.deactivate(id); + } +} diff --git a/apps/edr-freight-api/src/modules/routes/routes.module.ts b/apps/edr-freight-api/src/modules/routes/routes.module.ts new file mode 100644 index 000000000..c7033f25b --- /dev/null +++ b/apps/edr-freight-api/src/modules/routes/routes.module.ts @@ -0,0 +1,18 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { Yard } from '../rule-engine/entities/yard.entity'; +import { RouteMilestone } from './entities/route-milestone.entity'; +import { Route } from './entities/route.entity'; +import { RouteMilestonesRepository } from './route-milestones.repository'; +import { RoutesController } from './routes.controller'; +import { RoutesRepository } from './routes.repository'; +import { RoutesService } from './routes.service'; + +@Module({ + imports: [TypeOrmModule.forFeature([Route, RouteMilestone, Yard])], + controllers: [RoutesController], + providers: [RoutesRepository, RouteMilestonesRepository, RoutesService], + exports: [RoutesRepository, RouteMilestonesRepository, RoutesService], +}) +export class RoutesModule {} diff --git a/apps/edr-freight-api/src/modules/routes/routes.repository.ts b/apps/edr-freight-api/src/modules/routes/routes.repository.ts new file mode 100644 index 000000000..df6df41d0 --- /dev/null +++ b/apps/edr-freight-api/src/modules/routes/routes.repository.ts @@ -0,0 +1,13 @@ +import { BaseRepository } from '@edr/api-common'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { Route } from './entities/route.entity'; + +@Injectable() +export class RoutesRepository extends BaseRepository { + constructor(@InjectRepository(Route) repository: Repository) { + super(repository); + } +} diff --git a/apps/edr-freight-api/src/modules/routes/routes.service.ts b/apps/edr-freight-api/src/modules/routes/routes.service.ts new file mode 100644 index 000000000..4c8e62498 --- /dev/null +++ b/apps/edr-freight-api/src/modules/routes/routes.service.ts @@ -0,0 +1,171 @@ +import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common'; +import { DataSource, ILike } from 'typeorm'; + +import { Yard } from '../rule-engine/entities/yard.entity'; +import { CreateRouteDto } from './dto/create-route.dto'; +import { FilterRoutesDto } from './dto/filter-routes.dto'; +import { UpdateRouteDto } from './dto/update-route.dto'; +import { RouteMilestone } from './entities/route-milestone.entity'; +import { Route } from './entities/route.entity'; +import { RoutesRepository } from './routes.repository'; + +@Injectable() +export class RoutesService { + constructor( + private readonly dataSource: DataSource, + private readonly routesRepository: RoutesRepository, + ) {} + + findAll(filter: FilterRoutesDto): Promise { + return this.routesRepository.findAll({ + where: { + ...(filter.search ? { name: ILike(`%${filter.search.trim()}%`) } : {}), + ...(filter.isActive !== undefined ? { isActive: filter.isActive } : {}), + }, + relations: { + originYard: true, + destinationYard: true, + milestones: { yard: true }, + }, + order: { + name: 'ASC', + milestones: { sequenceNo: 'ASC' }, + }, + }); + } + + async findById(id: string): Promise { + const route = await this.dataSource.getRepository(Route).findOne({ + where: { id }, + relations: { + originYard: true, + destinationYard: true, + milestones: { yard: true }, + }, + order: { milestones: { sequenceNo: 'ASC' } }, + }); + + if (!route) { + throw new NotFoundException(`Route ${id} not found`); + } + + return route; + } + + async create(dto: CreateRouteDto): Promise { + await this.validateRouteName(dto.name); + const validated = await this.validateMilestones(dto.milestones); + + const route = await this.dataSource.transaction(async (manager) => { + const savedRoute = await manager.getRepository(Route).save( + manager.getRepository(Route).create({ + name: dto.name.trim(), + originYardId: validated.originYardId, + destinationYardId: validated.destinationYardId, + isActive: dto.isActive ?? true, + }), + ); + + await manager.getRepository(RouteMilestone).save( + validated.milestones.map((milestone) => + manager.getRepository(RouteMilestone).create({ + routeId: savedRoute.id, + yardId: milestone.yardId, + sequenceNo: milestone.sequenceNo, + }), + ), + ); + + return savedRoute; + }); + + return this.findById(route.id); + } + + async update(id: string, dto: UpdateRouteDto): Promise { + const existing = await this.findById(id); + + if (dto.name && dto.name.trim() !== existing.name) { + await this.validateRouteName(dto.name, id); + } + + const milestoneInput = dto.milestones + ? await this.validateMilestones(dto.milestones) + : null; + + await this.dataSource.transaction(async (manager) => { + await manager.getRepository(Route).update(id, { + name: dto.name?.trim() ?? existing.name, + originYardId: milestoneInput?.originYardId ?? existing.originYardId, + destinationYardId: milestoneInput?.destinationYardId ?? existing.destinationYardId, + isActive: dto.isActive ?? existing.isActive, + }); + + if (milestoneInput) { + await manager.getRepository(RouteMilestone).delete({ routeId: id }); + await manager.getRepository(RouteMilestone).save( + milestoneInput.milestones.map((milestone) => + manager.getRepository(RouteMilestone).create({ + routeId: id, + yardId: milestone.yardId, + sequenceNo: milestone.sequenceNo, + }), + ), + ); + } + }); + + return this.findById(id); + } + + async deactivate(id: string): Promise { + await this.findById(id); + const updated = await this.routesRepository.update(id, { isActive: false }); + + if (!updated) { + throw new NotFoundException(`Route ${id} not found`); + } + + return this.findById(id); + } + + private async validateRouteName(name: string, routeId?: string) { + const trimmedName = name.trim(); + const [existing] = await this.routesRepository.findAll({ where: { name: trimmedName } }); + + if (existing && existing.id !== routeId) { + throw new ConflictException(`Route name ${trimmedName} already exists`); + } + } + + private async validateMilestones(milestones: Array<{ yardId: string }>) { + if (milestones.length < 2) { + throw new BadRequestException('A route requires at least two yards'); + } + + const normalized = milestones.map((milestone, index) => ({ + yardId: milestone.yardId, + sequenceNo: index + 1, + })); + + const uniqueYardIds = [...new Set(normalized.map((milestone) => milestone.yardId))]; + const yards = await this.dataSource.getRepository(Yard).find({ where: uniqueYardIds.map((id) => ({ id })) }); + const yardIds = new Set(yards.map((yard) => yard.id)); + + for (const milestone of normalized) { + if (!yardIds.has(milestone.yardId)) { + throw new BadRequestException(`Yard ${milestone.yardId} does not exist`); + } + } + + if (normalized[0].yardId === normalized[normalized.length - 1].yardId) { + throw new BadRequestException('Origin and destination yards must be different'); + } + + return { + originYardId: normalized[0].yardId, + destinationYardId: normalized[normalized.length - 1].yardId, + milestones: normalized, + }; + } +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/approval-rules.defaults.ts b/apps/edr-freight-api/src/modules/rule-engine/approval-rules.defaults.ts new file mode 100644 index 000000000..19fd8c267 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/approval-rules.defaults.ts @@ -0,0 +1,31 @@ +/** ITMLS US-06 default approval chains — seeded automatically when missing. */ +export const DEFAULT_APPROVAL_RULE_ROWS = [ + { + requiresDirectorApproval: false, + stepOrder: 1, + requiredRole: 'LINE_STAFF', + actionLabel: 'Review & Approve', + blocksRole: null as string | null, + }, + { + requiresDirectorApproval: false, + stepOrder: 2, + requiredRole: 'DIRECTOR', + actionLabel: 'Final Signature', + blocksRole: 'LINE_STAFF', + }, + { + requiresDirectorApproval: true, + stepOrder: 1, + requiredRole: 'DIRECTOR', + actionLabel: 'Review & Approve', + blocksRole: 'LINE_STAFF', + }, + { + requiresDirectorApproval: true, + stepOrder: 2, + requiredRole: 'CEO', + actionLabel: 'Final Signature', + blocksRole: null as string | null, + }, +] as const; diff --git a/apps/edr-freight-api/src/modules/rule-engine/controllers/approval-rules.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/approval-rules.controller.ts new file mode 100644 index 000000000..8e13d3ec7 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/approval-rules.controller.ts @@ -0,0 +1,84 @@ +import { + Body, Controller, Delete, Get, HttpCode, HttpStatus, + Param, ParseUUIDPipe, Patch, Post, Query, +} from '@nestjs/common'; +import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { CreateApprovalRuleDto } from '../dto/create-approval-rule.dto'; +import { MoveOrderDto } from '../dto/move-order.dto'; +import { ReorderItemsDto } from '../dto/reorder-items.dto'; +import { UpdateApprovalRuleDto } from '../dto/update-approval-rule.dto'; +import { ApprovalRulesService } from '../services/approval-rules.service'; + +@ApiTags('approval-rules') +@Controller('approval-rules') +@ApiBearerAuth() +export class ApprovalRulesController { + constructor(private readonly service: ApprovalRulesService) {} + + @Get() + @RuleEngineView('approval-rules') + @ApiOperation({ summary: 'List approval rules' }) + findAll(@Query() query: Record) { + return this.service.findAll({ + requiresDirectorApproval: + query['requiresDirectorApproval'] !== undefined + ? query['requiresDirectorApproval'] === 'true' + : undefined, + page: query['page'] ? parseInt(query['page'], 10) : undefined, + pageSize: query['pageSize'] ? parseInt(query['pageSize'], 10) : undefined, + }); + } + + @Get('chain') + @RuleEngineView('approval-rules') + @ApiOperation({ summary: 'Get approval chain for cargo routing flag' }) + findChain(@Query('requiresDirectorApproval') flag: string) { + return this.service.findChain(flag === 'true'); + } + + @Post('reorder') + @RuleEngineManage('approval-rules') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'Bulk reorder approval steps within a chain' }) + reorder(@Body() dto: ReorderItemsDto) { + return this.service.reorder(dto); + } + + @Post(':id/move-order') + @RuleEngineManage('approval-rules') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'Move an approval step up or down within its chain' }) + moveOrder(@Param('id', ParseUUIDPipe) id: string, @Body() dto: MoveOrderDto) { + return this.service.moveOrder(id, dto.direction); + } + + @Get(':id') + @RuleEngineView('approval-rules') + @ApiOperation({ summary: 'Get an approval rule by ID' }) + findOne(@Param('id', ParseUUIDPipe) id: string) { + return this.service.findById(id); + } + + @Post() + @RuleEngineManage('approval-rules') + @ApiOperation({ summary: 'Create an approval rule step' }) + create(@Body() dto: CreateApprovalRuleDto) { + return this.service.create(dto); + } + + @Patch(':id') + @RuleEngineManage('approval-rules') + @ApiOperation({ summary: 'Update an approval rule' }) + update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateApprovalRuleDto) { + return this.service.update(id, dto); + } + + @Delete(':id') + @RuleEngineManage('approval-rules') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'Soft-delete an approval rule' }) + remove(@Param('id', ParseUUIDPipe) id: string) { + return this.service.remove(id); + } +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/controllers/cargo-types.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/cargo-types.controller.ts new file mode 100644 index 000000000..e2b8425bf --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/cargo-types.controller.ts @@ -0,0 +1,81 @@ +import { + Body, Controller, Delete, Get, HttpCode, HttpStatus, + Param, ParseUUIDPipe, Patch, Post, Query, +} from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards'; +import { CreateCargoTypeDto } from '../dto/create-cargo-type.dto'; +import { MoveOrderDto } from '../dto/move-order.dto'; +import { ReorderItemsDto } from '../dto/reorder-items.dto'; +import { UpdateCargoTypeDto } from '../dto/update-cargo-type.dto'; +import { CargoTypesService } from '../services/cargo-types.service'; + +@ApiTags('cargo-types') +@Controller('cargo-types') +@ApiBearerAuth() +export class CargoTypesController { + constructor(private readonly service: CargoTypesService) {} + + @Get() + @RuleEngineView('cargo-types') + @ApiOperation({ summary: 'List cargo types' }) + findAll(@Query() query: Record) { + return this.service.findAll({ + isActive: query['isActive'] !== undefined ? query['isActive'] === 'true' : undefined, + requiresDirectorApproval: query['requiresDirectorApproval'] !== undefined + ? query['requiresDirectorApproval'] === 'true' + : undefined, + parentGroupId: query['parentGroupId'], + search: query['search'], + page: query['page'] ? parseInt(query['page'], 10) : undefined, + pageSize: query['pageSize'] ? parseInt(query['pageSize'], 10) : undefined, + sortBy: query['sortBy'], + sortOrder: (query['sortOrder'] as 'ASC' | 'DESC') ?? 'ASC', + }); + } + + @Post('reorder') + @RuleEngineManage('cargo-types') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'Bulk reorder cargo types by ID list' }) + reorder(@Body() dto: ReorderItemsDto) { + return this.service.reorder(dto); + } + + @Post(':id/move-order') + @RuleEngineManage('cargo-types') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'Move a cargo type up or down in display order' }) + moveOrder(@Param('id', ParseUUIDPipe) id: string, @Body() dto: MoveOrderDto) { + return this.service.moveOrder(id, dto.direction); + } + + @Get(':id') + @RuleEngineView('cargo-types') + @ApiOperation({ summary: 'Get a cargo type by ID' }) + findOne(@Param('id', ParseUUIDPipe) id: string) { + return this.service.findById(id); + } + + @Post() + @RuleEngineManage('cargo-types') + @ApiOperation({ summary: 'Create a cargo type' }) + create(@Body() dto: CreateCargoTypeDto) { + return this.service.create(dto); + } + + @Patch(':id') + @RuleEngineManage('cargo-types') + @ApiOperation({ summary: 'Update a cargo type' }) + update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateCargoTypeDto) { + return this.service.update(id, dto); + } + + @Delete(':id') + @RuleEngineManage('cargo-types') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'Soft-delete a cargo type' }) + remove(@Param('id', ParseUUIDPipe) id: string) { + return this.service.remove(id); + } +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/controllers/container-types.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/container-types.controller.ts new file mode 100644 index 000000000..624cf4b03 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/container-types.controller.ts @@ -0,0 +1,74 @@ +import { + Body, Controller, Delete, Get, HttpCode, HttpStatus, + Param, ParseUUIDPipe, Patch, Post, Query, +} from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards'; +import { CreateContainerTypeDto } from '../dto/create-container-type.dto'; +import { MoveOrderDto } from '../dto/move-order.dto'; +import { ReorderItemsDto } from '../dto/reorder-items.dto'; +import { UpdateContainerTypeDto } from '../dto/update-container-type.dto'; +import { ContainerTypesService } from '../services/container-types.service'; + +@ApiTags('container-types') +@Controller('container-types') +@ApiBearerAuth() +export class ContainerTypesController { + constructor(private readonly service: ContainerTypesService) {} + + @Get() + @RuleEngineView('container-types') + @ApiOperation({ summary: 'List container types' }) + findAll(@Query() query: Record) { + return this.service.findAll({ + isActive: query['isActive'] !== undefined ? query['isActive'] === 'true' : undefined, + page: query['page'] ? parseInt(query['page'], 10) : undefined, + pageSize: query['pageSize'] ? parseInt(query['pageSize'], 10) : undefined, + }); + } + + @Post('reorder') + @RuleEngineManage('container-types') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'Bulk reorder container types by ID list' }) + reorder(@Body() dto: ReorderItemsDto) { + return this.service.reorder(dto); + } + + @Post(':id/move-order') + @RuleEngineManage('container-types') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'Move a container type up or down in display order' }) + moveOrder(@Param('id', ParseUUIDPipe) id: string, @Body() dto: MoveOrderDto) { + return this.service.moveOrder(id, dto.direction); + } + + @Get(':id') + @RuleEngineView('container-types') + @ApiOperation({ summary: 'Get a container type by ID' }) + findOne(@Param('id', ParseUUIDPipe) id: string) { + return this.service.findById(id); + } + + @Post() + @RuleEngineManage('container-types') + @ApiOperation({ summary: 'Create a container type' }) + create(@Body() dto: CreateContainerTypeDto) { + return this.service.create(dto); + } + + @Patch(':id') + @RuleEngineManage('container-types') + @ApiOperation({ summary: 'Update a container type' }) + update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateContainerTypeDto) { + return this.service.update(id, dto); + } + + @Delete(':id') + @RuleEngineManage('container-types') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'Soft-delete a container type' }) + remove(@Param('id', ParseUUIDPipe) id: string) { + return this.service.remove(id); + } +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/controllers/priority-configs.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/priority-configs.controller.ts new file mode 100644 index 000000000..36b863dd6 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/priority-configs.controller.ts @@ -0,0 +1,75 @@ +import { + Body, Controller, Delete, Get, HttpCode, HttpStatus, + Param, ParseUUIDPipe, Patch, Post, Query, +} from '@nestjs/common'; +import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { CreatePriorityConfigDto } from '../dto/create-priority-config.dto'; +import { UpdatePriorityConfigDto } from '../dto/update-priority-config.dto'; +import { MoveOrderDto } from '../dto/move-order.dto'; +import { ReorderItemsDto } from '../dto/reorder-items.dto'; +import { PriorityConfigsService } from '../services/priority-configs.service'; + +@ApiTags('priority-configs') +@Controller('priority-configs') +@ApiBearerAuth() +export class PriorityConfigsController { + constructor(private readonly service: PriorityConfigsService) {} + + @Get() + @RuleEngineView('priority-configs') + @ApiOperation({ summary: 'List priority configs' }) + findAll(@Query() query: Record) { + return this.service.findAll({ + type: (query['type'] as 'WAGON' | 'CURRENCY') || undefined, + isActive: query['isActive'] !== undefined ? query['isActive'] === 'true' : undefined, + page: query['page'] ? parseInt(query['page'], 10) : undefined, + pageSize: query['pageSize'] ? parseInt(query['pageSize'], 10) : undefined, + }); + } + + @Get(':id') + @RuleEngineView('priority-configs') + @ApiOperation({ summary: 'Get a priority config by ID' }) + findOne(@Param('id', ParseUUIDPipe) id: string) { + return this.service.findById(id); + } + + @Post() + @RuleEngineManage('priority-configs') + @ApiOperation({ summary: 'Create a priority config' }) + create(@Body() dto: CreatePriorityConfigDto) { + return this.service.create(dto); + } + + @Post('reorder') + @RuleEngineManage('priority-configs') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'Bulk reorder priority configs by ID list' }) + reorder(@Body() dto: ReorderItemsDto) { + return this.service.reorder(dto.ids); + } + + @Post(':id/move-order') + @RuleEngineManage('priority-configs') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'Move a priority config up or down in display order' }) + moveOrder(@Param('id', ParseUUIDPipe) id: string, @Body() dto: MoveOrderDto) { + return this.service.moveOrder(id, dto.direction); + } + + @Patch(':id') + @RuleEngineManage('priority-configs') + @ApiOperation({ summary: 'Update a priority config' }) + update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdatePriorityConfigDto) { + return this.service.update(id, dto); + } + + @Delete(':id') + @RuleEngineManage('priority-configs') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'Soft-delete a priority config' }) + remove(@Param('id', ParseUUIDPipe) id: string) { + return this.service.remove(id); + } +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/controllers/rates.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/rates.controller.ts new file mode 100644 index 000000000..3c7776a27 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/rates.controller.ts @@ -0,0 +1,89 @@ +import { + Body, Controller, Delete, Get, HttpCode, HttpStatus, + Param, ParseUUIDPipe, Patch, Post, Query, +} from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { CurrentUser } from '@edr/api-common'; +import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards'; +import { CreateRateDto } from '../dto/create-rate.dto'; +import { + type AuthUserPayload, + resolveAuthUserId, +} from '../../../common/resolve-auth-user-id'; +import { UpdateRateDto } from '../dto/update-rate.dto'; +import { RatesService } from '../services/rates.service'; + +@ApiTags('rates') +@Controller('rates') +@ApiBearerAuth() +export class RatesController { + constructor(private readonly service: RatesService) {} + + @Get() + @RuleEngineView('rates') + @ApiOperation({ summary: 'List rates' }) + findAll(@Query() query: Record) { + return this.service.findAll({ + status: query['status'], + rateType: query['rateType'], + page: query['page'] ? parseInt(query['page'], 10) : undefined, + pageSize: query['pageSize'] ? parseInt(query['pageSize'], 10) : undefined, + }); + } + + @Get('live') + @RuleEngineView('rates') + @ApiOperation({ summary: 'List all LIVE rates effective now' }) + findLive() { + return this.service.findLiveRates(); + } + + @Get(':id') + @RuleEngineView('rates') + @ApiOperation({ summary: 'Get a rate by ID' }) + findOne(@Param('id', ParseUUIDPipe) id: string) { + return this.service.findById(id); + } + + @Post() + @RuleEngineManage('rates') + @ApiOperation({ summary: 'Create a rate (DRAFT)' }) + create( + @Body() dto: CreateRateDto, + @CurrentUser() user: AuthUserPayload, + ) { + return this.service.create(dto, resolveAuthUserId(user)); + } + + @Patch(':id') + @RuleEngineManage('rates') + @ApiOperation({ summary: 'Update a DRAFT rate' }) + update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateRateDto) { + return this.service.update(id, dto); + } + + @Post(':id/submit') + @RuleEngineManage('rates') + @ApiOperation({ summary: 'Submit rate for CEO approval' }) + submit(@Param('id', ParseUUIDPipe) id: string) { + return this.service.submitForApproval(id); + } + + @Post(':id/approve') + @RuleEngineManage('rates') + @ApiOperation({ summary: 'CEO approves a rate' }) + approve( + @Param('id', ParseUUIDPipe) id: string, + @CurrentUser() user: AuthUserPayload, + ) { + return this.service.approve(id, resolveAuthUserId(user)); + } + + @Delete(':id') + @RuleEngineManage('rates') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'Soft-delete a rate' }) + remove(@Param('id', ParseUUIDPipe) id: string) { + return this.service.remove(id); + } +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/controllers/service-types.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/service-types.controller.ts new file mode 100644 index 000000000..18c597b38 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/service-types.controller.ts @@ -0,0 +1,78 @@ +import { + Body, Controller, Delete, Get, HttpCode, HttpStatus, + Param, ParseUUIDPipe, Patch, Post, Query, +} from '@nestjs/common'; +import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { CreateServiceTypeDto } from '../dto/create-service-type.dto'; +import { MoveOrderDto } from '../dto/move-order.dto'; +import { ReorderItemsDto } from '../dto/reorder-items.dto'; +import { UpdateServiceTypeDto } from '../dto/update-service-type.dto'; +import { ServiceTypesService } from '../services/service-types.service'; + +@ApiTags('service-types') +@Controller('service-types') +@ApiBearerAuth() +export class ServiceTypesController { + constructor(private readonly service: ServiceTypesService) {} + + @Get() + @RuleEngineView('service-types') + @ApiOperation({ summary: 'List service types' }) + findAll(@Query() query: Record) { + return this.service.findAll({ + isActive: query['isActive'] !== undefined ? query['isActive'] === 'true' : undefined, + canBeBookedAlone: query['canBeBookedAlone'] !== undefined ? query['canBeBookedAlone'] === 'true' : undefined, + search: query['search'], + page: query['page'] ? parseInt(query['page'], 10) : undefined, + pageSize: query['pageSize'] ? parseInt(query['pageSize'], 10) : undefined, + sortBy: query['sortBy'], + sortOrder: (query['sortOrder'] as 'ASC' | 'DESC') ?? 'ASC', + }); + } + + @Post('reorder') + @RuleEngineManage('service-types') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'Bulk reorder service types by ID list' }) + reorder(@Body() dto: ReorderItemsDto) { + return this.service.reorder(dto); + } + + @Post(':id/move-order') + @RuleEngineManage('service-types') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'Move a service type up or down in display order' }) + moveOrder(@Param('id', ParseUUIDPipe) id: string, @Body() dto: MoveOrderDto) { + return this.service.moveOrder(id, dto.direction); + } + + @Get(':id') + @RuleEngineView('service-types') + @ApiOperation({ summary: 'Get a service type by ID' }) + findOne(@Param('id', ParseUUIDPipe) id: string) { + return this.service.findById(id); + } + + @Post() + @RuleEngineManage('service-types') + @ApiOperation({ summary: 'Create a service type' }) + create(@Body() dto: CreateServiceTypeDto) { + return this.service.create(dto); + } + + @Patch(':id') + @RuleEngineManage('service-types') + @ApiOperation({ summary: 'Update a service type' }) + update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateServiceTypeDto) { + return this.service.update(id, dto); + } + + @Delete(':id') + @RuleEngineManage('service-types') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'Soft-delete a service type' }) + remove(@Param('id', ParseUUIDPipe) id: string) { + return this.service.remove(id); + } +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/controllers/shipping-lines.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/shipping-lines.controller.ts new file mode 100644 index 000000000..40a67c7f5 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/shipping-lines.controller.ts @@ -0,0 +1,56 @@ +import { + Body, Controller, Delete, Get, HttpCode, HttpStatus, + Param, ParseUUIDPipe, Patch, Post, Query, +} from '@nestjs/common'; +import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { CreateShippingLineDto } from '../dto/create-shipping-line.dto'; +import { UpdateShippingLineDto } from '../dto/update-shipping-line.dto'; +import { ShippingLinesService } from '../services/shipping-lines.service'; + +@ApiTags('shipping-lines') +@Controller('shipping-lines') +@ApiBearerAuth() +export class ShippingLinesController { + constructor(private readonly service: ShippingLinesService) {} + + @Get() + @RuleEngineView('shipping-lines') + @ApiOperation({ summary: 'List shipping lines' }) + findAll(@Query() query: Record) { + return this.service.findAll({ + isActive: query['isActive'] !== undefined ? query['isActive'] === 'true' : undefined, + page: query['page'] ? parseInt(query['page'], 10) : undefined, + pageSize: query['pageSize'] ? parseInt(query['pageSize'], 10) : undefined, + }); + } + + @Get(':id') + @RuleEngineView('shipping-lines') + @ApiOperation({ summary: 'Get a shipping line by ID' }) + findOne(@Param('id', ParseUUIDPipe) id: string) { + return this.service.findById(id); + } + + @Post() + @RuleEngineManage('shipping-lines') + @ApiOperation({ summary: 'Create a shipping line' }) + create(@Body() dto: CreateShippingLineDto) { + return this.service.create(dto); + } + + @Patch(':id') + @RuleEngineManage('shipping-lines') + @ApiOperation({ summary: 'Update a shipping line' }) + update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateShippingLineDto) { + return this.service.update(id, dto); + } + + @Delete(':id') + @RuleEngineManage('shipping-lines') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'Soft-delete a shipping line' }) + remove(@Param('id', ParseUUIDPipe) id: string) { + return this.service.remove(id); + } +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/controllers/surcharge-types.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/surcharge-types.controller.ts new file mode 100644 index 000000000..be4c3011a --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/surcharge-types.controller.ts @@ -0,0 +1,56 @@ +import { + Body, Controller, Delete, Get, HttpCode, HttpStatus, + Param, ParseUUIDPipe, Patch, Post, Query, +} from '@nestjs/common'; +import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { CreateSurchargeTypeDto } from '../dto/create-surcharge-type.dto'; +import { UpdateSurchargeTypeDto } from '../dto/update-surcharge-type.dto'; +import { SurchargeTypesService } from '../services/surcharge-types.service'; + +@ApiTags('surcharge-types') +@Controller('surcharge-types') +@ApiBearerAuth() +export class SurchargeTypesController { + constructor(private readonly service: SurchargeTypesService) {} + + @Get() + @RuleEngineView('surcharge-types') + @ApiOperation({ summary: 'List surcharge types' }) + findAll(@Query() query: Record) { + return this.service.findAll({ + isActive: query['isActive'] !== undefined ? query['isActive'] === 'true' : undefined, + page: query['page'] ? parseInt(query['page'], 10) : undefined, + pageSize: query['pageSize'] ? parseInt(query['pageSize'], 10) : undefined, + }); + } + + @Get(':id') + @RuleEngineView('surcharge-types') + @ApiOperation({ summary: 'Get a surcharge type by ID' }) + findOne(@Param('id', ParseUUIDPipe) id: string) { + return this.service.findById(id); + } + + @Post() + @RuleEngineManage('surcharge-types') + @ApiOperation({ summary: 'Create a surcharge type' }) + create(@Body() dto: CreateSurchargeTypeDto) { + return this.service.create(dto); + } + + @Patch(':id') + @RuleEngineManage('surcharge-types') + @ApiOperation({ summary: 'Update a surcharge type' }) + update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateSurchargeTypeDto) { + return this.service.update(id, dto); + } + + @Delete(':id') + @RuleEngineManage('surcharge-types') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'Soft-delete a surcharge type' }) + remove(@Param('id', ParseUUIDPipe) id: string) { + return this.service.remove(id); + } +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/controllers/weight-limit-rules.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/weight-limit-rules.controller.ts new file mode 100644 index 000000000..c3f0c1472 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/weight-limit-rules.controller.ts @@ -0,0 +1,57 @@ +import { + Body, Controller, Delete, Get, HttpCode, HttpStatus, + Param, ParseUUIDPipe, Patch, Post, Query, +} from '@nestjs/common'; +import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { CreateWeightLimitRuleDto } from '../dto/create-weight-limit-rule.dto'; +import { UpdateWeightLimitRuleDto } from '../dto/update-weight-limit-rule.dto'; +import { WeightLimitRulesService } from '../services/weight-limit-rules.service'; + +@ApiTags('weight-limit-rules') +@Controller('weight-limit-rules') +@ApiBearerAuth() +export class WeightLimitRulesController { + constructor(private readonly service: WeightLimitRulesService) {} + + @Get() + @RuleEngineView('weight-limit-rules') + @ApiOperation({ summary: 'List weight limit rules' }) + findAll(@Query() query: Record) { + return this.service.findAll({ + tradeDirection: query['tradeDirection'], + containerTypeId: query['containerTypeId'], + page: query['page'] ? parseInt(query['page'], 10) : undefined, + pageSize: query['pageSize'] ? parseInt(query['pageSize'], 10) : undefined, + }); + } + + @Get(':id') + @RuleEngineView('weight-limit-rules') + @ApiOperation({ summary: 'Get a weight limit rule by ID' }) + findOne(@Param('id', ParseUUIDPipe) id: string) { + return this.service.findById(id); + } + + @Post() + @RuleEngineManage('weight-limit-rules') + @ApiOperation({ summary: 'Create a weight limit rule' }) + create(@Body() dto: CreateWeightLimitRuleDto) { + return this.service.create(dto); + } + + @Patch(':id') + @RuleEngineManage('weight-limit-rules') + @ApiOperation({ summary: 'Update a weight limit rule' }) + update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateWeightLimitRuleDto) { + return this.service.update(id, dto); + } + + @Delete(':id') + @RuleEngineManage('weight-limit-rules') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'Soft-delete a weight limit rule' }) + remove(@Param('id', ParseUUIDPipe) id: string) { + return this.service.remove(id); + } +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/controllers/yards.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/yards.controller.ts new file mode 100644 index 000000000..d18d0b748 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/yards.controller.ts @@ -0,0 +1,75 @@ +import { + Body, Controller, Delete, Get, HttpCode, HttpStatus, + Param, ParseUUIDPipe, Patch, Post, Query, +} from '@nestjs/common'; +import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { CreateYardDto } from '../dto/create-yard.dto'; +import { MoveOrderDto } from '../dto/move-order.dto'; +import { ReorderItemsDto } from '../dto/reorder-items.dto'; +import { UpdateYardDto } from '../dto/update-yard.dto'; +import { YardsService } from '../services/yards.service'; + +@ApiTags('yards') +@Controller('yards') +@ApiBearerAuth() +export class YardsController { + constructor(private readonly service: YardsService) {} + + @Get() + @RuleEngineView('yards') + @ApiOperation({ summary: 'List yards' }) + findAll(@Query() query: Record) { + return this.service.findAll({ + isActive: query['isActive'] !== undefined ? query['isActive'] === 'true' : undefined, + country: query['country'], + page: query['page'] ? parseInt(query['page'], 10) : undefined, + pageSize: query['pageSize'] ? parseInt(query['pageSize'], 10) : undefined, + }); + } + + @Post('reorder') + @RuleEngineManage('yards') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'Bulk reorder yards by ID list' }) + reorder(@Body() dto: ReorderItemsDto) { + return this.service.reorder(dto); + } + + @Post(':id/move-order') + @RuleEngineManage('yards') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'Move a yard up or down in display order' }) + moveOrder(@Param('id', ParseUUIDPipe) id: string, @Body() dto: MoveOrderDto) { + return this.service.moveOrder(id, dto.direction); + } + + @Get(':id') + @RuleEngineView('yards') + @ApiOperation({ summary: 'Get a yard by ID' }) + findOne(@Param('id', ParseUUIDPipe) id: string) { + return this.service.findById(id); + } + + @Post() + @RuleEngineManage('yards') + @ApiOperation({ summary: 'Create a yard' }) + create(@Body() dto: CreateYardDto) { + return this.service.create(dto); + } + + @Patch(':id') + @RuleEngineManage('yards') + @ApiOperation({ summary: 'Update a yard' }) + update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateYardDto) { + return this.service.update(id, dto); + } + + @Delete(':id') + @RuleEngineManage('yards') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'Soft-delete a yard' }) + remove(@Param('id', ParseUUIDPipe) id: string) { + return this.service.remove(id); + } +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-approval-rule.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-approval-rule.dto.ts new file mode 100644 index 000000000..5861b1ad8 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-approval-rule.dto.ts @@ -0,0 +1,37 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsBoolean, IsInt, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator'; + +const ROLES = ['LINE_STAFF', 'DIRECTOR', 'CEO'] as const; + +export class CreateApprovalRuleDto { + @ApiProperty({ description: 'True = Director+CEO chain; False = LineStaff+Director chain' }) + @IsBoolean() + requiresDirectorApproval!: boolean; + + @ApiPropertyOptional({ description: 'Step sequence number (auto-assigned if omitted)', minimum: 1 }) + @IsOptional() + @IsInt() + @Min(1) + stepOrder?: number; + + @ApiPropertyOptional({ description: 'Insert after this step ID within the same chain' }) + @IsOptional() + @IsUUID('4') + insertAfterId?: string; + + @ApiProperty({ enum: ROLES, description: 'Role required to action this step' }) + @IsString() + @MaxLength(30) + requiredRole!: string; + + @ApiProperty({ description: 'Label shown in UI, e.g. "Review & Approve"', maxLength: 50 }) + @IsString() + @MaxLength(50) + actionLabel!: string; + + @ApiPropertyOptional({ enum: ROLES, description: 'Role explicitly blocked from actioning this step' }) + @IsOptional() + @IsString() + @MaxLength(30) + blocksRole?: string; +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts new file mode 100644 index 000000000..57fe48fed --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts @@ -0,0 +1,40 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsBoolean, IsInt, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator'; + +export class CreateCargoTypeDto { + @ApiProperty({ description: 'Cargo type display name', maxLength: 255 }) + @IsString() + @MaxLength(255) + cargoTypeName!: string; + + @ApiPropertyOptional({ description: 'Parent group ID for hierarchical cargo types' }) + @IsOptional() + @IsUUID() + parentGroupId?: string; + + @ApiPropertyOptional({ default: false }) + @IsOptional() + @IsBoolean() + showFreeTextBox?: boolean; + + @ApiPropertyOptional({ default: false }) + @IsOptional() + @IsBoolean() + requiresDirectorApproval?: boolean; + + @ApiPropertyOptional({ default: true }) + @IsOptional() + @IsBoolean() + isActive?: boolean; + + @ApiPropertyOptional({ default: 1 }) + @IsOptional() + @IsInt() + @Min(1) + displayOrder?: number; + + @ApiPropertyOptional({ description: 'Insert after this record ID' }) + @IsOptional() + @IsUUID('4') + insertAfterId?: string; +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-container-type.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-container-type.dto.ts new file mode 100644 index 000000000..52cfe274b --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-container-type.dto.ts @@ -0,0 +1,48 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Transform } from 'class-transformer'; +import { IsBoolean, IsInt, IsNumber, IsOptional, IsString, IsUUID, Max, MaxLength, Min } from 'class-validator'; + +export class CreateContainerTypeDto { + @ApiProperty({ description: 'Customer-facing label, e.g. "20ft Dry Container"', maxLength: 100 }) + @IsString() + @MaxLength(100) + label!: string; + + @ApiProperty({ description: 'Container size in feet: 20 or 40', enum: [20, 40] }) + @IsInt() + @Min(20) + @Max(40) + sizeFt!: number; + + @ApiProperty({ description: 'Wagon fraction per container: 0.50 for 20ft, 1.00 for 40ft' }) + @IsNumber() + @Min(0.01) + @Transform(({ value }) => Number(value)) + wagonsPerUnit!: number; + + @ApiPropertyOptional({ default: false, description: 'True if this is a reefer (refrigerated) container' }) + @IsOptional() + @IsBoolean() + isReefer?: boolean; + + @ApiPropertyOptional({ default: false, description: 'True if this is an open-top container' }) + @IsOptional() + @IsBoolean() + isOpenTop?: boolean; + + @ApiPropertyOptional({ default: true }) + @IsOptional() + @IsBoolean() + isActive?: boolean; + + @ApiPropertyOptional({ default: 1, description: 'UI display sort order' }) + @IsOptional() + @IsInt() + @Min(1) + displayOrder?: number; + + @ApiPropertyOptional({ description: 'Insert after this record ID' }) + @IsOptional() + @IsUUID('4') + insertAfterId?: string; +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-priority-config.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-priority-config.dto.ts new file mode 100644 index 000000000..140954e83 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-priority-config.dto.ts @@ -0,0 +1,42 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsBoolean, IsIn, IsInt, IsOptional, IsString, MaxLength, Min } from 'class-validator'; + +export class CreatePriorityConfigDto { + @ApiProperty({ description: 'Config type: WAGON or CURRENCY', enum: ['WAGON', 'CURRENCY'] }) + @IsIn(['WAGON', 'CURRENCY']) + type!: 'WAGON' | 'CURRENCY'; + + @ApiProperty({ description: 'Human-readable label', maxLength: 100 }) + @IsString() + @MaxLength(100) + label!: string; + + @ApiPropertyOptional({ + description: 'Currency code (e.g., USD, ETB). Required for type=CURRENCY, must be null for type=WAGON', + maxLength: 5, + }) + @IsOptional() + @IsString() + @MaxLength(5) + currency?: string; + + @ApiProperty({ description: 'Minimum wagon count in range (inclusive)' }) + @IsInt() + @Min(0) + minWagonCount!: number; + + @ApiProperty({ description: 'Maximum wagon count in range (inclusive)' }) + @IsInt() + @Min(0) + maxWagonCount!: number; + + @ApiProperty({ description: 'Points awarded when booking matches this rule', default: 0 }) + @IsInt() + @Min(0) + scorePoints!: number; + + @ApiPropertyOptional({ default: false, description: 'Feature flag — toggle without code deploy' }) + @IsOptional() + @IsBoolean() + isActive?: boolean; +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-rate.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-rate.dto.ts new file mode 100644 index 000000000..969c08876 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-rate.dto.ts @@ -0,0 +1,54 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Transform } from 'class-transformer'; +import { IsDateString, IsIn, IsNumber, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator'; +import { RATE_TYPES, RATE_UNITS } from '../entities/rate.entity'; + +const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'BOTH'] as const; +const CURRENCIES = ['USD'] as const; + +export class CreateRateDto { + @ApiProperty({ enum: RATE_TYPES, description: 'Rate type identifier' }) + @IsIn([...RATE_TYPES]) + rateType!: string; + + @ApiPropertyOptional({ description: 'FK to container_types.id — null for non-container rates' }) + @IsOptional() + @IsUUID() + containerTypeId?: string; + + @ApiPropertyOptional({ enum: TRADE_DIRECTIONS, description: 'Trade direction. Null = direction-agnostic' }) + @IsOptional() + @IsIn([...TRADE_DIRECTIONS]) + tradeDirection?: string; + + @ApiProperty({ enum: CURRENCIES }) + @IsIn([...CURRENCIES]) + currency!: string; + + @ApiProperty({ description: 'Numeric rate value', minimum: 0 }) + @IsNumber() + @Min(0) + @Transform(({ value }) => Number(value)) + rateValue!: number; + + @ApiProperty({ enum: RATE_UNITS, description: 'Unit basis for the rate' }) + @IsIn([...RATE_UNITS]) + rateUnit!: string; + + @ApiProperty({ description: 'Date from which this rate is effective (ISO date)', example: '2025-01-01' }) + @IsDateString() + effectiveFrom!: string; + + @ApiPropertyOptional({ description: 'Date when this rate expires. Null = currently active', example: '2025-12-31' }) + @IsOptional() + @IsDateString() + effectiveTo?: string; +} + +export class SubmitRateForApprovalDto { + @ApiPropertyOptional({ description: 'Optional note for the approval request', maxLength: 500 }) + @IsOptional() + @IsString() + @MaxLength(500) + note?: string; +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-service-type.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-service-type.dto.ts new file mode 100644 index 000000000..4683d448d --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-service-type.dto.ts @@ -0,0 +1,56 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsBoolean, IsInt, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator'; + +export class CreateServiceTypeDto { + @ApiProperty({ description: 'Service type display name', maxLength: 255 }) + @IsString() + @MaxLength(255) + serviceName!: string; + + @ApiPropertyOptional({ description: 'Detailed description' }) + @IsOptional() + @IsString() + description?: string; + + @ApiPropertyOptional({ default: true }) + @IsOptional() + @IsBoolean() + canBeBookedAlone?: boolean; + + @ApiPropertyOptional({ default: false }) + @IsOptional() + @IsBoolean() + includesFirstMile?: boolean; + + @ApiPropertyOptional({ default: false }) + @IsOptional() + @IsBoolean() + includesLastMile?: boolean; + + @ApiPropertyOptional({ default: false }) + @IsOptional() + @IsBoolean() + includesCustoms?: boolean; + + @ApiPropertyOptional({ description: 'Priority bonus points awarded when this service is used', default: 0 }) + @IsOptional() + @IsInt() + @Min(0) + priorityBonusPoints?: number; + + @ApiPropertyOptional({ default: true }) + @IsOptional() + @IsBoolean() + isActive?: boolean; + + @ApiPropertyOptional({ default: 1 }) + @IsOptional() + @IsInt() + @Min(1) + displayOrder?: number; + + @ApiPropertyOptional({ description: 'Insert after this record ID' }) + @IsOptional() + @IsUUID('4') + insertAfterId?: string; +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-shipping-line.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-shipping-line.dto.ts new file mode 100644 index 000000000..c5ccef22b --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-shipping-line.dto.ts @@ -0,0 +1,36 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsBoolean, IsOptional, IsString, MaxLength } from 'class-validator'; + +export class CreateShippingLineDto { + @ApiProperty({ description: 'Unique shipping line code, e.g. MSC, PIL, MAERSK', maxLength: 20 }) + @IsString() + @MaxLength(20) + code!: string; + + @ApiProperty({ description: 'Customer-facing label', maxLength: 100 }) + @IsString() + @MaxLength(100) + label!: string; + + @ApiPropertyOptional({ + description: 'If set, backend silently uses this code for pricing tier lookups (e.g. PIL → MAERSK)', + maxLength: 20, + }) + @IsOptional() + @IsString() + @MaxLength(20) + mappedToCode?: string; + + @ApiPropertyOptional({ + default: false, + description: 'If true, quotation renders additional fee notice to customer', + }) + @IsOptional() + @IsBoolean() + showExtraFeeNotice?: boolean; + + @ApiPropertyOptional({ default: true }) + @IsOptional() + @IsBoolean() + isActive?: boolean; +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-surcharge-type.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-surcharge-type.dto.ts new file mode 100644 index 000000000..7aef9bbde --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-surcharge-type.dto.ts @@ -0,0 +1,30 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsBoolean, IsIn, IsOptional, IsString, IsUUID, MaxLength } from 'class-validator'; + +const TRIGGER_CONDITIONS = [ + 'CARGO_FLAG_HAZARDOUS', + 'CARGO_FLAG_REEFER', + 'VGM_EXCEEDS_LIMIT', + 'SHIPPING_LINE_MAPPED', + 'CONSOLIDATION_ENABLED', +] as const; + +export class CreateSurchargeTypeDto { + @ApiProperty({ description: 'Human-readable label', maxLength: 100 }) + @IsString() + @MaxLength(100) + label!: string; + + @ApiProperty({ enum: TRIGGER_CONDITIONS, description: 'Condition that auto-fires this surcharge' }) + @IsIn([...TRIGGER_CONDITIONS]) + triggerCondition!: string; + + @ApiProperty({ description: 'FK to rates.id — the LIVE rate used to price this surcharge' }) + @IsUUID() + rateId!: string; + + @ApiPropertyOptional({ default: true }) + @IsOptional() + @IsBoolean() + isActive?: boolean; +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-weight-limit-rule.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-weight-limit-rule.dto.ts new file mode 100644 index 000000000..6be37214b --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-weight-limit-rule.dto.ts @@ -0,0 +1,33 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Transform } from 'class-transformer'; +import { IsDateString, IsIn, IsNumber, IsOptional, IsUUID, Min } from 'class-validator'; + +const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'BOTH', 'DOMESTIC'] as const; + +export class CreateWeightLimitRuleDto { + @ApiProperty({ description: 'FK to container_types.id' }) + @IsUUID() + containerTypeId!: string; + + @ApiProperty({ + enum: TRADE_DIRECTIONS, + description: 'Trade direction: IMPORT, EXPORT, BOTH, or DOMESTIC', + }) + @IsIn([...TRADE_DIRECTIONS]) + tradeDirection!: string; + + @ApiProperty({ description: 'Maximum allowed VGM in tons', minimum: 0 }) + @IsNumber() + @Min(0) + @Transform(({ value }) => Number(value)) + maxVgmTons!: number; + + @ApiProperty({ description: 'Date from which this rule is active (ISO date)', example: '2024-01-01' }) + @IsDateString() + effectiveFrom!: string; + + @ApiPropertyOptional({ description: 'Date when this rule expires (ISO date). Null = currently active', example: '2025-12-31' }) + @IsOptional() + @IsDateString() + effectiveTo?: string; +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-yard.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-yard.dto.ts new file mode 100644 index 000000000..38f2bc58b --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-yard.dto.ts @@ -0,0 +1,30 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsBoolean, IsInt, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator'; + +export class CreateYardDto { + @ApiProperty({ description: 'Customer-facing yard label', maxLength: 100 }) + @IsString() + @MaxLength(100) + label!: string; + + @ApiProperty({ description: 'Country where the yard is located, e.g. Ethiopia, Djibouti', maxLength: 50 }) + @IsString() + @MaxLength(50) + country!: string; + + @ApiPropertyOptional({ default: true }) + @IsOptional() + @IsBoolean() + isActive?: boolean; + + @ApiPropertyOptional({ default: 1, description: 'UI display sort order' }) + @IsOptional() + @IsInt() + @Min(1) + displayOrder?: number; + + @ApiPropertyOptional({ description: 'Insert after this record ID' }) + @IsOptional() + @IsUUID('4') + insertAfterId?: string; +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/move-order.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/move-order.dto.ts new file mode 100644 index 000000000..91eadc0d8 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/move-order.dto.ts @@ -0,0 +1,8 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsIn } from 'class-validator'; + +export class MoveOrderDto { + @ApiProperty({ enum: ['up', 'down'] }) + @IsIn(['up', 'down']) + direction!: 'up' | 'down'; +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/reorder-items.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/reorder-items.dto.ts new file mode 100644 index 000000000..48a3e6b6a --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/reorder-items.dto.ts @@ -0,0 +1,17 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { ArrayMinSize, IsArray, IsBoolean, IsOptional, IsUUID } from 'class-validator'; + +export class ReorderItemsDto { + @ApiProperty({ description: 'Ordered list of record IDs (new display/step order)', type: [String] }) + @IsArray() + @ArrayMinSize(1) + @IsUUID('4', { each: true }) + ids!: string[]; + + @ApiPropertyOptional({ + description: 'Approval-rules only: scope reorder to this chain', + }) + @IsOptional() + @IsBoolean() + requiresDirectorApproval?: boolean; +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/update-approval-rule.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/update-approval-rule.dto.ts new file mode 100644 index 000000000..74a4e9f58 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/update-approval-rule.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/mapped-types'; +import { CreateApprovalRuleDto } from './create-approval-rule.dto'; + +export class UpdateApprovalRuleDto extends PartialType(CreateApprovalRuleDto) {} diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/update-cargo-type.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/update-cargo-type.dto.ts new file mode 100644 index 000000000..fd7e82cff --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/update-cargo-type.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/mapped-types'; +import { CreateCargoTypeDto } from './create-cargo-type.dto'; + +export class UpdateCargoTypeDto extends PartialType(CreateCargoTypeDto) {} diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/update-container-type.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/update-container-type.dto.ts new file mode 100644 index 000000000..6fd94ceb8 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/update-container-type.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/mapped-types'; +import { CreateContainerTypeDto } from './create-container-type.dto'; + +export class UpdateContainerTypeDto extends PartialType(CreateContainerTypeDto) {} diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/update-priority-config.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/update-priority-config.dto.ts new file mode 100644 index 000000000..ad2e01bfc --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/update-priority-config.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/swagger'; +import { CreatePriorityConfigDto } from './create-priority-config.dto'; + +export class UpdatePriorityConfigDto extends PartialType(CreatePriorityConfigDto) {} diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/update-rate.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/update-rate.dto.ts new file mode 100644 index 000000000..daaf2af05 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/update-rate.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/mapped-types'; +import { CreateRateDto } from './create-rate.dto'; + +export class UpdateRateDto extends PartialType(CreateRateDto) {} diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/update-service-type.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/update-service-type.dto.ts new file mode 100644 index 000000000..8f85a656a --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/update-service-type.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/mapped-types'; +import { CreateServiceTypeDto } from './create-service-type.dto'; + +export class UpdateServiceTypeDto extends PartialType(CreateServiceTypeDto) {} diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/update-shipping-line.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/update-shipping-line.dto.ts new file mode 100644 index 000000000..7b839a0c2 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/update-shipping-line.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/mapped-types'; +import { CreateShippingLineDto } from './create-shipping-line.dto'; + +export class UpdateShippingLineDto extends PartialType(CreateShippingLineDto) {} diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/update-surcharge-type.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/update-surcharge-type.dto.ts new file mode 100644 index 000000000..cb9be80eb --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/update-surcharge-type.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/mapped-types'; +import { CreateSurchargeTypeDto } from './create-surcharge-type.dto'; + +export class UpdateSurchargeTypeDto extends PartialType(CreateSurchargeTypeDto) {} diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/update-weight-limit-rule.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/update-weight-limit-rule.dto.ts new file mode 100644 index 000000000..4841e9e42 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/update-weight-limit-rule.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/mapped-types'; +import { CreateWeightLimitRuleDto } from './create-weight-limit-rule.dto'; + +export class UpdateWeightLimitRuleDto extends PartialType(CreateWeightLimitRuleDto) {} diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/update-yard.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/update-yard.dto.ts new file mode 100644 index 000000000..f077559fe --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/update-yard.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/mapped-types'; +import { CreateYardDto } from './create-yard.dto'; + +export class UpdateYardDto extends PartialType(CreateYardDto) {} diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/approval-rule.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/approval-rule.entity.ts new file mode 100644 index 000000000..94fb4355d --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/approval-rule.entity.ts @@ -0,0 +1,23 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, Unique } from 'typeorm'; + +@Entity({ schema: 'freight', name: 'approval_rules' }) +@Unique(['requiresDirectorApproval', 'stepOrder']) +@Index(['requiresDirectorApproval']) +@Index(['stepOrder']) +export class ApprovalRule extends BaseEntity { + @Column({ name: 'requires_director_approval', type: 'boolean' }) + requiresDirectorApproval!: boolean; + + @Column({ name: 'step_order', type: 'smallint' }) + stepOrder!: number; + + @Column({ name: 'required_role', type: 'varchar', length: 30 }) + requiredRole!: string; + + @Column({ name: 'action_label', type: 'varchar', length: 50 }) + actionLabel!: string; + + @Column({ name: 'blocks_role', type: 'varchar', length: 30, nullable: true }) + blocksRole?: string | null; +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/cargo-type.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/cargo-type.entity.ts new file mode 100644 index 000000000..a0bd9ddaf --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/cargo-type.entity.ts @@ -0,0 +1,37 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm'; + +@Entity({ schema: 'freight', name: 'cargo_types' }) +@Index(['isActive']) +@Index(['displayOrder']) +@Index(['parentGroupId']) +@Index(['code']) +export class CargoType extends BaseEntity { + @Column({ name: 'code', type: 'varchar', length: 50, unique: true, default: '' }) + code!: string; + + @Column({ name: 'cargo_type_name', type: 'varchar', length: 255 }) + cargoTypeName!: string; + + @Column({ name: 'parent_group_id', type: 'uuid', nullable: true }) + parentGroupId?: string | null; + + @Column({ name: 'show_free_text_box', type: 'boolean', default: false }) + showFreeTextBox!: boolean; + + @Column({ name: 'requires_director_approval', type: 'boolean', default: false }) + requiresDirectorApproval!: boolean; + + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive!: boolean; + + @Column({ name: 'display_order', type: 'int', default: 1 }) + displayOrder!: number; + + @ManyToOne(() => CargoType, (ct) => ct.children, { nullable: true, onDelete: 'SET NULL' }) + @JoinColumn({ name: 'parent_group_id' }) + parent?: CargoType | null; + + @OneToMany(() => CargoType, (ct) => ct.parent) + children?: CargoType[]; +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/container-type.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/container-type.entity.ts new file mode 100644 index 000000000..e03078c19 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/container-type.entity.ts @@ -0,0 +1,35 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, OneToMany } from 'typeorm'; +import { WeightLimitRule } from './weight-limit-rule.entity'; + +@Entity({ schema: 'freight', name: 'container_types' }) +@Index(['code']) +@Index(['isActive']) +export class ContainerType extends BaseEntity { + @Column({ name: 'code', type: 'varchar', length: 20, unique: true }) + code!: string; + + @Column({ name: 'label', type: 'varchar', length: 100, nullable: true }) + label!: string; + + @Column({ name: 'size_ft', type: 'smallint', nullable: true }) + sizeFt!: number; + + @Column({ name: 'wagons_per_unit', type: 'numeric', precision: 4, scale: 2, nullable: true }) + wagonsPerUnit!: number; + + @Column({ name: 'is_reefer', type: 'boolean', default: false, nullable: true }) + isReefer!: boolean; + + @Column({ name: 'is_open_top', type: 'boolean', default: false, nullable: true }) + isOpenTop!: boolean; + + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive!: boolean; + + @Column({ name: 'display_order', type: 'int', default: 1, nullable: true }) + displayOrder!: number; + + @OneToMany(() => WeightLimitRule, (rule) => rule.containerType) + weightLimitRules?: WeightLimitRule[]; +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/priority-config.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/priority-config.entity.ts new file mode 100644 index 000000000..df60b3ea1 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/priority-config.entity.ts @@ -0,0 +1,31 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index } from 'typeorm'; + +@Entity({ schema: 'freight', name: 'priority_configs' }) +@Index(['type', 'isActive']) +@Index(['currency', 'type']) +export class PriorityConfig extends BaseEntity { + @Column({ name: 'type', type: 'varchar', length: 20 }) + type!: 'WAGON' | 'CURRENCY'; + + @Column({ name: 'label', type: 'varchar', length: 100 }) + label!: string; + + @Column({ name: 'currency', type: 'varchar', length: 5, nullable: true }) + currency?: string | null; + + @Column({ name: 'min_wagon_count', type: 'int' }) + minWagonCount!: number; + + @Column({ name: 'max_wagon_count', type: 'int' }) + maxWagonCount!: number; + + @Column({ name: 'score_points', type: 'int', default: 0 }) + scorePoints!: number; + + @Column({ name: 'is_active', type: 'boolean', default: false }) + isActive!: boolean; + + @Column({ name: 'display_order', type: 'int', default: 1 }) + displayOrder!: number; +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/rate.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/rate.entity.ts new file mode 100644 index 000000000..33030e95f --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/rate.entity.ts @@ -0,0 +1,78 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; +import { ContainerType } from './container-type.entity'; + +export const RATE_TYPES = [ + 'CONTAINER_IMPORT', + 'CONTAINER_EXPORT', + 'BULK_IMPORT', + 'BULK_EXPORT', + 'INTERCITY_BULK', + 'INTERCITY_CONTAINER', + 'FIRST_MILE', + 'LAST_MILE', + 'DEMURRAGE', + 'LASHING', + 'DOUBLE_HANDLING', + 'CONTAINER_WITH_RETURN', + 'CANCELLATION_FEE', + 'OVERWEIGHT_PER_TON', + 'HAZARD_SURCHARGE', + 'REEFER_SURCHARGE', + 'PIL_EXTRA_FEE', +] as const; + +export type RateType = typeof RATE_TYPES[number]; + +export const RATE_STATUSES = ['DRAFT', 'PENDING_APPROVAL', 'LIVE', 'SUPERSEDED'] as const; +export type RateStatus = typeof RATE_STATUSES[number]; + +export const RATE_UNITS = ['PER_WAGON', 'PER_TON', 'PER_CONTAINER', 'PER_KM', 'FLAT'] as const; +export type RateUnit = typeof RATE_UNITS[number]; + +@Entity({ schema: 'freight', name: 'rates' }) +@Index(['rateType']) +@Index(['status']) +@Index(['effectiveFrom']) +@Index(['containerTypeId']) +export class Rate extends BaseEntity { + @Column({ name: 'rate_type', type: 'varchar', length: 50 }) + rateType!: RateType; + + @Column({ name: 'container_type_id', type: 'uuid', nullable: true }) + containerTypeId?: string | null; + + @ManyToOne(() => ContainerType, { nullable: true, eager: false }) + @JoinColumn({ name: 'container_type_id' }) + containerType?: ContainerType | null; + + @Column({ name: 'trade_direction', type: 'varchar', length: 10, nullable: true }) + tradeDirection?: string | null; + + @Column({ name: 'currency', type: 'varchar', length: 5 }) + currency!: string; + + @Column({ name: 'rate_value', type: 'numeric', precision: 14, scale: 4 }) + rateValue!: number; + + @Column({ name: 'rate_unit', type: 'varchar', length: 30 }) + rateUnit!: RateUnit; + + @Column({ name: 'status', type: 'varchar', length: 20, default: 'DRAFT' }) + status!: RateStatus; + + @Column({ name: 'proposed_by_staff_id', type: 'uuid' }) + proposedByStaffId!: string; + + @Column({ name: 'approved_by_ceo_id', type: 'uuid', nullable: true }) + approvedByCeoId?: string | null; + + @Column({ name: 'approved_at', type: 'timestamptz', nullable: true }) + approvedAt?: Date | null; + + @Column({ name: 'effective_from', type: 'date' }) + effectiveFrom!: Date; + + @Column({ name: 'effective_to', type: 'date', nullable: true }) + effectiveTo?: Date | null; +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/service-type.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/service-type.entity.ts new file mode 100644 index 000000000..2b7cb3f23 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/service-type.entity.ts @@ -0,0 +1,38 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index } from 'typeorm'; + +@Entity({ schema: 'freight', name: 'service_types' }) +@Index(['isActive']) +@Index(['displayOrder']) +@Index(['code']) +export class ServiceType extends BaseEntity { + @Column({ name: 'code', type: 'varchar', length: 50, unique: true, default: '' }) + code!: string; + + @Column({ name: 'service_name', type: 'varchar', length: 255 }) + serviceName!: string; + + @Column({ name: 'description', type: 'text', nullable: true }) + description?: string | null; + + @Column({ name: 'can_be_booked_alone', type: 'boolean', default: true }) + canBeBookedAlone!: boolean; + + @Column({ name: 'includes_first_mile', type: 'boolean', default: false }) + includesFirstMile!: boolean; + + @Column({ name: 'includes_last_mile', type: 'boolean', default: false }) + includesLastMile!: boolean; + + @Column({ name: 'includes_customs', type: 'boolean', default: false }) + includesCustoms!: boolean; + + @Column({ name: 'priority_bonus_points', type: 'int', default: 0 }) + priorityBonusPoints!: number; + + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive!: boolean; + + @Column({ name: 'display_order', type: 'int', default: 1 }) + displayOrder!: number; +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/shipping-line.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/shipping-line.entity.ts new file mode 100644 index 000000000..9be7a8b92 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/shipping-line.entity.ts @@ -0,0 +1,22 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index } from 'typeorm'; + +@Entity({ schema: 'freight', name: 'shipping_lines' }) +@Index(['code']) +@Index(['isActive']) +export class ShippingLine extends BaseEntity { + @Column({ name: 'code', type: 'varchar', length: 20, unique: true }) + code!: string; + + @Column({ name: 'label', type: 'varchar', length: 100 }) + label!: string; + + @Column({ name: 'mapped_to_code', type: 'varchar', length: 20, nullable: true }) + mappedToCode?: string | null; + + @Column({ name: 'show_extra_fee_notice', type: 'boolean', default: false }) + showExtraFeeNotice!: boolean; + + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive!: boolean; +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/surcharge-type.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/surcharge-type.entity.ts new file mode 100644 index 000000000..2b9934a1e --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/surcharge-type.entity.ts @@ -0,0 +1,38 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; +import { Rate } from './rate.entity'; + +const TRIGGER_CONDITIONS = [ + 'CARGO_FLAG_HAZARDOUS', + 'CARGO_FLAG_REEFER', + 'VGM_EXCEEDS_LIMIT', + 'SHIPPING_LINE_MAPPED', + 'CONSOLIDATION_ENABLED', +] as const; + +export type TriggerCondition = typeof TRIGGER_CONDITIONS[number]; + +@Entity({ schema: 'freight', name: 'surcharge_types' }) +@Index(['code']) +@Index(['isActive']) +@Index(['rateId']) +export class SurchargeType extends BaseEntity { + @Column({ name: 'code', type: 'varchar', length: 40, unique: true }) + code!: string; + + @Column({ name: 'label', type: 'varchar', length: 100, nullable: true }) + label!: string; + + @Column({ name: 'trigger_condition', type: 'varchar', length: 50, nullable: true }) + triggerCondition!: TriggerCondition; + + @Column({ name: 'rate_id', type: 'uuid', nullable: true }) + rateId!: string; + + @ManyToOne(() => Rate, { eager: false }) + @JoinColumn({ name: 'rate_id' }) + rate?: Rate; + + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive!: boolean; +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/weight-limit-rule.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/weight-limit-rule.entity.ts new file mode 100644 index 000000000..39557eec9 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/weight-limit-rule.entity.ts @@ -0,0 +1,28 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; +import { ContainerType } from './container-type.entity'; + +@Entity({ schema: 'freight', name: 'weight_limit_rules' }) +@Index(['containerTypeId']) +@Index(['tradeDirection']) +@Index(['effectiveFrom']) +export class WeightLimitRule extends BaseEntity { + @Column({ name: 'container_type_id', type: 'uuid' }) + containerTypeId!: string; + + @ManyToOne(() => ContainerType, (ct) => ct.weightLimitRules) + @JoinColumn({ name: 'container_type_id' }) + containerType!: ContainerType; + + @Column({ name: 'trade_direction', type: 'varchar', length: 10 }) + tradeDirection!: string; + + @Column({ name: 'max_vgm_tons', type: 'numeric', precision: 8, scale: 3, nullable: true }) + maxVgmTons!: number; + + @Column({ name: 'effective_from', type: 'date', nullable: true }) + effectiveFrom!: Date; + + @Column({ name: 'effective_to', type: 'date', nullable: true }) + effectiveTo?: Date | null; +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/yard.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/yard.entity.ts new file mode 100644 index 000000000..249aa1847 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/yard.entity.ts @@ -0,0 +1,23 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index } from 'typeorm'; + +@Entity({ schema: 'freight', name: 'yards' }) +@Index(['code']) +@Index(['country']) +@Index(['isActive']) +export class Yard extends BaseEntity { + @Column({ name: 'code', type: 'varchar', length: 20, unique: true }) + code!: string; + + @Column({ name: 'label', type: 'varchar', length: 100 }) + label!: string; + + @Column({ name: 'country', type: 'varchar', length: 50 }) + country!: string; + + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive!: boolean; + + @Column({ name: 'display_order', type: 'int', default: 1 }) + displayOrder!: number; +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/government-priority.constants.ts b/apps/edr-freight-api/src/modules/rule-engine/government-priority.constants.ts new file mode 100644 index 000000000..690352602 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/government-priority.constants.ts @@ -0,0 +1,2 @@ +/** Ensures government bookings outrank commercial priority (max ~1,500 today). */ +export const GOVERNMENT_PRIORITY_BONUS = 50_000; diff --git a/apps/edr-freight-api/src/modules/rule-engine/interfaces/approval-rules.repository.interface.ts b/apps/edr-freight-api/src/modules/rule-engine/interfaces/approval-rules.repository.interface.ts new file mode 100644 index 000000000..95c8d2568 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/interfaces/approval-rules.repository.interface.ts @@ -0,0 +1,14 @@ +import { FindManyOptions } from 'typeorm'; +import { ApprovalRule } from '../entities/approval-rule.entity'; + +export interface IApprovalRulesRepository { + findById(id: string): Promise; + findChainForCargo(requiresDirectorApproval: boolean): Promise; + findAll(options?: FindManyOptions): Promise; + findAndCount(options?: FindManyOptions): Promise<[ApprovalRule[], number]>; + create(data: Partial): Promise; + update(id: string, data: Partial): Promise; + softDelete(id: string): Promise; +} + +export const APPROVAL_RULES_REPOSITORY = Symbol('APPROVAL_RULES_REPOSITORY'); diff --git a/apps/edr-freight-api/src/modules/rule-engine/interfaces/cargo-types.repository.interface.ts b/apps/edr-freight-api/src/modules/rule-engine/interfaces/cargo-types.repository.interface.ts new file mode 100644 index 000000000..d757b6569 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/interfaces/cargo-types.repository.interface.ts @@ -0,0 +1,14 @@ +import { FindManyOptions } from 'typeorm'; +import { CargoType } from '../entities/cargo-type.entity'; + +export interface ICargoTypesRepository { + findById(id: string): Promise; + findByCode(code: string): Promise; + findAll(options?: FindManyOptions): Promise; + findAndCount(options?: FindManyOptions): Promise<[CargoType[], number]>; + create(data: Partial): Promise; + update(id: string, data: Partial): Promise; + softDelete(id: string): Promise; +} + +export const CARGO_TYPES_REPOSITORY = Symbol('CARGO_TYPES_REPOSITORY'); diff --git a/apps/edr-freight-api/src/modules/rule-engine/interfaces/container-types.repository.interface.ts b/apps/edr-freight-api/src/modules/rule-engine/interfaces/container-types.repository.interface.ts new file mode 100644 index 000000000..f8e097309 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/interfaces/container-types.repository.interface.ts @@ -0,0 +1,14 @@ +import { FindManyOptions } from 'typeorm'; +import { ContainerType } from '../entities/container-type.entity'; + +export interface IContainerTypesRepository { + findById(id: string): Promise; + findByCode(code: string): Promise; + findAll(options?: FindManyOptions): Promise; + findAndCount(options?: FindManyOptions): Promise<[ContainerType[], number]>; + create(data: Partial): Promise; + update(id: string, data: Partial): Promise; + softDelete(id: string): Promise; +} + +export const CONTAINER_TYPES_REPOSITORY = Symbol('CONTAINER_TYPES_REPOSITORY'); diff --git a/apps/edr-freight-api/src/modules/rule-engine/interfaces/priority-configs.repository.interface.ts b/apps/edr-freight-api/src/modules/rule-engine/interfaces/priority-configs.repository.interface.ts new file mode 100644 index 000000000..e4ca08234 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/interfaces/priority-configs.repository.interface.ts @@ -0,0 +1,14 @@ +import { FindManyOptions } from 'typeorm'; +import { PriorityConfig } from '../entities/priority-config.entity'; + +export const PRIORITY_CONFIGS_REPOSITORY = Symbol('PRIORITY_CONFIGS_REPOSITORY'); + +export interface IPriorityConfigsRepository { + findById(id: string): Promise; + findAll(options?: FindManyOptions): Promise; + findAndCount(options?: FindManyOptions): Promise<[PriorityConfig[], number]>; + findAllActive(): Promise; + create(data: Partial): Promise; + update(id: string, data: Partial): Promise; + softDelete(id: string): Promise; +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/interfaces/rates.repository.interface.ts b/apps/edr-freight-api/src/modules/rule-engine/interfaces/rates.repository.interface.ts new file mode 100644 index 000000000..52b991155 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/interfaces/rates.repository.interface.ts @@ -0,0 +1,14 @@ +import { FindManyOptions } from 'typeorm'; +import { Rate } from '../entities/rate.entity'; + +export interface IRatesRepository { + findById(id: string): Promise; + findLiveRates(): Promise; + findAll(options?: FindManyOptions): Promise; + findAndCount(options?: FindManyOptions): Promise<[Rate[], number]>; + create(data: Partial): Promise; + update(id: string, data: Partial): Promise; + softDelete(id: string): Promise; +} + +export const RATES_REPOSITORY = Symbol('RATES_REPOSITORY'); diff --git a/apps/edr-freight-api/src/modules/rule-engine/interfaces/service-types.repository.interface.ts b/apps/edr-freight-api/src/modules/rule-engine/interfaces/service-types.repository.interface.ts new file mode 100644 index 000000000..49c7f08e3 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/interfaces/service-types.repository.interface.ts @@ -0,0 +1,14 @@ +import { FindManyOptions } from 'typeorm'; +import { ServiceType } from '../entities/service-type.entity'; + +export interface IServiceTypesRepository { + findById(id: string): Promise; + findByCode(code: string): Promise; + findAll(options?: FindManyOptions): Promise; + findAndCount(options?: FindManyOptions): Promise<[ServiceType[], number]>; + create(data: Partial): Promise; + update(id: string, data: Partial): Promise; + softDelete(id: string): Promise; +} + +export const SERVICE_TYPES_REPOSITORY = Symbol('SERVICE_TYPES_REPOSITORY'); diff --git a/apps/edr-freight-api/src/modules/rule-engine/interfaces/shipping-lines.repository.interface.ts b/apps/edr-freight-api/src/modules/rule-engine/interfaces/shipping-lines.repository.interface.ts new file mode 100644 index 000000000..88f36933d --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/interfaces/shipping-lines.repository.interface.ts @@ -0,0 +1,14 @@ +import { FindManyOptions } from 'typeorm'; +import { ShippingLine } from '../entities/shipping-line.entity'; + +export interface IShippingLinesRepository { + findById(id: string): Promise; + findByCode(code: string): Promise; + findAll(options?: FindManyOptions): Promise; + findAndCount(options?: FindManyOptions): Promise<[ShippingLine[], number]>; + create(data: Partial): Promise; + update(id: string, data: Partial): Promise; + softDelete(id: string): Promise; +} + +export const SHIPPING_LINES_REPOSITORY = Symbol('SHIPPING_LINES_REPOSITORY'); diff --git a/apps/edr-freight-api/src/modules/rule-engine/interfaces/surcharge-types.repository.interface.ts b/apps/edr-freight-api/src/modules/rule-engine/interfaces/surcharge-types.repository.interface.ts new file mode 100644 index 000000000..a6931aaf2 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/interfaces/surcharge-types.repository.interface.ts @@ -0,0 +1,15 @@ +import { FindManyOptions } from 'typeorm'; +import { SurchargeType } from '../entities/surcharge-type.entity'; + +export interface ISurchargeTypesRepository { + findById(id: string): Promise; + findByCode(code: string): Promise; + findAllActiveWithRate(): Promise; + findAll(options?: FindManyOptions): Promise; + findAndCount(options?: FindManyOptions): Promise<[SurchargeType[], number]>; + create(data: Partial): Promise; + update(id: string, data: Partial): Promise; + softDelete(id: string): Promise; +} + +export const SURCHARGE_TYPES_REPOSITORY = Symbol('SURCHARGE_TYPES_REPOSITORY'); diff --git a/apps/edr-freight-api/src/modules/rule-engine/interfaces/weight-limit-rules.repository.interface.ts b/apps/edr-freight-api/src/modules/rule-engine/interfaces/weight-limit-rules.repository.interface.ts new file mode 100644 index 000000000..cedbd1eee --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/interfaces/weight-limit-rules.repository.interface.ts @@ -0,0 +1,17 @@ +import { FindManyOptions } from 'typeorm'; +import { WeightLimitRule } from '../entities/weight-limit-rule.entity'; + +export interface IWeightLimitRulesRepository { + findById(id: string): Promise; + findActiveByContainerTypeId( + containerTypeId: string, + tradeDirection: string, + ): Promise; + findAll(options?: FindManyOptions): Promise; + findAndCount(options?: FindManyOptions): Promise<[WeightLimitRule[], number]>; + create(data: Partial): Promise; + update(id: string, data: Partial): Promise; + softDelete(id: string): Promise; +} + +export const WEIGHT_LIMIT_RULES_REPOSITORY = Symbol('WEIGHT_LIMIT_RULES_REPOSITORY'); diff --git a/apps/edr-freight-api/src/modules/rule-engine/interfaces/yards.repository.interface.ts b/apps/edr-freight-api/src/modules/rule-engine/interfaces/yards.repository.interface.ts new file mode 100644 index 000000000..9cfcfd940 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/interfaces/yards.repository.interface.ts @@ -0,0 +1,14 @@ +import { FindManyOptions } from 'typeorm'; +import { Yard } from '../entities/yard.entity'; + +export interface IYardsRepository { + findById(id: string): Promise; + findByCode(code: string): Promise; + findAll(options?: FindManyOptions): Promise; + findAndCount(options?: FindManyOptions): Promise<[Yard[], number]>; + create(data: Partial): Promise; + update(id: string, data: Partial): Promise; + softDelete(id: string): Promise; +} + +export const YARDS_REPOSITORY = Symbol('YARDS_REPOSITORY'); diff --git a/apps/edr-freight-api/src/modules/rule-engine/repositories/approval-rules.repository.ts b/apps/edr-freight-api/src/modules/rule-engine/repositories/approval-rules.repository.ts new file mode 100644 index 000000000..c77695395 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/repositories/approval-rules.repository.ts @@ -0,0 +1,46 @@ +import { Injectable } from '@nestjs/common'; +import { DataSource, FindManyOptions, Repository } from 'typeorm'; +import { ApprovalRule } from '../entities/approval-rule.entity'; +import { IApprovalRulesRepository } from '../interfaces/approval-rules.repository.interface'; + +@Injectable() +export class ApprovalRulesRepository implements IApprovalRulesRepository { + private readonly repo: Repository; + + constructor(private readonly dataSource: DataSource) { + this.repo = this.dataSource.getRepository(ApprovalRule); + } + + findById(id: string): Promise { + return this.repo.findOne({ where: { id } }); + } + + findChainForCargo(requiresDirectorApproval: boolean): Promise { + return this.repo.find({ + where: { requiresDirectorApproval }, + order: { stepOrder: 'ASC' }, + }); + } + + findAll(options?: FindManyOptions): Promise { + return this.repo.find(options); + } + + findAndCount(options?: FindManyOptions): Promise<[ApprovalRule[], number]> { + return this.repo.findAndCount(options); + } + + async create(data: Partial): Promise { + const entity = this.repo.create(data); + return this.repo.save(entity); + } + + async update(id: string, data: Partial): Promise { + await this.repo.update(id, data); + return this.findById(id); + } + + async softDelete(id: string): Promise { + await this.repo.softDelete(id); + } +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/repositories/cargo-types.repository.ts b/apps/edr-freight-api/src/modules/rule-engine/repositories/cargo-types.repository.ts new file mode 100644 index 000000000..496c2ce7b --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/repositories/cargo-types.repository.ts @@ -0,0 +1,43 @@ +import { Injectable } from '@nestjs/common'; +import { DataSource, FindManyOptions, Repository } from 'typeorm'; +import { CargoType } from '../entities/cargo-type.entity'; +import { ICargoTypesRepository } from '../interfaces/cargo-types.repository.interface'; + +@Injectable() +export class CargoTypesRepository implements ICargoTypesRepository { + private readonly repo: Repository; + + constructor(private readonly dataSource: DataSource) { + this.repo = this.dataSource.getRepository(CargoType); + } + + findById(id: string): Promise { + return this.repo.findOne({ where: { id }, relations: { parent: true } }); + } + + findByCode(code: string): Promise { + return this.repo.findOne({ where: { code } }); + } + + findAll(options?: FindManyOptions): Promise { + return this.repo.find(options); + } + + findAndCount(options?: FindManyOptions): Promise<[CargoType[], number]> { + return this.repo.findAndCount(options); + } + + async create(data: Partial): Promise { + const entity = this.repo.create(data); + return this.repo.save(entity); + } + + async update(id: string, data: Partial): Promise { + await this.repo.update(id, data); + return this.findById(id); + } + + async softDelete(id: string): Promise { + await this.repo.softDelete(id); + } +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/repositories/container-types.repository.ts b/apps/edr-freight-api/src/modules/rule-engine/repositories/container-types.repository.ts new file mode 100644 index 000000000..fe0a8f41e --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/repositories/container-types.repository.ts @@ -0,0 +1,43 @@ +import { Injectable } from '@nestjs/common'; +import { DataSource, FindManyOptions, Repository } from 'typeorm'; +import { ContainerType } from '../entities/container-type.entity'; +import { IContainerTypesRepository } from '../interfaces/container-types.repository.interface'; + +@Injectable() +export class ContainerTypesRepository implements IContainerTypesRepository { + private readonly repo: Repository; + + constructor(private readonly dataSource: DataSource) { + this.repo = this.dataSource.getRepository(ContainerType); + } + + findById(id: string): Promise { + return this.repo.findOne({ where: { id } }); + } + + findByCode(code: string): Promise { + return this.repo.findOne({ where: { code } }); + } + + findAll(options?: FindManyOptions): Promise { + return this.repo.find(options); + } + + findAndCount(options?: FindManyOptions): Promise<[ContainerType[], number]> { + return this.repo.findAndCount(options); + } + + async create(data: Partial): Promise { + const entity = this.repo.create(data); + return this.repo.save(entity); + } + + async update(id: string, data: Partial): Promise { + await this.repo.update(id, data); + return this.findById(id); + } + + async softDelete(id: string): Promise { + await this.repo.softDelete(id); + } +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/repositories/priority-configs.repository.ts b/apps/edr-freight-api/src/modules/rule-engine/repositories/priority-configs.repository.ts new file mode 100644 index 000000000..d8326b794 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/repositories/priority-configs.repository.ts @@ -0,0 +1,46 @@ +import { Injectable } from '@nestjs/common'; +import { DataSource, FindManyOptions, Repository } from 'typeorm'; +import { PriorityConfig } from '../entities/priority-config.entity'; +import { IPriorityConfigsRepository } from '../interfaces/priority-configs.repository.interface'; + +@Injectable() +export class PriorityConfigsRepository implements IPriorityConfigsRepository { + private readonly repo: Repository; + + constructor(private readonly dataSource: DataSource) { + this.repo = this.dataSource.getRepository(PriorityConfig); + } + + async findById(id: string): Promise { + return this.repo.findOne({ where: { id } }); + } + + async findAll(options?: FindManyOptions): Promise { + return this.repo.find(options); + } + + async findAndCount(options?: FindManyOptions): Promise<[PriorityConfig[], number]> { + return this.repo.findAndCount(options); + } + + async findAllActive(): Promise { + return this.repo.find({ + where: { isActive: true }, + order: { displayOrder: 'ASC' }, + }); + } + + async create(data: Partial): Promise { + const entity = this.repo.create(data); + return this.repo.save(entity); + } + + async update(id: string, data: Partial): Promise { + await this.repo.update(id, data); + return this.findById(id); + } + + async softDelete(id: string): Promise { + await this.repo.softDelete(id); + } +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/repositories/rates.repository.ts b/apps/edr-freight-api/src/modules/rule-engine/repositories/rates.repository.ts new file mode 100644 index 000000000..0d49a0bf3 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/repositories/rates.repository.ts @@ -0,0 +1,49 @@ +import { Injectable } from '@nestjs/common'; +import { DataSource, FindManyOptions, Repository } from 'typeorm'; +import { Rate } from '../entities/rate.entity'; +import { IRatesRepository } from '../interfaces/rates.repository.interface'; + +@Injectable() +export class RatesRepository implements IRatesRepository { + private readonly repo: Repository; + + constructor(private readonly dataSource: DataSource) { + this.repo = this.dataSource.getRepository(Rate); + } + + findById(id: string): Promise { + return this.repo.findOne({ where: { id } }); + } + + findLiveRates(): Promise { + const now = new Date(); + return this.repo + .createQueryBuilder('rate') + .where('rate.status = :status', { status: 'LIVE' }) + .andWhere('rate.effective_from <= :now', { now }) + .andWhere('(rate.effective_to IS NULL OR rate.effective_to > :now)', { now }) + .getMany(); + } + + findAll(options?: FindManyOptions): Promise { + return this.repo.find(options); + } + + findAndCount(options?: FindManyOptions): Promise<[Rate[], number]> { + return this.repo.findAndCount(options); + } + + async create(data: Partial): Promise { + const entity = this.repo.create(data); + return this.repo.save(entity); + } + + async update(id: string, data: Partial): Promise { + await this.repo.update(id, data); + return this.findById(id); + } + + async softDelete(id: string): Promise { + await this.repo.softDelete(id); + } +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/repositories/service-types.repository.ts b/apps/edr-freight-api/src/modules/rule-engine/repositories/service-types.repository.ts new file mode 100644 index 000000000..5e5f88b0a --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/repositories/service-types.repository.ts @@ -0,0 +1,43 @@ +import { Injectable } from '@nestjs/common'; +import { DataSource, FindManyOptions, Repository } from 'typeorm'; +import { ServiceType } from '../entities/service-type.entity'; +import { IServiceTypesRepository } from '../interfaces/service-types.repository.interface'; + +@Injectable() +export class ServiceTypesRepository implements IServiceTypesRepository { + private readonly repo: Repository; + + constructor(private readonly dataSource: DataSource) { + this.repo = this.dataSource.getRepository(ServiceType); + } + + findById(id: string): Promise { + return this.repo.findOne({ where: { id } }); + } + + findByCode(code: string): Promise { + return this.repo.findOne({ where: { code } }); + } + + findAll(options?: FindManyOptions): Promise { + return this.repo.find(options); + } + + findAndCount(options?: FindManyOptions): Promise<[ServiceType[], number]> { + return this.repo.findAndCount(options); + } + + async create(data: Partial): Promise { + const entity = this.repo.create(data); + return this.repo.save(entity); + } + + async update(id: string, data: Partial): Promise { + await this.repo.update(id, data); + return this.findById(id); + } + + async softDelete(id: string): Promise { + await this.repo.softDelete(id); + } +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/repositories/shipping-lines.repository.ts b/apps/edr-freight-api/src/modules/rule-engine/repositories/shipping-lines.repository.ts new file mode 100644 index 000000000..521a72b95 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/repositories/shipping-lines.repository.ts @@ -0,0 +1,43 @@ +import { Injectable } from '@nestjs/common'; +import { DataSource, FindManyOptions, Repository } from 'typeorm'; +import { ShippingLine } from '../entities/shipping-line.entity'; +import { IShippingLinesRepository } from '../interfaces/shipping-lines.repository.interface'; + +@Injectable() +export class ShippingLinesRepository implements IShippingLinesRepository { + private readonly repo: Repository; + + constructor(private readonly dataSource: DataSource) { + this.repo = this.dataSource.getRepository(ShippingLine); + } + + findById(id: string): Promise { + return this.repo.findOne({ where: { id } }); + } + + findByCode(code: string): Promise { + return this.repo.findOne({ where: { code } }); + } + + findAll(options?: FindManyOptions): Promise { + return this.repo.find(options); + } + + findAndCount(options?: FindManyOptions): Promise<[ShippingLine[], number]> { + return this.repo.findAndCount(options); + } + + async create(data: Partial): Promise { + const entity = this.repo.create(data); + return this.repo.save(entity); + } + + async update(id: string, data: Partial): Promise { + await this.repo.update(id, data); + return this.findById(id); + } + + async softDelete(id: string): Promise { + await this.repo.softDelete(id); + } +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/repositories/surcharge-types.repository.ts b/apps/edr-freight-api/src/modules/rule-engine/repositories/surcharge-types.repository.ts new file mode 100644 index 000000000..7b44e73e1 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/repositories/surcharge-types.repository.ts @@ -0,0 +1,50 @@ +import { Injectable } from '@nestjs/common'; +import { DataSource, FindManyOptions, Repository } from 'typeorm'; +import { SurchargeType } from '../entities/surcharge-type.entity'; +import { ISurchargeTypesRepository } from '../interfaces/surcharge-types.repository.interface'; + +@Injectable() +export class SurchargeTypesRepository implements ISurchargeTypesRepository { + private readonly repo: Repository; + + constructor(private readonly dataSource: DataSource) { + this.repo = this.dataSource.getRepository(SurchargeType); + } + + findById(id: string): Promise { + return this.repo.findOne({ where: { id } }); + } + + findByCode(code: string): Promise { + return this.repo.findOne({ where: { code } }); + } + + findAllActiveWithRate(): Promise { + return this.repo.find({ + where: { isActive: true }, + relations: { rate: true }, + }); + } + + findAll(options?: FindManyOptions): Promise { + return this.repo.find(options); + } + + findAndCount(options?: FindManyOptions): Promise<[SurchargeType[], number]> { + return this.repo.findAndCount(options); + } + + async create(data: Partial): Promise { + const entity = this.repo.create(data); + return this.repo.save(entity); + } + + async update(id: string, data: Partial): Promise { + await this.repo.update(id, data); + return this.findById(id); + } + + async softDelete(id: string): Promise { + await this.repo.softDelete(id); + } +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/repositories/weight-limit-rules.repository.ts b/apps/edr-freight-api/src/modules/rule-engine/repositories/weight-limit-rules.repository.ts new file mode 100644 index 000000000..0d151c561 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/repositories/weight-limit-rules.repository.ts @@ -0,0 +1,60 @@ +import { Injectable } from '@nestjs/common'; +import { DataSource, FindManyOptions, Repository } from 'typeorm'; +import { WeightLimitRule } from '../entities/weight-limit-rule.entity'; +import { IWeightLimitRulesRepository } from '../interfaces/weight-limit-rules.repository.interface'; + +@Injectable() +export class WeightLimitRulesRepository implements IWeightLimitRulesRepository { + private readonly repo: Repository; + + constructor(private readonly dataSource: DataSource) { + this.repo = this.dataSource.getRepository(WeightLimitRule); + } + + findById(id: string): Promise { + return this.repo.findOne({ + where: { id }, + relations: { containerType: true }, + }); + } + + findActiveByContainerTypeId( + containerTypeId: string, + tradeDirection: string, + ): Promise { + const now = new Date(); + return this.repo + .createQueryBuilder('rule') + .innerJoinAndSelect('rule.containerType', 'ct') + .where('rule.container_type_id = :containerTypeId', { containerTypeId }) + .andWhere('(rule.trade_direction = :dir OR rule.trade_direction = :both)', { + dir: tradeDirection, + both: 'BOTH', + }) + .andWhere('rule.effective_from <= :now', { now }) + .andWhere('(rule.effective_to IS NULL OR rule.effective_to > :now)', { now }) + .getMany(); + } + + findAll(options?: FindManyOptions): Promise { + return this.repo.find(options); + } + + findAndCount(options?: FindManyOptions): Promise<[WeightLimitRule[], number]> { + return this.repo.findAndCount(options); + } + + async create(data: Partial): Promise { + const entity = this.repo.create(data); + return this.repo.save(entity); + } + + async update(id: string, data: Partial): Promise { + await this.repo.update(id, data); + return this.findById(id); + } + + async softDelete(id: string): Promise { + await this.repo.softDelete(id); + } +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/repositories/yards.repository.ts b/apps/edr-freight-api/src/modules/rule-engine/repositories/yards.repository.ts new file mode 100644 index 000000000..c2f1c62f1 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/repositories/yards.repository.ts @@ -0,0 +1,43 @@ +import { Injectable } from '@nestjs/common'; +import { DataSource, FindManyOptions, Repository } from 'typeorm'; +import { Yard } from '../entities/yard.entity'; +import { IYardsRepository } from '../interfaces/yards.repository.interface'; + +@Injectable() +export class YardsRepository implements IYardsRepository { + private readonly repo: Repository; + + constructor(private readonly dataSource: DataSource) { + this.repo = this.dataSource.getRepository(Yard); + } + + findById(id: string): Promise { + return this.repo.findOne({ where: { id } }); + } + + findByCode(code: string): Promise { + return this.repo.findOne({ where: { code } }); + } + + findAll(options?: FindManyOptions): Promise { + return this.repo.find(options); + } + + findAndCount(options?: FindManyOptions): Promise<[Yard[], number]> { + return this.repo.findAndCount(options); + } + + async create(data: Partial): Promise { + const entity = this.repo.create(data); + return this.repo.save(entity); + } + + async update(id: string, data: Partial): Promise { + await this.repo.update(id, data); + return this.findById(id); + } + + async softDelete(id: string): Promise { + await this.repo.softDelete(id); + } +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.module.ts b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.module.ts new file mode 100644 index 000000000..49f1c446c --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.module.ts @@ -0,0 +1,152 @@ +import { Global, Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { ApprovalRulesController } from './controllers/approval-rules.controller'; +import { CargoTypesController } from './controllers/cargo-types.controller'; +import { ContainerTypesController } from './controllers/container-types.controller'; +import { PriorityConfigsController } from './controllers/priority-configs.controller'; +import { RatesController } from './controllers/rates.controller'; +import { ServiceTypesController } from './controllers/service-types.controller'; +import { ShippingLinesController } from './controllers/shipping-lines.controller'; +import { SurchargeTypesController } from './controllers/surcharge-types.controller'; +import { WeightLimitRulesController } from './controllers/weight-limit-rules.controller'; +import { YardsController } from './controllers/yards.controller'; + +import { ApprovalRule } from './entities/approval-rule.entity'; +import { CargoType } from './entities/cargo-type.entity'; +import { ContainerType } from './entities/container-type.entity'; +import { PriorityConfig } from './entities/priority-config.entity'; +import { Rate } from './entities/rate.entity'; +import { ServiceType } from './entities/service-type.entity'; +import { ShippingLine } from './entities/shipping-line.entity'; +import { SurchargeType } from './entities/surcharge-type.entity'; +import { WeightLimitRule } from './entities/weight-limit-rule.entity'; +import { Yard } from './entities/yard.entity'; + +import { APPROVAL_RULES_REPOSITORY } from './interfaces/approval-rules.repository.interface'; +import { CARGO_TYPES_REPOSITORY } from './interfaces/cargo-types.repository.interface'; +import { CONTAINER_TYPES_REPOSITORY } from './interfaces/container-types.repository.interface'; +import { PRIORITY_CONFIGS_REPOSITORY } from './interfaces/priority-configs.repository.interface'; +import { RATES_REPOSITORY } from './interfaces/rates.repository.interface'; +import { SERVICE_TYPES_REPOSITORY } from './interfaces/service-types.repository.interface'; +import { SHIPPING_LINES_REPOSITORY } from './interfaces/shipping-lines.repository.interface'; +import { SURCHARGE_TYPES_REPOSITORY } from './interfaces/surcharge-types.repository.interface'; +import { WEIGHT_LIMIT_RULES_REPOSITORY } from './interfaces/weight-limit-rules.repository.interface'; +import { YARDS_REPOSITORY } from './interfaces/yards.repository.interface'; + +import { ApprovalRulesRepository } from './repositories/approval-rules.repository'; +import { CargoTypesRepository } from './repositories/cargo-types.repository'; +import { ContainerTypesRepository } from './repositories/container-types.repository'; +import { PriorityConfigsRepository } from './repositories/priority-configs.repository'; +import { RatesRepository } from './repositories/rates.repository'; +import { ServiceTypesRepository } from './repositories/service-types.repository'; +import { ShippingLinesRepository } from './repositories/shipping-lines.repository'; +import { SurchargeTypesRepository } from './repositories/surcharge-types.repository'; +import { WeightLimitRulesRepository } from './repositories/weight-limit-rules.repository'; +import { YardsRepository } from './repositories/yards.repository'; + +import { ApprovalRulesService } from './services/approval-rules.service'; +import { DisplayOrderService } from './services/display-order.service'; +import { CargoTypesService } from './services/cargo-types.service'; +import { ContainerTypesService } from './services/container-types.service'; +import { PriorityConfigsService } from './services/priority-configs.service'; +import { RatesService } from './services/rates.service'; +import { ServiceTypesService } from './services/service-types.service'; +import { ShippingLinesService } from './services/shipping-lines.service'; +import { SurchargeTypesService } from './services/surcharge-types.service'; +import { WeightLimitRulesService } from './services/weight-limit-rules.service'; +import { YardsService } from './services/yards.service'; + +import { RuleEngineService } from './rule-engine.service'; + +import { BookingApprovalStep } from '../bookings/entities/booking-approval-step.entity'; +import { BookingCargoModifier } from '../bookings/entities/booking-cargo-modifier.entity'; +import { BookingContainer } from '../bookings/entities/booking-container.entity'; +import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.entity'; + +@Global() +@Module({ + imports: [ + TypeOrmModule.forFeature([ + CargoType, + ContainerType, + PriorityConfig, + SurchargeType, + ServiceType, + WeightLimitRule, + Yard, + ShippingLine, + Rate, + ApprovalRule, + BookingContainer, + BookingCargoModifier, + BookingApprovalStep, + BookingRateSnapshot, + ]), + ], + controllers: [ + CargoTypesController, + ContainerTypesController, + PriorityConfigsController, + SurchargeTypesController, + ServiceTypesController, + WeightLimitRulesController, + YardsController, + ShippingLinesController, + RatesController, + ApprovalRulesController, + ], + providers: [ + CargoTypesRepository, + { provide: CARGO_TYPES_REPOSITORY, useExisting: CargoTypesRepository }, + ContainerTypesRepository, + { provide: CONTAINER_TYPES_REPOSITORY, useExisting: ContainerTypesRepository }, + PriorityConfigsRepository, + { provide: PRIORITY_CONFIGS_REPOSITORY, useExisting: PriorityConfigsRepository }, + SurchargeTypesRepository, + { provide: SURCHARGE_TYPES_REPOSITORY, useExisting: SurchargeTypesRepository }, + ServiceTypesRepository, + { provide: SERVICE_TYPES_REPOSITORY, useExisting: ServiceTypesRepository }, + WeightLimitRulesRepository, + { provide: WEIGHT_LIMIT_RULES_REPOSITORY, useExisting: WeightLimitRulesRepository }, + YardsRepository, + { provide: YARDS_REPOSITORY, useExisting: YardsRepository }, + ShippingLinesRepository, + { provide: SHIPPING_LINES_REPOSITORY, useExisting: ShippingLinesRepository }, + RatesRepository, + { provide: RATES_REPOSITORY, useExisting: RatesRepository }, + ApprovalRulesRepository, + { provide: APPROVAL_RULES_REPOSITORY, useExisting: ApprovalRulesRepository }, + CargoTypesService, + ContainerTypesService, + PriorityConfigsService, + SurchargeTypesService, + ServiceTypesService, + WeightLimitRulesService, + YardsService, + ShippingLinesService, + RatesService, + ApprovalRulesService, + DisplayOrderService, + RuleEngineService, + ], + exports: [ + RuleEngineService, + CargoTypesService, + ServiceTypesService, + ContainerTypesService, + SurchargeTypesService, + WeightLimitRulesService, + PriorityConfigsService, + YardsService, + ShippingLinesService, + RatesService, + ApprovalRulesService, + CARGO_TYPES_REPOSITORY, + CONTAINER_TYPES_REPOSITORY, + SERVICE_TYPES_REPOSITORY, + SHIPPING_LINES_REPOSITORY, + YARDS_REPOSITORY, + ], +}) +export class RuleEngineModule {} diff --git a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts new file mode 100644 index 000000000..35dbef868 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts @@ -0,0 +1,400 @@ +import { Inject, Injectable, BadRequestException } from '@nestjs/common'; +import { DataSource } from 'typeorm'; +import { BookingApprovalStep } from '../bookings/entities/booking-approval-step.entity'; +import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.entity'; +import { TriggerCondition } from './entities/surcharge-type.entity'; +import { + ICargoTypesRepository, + CARGO_TYPES_REPOSITORY, +} from './interfaces/cargo-types.repository.interface'; +import { + IServiceTypesRepository, + SERVICE_TYPES_REPOSITORY, +} from './interfaces/service-types.repository.interface'; +import { + IWeightLimitRulesRepository, + WEIGHT_LIMIT_RULES_REPOSITORY, +} from './interfaces/weight-limit-rules.repository.interface'; +import { + IPriorityConfigsRepository, + PRIORITY_CONFIGS_REPOSITORY, +} from './interfaces/priority-configs.repository.interface'; +import { + ISurchargeTypesRepository, + SURCHARGE_TYPES_REPOSITORY, +} from './interfaces/surcharge-types.repository.interface'; +import { + IRatesRepository, + RATES_REPOSITORY, +} from './interfaces/rates.repository.interface'; +import { + IApprovalRulesRepository, + APPROVAL_RULES_REPOSITORY, +} from './interfaces/approval-rules.repository.interface'; +import { + IShippingLinesRepository, + SHIPPING_LINES_REPOSITORY, +} from './interfaces/shipping-lines.repository.interface'; +import { DEFAULT_APPROVAL_RULE_ROWS } from './approval-rules.defaults'; +import { GOVERNMENT_PRIORITY_BONUS } from './government-priority.constants'; + +export interface BookingContainerEvalInput { + containerTypeId: string; + quantity: number; + vgmPerUnitTons: number; + totalVgmTons: number; + isReefer?: boolean; + isOverweight?: boolean; + overweightExcessTons?: number | null; +} + +export interface BookingEvaluationInput { + cargoTypeId?: string | null; + freightType?: 'CONTAINER' | 'BULK'; + serviceTypeId: string; + paymentCurrency: string; + tradeDirection: string; + isHazardous: boolean; + isGovernment?: boolean; + allowConsolidation?: boolean; + shippingLineId?: string | null; + totalWagons: number; + containers: BookingContainerEvalInput[]; +} + +export interface AppliedCargoModifier { + surchargeTypeId: string; + surchargeTypeCode: string; + triggerValue: number | null; + calculatedAmount: number; + rateId: string; + currency: string; +} + +export interface ContainerWeightResult { + containerTypeId: string; + weightLimitRuleId: string | null; + isOverweight: boolean; + overweightExcessTons: number | null; +} + +export interface RuleEvaluationResult { + priorityScore: number; + appliedModifiers: AppliedCargoModifier[]; + containerWeightResults: ContainerWeightResult[]; + warnings: string[]; + hardBlocked: string[]; + requiresDirectorApproval: boolean; +} + +@Injectable() +export class RuleEngineService { + constructor( + @Inject(CARGO_TYPES_REPOSITORY) + private readonly cargoTypesRepo: ICargoTypesRepository, + @Inject(SERVICE_TYPES_REPOSITORY) + private readonly serviceTypesRepo: IServiceTypesRepository, + @Inject(WEIGHT_LIMIT_RULES_REPOSITORY) + private readonly weightLimitRulesRepo: IWeightLimitRulesRepository, + @Inject(PRIORITY_CONFIGS_REPOSITORY) + private readonly priorityConfigsRepo: IPriorityConfigsRepository, + @Inject(SURCHARGE_TYPES_REPOSITORY) + private readonly surchargeTypesRepo: ISurchargeTypesRepository, + @Inject(RATES_REPOSITORY) + private readonly ratesRepo: IRatesRepository, + @Inject(APPROVAL_RULES_REPOSITORY) + private readonly approvalRulesRepo: IApprovalRulesRepository, + @Inject(SHIPPING_LINES_REPOSITORY) + private readonly shippingLinesRepo: IShippingLinesRepository, + private readonly dataSource: DataSource, + ) {} + + /** + * Evaluate all rule engine rules against a booking snapshot. + */ + async evaluate(input: BookingEvaluationInput): Promise { + const warnings: string[] = []; + const hardBlocked: string[] = []; + const appliedModifiers: AppliedCargoModifier[] = []; + const containerWeightResults: ContainerWeightResult[] = []; + let priorityScore = 0; + let requiresDirectorApproval = false; + + if (input.freightType === 'BULK') { + requiresDirectorApproval = true; + } + + if (input.cargoTypeId) { + const cargoType = await this.cargoTypesRepo.findById(input.cargoTypeId); + if (!cargoType) { + hardBlocked.push(`Cargo type ${input.cargoTypeId} not found`); + } else if (cargoType.requiresDirectorApproval) { + requiresDirectorApproval = true; + } + } + + for (const container of input.containers) { + const rules = await this.weightLimitRulesRepo.findActiveByContainerTypeId( + container.containerTypeId, + input.tradeDirection, + ); + const rule = rules[0]; + let isOverweight = container.isOverweight ?? false; + let excess = container.overweightExcessTons ?? null; + + if (rule) { + const maxTotal = Number(rule.maxVgmTons) * container.quantity; + const totalVgm = container.totalVgmTons; + if (totalVgm > maxTotal) { + isOverweight = true; + excess = Math.max(0, totalVgm - maxTotal); + warnings.push( + `Container type ${container.containerTypeId} VGM ${totalVgm}t exceeds limit ${maxTotal}t`, + ); + } + containerWeightResults.push({ + containerTypeId: container.containerTypeId, + weightLimitRuleId: rule.id, + isOverweight, + overweightExcessTons: excess, + }); + } else { + containerWeightResults.push({ + containerTypeId: container.containerTypeId, + weightLimitRuleId: null, + isOverweight, + overweightExcessTons: excess, + }); + } + } + + const serviceType = await this.serviceTypesRepo.findById(input.serviceTypeId); + if (serviceType) { + priorityScore += serviceType.priorityBonusPoints; + } + + // Additive priority blocks, each keyed on the booking's total wagon count: + // - WAGON rules apply regardless of currency. + // - CURRENCY rules apply only when the payment currency matches. + const priorityConfigs = await this.priorityConfigsRepo.findAllActive(); + const wagonsInRange = (cfg: { minWagonCount: number; maxWagonCount: number }) => + input.totalWagons >= cfg.minWagonCount && + input.totalWagons <= cfg.maxWagonCount; + + for (const cfg of priorityConfigs) { + const applies = + cfg.type === 'WAGON' || + (cfg.type === 'CURRENCY' && cfg.currency === input.paymentCurrency); + if (applies && wagonsInRange(cfg)) { + priorityScore += cfg.scorePoints; + } + } + + if (input.isGovernment) { + priorityScore += GOVERNMENT_PRIORITY_BONUS; + } + + let shippingLineMapped = false; + if (input.shippingLineId) { + const line = await this.shippingLinesRepo.findById(input.shippingLineId); + shippingLineMapped = Boolean(line?.mappedToCode); + } + + const hasReefer = input.containers.some((c) => c.isReefer); + const hasOverweight = containerWeightResults.some((r) => r.isOverweight); + + const surchargeTypes = await this.surchargeTypesRepo.findAllActiveWithRate(); + const liveRates = await this.ratesRepo.findLiveRates(); + const rateById = new Map(liveRates.map((r) => [r.id, r])); + + for (const st of surchargeTypes) { + const triggered = this.matchesTrigger(st.triggerCondition, { + isHazardous: input.isHazardous, + hasReefer, + hasOverweight, + shippingLineMapped, + allowConsolidation: input.allowConsolidation ?? false, + }); + if (!triggered) continue; + + const rate = st.rate ?? rateById.get(st.rateId); + if (!rate) continue; + + let triggerValue: number | null = null; + let calculatedAmount = Number(rate.rateValue); + + if (st.triggerCondition === 'VGM_EXCEEDS_LIMIT') { + triggerValue = containerWeightResults.reduce( + (sum, r) => sum + (r.overweightExcessTons ?? 0), + 0, + ); + if (rate.rateUnit === 'PER_TON') { + calculatedAmount = triggerValue * Number(rate.rateValue); + } + } + + appliedModifiers.push({ + surchargeTypeId: st.id, + surchargeTypeCode: st.code, + triggerValue, + calculatedAmount, + rateId: rate.id, + currency: rate.currency, + }); + } + + return { + priorityScore, + appliedModifiers, + containerWeightResults, + warnings, + hardBlocked, + requiresDirectorApproval, + }; + } + + /** + * Ensure ITMLS default approval chains exist (container + bulk). Idempotent. + */ + async ensureDefaultApprovalRules(): Promise { + for (const flag of [false, true] as const) { + const existing = await this.approvalRulesRepo.findChainForCargo(flag); + if (existing.length > 0) continue; + + const rows = DEFAULT_APPROVAL_RULE_ROWS.filter( + (r) => r.requiresDirectorApproval === flag, + ); + for (const row of rows) { + await this.approvalRulesRepo.create({ + requiresDirectorApproval: row.requiresDirectorApproval, + stepOrder: row.stepOrder, + requiredRole: row.requiredRole, + actionLabel: row.actionLabel, + blocksRole: row.blocksRole, + }); + } + } + } + + /** + * Instantiate booking_approval_step rows from approval_rules by freight type. + */ + async instantiateApprovalSteps( + bookingId: string, + options: { + freightType: 'CONTAINER' | 'BULK'; + cargoTypeId?: string | null; + }, + ): Promise { + await this.ensureDefaultApprovalRules(); + + let requiresDirectorApproval = options.freightType === 'BULK'; + + if (options.cargoTypeId) { + const cargoType = await this.cargoTypesRepo.findById(options.cargoTypeId); + if (!cargoType) { + throw new BadRequestException(`Cargo type ${options.cargoTypeId} not found`); + } + if (cargoType.requiresDirectorApproval) { + requiresDirectorApproval = true; + } + } + + const chain = await this.approvalRulesRepo.findChainForCargo( + requiresDirectorApproval, + ); + + if (chain.length === 0) { + throw new BadRequestException( + `Approval chain could not be loaded for requiresDirectorApproval=${requiresDirectorApproval}.`, + ); + } + + const stepRepo = this.dataSource.getRepository(BookingApprovalStep); + const steps: BookingApprovalStep[] = []; + + for (const rule of chain) { + const step = stepRepo.create({ + bookingId, + approvalRuleId: rule.id, + stepOrder: rule.stepOrder, + requiredRole: rule.requiredRole, + blocksRole: rule.blocksRole ?? null, + status: 'PENDING', + }); + steps.push(await stepRepo.save(step)); + } + + return steps; + } + + /** + * Snapshot only the rates used in a booking's final price. + */ + async snapshotRates( + bookingId: string, + rates: Array<{ + id: string; + rateType: string; + rateValue: number; + rateUnit: string; + currency: string; + }>, + ): Promise { + const snapshotRepo = this.dataSource.getRepository(BookingRateSnapshot); + const now = new Date(); + const seen = new Set(); + const snapshots: BookingRateSnapshot[] = []; + + for (const rate of rates) { + if (seen.has(rate.id)) continue; + seen.add(rate.id); + + const snapshot = snapshotRepo.create({ + bookingId, + rateId: rate.id, + rateType: rate.rateType, + rateValue: rate.rateValue, + rateUnit: rate.rateUnit, + currency: rate.currency, + snapshottedAt: now, + }); + snapshots.push(await snapshotRepo.save(snapshot)); + } + + return snapshots; + } + + /** Guard helper — throws BadRequestException if hardBlocked is non-empty. */ + assertNoHardBlocks(result: RuleEvaluationResult): void { + if (result.hardBlocked.length > 0) { + throw new BadRequestException(result.hardBlocked.join('; ')); + } + } + + private matchesTrigger( + condition: TriggerCondition, + state: { + isHazardous: boolean; + hasReefer: boolean; + hasOverweight: boolean; + shippingLineMapped: boolean; + allowConsolidation: boolean; + }, + ): boolean { + switch (condition) { + case 'CARGO_FLAG_HAZARDOUS': + return state.isHazardous; + case 'CARGO_FLAG_REEFER': + return state.hasReefer; + case 'VGM_EXCEEDS_LIMIT': + return state.hasOverweight; + case 'SHIPPING_LINE_MAPPED': + return state.shippingLineMapped; + case 'CONSOLIDATION_ENABLED': + return state.allowConsolidation; + default: + return false; + } + } +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/approval-rules.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/approval-rules.service.ts new file mode 100644 index 000000000..063cc6439 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/services/approval-rules.service.ts @@ -0,0 +1,105 @@ +import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common'; +import { CreateApprovalRuleDto } from '../dto/create-approval-rule.dto'; +import { ReorderItemsDto } from '../dto/reorder-items.dto'; +import { UpdateApprovalRuleDto } from '../dto/update-approval-rule.dto'; +import { ApprovalRule } from '../entities/approval-rule.entity'; +import { + APPROVAL_RULES_REPOSITORY, + IApprovalRulesRepository, +} from '../interfaces/approval-rules.repository.interface'; +import { DisplayOrderService } from './display-order.service'; + +@Injectable() +export class ApprovalRulesService { + constructor( + @Inject(APPROVAL_RULES_REPOSITORY) + private readonly repository: IApprovalRulesRepository, + private readonly displayOrder: DisplayOrderService, + ) {} + + /** List approval rules. */ + async findAll(filter: { + requiresDirectorApproval?: boolean; + page?: number; + pageSize?: number; + }): Promise<{ data: ApprovalRule[]; meta: { total: number; page: number; pageSize: number; totalPages: number } }> { + const page = filter.page ?? 1; + const pageSize = filter.pageSize ?? 10; + const where: Record = {}; + if (filter.requiresDirectorApproval !== undefined) { + where.requiresDirectorApproval = filter.requiresDirectorApproval; + } + + const [data, total] = await this.repository.findAndCount({ + where, + order: { requiresDirectorApproval: 'ASC', stepOrder: 'ASC' }, + skip: (page - 1) * pageSize, + take: pageSize, + }); + return { data, meta: { total, page, pageSize, totalPages: Math.ceil(total / pageSize) } }; + } + + /** Get approval chain for a cargo type flag. */ + async findChain(requiresDirectorApproval: boolean): Promise { + return this.repository.findChainForCargo(requiresDirectorApproval); + } + + /** Get an approval rule by ID. */ + async findById(id: string): Promise { + const entity = await this.repository.findById(id); + if (!entity) throw new NotFoundException(`Approval rule ${id} not found`); + return entity; + } + + /** Create an approval rule step. */ + async create(dto: CreateApprovalRuleDto): Promise { + if (dto.stepOrder !== undefined && dto.insertAfterId) { + throw new BadRequestException('Cannot set both stepOrder and insertAfterId'); + } + + const scopeWhere = { requiresDirectorApproval: dto.requiresDirectorApproval }; + const stepOrder = await this.displayOrder.resolveCreateOrder(ApprovalRule, 'stepOrder', { + explicitOrder: dto.stepOrder, + insertAfterId: dto.insertAfterId, + scopeWhere, + }); + + return this.repository.create({ + requiresDirectorApproval: dto.requiresDirectorApproval, + stepOrder, + requiredRole: dto.requiredRole, + actionLabel: dto.actionLabel, + blocksRole: dto.blocksRole, + }); + } + + /** Update an approval rule. */ + async update(id: string, dto: UpdateApprovalRuleDto): Promise { + await this.findById(id); + const updated = await this.repository.update(id, dto); + if (!updated) throw new NotFoundException(`Approval rule ${id} not found`); + return updated; + } + + /** Soft-delete an approval rule. */ + async remove(id: string): Promise { + await this.findById(id); + await this.repository.softDelete(id); + } + + async reorder(dto: ReorderItemsDto): Promise { + if (dto.requiresDirectorApproval === undefined) { + throw new BadRequestException('requiresDirectorApproval is required for approval rule reorder'); + } + await this.displayOrder.reorderByIds(ApprovalRule, 'stepOrder', dto.ids, { + requiresDirectorApproval: dto.requiresDirectorApproval, + }); + } + + async moveOrder(id: string, direction: 'up' | 'down'): Promise { + const rule = await this.findById(id); + await this.displayOrder.moveOne(ApprovalRule, 'stepOrder', id, direction, { + requiresDirectorApproval: rule.requiresDirectorApproval, + }); + } +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/cargo-types.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/cargo-types.service.ts new file mode 100644 index 000000000..634ac5faa --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/services/cargo-types.service.ts @@ -0,0 +1,116 @@ +import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common'; +import { ILike } from 'typeorm'; +import { generateCode } from '../../../common/utils/generate-code.util'; +import { CreateCargoTypeDto } from '../dto/create-cargo-type.dto'; +import { ReorderItemsDto } from '../dto/reorder-items.dto'; +import { UpdateCargoTypeDto } from '../dto/update-cargo-type.dto'; +import { CargoType } from '../entities/cargo-type.entity'; +import { + CARGO_TYPES_REPOSITORY, + ICargoTypesRepository, +} from '../interfaces/cargo-types.repository.interface'; +import { DisplayOrderService } from './display-order.service'; + +@Injectable() +export class CargoTypesService { + constructor( + @Inject(CARGO_TYPES_REPOSITORY) + private readonly repository: ICargoTypesRepository, + private readonly displayOrder: DisplayOrderService, + ) {} + + /** List cargo types with pagination and optional filtering. */ + async findAll(filter: { + isActive?: boolean; + requiresDirectorApproval?: boolean; + parentGroupId?: string; + search?: string; + page?: number; + pageSize?: number; + sortBy?: string; + sortOrder?: 'ASC' | 'DESC'; + }): Promise<{ data: CargoType[]; meta: { total: number; page: number; pageSize: number; totalPages: number } }> { + const page = filter.page ?? 1; + const pageSize = filter.pageSize ?? 10; + const where: Record = {}; + if (filter.isActive !== undefined) where.isActive = filter.isActive; + if (filter.requiresDirectorApproval !== undefined) where.requiresDirectorApproval = filter.requiresDirectorApproval; + if (filter.parentGroupId !== undefined) where.parentGroupId = filter.parentGroupId; + if (filter.search) where.cargoTypeName = ILike(`%${filter.search}%`); + + const [data, total] = await this.repository.findAndCount({ + where, + order: { [filter.sortBy ?? 'displayOrder']: filter.sortOrder ?? 'ASC' }, + skip: (page - 1) * pageSize, + take: pageSize, + relations: { parent: true }, + }); + return { data, meta: { total, page, pageSize, totalPages: Math.ceil(total / pageSize) } }; + } + + /** Get a single cargo type by ID. */ + async findById(id: string): Promise { + const entity = await this.repository.findById(id); + if (!entity) throw new NotFoundException(`Cargo type ${id} not found`); + return entity; + } + + /** Get a cargo type by code. */ + async findByCode(code: string): Promise { + return this.repository.findByCode(code); + } + + /** Create a new cargo type. */ + async create(dto: CreateCargoTypeDto): Promise { + const code = generateCode(dto.cargoTypeName); + const existing = await this.repository.findByCode(code); + if (existing) throw new ConflictException(`Cargo type with name "${dto.cargoTypeName}" conflicts with existing code "${code}"`); + if (dto.parentGroupId) { + const parent = await this.repository.findById(dto.parentGroupId); + if (!parent) throw new NotFoundException(`Parent cargo type ${dto.parentGroupId} not found`); + } + + const displayOrder = await this.displayOrder.resolveCreateOrder(CargoType, 'displayOrder', { + explicitOrder: dto.displayOrder, + insertAfterId: dto.insertAfterId, + }); + + return this.repository.create({ + code, + cargoTypeName: dto.cargoTypeName, + parentGroupId: dto.parentGroupId ?? null, + showFreeTextBox: dto.showFreeTextBox ?? false, + requiresDirectorApproval: dto.requiresDirectorApproval ?? false, + isActive: dto.isActive ?? true, + displayOrder, + }); + } + + /** Update an existing cargo type. */ + async update(id: string, dto: UpdateCargoTypeDto): Promise { + await this.findById(id); + if (dto.parentGroupId) { + if (dto.parentGroupId === id) throw new ConflictException('A cargo type cannot be its own parent'); + const parent = await this.repository.findById(dto.parentGroupId); + if (!parent) throw new NotFoundException(`Parent cargo type ${dto.parentGroupId} not found`); + } + const updated = await this.repository.update(id, dto); + if (!updated) throw new NotFoundException(`Cargo type ${id} not found`); + return updated; + } + + /** Soft-delete a cargo type. */ + async remove(id: string): Promise { + await this.findById(id); + await this.repository.softDelete(id); + } + + async reorder(dto: ReorderItemsDto): Promise { + await this.displayOrder.reorderByIds(CargoType, 'displayOrder', dto.ids); + } + + async moveOrder(id: string, direction: 'up' | 'down'): Promise { + await this.findById(id); + await this.displayOrder.moveOne(CargoType, 'displayOrder', id, direction); + } +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/container-types.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/container-types.service.ts new file mode 100644 index 000000000..38407f36a --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/services/container-types.service.ts @@ -0,0 +1,93 @@ +import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common'; +import { generateCode } from '../../../common/utils/generate-code.util'; +import { CreateContainerTypeDto } from '../dto/create-container-type.dto'; +import { ReorderItemsDto } from '../dto/reorder-items.dto'; +import { UpdateContainerTypeDto } from '../dto/update-container-type.dto'; +import { ContainerType } from '../entities/container-type.entity'; +import { + CONTAINER_TYPES_REPOSITORY, + IContainerTypesRepository, +} from '../interfaces/container-types.repository.interface'; +import { DisplayOrderService } from './display-order.service'; + +@Injectable() +export class ContainerTypesService { + constructor( + @Inject(CONTAINER_TYPES_REPOSITORY) + private readonly repository: IContainerTypesRepository, + private readonly displayOrder: DisplayOrderService, + ) {} + + /** List container types with pagination. */ + async findAll(filter: { + isActive?: boolean; + page?: number; + pageSize?: number; + }): Promise<{ data: ContainerType[]; meta: { total: number; page: number; pageSize: number; totalPages: number } }> { + const page = filter.page ?? 1; + const pageSize = filter.pageSize ?? 10; + const where: Record = {}; + if (filter.isActive !== undefined) where.isActive = filter.isActive; + + const [data, total] = await this.repository.findAndCount({ + where, + order: { displayOrder: 'ASC' }, + skip: (page - 1) * pageSize, + take: pageSize, + }); + return { data, meta: { total, page, pageSize, totalPages: Math.ceil(total / pageSize) } }; + } + + /** Get a single container type by ID. */ + async findById(id: string): Promise { + const entity = await this.repository.findById(id); + if (!entity) throw new NotFoundException(`Container type ${id} not found`); + return entity; + } + + /** Create a new container type. */ + async create(dto: CreateContainerTypeDto): Promise { + const code = generateCode(dto.label); + const existing = await this.repository.findByCode(code); + if (existing) throw new ConflictException(`Container type with label "${dto.label}" conflicts with existing code "${code}"`); + + const displayOrder = await this.displayOrder.resolveCreateOrder(ContainerType, 'displayOrder', { + explicitOrder: dto.displayOrder, + insertAfterId: dto.insertAfterId, + }); + + return this.repository.create({ + code, + label: dto.label, + sizeFt: dto.sizeFt, + wagonsPerUnit: dto.wagonsPerUnit, + isReefer: dto.isReefer ?? false, + isOpenTop: dto.isOpenTop ?? false, + isActive: dto.isActive ?? true, + displayOrder, + }); + } + + /** Update an existing container type. */ + async update(id: string, dto: UpdateContainerTypeDto): Promise { + await this.findById(id); + const updated = await this.repository.update(id, dto); + if (!updated) throw new NotFoundException(`Container type ${id} not found`); + return updated; + } + + /** Soft-delete a container type. */ + async remove(id: string): Promise { + await this.findById(id); + await this.repository.softDelete(id); + } + + async reorder(dto: ReorderItemsDto): Promise { + await this.displayOrder.reorderByIds(ContainerType, 'displayOrder', dto.ids); + } + + async moveOrder(id: string, direction: 'up' | 'down'): Promise { + await this.findById(id); + await this.displayOrder.moveOne(ContainerType, 'displayOrder', id, direction); + } +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/display-order.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/display-order.service.ts new file mode 100644 index 000000000..e0ee48106 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/services/display-order.service.ts @@ -0,0 +1,175 @@ +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { DataSource, EntityTarget, FindOptionsWhere, ObjectLiteral } from 'typeorm'; + +export type OrderField = 'displayOrder' | 'stepOrder'; + +@Injectable() +export class DisplayOrderService { + constructor(private readonly dataSource: DataSource) {} + + async getMaxOrder( + entity: EntityTarget, + field: OrderField, + where?: FindOptionsWhere, + ): Promise { + const repo = this.dataSource.getRepository(entity); + const qb = repo.createQueryBuilder('e').select(`MAX(e.${field})`, 'max'); + if (where) { + Object.entries(where).forEach(([key, value]) => { + if (value !== undefined) { + qb.andWhere(`e.${key} = :${key}`, { [key]: value }); + } + }); + } + const row = await qb.getRawOne<{ max: string | null }>(); + return row?.max ? Number(row.max) : 0; + } + + async resolveCreateOrder( + entity: EntityTarget, + field: OrderField, + options: { + explicitOrder?: number; + insertAfterId?: string; + scopeWhere?: FindOptionsWhere; + }, + ): Promise { + const { explicitOrder, insertAfterId, scopeWhere } = options; + + if (insertAfterId) { + if (explicitOrder !== undefined) { + throw new BadRequestException('Cannot set both explicit order and insertAfterId'); + } + const repo = this.dataSource.getRepository(entity); + const after = await repo.findOne({ + where: { id: insertAfterId, ...scopeWhere } as unknown as FindOptionsWhere, + }); + if (!after) { + throw new NotFoundException(`Record ${insertAfterId} not found in scope`); + } + const afterOrder = Number((after as Record)[field]); + await this.shiftOrdersFrom(entity, field, afterOrder + 1, 1, scopeWhere); + return afterOrder + 1; + } + + if (explicitOrder !== undefined) { + return explicitOrder; + } + + const max = await this.getMaxOrder(entity, field, scopeWhere); + return max + 1; + } + + async reorderByIds( + entity: EntityTarget, + field: OrderField, + ids: string[], + scopeWhere?: FindOptionsWhere, + ): Promise { + const repo = this.dataSource.getRepository(entity); + const existing = await repo.find({ + where: scopeWhere, + order: { [field]: 'ASC' } as never, + }); + + const scopedIds = new Set(existing.map((row) => String(row.id))); + if (ids.length !== scopedIds.size) { + throw new BadRequestException('Reorder list must include every item in scope exactly once'); + } + for (const id of ids) { + if (!scopedIds.has(id)) { + throw new BadRequestException(`ID ${id} is not in the reorder scope`); + } + } + + const queryRunner = this.dataSource.createQueryRunner(); + await queryRunner.connect(); + await queryRunner.startTransaction(); + try { + for (let i = 0; i < ids.length; i++) { + await queryRunner.manager.update(entity, ids[i], { [field]: -(i + 1) } as never); + } + for (let i = 0; i < ids.length; i++) { + await queryRunner.manager.update(entity, ids[i], { [field]: i + 1 } as never); + } + await queryRunner.commitTransaction(); + } catch (err) { + await queryRunner.rollbackTransaction(); + throw err; + } finally { + await queryRunner.release(); + } + } + + async moveOne( + entity: EntityTarget, + field: OrderField, + id: string, + direction: 'up' | 'down', + scopeWhere?: FindOptionsWhere, + ): Promise { + const repo = this.dataSource.getRepository(entity); + const items = await repo.find({ + where: scopeWhere, + order: { [field]: 'ASC' } as never, + }); + + const index = items.findIndex((row) => String(row.id) === id); + if (index === -1) { + throw new NotFoundException(`Record ${id} not found in scope`); + } + + const targetIndex = direction === 'up' ? index - 1 : index + 1; + if (targetIndex < 0 || targetIndex >= items.length) { + throw new BadRequestException(`Cannot move ${direction}`); + } + + const current = items[index] as Record; + const neighbor = items[targetIndex] as Record; + const currentOrder = Number(current[field]); + const neighborOrder = Number(neighbor[field]); + + const queryRunner = this.dataSource.createQueryRunner(); + await queryRunner.connect(); + await queryRunner.startTransaction(); + try { + await queryRunner.manager.update(entity, String(current.id), { [field]: -1 } as never); + await queryRunner.manager.update(entity, String(neighbor.id), { [field]: -2 } as never); + await queryRunner.manager.update(entity, String(current.id), { [field]: neighborOrder } as never); + await queryRunner.manager.update(entity, String(neighbor.id), { [field]: currentOrder } as never); + await queryRunner.commitTransaction(); + } catch (err) { + await queryRunner.rollbackTransaction(); + throw err; + } finally { + await queryRunner.release(); + } + } + + private async shiftOrdersFrom( + entity: EntityTarget, + field: OrderField, + fromOrder: number, + delta: number, + scopeWhere?: FindOptionsWhere, + ): Promise { + const repo = this.dataSource.getRepository(entity); + const orderColumn = repo.metadata.findColumnWithPropertyName(field)?.databaseName ?? field; + const qb = repo + .createQueryBuilder() + .update() + .set({ [field]: () => `"${orderColumn}" + ${delta}` } as never) + .where(`"${orderColumn}" >= :fromOrder`, { fromOrder }); + + if (scopeWhere) { + Object.entries(scopeWhere).forEach(([key, value]) => { + if (value !== undefined) { + const col = repo.metadata.findColumnWithPropertyName(key)?.databaseName ?? key; + qb.andWhere(`"${col}" = :scope_${key}`, { [`scope_${key}`]: value }); + } + }); + } + + await qb.execute(); + } +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/priority-configs.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/priority-configs.service.ts new file mode 100644 index 000000000..173c63f21 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/services/priority-configs.service.ts @@ -0,0 +1,98 @@ +import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common'; +import { CreatePriorityConfigDto } from '../dto/create-priority-config.dto'; +import { UpdatePriorityConfigDto } from '../dto/update-priority-config.dto'; +import { PriorityConfig } from '../entities/priority-config.entity'; +import { + IPriorityConfigsRepository, + PRIORITY_CONFIGS_REPOSITORY, +} from '../interfaces/priority-configs.repository.interface'; +import { DisplayOrderService } from './display-order.service'; + +@Injectable() +export class PriorityConfigsService { + constructor( + @Inject(PRIORITY_CONFIGS_REPOSITORY) + private readonly repository: IPriorityConfigsRepository, + private readonly displayOrder: DisplayOrderService, + ) {} + + async findAll(filter: { + type?: 'WAGON' | 'CURRENCY'; + isActive?: boolean; + page?: number; + pageSize?: number; + }): Promise<{ data: PriorityConfig[]; meta: { total: number; page: number; pageSize: number; totalPages: number } }> { + const page = filter.page ?? 1; + const pageSize = filter.pageSize ?? 20; + const where: Record = {}; + if (filter.type !== undefined) where.type = filter.type; + if (filter.isActive !== undefined) where.isActive = filter.isActive; + + const [data, total] = await this.repository.findAndCount({ + where, + order: { displayOrder: 'ASC' }, + skip: (page - 1) * pageSize, + take: pageSize, + }); + return { data, meta: { total, page, pageSize, totalPages: Math.ceil(total / pageSize) } }; + } + + async findById(id: string): Promise { + const entity = await this.repository.findById(id); + if (!entity) throw new NotFoundException(`Priority config ${id} not found`); + return entity; + } + + async create(dto: CreatePriorityConfigDto): Promise { + this.validateCurrencyField(dto.type, dto.currency); + + const displayOrder = await this.displayOrder.resolveCreateOrder(PriorityConfig, 'displayOrder', {}); + + return this.repository.create({ + type: dto.type, + label: dto.label, + currency: dto.currency ?? null, + minWagonCount: dto.minWagonCount, + maxWagonCount: dto.maxWagonCount, + scorePoints: dto.scorePoints ?? 0, + isActive: dto.isActive ?? false, + displayOrder, + }); + } + + async update(id: string, dto: UpdatePriorityConfigDto): Promise { + const existing = await this.findById(id); + + const type = dto.type ?? existing.type; + const currency = dto.currency !== undefined ? dto.currency : existing.currency; + this.validateCurrencyField(type, currency); + + const { ...patch } = dto; + const updated = await this.repository.update(id, patch); + if (!updated) throw new NotFoundException(`Priority config ${id} not found`); + return updated; + } + + async remove(id: string): Promise { + await this.findById(id); + await this.repository.softDelete(id); + } + + async reorder(ids: string[]): Promise { + await this.displayOrder.reorderByIds(PriorityConfig, 'displayOrder', ids); + } + + async moveOrder(id: string, direction: 'up' | 'down'): Promise { + await this.findById(id); + await this.displayOrder.moveOne(PriorityConfig, 'displayOrder', id, direction); + } + + private validateCurrencyField(type: 'WAGON' | 'CURRENCY', currency: string | undefined | null): void { + if (type === 'CURRENCY' && !currency) { + throw new BadRequestException('currency field is required when type is CURRENCY'); + } + if (type === 'WAGON' && currency) { + throw new BadRequestException('currency field must be null when type is WAGON'); + } + } +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts new file mode 100644 index 000000000..291d1959d --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts @@ -0,0 +1,113 @@ +import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common'; +import { CreateRateDto } from '../dto/create-rate.dto'; +import { UpdateRateDto } from '../dto/update-rate.dto'; +import { Rate } from '../entities/rate.entity'; +import { IRatesRepository, RATES_REPOSITORY } from '../interfaces/rates.repository.interface'; + +@Injectable() +export class RatesService { + constructor( + @Inject(RATES_REPOSITORY) + private readonly repository: IRatesRepository, + ) {} + + /** List rates with pagination. */ + async findAll(filter: { + status?: string; + rateType?: string; + page?: number; + pageSize?: number; + }): Promise<{ data: Rate[]; meta: { total: number; page: number; pageSize: number; totalPages: number } }> { + const page = filter.page ?? 1; + const pageSize = filter.pageSize ?? 20; + const where: Record = {}; + if (filter.status) where.status = filter.status; + if (filter.rateType) where.rateType = filter.rateType; + + const [data, total] = await this.repository.findAndCount({ + where, + order: { effectiveFrom: 'DESC' }, + skip: (page - 1) * pageSize, + take: pageSize, + }); + return { data, meta: { total, page, pageSize, totalPages: Math.max(1, Math.ceil(total / pageSize)) } }; + } + + /** Return all currently LIVE rates. */ + async findLiveRates(): Promise { + return this.repository.findLiveRates(); + } + + /** Get a rate by ID. */ + async findById(id: string): Promise { + const entity = await this.repository.findById(id); + if (!entity) throw new NotFoundException(`Rate ${id} not found`); + return entity; + } + + /** Create a rate in DRAFT status. */ + async create(dto: CreateRateDto, proposedByStaffId: string): Promise { + return this.repository.create({ + rateType: dto.rateType as Rate['rateType'], + containerTypeId: dto.containerTypeId, + tradeDirection: dto.tradeDirection, + currency: dto.currency ?? 'USD', + rateValue: dto.rateValue, + rateUnit: dto.rateUnit as Rate['rateUnit'], + status: 'DRAFT', + proposedByStaffId, + effectiveFrom: new Date(dto.effectiveFrom), + effectiveTo: dto.effectiveTo ? new Date(dto.effectiveTo) : undefined, + }); + } + + /** Update a DRAFT rate. */ + async update(id: string, dto: UpdateRateDto): Promise { + const existing = await this.findById(id); + if (existing.status !== 'DRAFT') { + throw new BadRequestException('Only DRAFT rates can be updated'); + } + const updates: Partial = {}; + if (dto.rateType) updates.rateType = dto.rateType as Rate['rateType']; + if (dto.containerTypeId !== undefined) updates.containerTypeId = dto.containerTypeId; + if (dto.tradeDirection !== undefined) updates.tradeDirection = dto.tradeDirection; + updates.currency = dto.currency ?? existing.currency ?? 'USD'; + if (dto.rateValue !== undefined) updates.rateValue = dto.rateValue; + if (dto.rateUnit) updates.rateUnit = dto.rateUnit as Rate['rateUnit']; + if (dto.effectiveFrom) updates.effectiveFrom = new Date(dto.effectiveFrom); + if (dto.effectiveTo) updates.effectiveTo = new Date(dto.effectiveTo); + const updated = await this.repository.update(id, updates); + if (!updated) throw new NotFoundException(`Rate ${id} not found`); + return updated; + } + + /** Submit a DRAFT rate for CEO approval. */ + async submitForApproval(id: string): Promise { + const rate = await this.findById(id); + if (rate.status !== 'DRAFT') { + throw new BadRequestException('Only DRAFT rates can be submitted for approval'); + } + const updated = await this.repository.update(id, { status: 'PENDING_APPROVAL' }); + return updated!; + } + + /** CEO approves a rate — moves to LIVE. */ + async approve(id: string, approverUserId: string): Promise { + const rate = await this.findById(id); + if (rate.status !== 'PENDING_APPROVAL') { + throw new BadRequestException('Only PENDING_APPROVAL rates can be approved'); + } + const updated = await this.repository.update(id, { + status: 'LIVE', + approvedByCeoId: approverUserId, + approvedAt: new Date(), + }); + return updated!; + } + + /** Soft-delete a rate. */ + async remove(id: string): Promise { + await this.findById(id); + await this.repository.softDelete(id); + } +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/service-types.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/service-types.service.ts new file mode 100644 index 000000000..2ad8753c3 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/services/service-types.service.ts @@ -0,0 +1,108 @@ +import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common'; +import { ILike } from 'typeorm'; +import { generateCode } from '../../../common/utils/generate-code.util'; +import { CreateServiceTypeDto } from '../dto/create-service-type.dto'; +import { ReorderItemsDto } from '../dto/reorder-items.dto'; +import { UpdateServiceTypeDto } from '../dto/update-service-type.dto'; +import { ServiceType } from '../entities/service-type.entity'; +import { + IServiceTypesRepository, + SERVICE_TYPES_REPOSITORY, +} from '../interfaces/service-types.repository.interface'; +import { DisplayOrderService } from './display-order.service'; + +@Injectable() +export class ServiceTypesService { + constructor( + @Inject(SERVICE_TYPES_REPOSITORY) + private readonly repository: IServiceTypesRepository, + private readonly displayOrder: DisplayOrderService, + ) {} + + /** List service types with pagination and optional filtering. */ + async findAll(filter: { + isActive?: boolean; + canBeBookedAlone?: boolean; + search?: string; + page?: number; + pageSize?: number; + sortBy?: string; + sortOrder?: 'ASC' | 'DESC'; + }): Promise<{ data: ServiceType[]; meta: { total: number; page: number; pageSize: number; totalPages: number } }> { + const page = filter.page ?? 1; + const pageSize = filter.pageSize ?? 10; + const where: Record = {}; + if (filter.isActive !== undefined) where.isActive = filter.isActive; + if (filter.canBeBookedAlone !== undefined) where.canBeBookedAlone = filter.canBeBookedAlone; + if (filter.search) where.serviceName = ILike(`%${filter.search}%`); + + const [data, total] = await this.repository.findAndCount({ + where, + order: { [filter.sortBy ?? 'displayOrder']: filter.sortOrder ?? 'ASC' }, + skip: (page - 1) * pageSize, + take: pageSize, + }); + return { data, meta: { total, page, pageSize, totalPages: Math.ceil(total / pageSize) } }; + } + + /** Get a single service type by ID. */ + async findById(id: string): Promise { + const entity = await this.repository.findById(id); + if (!entity) throw new NotFoundException(`Service type ${id} not found`); + return entity; + } + + /** Get a service type by code. */ + async findByCode(code: string): Promise { + return this.repository.findByCode(code); + } + + /** Create a new service type. */ + async create(dto: CreateServiceTypeDto): Promise { + const code = generateCode(dto.serviceName); + const existing = await this.repository.findByCode(code); + if (existing) throw new ConflictException(`Service type with name "${dto.serviceName}" conflicts with existing code "${code}"`); + + const displayOrder = await this.displayOrder.resolveCreateOrder(ServiceType, 'displayOrder', { + explicitOrder: dto.displayOrder, + insertAfterId: dto.insertAfterId, + }); + + return this.repository.create({ + code, + serviceName: dto.serviceName, + description: dto.description ?? null, + canBeBookedAlone: dto.canBeBookedAlone ?? true, + includesFirstMile: dto.includesFirstMile ?? false, + includesLastMile: dto.includesLastMile ?? false, + includesCustoms: dto.includesCustoms ?? false, + priorityBonusPoints: dto.priorityBonusPoints ?? 0, + isActive: dto.isActive ?? true, + displayOrder, + }); + } + + /** Update an existing service type. */ + async update(id: string, dto: UpdateServiceTypeDto): Promise { + await this.findById(id); + const { ...patch } = dto; + const updated = await this.repository.update(id, patch); + if (!updated) throw new NotFoundException(`Service type ${id} not found`); + return updated; + } + + /** Soft-delete a service type. */ + async remove(id: string): Promise { + await this.findById(id); + await this.repository.softDelete(id); + } + + async reorder(dto: ReorderItemsDto): Promise { + await this.displayOrder.reorderByIds(ServiceType, 'displayOrder', dto.ids); + } + + async moveOrder(id: string, direction: 'up' | 'down'): Promise { + await this.findById(id); + await this.displayOrder.moveOne(ServiceType, 'displayOrder', id, direction); + } +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/shipping-lines.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/shipping-lines.service.ts new file mode 100644 index 000000000..4eaa47a26 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/services/shipping-lines.service.ts @@ -0,0 +1,76 @@ +import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common'; +import { CreateShippingLineDto } from '../dto/create-shipping-line.dto'; +import { UpdateShippingLineDto } from '../dto/update-shipping-line.dto'; +import { ShippingLine } from '../entities/shipping-line.entity'; +import { + IShippingLinesRepository, + SHIPPING_LINES_REPOSITORY, +} from '../interfaces/shipping-lines.repository.interface'; + +@Injectable() +export class ShippingLinesService { + constructor( + @Inject(SHIPPING_LINES_REPOSITORY) + private readonly repository: IShippingLinesRepository, + ) {} + + /** List shipping lines with pagination. */ + async findAll(filter: { + isActive?: boolean; + page?: number; + pageSize?: number; + }): Promise<{ data: ShippingLine[]; meta: { total: number; page: number; pageSize: number; totalPages: number } }> { + const page = filter.page ?? 1; + const pageSize = filter.pageSize ?? 20; + const where: Record = {}; + if (filter.isActive !== undefined) where.isActive = filter.isActive; + + const [data, total] = await this.repository.findAndCount({ + where, + order: { code: 'ASC' }, + skip: (page - 1) * pageSize, + take: pageSize, + }); + return { data, meta: { total, page, pageSize, totalPages: Math.ceil(total / pageSize) } }; + } + + /** Get a shipping line by ID. */ + async findById(id: string): Promise { + const entity = await this.repository.findById(id); + if (!entity) throw new NotFoundException(`Shipping line ${id} not found`); + return entity; + } + + /** Create a shipping line. */ + async create(dto: CreateShippingLineDto): Promise { + const existing = await this.repository.findByCode(dto.code); + if (existing) throw new ConflictException(`Shipping line with code "${dto.code}" already exists`); + return this.repository.create({ + code: dto.code, + label: dto.label, + mappedToCode: dto.mappedToCode, + showExtraFeeNotice: dto.showExtraFeeNotice ?? false, + isActive: dto.isActive ?? true, + }); + } + + /** Update a shipping line. */ + async update(id: string, dto: UpdateShippingLineDto): Promise { + await this.findById(id); + if (dto.code) { + const conflict = await this.repository.findByCode(dto.code); + if (conflict && conflict.id !== id) { + throw new ConflictException(`Shipping line with code "${dto.code}" already exists`); + } + } + const updated = await this.repository.update(id, dto); + if (!updated) throw new NotFoundException(`Shipping line ${id} not found`); + return updated; + } + + /** Soft-delete a shipping line. */ + async remove(id: string): Promise { + await this.findById(id); + await this.repository.softDelete(id); + } +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/surcharge-types.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/surcharge-types.service.ts new file mode 100644 index 000000000..387e26ba9 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/services/surcharge-types.service.ts @@ -0,0 +1,78 @@ +import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common'; +import { generateCode } from '../../../common/utils/generate-code.util'; +import { CreateSurchargeTypeDto } from '../dto/create-surcharge-type.dto'; +import { UpdateSurchargeTypeDto } from '../dto/update-surcharge-type.dto'; +import { SurchargeType } from '../entities/surcharge-type.entity'; +import { + ISurchargeTypesRepository, + SURCHARGE_TYPES_REPOSITORY, +} from '../interfaces/surcharge-types.repository.interface'; + +@Injectable() +export class SurchargeTypesService { + constructor( + @Inject(SURCHARGE_TYPES_REPOSITORY) + private readonly repository: ISurchargeTypesRepository, + ) {} + + /** List surcharge types with pagination. */ + async findAll(filter: { + isActive?: boolean; + page?: number; + pageSize?: number; + }): Promise<{ data: SurchargeType[]; meta: { total: number; page: number; pageSize: number; totalPages: number } }> { + const page = filter.page ?? 1; + const pageSize = filter.pageSize ?? 20; + const where: Record = {}; + if (filter.isActive !== undefined) where.isActive = filter.isActive; + + const [data, total] = await this.repository.findAndCount({ + where, + relations: { rate: true }, + order: { label: 'ASC' }, + skip: (page - 1) * pageSize, + take: pageSize, + }); + return { data, meta: { total, page, pageSize, totalPages: Math.ceil(total / pageSize) } }; + } + + /** Get a single surcharge type by ID. */ + async findById(id: string): Promise { + const entity = await this.repository.findById(id); + if (!entity) throw new NotFoundException(`Surcharge type ${id} not found`); + return entity; + } + + /** Create a new surcharge type. */ + async create(dto: CreateSurchargeTypeDto): Promise { + const code = generateCode(dto.label); + const existing = await this.repository.findByCode(code); + if (existing) throw new ConflictException(`Surcharge type with label "${dto.label}" conflicts with existing code "${code}"`); + return this.repository.create({ + code, + label: dto.label, + triggerCondition: dto.triggerCondition as SurchargeType['triggerCondition'], + rateId: dto.rateId, + isActive: dto.isActive ?? true, + }); + } + + /** Update an existing surcharge type. */ + async update(id: string, dto: UpdateSurchargeTypeDto): Promise { + await this.findById(id); + const patch: Partial = {}; + if (dto.label !== undefined) patch.label = dto.label; + if (dto.triggerCondition !== undefined) patch.triggerCondition = dto.triggerCondition as SurchargeType['triggerCondition']; + if (dto.rateId !== undefined) patch.rateId = dto.rateId; + if (dto.isActive !== undefined) patch.isActive = dto.isActive; + const updated = await this.repository.update(id, patch); + if (!updated) throw new NotFoundException(`Surcharge type ${id} not found`); + return updated; + } + + /** Soft-delete a surcharge type. */ + async remove(id: string): Promise { + await this.findById(id); + await this.repository.softDelete(id); + } +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/weight-limit-rules.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/weight-limit-rules.service.ts new file mode 100644 index 000000000..d171f55aa --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/services/weight-limit-rules.service.ts @@ -0,0 +1,77 @@ +import { Inject, Injectable, NotFoundException } from '@nestjs/common'; +import { CreateWeightLimitRuleDto } from '../dto/create-weight-limit-rule.dto'; +import { UpdateWeightLimitRuleDto } from '../dto/update-weight-limit-rule.dto'; +import { WeightLimitRule } from '../entities/weight-limit-rule.entity'; +import { + IWeightLimitRulesRepository, + WEIGHT_LIMIT_RULES_REPOSITORY, +} from '../interfaces/weight-limit-rules.repository.interface'; + +@Injectable() +export class WeightLimitRulesService { + constructor( + @Inject(WEIGHT_LIMIT_RULES_REPOSITORY) + private readonly repository: IWeightLimitRulesRepository, + ) {} + + /** List weight limit rules with pagination. */ + async findAll(filter: { + containerTypeId?: string; + tradeDirection?: string; + page?: number; + pageSize?: number; + }): Promise<{ data: WeightLimitRule[]; meta: { total: number; page: number; pageSize: number; totalPages: number } }> { + const page = filter.page ?? 1; + const pageSize = filter.pageSize ?? 20; + const where: Record = {}; + if (filter.containerTypeId) where.containerTypeId = filter.containerTypeId; + if (filter.tradeDirection) where.tradeDirection = filter.tradeDirection; + + const [data, total] = await this.repository.findAndCount({ + where, + relations: { containerType: true }, + order: { effectiveFrom: 'DESC' }, + skip: (page - 1) * pageSize, + take: pageSize, + }); + return { data, meta: { total, page, pageSize, totalPages: Math.ceil(total / pageSize) } }; + } + + /** Get a single weight limit rule by ID. */ + async findById(id: string): Promise { + const entity = await this.repository.findById(id); + if (!entity) throw new NotFoundException(`Weight limit rule ${id} not found`); + return entity; + } + + /** Create a new weight limit rule. */ + async create(dto: CreateWeightLimitRuleDto): Promise { + return this.repository.create({ + containerTypeId: dto.containerTypeId, + tradeDirection: dto.tradeDirection, + maxVgmTons: dto.maxVgmTons, + effectiveFrom: new Date(dto.effectiveFrom), + effectiveTo: dto.effectiveTo ? new Date(dto.effectiveTo) : null, + }); + } + + /** Update an existing weight limit rule. */ + async update(id: string, dto: UpdateWeightLimitRuleDto): Promise { + await this.findById(id); + const patch: Partial = {}; + if (dto.containerTypeId !== undefined) patch.containerTypeId = dto.containerTypeId; + if (dto.tradeDirection !== undefined) patch.tradeDirection = dto.tradeDirection; + if (dto.maxVgmTons !== undefined) patch.maxVgmTons = dto.maxVgmTons; + if (dto.effectiveFrom !== undefined) patch.effectiveFrom = new Date(dto.effectiveFrom); + if (dto.effectiveTo !== undefined) patch.effectiveTo = new Date(dto.effectiveTo); + const updated = await this.repository.update(id, patch); + if (!updated) throw new NotFoundException(`Weight limit rule ${id} not found`); + return updated; + } + + /** Soft-delete a weight limit rule. */ + async remove(id: string): Promise { + await this.findById(id); + await this.repository.softDelete(id); + } +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/yards.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/yards.service.ts new file mode 100644 index 000000000..0c95582af --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/services/yards.service.ts @@ -0,0 +1,89 @@ +import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common'; +import { generateCode } from '../../../common/utils/generate-code.util'; +import { CreateYardDto } from '../dto/create-yard.dto'; +import { ReorderItemsDto } from '../dto/reorder-items.dto'; +import { UpdateYardDto } from '../dto/update-yard.dto'; +import { Yard } from '../entities/yard.entity'; +import { IYardsRepository, YARDS_REPOSITORY } from '../interfaces/yards.repository.interface'; +import { DisplayOrderService } from './display-order.service'; + +@Injectable() +export class YardsService { + constructor( + @Inject(YARDS_REPOSITORY) + private readonly repository: IYardsRepository, + private readonly displayOrder: DisplayOrderService, + ) {} + + /** List yards with pagination. */ + async findAll(filter: { + isActive?: boolean; + country?: string; + page?: number; + pageSize?: number; + }): Promise<{ data: Yard[]; meta: { total: number; page: number; pageSize: number; totalPages: number } }> { + const page = filter.page ?? 1; + const pageSize = filter.pageSize ?? 10; + const where: Record = {}; + if (filter.isActive !== undefined) where.isActive = filter.isActive; + if (filter.country) where.country = filter.country; + + const [data, total] = await this.repository.findAndCount({ + where, + order: { displayOrder: 'ASC', label: 'ASC' }, + skip: (page - 1) * pageSize, + take: pageSize, + }); + return { data, meta: { total, page, pageSize, totalPages: Math.ceil(total / pageSize) } }; + } + + /** Get a yard by ID. */ + async findById(id: string): Promise { + const entity = await this.repository.findById(id); + if (!entity) throw new NotFoundException(`Yard ${id} not found`); + return entity; + } + + /** Create a yard. */ + async create(dto: CreateYardDto): Promise { + const code = generateCode(dto.label); + const existing = await this.repository.findByCode(code); + if (existing) throw new ConflictException(`Yard with label "${dto.label}" conflicts with existing code "${code}"`); + + const displayOrder = await this.displayOrder.resolveCreateOrder(Yard, 'displayOrder', { + explicitOrder: dto.displayOrder, + insertAfterId: dto.insertAfterId, + }); + + return this.repository.create({ + code, + label: dto.label, + country: dto.country, + isActive: dto.isActive ?? true, + displayOrder, + }); + } + + /** Update a yard. */ + async update(id: string, dto: UpdateYardDto): Promise { + await this.findById(id); + const updated = await this.repository.update(id, dto); + if (!updated) throw new NotFoundException(`Yard ${id} not found`); + return updated; + } + + /** Soft-delete a yard. */ + async remove(id: string): Promise { + await this.findById(id); + await this.repository.softDelete(id); + } + + async reorder(dto: ReorderItemsDto): Promise { + await this.displayOrder.reorderByIds(Yard, 'displayOrder', dto.ids); + } + + async moveOrder(id: string, direction: 'up' | 'down'): Promise { + await this.findById(id); + await this.displayOrder.moveOne(Yard, 'displayOrder', id, direction); + } +} diff --git a/apps/edr-freight-api/src/modules/scheduling-reschedule/dto/preview-reschedule.dto.ts b/apps/edr-freight-api/src/modules/scheduling-reschedule/dto/preview-reschedule.dto.ts new file mode 100644 index 000000000..d1b6f80f3 --- /dev/null +++ b/apps/edr-freight-api/src/modules/scheduling-reschedule/dto/preview-reschedule.dto.ts @@ -0,0 +1,46 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { + ArrayMinSize, + IsArray, + IsDateString, + IsIn, + IsOptional, + IsString, + IsUUID, +} from 'class-validator'; + +import { RESCHEDULE_TRIGGERS } from '../entities/scheduling-event.entity'; + +export class PreviewRescheduleDto { + @ApiProperty({ type: [String] }) + @IsArray() + @ArrayMinSize(1) + @IsUUID('4', { each: true }) + incomingBookingIds!: string[]; + + @ApiProperty({ enum: RESCHEDULE_TRIGGERS }) + @IsIn([...RESCHEDULE_TRIGGERS]) + trigger!: (typeof RESCHEDULE_TRIGGERS)[number]; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + reason?: string; + + @ApiPropertyOptional({ example: '2026-06-22T08:00:00.000Z' }) + @IsOptional() + @IsDateString() + newDepartureDate?: string; +} + +export class ExecuteRescheduleDto extends PreviewRescheduleDto { + @ApiProperty({ type: [String], description: 'Booking IDs to assign after reschedule' }) + @IsArray() + @IsUUID('4', { each: true }) + finalBookingIds!: string[]; + + @ApiProperty({ type: [String], description: 'Booking IDs removed from the schedule' }) + @IsArray() + @IsUUID('4', { each: true }) + displacedBookingIds!: string[]; +} diff --git a/apps/edr-freight-api/src/modules/scheduling-reschedule/entities/scheduling-event.entity.ts b/apps/edr-freight-api/src/modules/scheduling-reschedule/entities/scheduling-event.entity.ts new file mode 100644 index 000000000..c8814755c --- /dev/null +++ b/apps/edr-freight-api/src/modules/scheduling-reschedule/entities/scheduling-event.entity.ts @@ -0,0 +1,33 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index } from 'typeorm'; + +export const RESCHEDULE_TRIGGERS = [ + 'GOVERNMENT_PREEMPT', + 'TRAIN_MAINTENANCE', + 'MANUAL', + 'CAPACITY_REBALANCE', +] as const; + +export type RescheduleTrigger = (typeof RESCHEDULE_TRIGGERS)[number]; + +@Entity({ schema: 'freight', name: 'scheduling_events' }) +@Index(['trainScheduleId']) +export class SchedulingEvent extends BaseEntity { + @Column({ name: 'train_schedule_id', type: 'uuid' }) + trainScheduleId!: string; + + @Column({ name: 'trigger', type: 'varchar', length: 40 }) + trigger!: RescheduleTrigger; + + @Column({ name: 'actor_user_id', type: 'uuid', nullable: true }) + actorUserId?: string | null; + + @Column({ name: 'reason', type: 'text', nullable: true }) + reason?: string | null; + + @Column({ name: 'plan_snapshot', type: 'jsonb' }) + planSnapshot!: Record; + + @Column({ name: 'displaced_booking_ids', type: 'jsonb', default: '[]' }) + displacedBookingIds!: string[]; +} diff --git a/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.controller.ts b/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.controller.ts new file mode 100644 index 000000000..0145db3db --- /dev/null +++ b/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.controller.ts @@ -0,0 +1,65 @@ +import { Body, Controller, Param, ParseUUIDPipe, Post } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { CurrentUser } from '@edr/api-common'; + +import { TrainSchedulingManage } from '../../common/booking-guards'; +import { + type AuthUserPayload, + resolveAuthUserId, +} from '../../common/resolve-auth-user-id'; +import { ExecuteRescheduleDto, PreviewRescheduleDto } from './dto/preview-reschedule.dto'; +import { SchedulingRescheduleService } from './scheduling-reschedule.service'; + +@ApiTags('train-scheduling') +@ApiBearerAuth() +@Controller('train-scheduling/schedules/:id/reschedule') +export class SchedulingRescheduleController { + constructor(private readonly schedulingRescheduleService: SchedulingRescheduleService) {} + + @Post('preview') + @TrainSchedulingManage() + @ApiOperation({ summary: 'Preview reschedule / government preempt plan' }) + preview( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: PreviewRescheduleDto, + ) { + return this.schedulingRescheduleService.previewReschedule(id, dto); + } + + @Post('execute') + @TrainSchedulingManage() + @ApiOperation({ summary: 'Execute a confirmed reschedule plan' }) + execute( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: ExecuteRescheduleDto, + @CurrentUser() user: AuthUserPayload, + ) { + return this.schedulingRescheduleService.executeReschedule( + id, + dto, + resolveAuthUserId(user), + ); + } +} + +@ApiTags('train-scheduling') +@ApiBearerAuth() +@Controller('train-scheduling/schedules/:id') +export class SchedulingMaintenanceController { + constructor(private readonly schedulingRescheduleService: SchedulingRescheduleService) {} + + @Post('maintenance') + @TrainSchedulingManage() + @ApiOperation({ summary: 'Reschedule train for maintenance (new departure + rebalance)' }) + maintenance( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: PreviewRescheduleDto & { newDepartureDate: string }, + @CurrentUser() user: AuthUserPayload, + ) { + return this.schedulingRescheduleService.maintenanceReschedule( + id, + dto, + resolveAuthUserId(user), + ); + } +} diff --git a/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.module.ts b/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.module.ts new file mode 100644 index 000000000..fa141057f --- /dev/null +++ b/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.module.ts @@ -0,0 +1,26 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { BookingsModule } from '../bookings/bookings.module'; +import { TrainSchedulesModule } from '../train-schedules/train-schedules.module'; +import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module'; +import { SchedulingEvent } from './entities/scheduling-event.entity'; +import { + SchedulingMaintenanceController, + SchedulingRescheduleController, +} from './scheduling-reschedule.controller'; +import { SchedulingRescheduleRepository } from './scheduling-reschedule.repository'; +import { SchedulingRescheduleService } from './scheduling-reschedule.service'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([SchedulingEvent]), + BookingsModule, + TrainSchedulesModule, + TrainSchedulingModule, + ], + controllers: [SchedulingRescheduleController, SchedulingMaintenanceController], + providers: [SchedulingRescheduleRepository, SchedulingRescheduleService], + exports: [SchedulingRescheduleService], +}) +export class SchedulingRescheduleModule {} diff --git a/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.repository.ts b/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.repository.ts new file mode 100644 index 000000000..5a328ed0b --- /dev/null +++ b/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.repository.ts @@ -0,0 +1,25 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { SchedulingEvent, type RescheduleTrigger } from './entities/scheduling-event.entity'; + +@Injectable() +export class SchedulingRescheduleRepository { + constructor( + @InjectRepository(SchedulingEvent) + private readonly repository: Repository, + ) {} + + /** Persist an audit record for a completed reschedule. */ + async createEvent(data: { + trainScheduleId: string; + trigger: RescheduleTrigger; + actorUserId?: string; + reason?: string; + planSnapshot: Record; + displacedBookingIds: string[]; + }): Promise { + return this.repository.save(this.repository.create(data)); + } +} diff --git a/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.spec.ts b/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.spec.ts new file mode 100644 index 000000000..905e827e8 --- /dev/null +++ b/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.spec.ts @@ -0,0 +1,230 @@ +import { BadRequestException } from '@nestjs/common'; + +import { compareSchedulingPriority } from '../scheduling/compare-scheduling-priority.util'; +import { SchedulingRescheduleService } from './scheduling-reschedule.service'; + +const makeBooking = ( + id: string, + reference: string, + extra: Record = {}, +) => ({ + id, + reference, + freightType: 'CONTAINER', + cargoTotalWeightVgm: 100, + scheduledDate: new Date('2026-06-20T08:00:00.000Z'), + originYardId: 'yard-origin', + destinationYardId: 'yard-destination', + status: 'PAID', + isGovernment: false, + priorityScore: 50, + bookingContainers: [ + { + id: `${id}-line`, + wagonsRequired: 5, + quantity: 1, + vgmPerUnitTons: 100, + }, + ], + ...extra, +}); + +describe('compareSchedulingPriority', () => { + it('orders government before commercial', () => { + const sorted = [ + { + isGovernment: false, + priorityScore: 50000, + scheduledDate: new Date('2026-06-20'), + }, + { + isGovernment: true, + priorityScore: 100, + scheduledDate: new Date('2026-06-25'), + }, + ].sort(compareSchedulingPriority); + + expect(sorted[0]?.isGovernment).toBe(true); + }); +}); + +describe('SchedulingRescheduleService', () => { + let service: SchedulingRescheduleService; + let trainSchedulesRepository: Record; + let bookingsRepository: Record; + let trainSchedulingService: Record; + let schedulingRescheduleRepository: Record; + + beforeEach(() => { + trainSchedulesRepository = { + findByIdWithFullGraph: jest.fn(), + updateStatus: jest.fn(), + }; + bookingsRepository = { + findByIdsForScheduling: jest.fn(), + updateSchedulingFields: jest.fn(), + }; + trainSchedulingService = { + previewTrainSchedule: jest.fn(), + unassignBooking: jest.fn(), + assignBookingsToSchedule: jest.fn(), + }; + schedulingRescheduleRepository = { + createEvent: jest.fn().mockResolvedValue({ id: 'event-1' }), + }; + + service = new SchedulingRescheduleService( + trainSchedulesRepository as never, + bookingsRepository as never, + trainSchedulingService as never, + schedulingRescheduleRepository as never, + ); + }); + + it('rejects reschedule on dispatched trains', async () => { + trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue({ + id: 'sched-1', + status: 'DISPATCHED', + scheduleBookings: [], + }); + + await expect( + service.previewReschedule('sched-1', { + incomingBookingIds: ['gov-1'], + trigger: 'GOVERNMENT_PREEMPT', + }), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('displaces lower-priority commercial when government incoming exceeds capacity', async () => { + const commercial = makeBooking('c1', 'BKG-COMM', { priorityScore: 10, isGovernment: false }); + const government = makeBooking('g1', 'BKG-GOV', { + isGovernment: true, + priorityScore: 60000, + governmentInstitution: 'Ministry', + }); + + trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue({ + id: 'sched-1', + status: 'DRAFT', + scheduledDepartureDate: new Date('2026-06-20T08:00:00.000Z'), + originStationId: 'yard-origin', + destinationStationId: 'yard-destination', + scheduleBookings: [{ bookingId: 'c1', booking: commercial }], + }); + bookingsRepository.findByIdsForScheduling.mockResolvedValue([government]); + + trainSchedulingService.previewTrainSchedule.mockImplementation( + async ({ bookingIds }: { bookingIds: string[] }) => ({ + valid: bookingIds.length <= 1, + violations: bookingIds.length > 1 ? ['Train capacity exceeded'] : [], + warnings: [], + }), + ); + + const plan = await service.previewReschedule('sched-1', { + incomingBookingIds: ['g1'], + trigger: 'GOVERNMENT_PREEMPT', + }); + + expect(plan.retained.map((b) => b.id)).toEqual(['g1']); + expect(plan.displaced.map((b) => b.id)).toEqual(['c1']); + expect(plan.finalBookingIds).toEqual(['g1']); + }); + + it('readmits high-priority commercial when spare capacity remains', async () => { + const low = makeBooking('c-low', 'BKG-LOW', { priorityScore: 5 }); + const high = makeBooking('c-high', 'BKG-HIGH', { priorityScore: 500 }); + const government = makeBooking('g1', 'BKG-GOV', { isGovernment: true, priorityScore: 60000 }); + + trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue({ + id: 'sched-1', + status: 'DRAFT', + scheduledDepartureDate: new Date('2026-06-20T08:00:00.000Z'), + originStationId: 'yard-origin', + destinationStationId: 'yard-destination', + scheduleBookings: [ + { bookingId: 'c-low', booking: low }, + { bookingId: 'c-high', booking: high }, + ], + }); + bookingsRepository.findByIdsForScheduling.mockResolvedValue([government]); + + const fitAttempts = new Map(); + trainSchedulingService.previewTrainSchedule.mockImplementation( + async ({ bookingIds }: { bookingIds: string[] }) => { + const key = [...bookingIds].sort().join(','); + const attempt = (fitAttempts.get(key) ?? 0) + 1; + fitAttempts.set(key, attempt); + + const fits = + bookingIds.length === 1 || + (key === 'c-high,g1' && attempt > 1); + + return { + valid: fits, + violations: fits ? [] : ['Train capacity exceeded'], + warnings: [], + }; + }, + ); + + const plan = await service.previewReschedule('sched-1', { + incomingBookingIds: ['g1'], + trigger: 'GOVERNMENT_PREEMPT', + }); + + expect(plan.retained.map((b) => b.id)).toEqual(['g1']); + expect(plan.readmitted.map((b) => b.id)).toEqual(['c-high']); + expect(plan.displaced.map((b) => b.id)).toEqual(['c-low']); + expect(plan.finalBookingIds).toEqual(['g1', 'c-high']); + }); + + it('maintenance reschedule updates departure and rebalances bookings', async () => { + const commercial = makeBooking('c1', 'BKG-COMM', { priorityScore: 10 }); + const schedule = { + id: 'sched-1', + status: 'DRAFT', + scheduledDepartureDate: new Date('2026-06-20T08:00:00.000Z'), + originStationId: 'yard-origin', + destinationStationId: 'yard-destination', + scheduleBookings: [{ bookingId: 'c1', booking: commercial }], + }; + + trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue(schedule); + bookingsRepository.findByIdsForScheduling.mockResolvedValue([commercial]); + trainSchedulingService.previewTrainSchedule.mockResolvedValue({ + valid: true, + violations: [], + warnings: [], + }); + trainSchedulesRepository.updateStatus.mockResolvedValue(undefined); + trainSchedulingService.assignBookingsToSchedule.mockResolvedValue({ id: 'sched-1' }); + + const result = await service.maintenanceReschedule( + 'sched-1', + { + incomingBookingIds: ['c1'], + trigger: 'TRAIN_MAINTENANCE', + reason: 'Locomotive service', + newDepartureDate: '2026-06-22T10:00:00.000Z', + }, + 'staff-1', + ); + + expect(trainSchedulesRepository.updateStatus).toHaveBeenCalledWith( + 'sched-1', + 'DRAFT', + { scheduledDepartureDate: new Date('2026-06-22T10:00:00.000Z') }, + ); + expect(schedulingRescheduleRepository.createEvent).toHaveBeenCalledWith( + expect.objectContaining({ + trigger: 'TRAIN_MAINTENANCE', + actorUserId: 'staff-1', + reason: 'Locomotive service', + }), + ); + expect(result.plan.trigger).toBe('TRAIN_MAINTENANCE'); + expect(result.plan.finalBookingIds).toEqual(['c1']); + }); +}); diff --git a/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.ts b/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.ts new file mode 100644 index 000000000..dd20b100d --- /dev/null +++ b/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.ts @@ -0,0 +1,253 @@ +import { + BadRequestException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { SchedulingStatus, TrainScheduleStatus } from '@edr/types'; + +import { Booking } from '../bookings/entities/booking.entity'; +import { BookingsRepository } from '../bookings/bookings.repository'; +import { compareSchedulingPriority } from '../scheduling/compare-scheduling-priority.util'; +import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository'; +import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service'; +import { ExecuteRescheduleDto, PreviewRescheduleDto } from './dto/preview-reschedule.dto'; +import { SchedulingRescheduleRepository } from './scheduling-reschedule.repository'; + +export interface RescheduleBookingSummary { + id: string; + reference: string; + isGovernment: boolean; + priorityScore: number; + governmentInstitution?: string | null; +} + +export interface ReschedulePlan { + scheduleId: string; + trigger: PreviewRescheduleDto['trigger']; + retained: RescheduleBookingSummary[]; + displaced: RescheduleBookingSummary[]; + readmitted: RescheduleBookingSummary[]; + finalBookingIds: string[]; + warnings: string[]; +} + +@Injectable() +export class SchedulingRescheduleService { + constructor( + private readonly trainSchedulesRepository: TrainSchedulesRepository, + private readonly bookingsRepository: BookingsRepository, + private readonly trainSchedulingService: TrainSchedulingService, + private readonly schedulingRescheduleRepository: SchedulingRescheduleRepository, + ) {} + + /** Preview who is retained, displaced, and readmitted on a schedule. */ + async previewReschedule( + scheduleId: string, + dto: PreviewRescheduleDto, + ): Promise { + const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); + if (!schedule) { + throw new NotFoundException(`Train schedule ${scheduleId} not found`); + } + // A train can be rescheduled (with or without bookings) at any time UNLESS it + // is already on the move (DISPATCHED), has completed its run (ARRIVED), or was + // cancelled. Only DRAFT / SCHEDULED trains are reschedulable. + if (schedule.status === TrainScheduleStatus.Dispatched) { + throw new BadRequestException('Cannot reschedule a train that is already dispatched'); + } + if (schedule.status === TrainScheduleStatus.Arrived) { + throw new BadRequestException('Cannot reschedule a train that has already arrived'); + } + if (schedule.status === TrainScheduleStatus.Cancelled) { + throw new BadRequestException('Cannot reschedule a cancelled train'); + } + + const currentOnSchedule = (schedule.scheduleBookings ?? []) + .map((link) => link.booking) + .filter((b): b is Booking => Boolean(b)); + + const incoming = await this.bookingsRepository.findByIdsForScheduling(dto.incomingBookingIds); + if (incoming.length !== dto.incomingBookingIds.length) { + throw new BadRequestException('One or more incoming bookings were not found'); + } + + const mergedMap = new Map(); + for (const booking of [...currentOnSchedule, ...incoming]) { + mergedMap.set(booking.id, booking); + } + const sorted = [...mergedMap.values()].sort(compareSchedulingPriority); + + const warnings: string[] = []; + const retained: Booking[] = []; + + for (const booking of sorted) { + const candidate = [...retained, booking]; + const fits = await this.bookingsFitOnSchedule(candidate, schedule, scheduleId); + if (fits) { + retained.push(booking); + } else if (currentOnSchedule.some((b) => b.id === booking.id)) { + warnings.push(`Booking ${booking.reference} will be displaced from the train`); + } + } + + const retainedIds = new Set(retained.map((b) => b.id)); + const displacedFromCurrent = currentOnSchedule.filter((b) => !retainedIds.has(b.id)); + const readmitted: Booking[] = []; + + const displacedCommercial = displacedFromCurrent + .filter((b) => !b.isGovernment) + .sort(compareSchedulingPriority); + + for (const booking of displacedCommercial) { + const candidate = [...retained, ...readmitted, booking]; + const fits = await this.bookingsFitOnSchedule(candidate, schedule, scheduleId); + if (fits) { + readmitted.push(booking); + warnings.push(`Booking ${booking.reference} readmitted after government placement`); + } + } + + const finalIds = [...retained, ...readmitted].map((b) => b.id); + const displacedIds = new Set(displacedFromCurrent.map((b) => b.id)); + for (const id of readmitted.map((b) => b.id)) { + displacedIds.delete(id); + } + const displaced = displacedFromCurrent.filter((b) => displacedIds.has(b.id)); + + return { + scheduleId, + trigger: dto.trigger, + retained: retained.map((b) => this.toSummary(b)), + displaced: displaced.map((b) => this.toSummary(b)), + readmitted: readmitted.map((b) => this.toSummary(b)), + finalBookingIds: finalIds, + warnings, + }; + } + + /** Execute a confirmed reschedule plan. */ + async executeReschedule( + scheduleId: string, + dto: ExecuteRescheduleDto, + actorUserId?: string, + ) { + const plan = await this.previewReschedule(scheduleId, dto); + const expectedDisplaced = new Set(plan.displaced.map((b) => b.id)); + const providedDisplaced = new Set(dto.displacedBookingIds); + if ( + expectedDisplaced.size !== providedDisplaced.size || + [...expectedDisplaced].some((id) => !providedDisplaced.has(id)) + ) { + throw new BadRequestException('Displaced booking list does not match current preview'); + } + + const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); + if (!schedule) { + throw new NotFoundException(`Train schedule ${scheduleId} not found`); + } + + if (dto.newDepartureDate && schedule) { + await this.trainSchedulesRepository.updateStatus( + scheduleId, + schedule.status as TrainScheduleStatus, + { scheduledDepartureDate: new Date(dto.newDepartureDate) }, + ); + } + + for (const bookingId of dto.displacedBookingIds) { + try { + await this.trainSchedulingService.unassignBooking(scheduleId, bookingId); + } catch { + await this.bookingsRepository.updateSchedulingFields(bookingId, { + schedulingStatus: SchedulingStatus.Eligible, + wagonsRequired: null, + }); + } + } + + // A train can be rescheduled even with no bookings (e.g. moved for + // maintenance). assignBookingsToSchedule requires at least one booking, so + // only call it when something is actually being (re)assigned — the new + // departure date above is the meaningful change for an empty train. The + // empty-train branch returns the same schedule-detail shape as the assign + // path so callers get a consistent response. + const assignResult = dto.finalBookingIds.length + ? await this.trainSchedulingService.assignBookingsToSchedule(scheduleId, { + bookingIds: dto.finalBookingIds, + forceAssign: dto.trigger === 'GOVERNMENT_PREEMPT', + }) + : { + ...(await this.trainSchedulingService.getContainerTrainScheduleById( + scheduleId, + )), + warnings: [] as string[], + deferredBookings: [] as unknown[], + }; + + await this.schedulingRescheduleRepository.createEvent({ + trainScheduleId: scheduleId, + trigger: dto.trigger, + actorUserId, + reason: dto.reason, + planSnapshot: plan as unknown as Record, + displacedBookingIds: dto.displacedBookingIds, + }); + + return { plan, schedule: assignResult }; + } + + /** Maintenance shortcut: new departure + rebalance. */ + async maintenanceReschedule( + scheduleId: string, + dto: PreviewRescheduleDto & { newDepartureDate: string }, + actorUserId?: string, + ) { + const currentIds = ( + await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId) + )?.scheduleBookings?.map((l) => l.bookingId) ?? []; + + const preview = await this.previewReschedule(scheduleId, { + ...dto, + trigger: 'TRAIN_MAINTENANCE', + incomingBookingIds: currentIds.length ? currentIds : dto.incomingBookingIds, + }); + + return this.executeReschedule( + scheduleId, + { + ...dto, + trigger: 'TRAIN_MAINTENANCE', + incomingBookingIds: dto.incomingBookingIds, + finalBookingIds: preview.finalBookingIds, + displacedBookingIds: preview.displaced.map((b) => b.id), + }, + actorUserId, + ); + } + + private async bookingsFitOnSchedule( + bookings: Booking[], + schedule: { scheduledDepartureDate: Date; originStationId: string; destinationStationId: string }, + scheduleId: string, + ): Promise { + if (!bookings.length) return true; + const preview = await this.trainSchedulingService.previewTrainSchedule({ + bookingIds: bookings.map((b) => b.id), + scheduleDate: schedule.scheduledDepartureDate.toISOString(), + originStationId: schedule.originStationId, + destinationStationId: schedule.destinationStationId, + targetScheduleId: scheduleId, + }); + return preview.valid; + } + + private toSummary(booking: Booking): RescheduleBookingSummary { + return { + id: booking.id, + reference: booking.reference, + isGovernment: booking.isGovernment, + priorityScore: booking.priorityScore, + governmentInstitution: booking.governmentInstitution, + }; + } +} diff --git a/apps/edr-freight-api/src/modules/scheduling/compare-scheduling-priority.util.ts b/apps/edr-freight-api/src/modules/scheduling/compare-scheduling-priority.util.ts new file mode 100644 index 000000000..a7f7350c4 --- /dev/null +++ b/apps/edr-freight-api/src/modules/scheduling/compare-scheduling-priority.util.ts @@ -0,0 +1,19 @@ +export interface SchedulingPriorityBooking { + isGovernment?: boolean; + priorityScore?: number | null; + scheduledDate: Date | string; +} + +/** Government first, then priority score, then earliest scheduled date. */ +export function compareSchedulingPriority( + a: SchedulingPriorityBooking, + b: SchedulingPriorityBooking, +): number { + const govDiff = Number(Boolean(b.isGovernment)) - Number(Boolean(a.isGovernment)); + if (govDiff !== 0) return govDiff; + + const priorityDiff = (b.priorityScore ?? 0) - (a.priorityScore ?? 0); + if (priorityDiff !== 0) return priorityDiff; + + return new Date(a.scheduledDate).getTime() - new Date(b.scheduledDate).getTime(); +} diff --git a/apps/edr-freight-api/src/modules/signatures/dto/save-signature.dto.ts b/apps/edr-freight-api/src/modules/signatures/dto/save-signature.dto.ts new file mode 100644 index 000000000..a5ae63100 --- /dev/null +++ b/apps/edr-freight-api/src/modules/signatures/dto/save-signature.dto.ts @@ -0,0 +1,24 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsString, MinLength } from 'class-validator'; + +export class SaveSignatureDto { + @ApiProperty() + @IsString() + @MinLength(1) + signerDisplayName!: string; + + @ApiProperty({ + description: 'PNG signature image as base64 (with or without data URL prefix)', + }) + @IsString() + @MinLength(20) + signatureImageBase64!: string; +} + +export class SavedSignatureDto { + @ApiProperty() + signerDisplayName!: string; + + @ApiProperty({ nullable: true }) + signatureImageUrl!: string | null; +} diff --git a/apps/edr-freight-api/src/modules/signatures/entities/saved-signature.entity.ts b/apps/edr-freight-api/src/modules/signatures/entities/saved-signature.entity.ts new file mode 100644 index 000000000..08263cf7d --- /dev/null +++ b/apps/edr-freight-api/src/modules/signatures/entities/saved-signature.entity.ts @@ -0,0 +1,25 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; +import { FileRecord } from '../../files/entities/file.entity'; + +/** + * A reusable signature that belongs to a single user (customer or staff). + * Captured once and applied to many booking contracts so the signer does not + * have to redraw it every time. One active saved signature per user. + */ +@Entity({ schema: 'freight', name: 'saved_signatures' }) +@Index(['userId'], { unique: true }) +export class SavedSignature extends BaseEntity { + @Column({ name: 'user_id', type: 'uuid' }) + userId!: string; + + @Column({ name: 'signer_display_name', type: 'varchar', length: 200 }) + signerDisplayName!: string; + + @Column({ name: 'signature_file_id', type: 'uuid', nullable: true }) + signatureFileId?: string | null; + + @ManyToOne(() => FileRecord, { nullable: true }) + @JoinColumn({ name: 'signature_file_id' }) + signatureFile?: FileRecord | null; +} diff --git a/apps/edr-freight-api/src/modules/signatures/signatures.controller.ts b/apps/edr-freight-api/src/modules/signatures/signatures.controller.ts new file mode 100644 index 000000000..d112edef3 --- /dev/null +++ b/apps/edr-freight-api/src/modules/signatures/signatures.controller.ts @@ -0,0 +1,39 @@ +import { Body, Controller, Get, Put, Request } from '@nestjs/common'; +import { ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { SignaturesService } from './signatures.service'; +import { SaveSignatureDto, SavedSignatureDto } from './dto/save-signature.dto'; + +@ApiTags('Signatures') +@Controller('me/signature') +export class SignaturesController { + constructor(private readonly signaturesService: SignaturesService) {} + + @Get() + @ApiOkResponse({ type: SavedSignatureDto }) + @ApiOperation({ summary: "Current user's reusable saved signature" }) + getMySignature( + @Request() req: { user?: { id?: string; sub?: string } }, + ): Promise { + const userId = req.user?.id ?? req.user?.sub; + if (!userId) return Promise.resolve(null); + return this.signaturesService.getForUser(userId); + } + + @Put() + @ApiOkResponse({ type: SavedSignatureDto }) + @ApiOperation({ summary: 'Create or update the reusable saved signature' }) + async saveMySignature( + @Body() dto: SaveSignatureDto, + @Request() req: { user?: { id?: string; sub?: string } }, + ): Promise { + const userId = req.user?.id ?? req.user?.sub; + if (!userId) return null; + await this.signaturesService.upsertForUser({ + userId, + signerDisplayName: dto.signerDisplayName, + signatureImageBase64: dto.signatureImageBase64, + }); + return this.signaturesService.getForUser(userId); + } +} diff --git a/apps/edr-freight-api/src/modules/signatures/signatures.module.ts b/apps/edr-freight-api/src/modules/signatures/signatures.module.ts new file mode 100644 index 000000000..32292d995 --- /dev/null +++ b/apps/edr-freight-api/src/modules/signatures/signatures.module.ts @@ -0,0 +1,21 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { FilesModule } from '../files/files.module'; +import { MinioModule } from '../minio/minio.module'; +import { SignaturesController } from './signatures.controller'; +import { SignaturesService } from './signatures.service'; +import { SignaturesRepository } from './signatures.repository'; +import { SavedSignature } from './entities/saved-signature.entity'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([SavedSignature]), + FilesModule, + MinioModule, + ], + controllers: [SignaturesController], + providers: [SignaturesService, SignaturesRepository], + exports: [SignaturesService], +}) +export class SignaturesModule {} diff --git a/apps/edr-freight-api/src/modules/signatures/signatures.repository.ts b/apps/edr-freight-api/src/modules/signatures/signatures.repository.ts new file mode 100644 index 000000000..70c04ad7b --- /dev/null +++ b/apps/edr-freight-api/src/modules/signatures/signatures.repository.ts @@ -0,0 +1,34 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { BaseRepository } from '@edr/api-common'; +import { SavedSignature } from './entities/saved-signature.entity'; + +@Injectable() +export class SignaturesRepository extends BaseRepository { + constructor( + @InjectRepository(SavedSignature) + repo: Repository, + ) { + super(repo); + } + + findByUserId(userId: string): Promise { + return this.repository.findOne({ + where: { userId } as never, + relations: ['signatureFile'], + }); + } + + /** Insert or update the single saved signature for a user. */ + async upsert(data: Partial): Promise { + const existing = await this.repository.findOne({ + where: { userId: data.userId! } as never, + }); + if (existing) { + Object.assign(existing, data); + return this.repository.save(existing); + } + return this.repository.save(this.repository.create(data)); + } +} diff --git a/apps/edr-freight-api/src/modules/signatures/signatures.service.ts b/apps/edr-freight-api/src/modules/signatures/signatures.service.ts new file mode 100644 index 000000000..7137ab6a5 --- /dev/null +++ b/apps/edr-freight-api/src/modules/signatures/signatures.service.ts @@ -0,0 +1,111 @@ +import { Injectable } from '@nestjs/common'; +import { Readable } from 'stream'; +import { DataSource } from 'typeorm'; + +import { FilesService } from '../files/files.service'; +import { FileRecord } from '../files/entities/file.entity'; +import { MinioService } from '../minio/minio.service'; +import { SignaturesRepository } from './signatures.repository'; +import { SavedSignature } from './entities/saved-signature.entity'; +import { SavedSignatureDto } from './dto/save-signature.dto'; + +export interface UpsertSignatureInput { + userId: string; + signerDisplayName: string; + signatureImageBase64: string; +} + +@Injectable() +export class SignaturesService { + constructor( + private readonly signaturesRepository: SignaturesRepository, + private readonly filesService: FilesService, + private readonly minioService: MinioService, + private readonly dataSource: DataSource, + ) {} + + /** Saved signature for a user, with the image inlined as a data URL (or null). */ + async getForUser(userId: string): Promise { + const saved = await this.signaturesRepository.findByUserId(userId); + if (!saved) return null; + return { + signerDisplayName: saved.signerDisplayName, + signatureImageUrl: await this.inlineImageUrl(saved.signatureFile?.url), + }; + } + + /** Insert or update the user's reusable signature, storing the image in MinIO. */ + async upsertForUser(input: UpsertSignatureInput): Promise { + const buffer = this.decodeSignatureImage(input.signatureImageBase64); + const file: Express.Multer.File = { + fieldname: 'signature', + originalname: `signature-${input.userId}.png`, + encoding: '7bit', + mimetype: 'image/png', + size: buffer.length, + buffer, + stream: Readable.from(buffer), + destination: '', + filename: '', + path: '', + }; + + // Capture the previously referenced file so we can remove it only AFTER the + // saved_signatures row is repointed — deleting it first would violate the + // FK constraint (saved_signatures.signature_file_id -> files.id). + const existing = await this.signaturesRepository.findByUserId(input.userId); + const previousFileId = existing?.signatureFileId ?? null; + + const fileRecord = await this.filesService.upload({ + resourceId: input.userId, + resource: 'saved_signatures', + code: 'signature', + file, + }); + + const saved = await this.signaturesRepository.upsert({ + userId: input.userId, + signerDisplayName: input.signerDisplayName, + signatureFileId: fileRecord.id, + }); + + if (previousFileId && previousFileId !== fileRecord.id) { + await this.dataSource + .getRepository(FileRecord) + .delete({ id: previousFileId }); + } + + return saved; + } + + private async inlineImageUrl( + url?: string | null, + ): Promise { + if (!url) return null; + if (url.startsWith('data:')) return url; + try { + const objectName = this.minioService.getObjectNameFromUrl(url); + const stream = await this.minioService.getFileStream(objectName); + const buffer = await this.streamToBuffer(stream); + return `data:image/png;base64,${buffer.toString('base64')}`; + } catch { + return url; + } + } + + private decodeSignatureImage(base64: string): Buffer { + const raw = base64.includes(',') ? base64.split(',')[1]! : base64; + return Buffer.from(raw, 'base64'); + } + + private streamToBuffer(stream: Readable): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + stream.on('data', (chunk: Buffer | string) => { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + }); + stream.on('error', reject); + stream.on('end', () => resolve(Buffer.concat(chunks))); + }); + } +} diff --git a/apps/edr-freight-api/src/modules/tracking/entities/tracking-event.entity.ts b/apps/edr-freight-api/src/modules/tracking/entities/tracking-event.entity.ts index aed5420d7..40e0594ae 100644 --- a/apps/edr-freight-api/src/modules/tracking/entities/tracking-event.entity.ts +++ b/apps/edr-freight-api/src/modules/tracking/entities/tracking-event.entity.ts @@ -2,7 +2,7 @@ import { BaseEntity } from "@edr/api-common"; import { Freight } from "@edr/types"; import { Column, Entity } from "typeorm"; -@Entity({ name: "tracking_events" }) +@Entity({schema:"freight", name: "tracking_events" }) export class TrackingEvent extends BaseEntity { @Column({ name: "consignment_id", type: "uuid" }) consignmentId!: string; diff --git a/apps/edr-freight-api/src/modules/tracking/tracking.controller.ts b/apps/edr-freight-api/src/modules/tracking/tracking.controller.ts index 6d645c40a..0e996fe7c 100644 --- a/apps/edr-freight-api/src/modules/tracking/tracking.controller.ts +++ b/apps/edr-freight-api/src/modules/tracking/tracking.controller.ts @@ -4,7 +4,6 @@ import { ApiOperation, ApiTags } from "@nestjs/swagger"; import { TrackingService } from "./tracking.service"; @ApiTags("tracking") -// @UseGuards(JwtAuthGuard) — TODO: integrate @edr/auth @Controller("tracking") export class TrackingController { constructor(private readonly trackingService: TrackingService) {} diff --git a/apps/edr-freight-api/src/modules/train-schedules/entities/train-composition-removal-log.entity.ts b/apps/edr-freight-api/src/modules/train-schedules/entities/train-composition-removal-log.entity.ts new file mode 100644 index 000000000..b32b42148 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-schedules/entities/train-composition-removal-log.entity.ts @@ -0,0 +1,22 @@ +import { Column, Entity, Index } from 'typeorm'; +import { BaseEntity } from '@edr/api-common'; + +@Entity({ schema: 'freight', name: 'train_composition_removal_logs' }) +@Index(['scheduleId']) +export class TrainCompositionRemovalLog extends BaseEntity { + @Column({ name: 'schedule_id', type: 'uuid' }) scheduleId!: string; + + @Column({ name: 'booking_id', type: 'uuid' }) bookingId!: string; + + @Column({ name: 'booking_reference', type: 'varchar', length: 64, nullable: true }) + bookingReference?: string | null; + + @Column({ name: 'removed_by_user_id', type: 'uuid', nullable: true }) + removedByUserId?: string | null; + + @Column({ name: 'removed_at', type: 'timestamptz', default: () => 'NOW()' }) + removedAt!: Date; + + @Column({ name: 'notes', type: 'text', nullable: true }) + notes?: string | null; +} diff --git a/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule-booking.entity.ts b/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule-booking.entity.ts new file mode 100644 index 000000000..4ffecea26 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule-booking.entity.ts @@ -0,0 +1,26 @@ +import { BaseEntity } from '@edr/api-common'; +import { Entity, Index, JoinColumn, ManyToOne, Column } from 'typeorm'; + +import { Booking } from '../../bookings/entities/booking.entity'; +import { TrainSchedule } from './train-schedule.entity'; + +@Entity({ schema: 'freight', name: 'train_schedule_bookings' }) +@Index(['trainScheduleId', 'bookingId'], { unique: true }) +@Index(['bookingId'], { unique: true }) +export class TrainScheduleBooking extends BaseEntity { + @Column({ name: 'train_schedule_id', type: 'uuid' }) + trainScheduleId!: string; + + @ManyToOne(() => TrainSchedule, (trainSchedule) => trainSchedule.scheduleBookings, { + onDelete: 'CASCADE', + }) + @JoinColumn({ name: 'train_schedule_id' }) + trainSchedule?: TrainSchedule; + + @Column({ name: 'booking_id', type: 'uuid' }) + bookingId!: string; + + @ManyToOne(() => Booking) + @JoinColumn({ name: 'booking_id' }) + booking?: Booking; +} diff --git a/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts b/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts new file mode 100644 index 000000000..d1ed23ef7 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts @@ -0,0 +1,88 @@ +import { BaseEntity } from '@edr/api-common'; +import { TrainScheduleStatus as TrainScheduleStatusEnum } from '@edr/types'; +import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany, OneToOne } from 'typeorm'; + +import { Yard } from '../../rule-engine/entities/yard.entity'; +import { Route } from '../../routes/entities/route.entity'; +import { TrainSet } from '../../train-sets/entities/train-set.entity'; +import { TrainScheduleBooking } from './train-schedule-booking.entity'; + +export const TRAIN_SCHEDULE_STATUSES = [ + TrainScheduleStatusEnum.Draft, + TrainScheduleStatusEnum.Scheduled, + TrainScheduleStatusEnum.Dispatched, + TrainScheduleStatusEnum.Arrived, + TrainScheduleStatusEnum.Cancelled, +] as const; + +export type TrainScheduleStatus = (typeof TRAIN_SCHEDULE_STATUSES)[number]; + +@Entity({ schema: 'freight', name: 'train_schedules' }) +@Index(['scheduledDepartureDate']) +@Index(['status']) +export class TrainSchedule extends BaseEntity { + @Column({ name: 'train_set_id', type: 'uuid', unique: true }) + trainSetId!: string; + + @OneToOne(() => TrainSet, (trainSet) => trainSet.trainSchedule) + @JoinColumn({ name: 'train_set_id' }) + trainSet?: TrainSet; + + @Column({ name: 'route_id', type: 'uuid', nullable: true }) + routeId?: string | null; + + @ManyToOne(() => Route) + @JoinColumn({ name: 'route_id' }) + route?: Route | null; + + @Column({ name: 'origin_station_id', type: 'uuid' }) + originStationId!: string; + + @ManyToOne(() => Yard) + @JoinColumn({ name: 'origin_station_id' }) + originStation?: Yard; + + @Column({ name: 'destination_station_id', type: 'uuid' }) + destinationStationId!: string; + + @ManyToOne(() => Yard) + @JoinColumn({ name: 'destination_station_id' }) + destinationStation?: Yard; + + @Column({ name: 'scheduled_departure_date', type: 'timestamptz' }) + scheduledDepartureDate!: Date; + + @Column({ name: 'scheduled_arrival_date', type: 'timestamptz', nullable: true }) + scheduledArrivalDate?: Date | null; + + @Column({ name: 'status', type: 'varchar', length: 20, default: 'DRAFT' }) + status!: TrainScheduleStatus; + + @Column({ name: 'train_number', type: 'varchar', length: 20, nullable: true }) + trainNumber?: string | null; + + @Column({ name: 'direction', type: 'varchar', length: 10, nullable: true }) + direction?: string | null; + + @Column({ name: 'actual_departure_at', type: 'timestamptz', nullable: true }) + actualDepartureAt?: Date | null; + + @Column({ name: 'actual_arrival_at', type: 'timestamptz', nullable: true }) + actualArrivalAt?: Date | null; + + @Column({ name: 'prepared_by_user_id', type: 'uuid', nullable: true }) + preparedByUserId?: string | null; + + @Column({ name: 'checked_by_user_id', type: 'uuid', nullable: true }) + checkedByUserId?: string | null; + + @Column({ name: 'max_wagons', type: 'int', default: 53 }) + maxWagons!: number; + + /** OPEN = accepting/holding bookings; FULL = train filled; CLOSED = manually closed. Orthogonal to `status`. */ + @Column({ name: 'booking_window_status', type: 'varchar', length: 10, default: 'OPEN' }) + bookingWindowStatus!: string; + + @OneToMany(() => TrainScheduleBooking, (scheduleBooking) => scheduleBooking.trainSchedule) + scheduleBookings?: TrainScheduleBooking[]; +} diff --git a/apps/edr-freight-api/src/modules/train-schedules/entities/wagon-allocation-bulk-load.entity.ts b/apps/edr-freight-api/src/modules/train-schedules/entities/wagon-allocation-bulk-load.entity.ts new file mode 100644 index 000000000..b3ecb4618 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-schedules/entities/wagon-allocation-bulk-load.entity.ts @@ -0,0 +1,53 @@ +import { BaseEntity } from '@edr/api-common'; +import { BulkPricingUnit } from '@edr/types'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; + +import { Booking } from '../../bookings/entities/booking.entity'; +import { CargoType } from '../../rule-engine/entities/cargo-type.entity'; +import { WagonBookingAllocation } from './wagon-booking-allocation.entity'; + +export const BULK_PRICING_UNITS = [ + BulkPricingUnit.PerWagon, + BulkPricingUnit.PerTon, + BulkPricingUnit.PerItem, +] as const; + +@Entity({ schema: 'freight', name: 'wagon_allocation_bulk_loads' }) +@Index(['bookingId']) +export class WagonAllocationBulkLoad extends BaseEntity { + @Column({ name: 'wagon_booking_allocation_id', type: 'uuid', unique: true }) + wagonBookingAllocationId!: string; + + @ManyToOne(() => WagonBookingAllocation, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'wagon_booking_allocation_id' }) + allocation?: WagonBookingAllocation; + + @Column({ name: 'booking_id', type: 'uuid' }) + bookingId!: string; + + @ManyToOne(() => Booking) + @JoinColumn({ name: 'booking_id' }) + booking?: Booking; + + @Column({ name: 'cargo_type_id', type: 'uuid', nullable: true }) + cargoTypeId?: string | null; + + @ManyToOne(() => CargoType, { nullable: true, onDelete: 'SET NULL' }) + @JoinColumn({ name: 'cargo_type_id' }) + cargoType?: CargoType | null; + + @Column({ name: 'cargo_description', type: 'text', nullable: true }) + cargoDescription?: string | null; + + @Column({ name: 'pricing_unit', type: 'varchar', length: 20, default: BulkPricingUnit.PerTon }) + pricingUnit!: string; + + @Column({ name: 'quantity', type: 'numeric', precision: 12, scale: 3, default: 0 }) + quantity!: number; + + @Column({ name: 'weight_tons', type: 'numeric', precision: 10, scale: 3, default: 0 }) + weightTons!: number; + + @Column({ name: 'truck_plate_number', type: 'varchar', length: 32, nullable: true }) + truckPlateNumber?: string | null; +} diff --git a/apps/edr-freight-api/src/modules/train-schedules/entities/wagon-allocation-container-item.entity.ts b/apps/edr-freight-api/src/modules/train-schedules/entities/wagon-allocation-container-item.entity.ts new file mode 100644 index 000000000..3885e6d15 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-schedules/entities/wagon-allocation-container-item.entity.ts @@ -0,0 +1,54 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; + +import { BookingContainer } from '../../bookings/entities/booking-container.entity'; +import { Container } from '../../container-management/entities/container.entity'; +import { ContainerType } from '../../rule-engine/entities/container-type.entity'; +import { WagonBookingAllocation } from './wagon-booking-allocation.entity'; + +@Entity({ schema: 'freight', name: 'wagon_allocation_container_items' }) +@Index(['wagonBookingAllocationId']) +export class WagonAllocationContainerItem extends BaseEntity { + @Column({ name: 'wagon_booking_allocation_id', type: 'uuid' }) + wagonBookingAllocationId!: string; + + @ManyToOne(() => WagonBookingAllocation, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'wagon_booking_allocation_id' }) + allocation?: WagonBookingAllocation; + + @Column({ name: 'booking_container_id', type: 'uuid', nullable: true }) + bookingContainerId?: string | null; + + @ManyToOne(() => BookingContainer, { nullable: true, onDelete: 'SET NULL' }) + @JoinColumn({ name: 'booking_container_id' }) + bookingContainer?: BookingContainer | null; + + @Column({ name: 'container_id', type: 'uuid', nullable: true }) + containerId?: string | null; + + @ManyToOne(() => Container, { nullable: true, onDelete: 'SET NULL' }) + @JoinColumn({ name: 'container_id' }) + container?: Container | null; + + @Column({ name: 'container_number', type: 'varchar', length: 64, nullable: true }) + containerNumber?: string | null; + + @Column({ name: 'container_type_id', type: 'uuid', nullable: true }) + containerTypeId?: string | null; + + @ManyToOne(() => ContainerType, { nullable: true }) + @JoinColumn({ name: 'container_type_id' }) + containerType?: ContainerType | null; + + @Column({ name: 'position_on_wagon', type: 'smallint', nullable: true }) + positionOnWagon?: number | null; + + @Column({ name: 'seal_number', type: 'varchar', length: 64, nullable: true }) + sealNumber?: string | null; + + @Column({ name: 'chassis_number', type: 'varchar', length: 64, nullable: true }) + chassisNumber?: string | null; + + @Column({ name: 'gross_weight_tons', type: 'numeric', precision: 10, scale: 3, nullable: true }) + grossWeightTons?: number | null; +} diff --git a/apps/edr-freight-api/src/modules/train-schedules/entities/wagon-booking-allocation.entity.ts b/apps/edr-freight-api/src/modules/train-schedules/entities/wagon-booking-allocation.entity.ts new file mode 100644 index 000000000..6bec9f74e --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-schedules/entities/wagon-booking-allocation.entity.ts @@ -0,0 +1,55 @@ +import { BaseEntity } from '@edr/api-common'; +import { AllocationLoadType, AllocationStatus } from '@edr/types'; +import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm'; + +import { Booking } from '../../bookings/entities/booking.entity'; +import { TrainSetWagon } from '../../train-sets/entities/train-set-wagon.entity'; +import { WagonAllocationContainerItem } from './wagon-allocation-container-item.entity'; + +export const ALLOCATION_LOAD_TYPES = [ + AllocationLoadType.Container, + AllocationLoadType.Bulk, +] as const; + +export const ALLOCATION_STATUSES = [ + AllocationStatus.Planned, + AllocationStatus.Reserved, + AllocationStatus.Loaded, + AllocationStatus.Departed, +] as const; + +@Entity({ schema: 'freight', name: 'wagon_booking_allocations' }) +@Index(['trainSetWagonId', 'bookingId']) +export class WagonBookingAllocation extends BaseEntity { + @Column({ name: 'train_set_wagon_id', type: 'uuid' }) + trainSetWagonId!: string; + + @ManyToOne(() => TrainSetWagon, (wagon) => wagon.allocations, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'train_set_wagon_id' }) + trainSetWagon?: TrainSetWagon; + + @Column({ name: 'booking_id', type: 'uuid' }) + bookingId!: string; + + @ManyToOne(() => Booking) + @JoinColumn({ name: 'booking_id' }) + booking?: Booking; + + @Column({ name: 'allocated_weight_tons', type: 'numeric', precision: 10, scale: 3 }) + allocatedWeightTons!: number; + + @Column({ name: 'load_type', type: 'varchar', length: 20, nullable: true }) + loadType?: string | null; + + @Column({ name: 'status', type: 'varchar', length: 20, default: 'PLANNED' }) + status!: string; + + @Column({ name: 'confirmed_at', type: 'timestamptz', nullable: true }) + confirmedAt?: Date | null; + + @Column({ name: 'confirmed_by_user_id', type: 'uuid', nullable: true }) + confirmedByUserId?: string | null; + + @OneToMany(() => WagonAllocationContainerItem, (item) => item.allocation) + containerItems?: WagonAllocationContainerItem[]; +} diff --git a/apps/edr-freight-api/src/modules/train-schedules/train-composition-removal-log.repository.ts b/apps/edr-freight-api/src/modules/train-schedules/train-composition-removal-log.repository.ts new file mode 100644 index 000000000..c8e0d6053 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-schedules/train-composition-removal-log.repository.ts @@ -0,0 +1,18 @@ +import { Injectable } from '@nestjs/common'; +import { DataSource } from 'typeorm'; +import { BaseRepository } from '@edr/api-common'; +import { TrainCompositionRemovalLog } from './entities/train-composition-removal-log.entity'; + +@Injectable() +export class TrainCompositionRemovalLogRepository extends BaseRepository { + constructor(dataSource: DataSource) { + super(dataSource.getRepository(TrainCompositionRemovalLog)); + } + + async findByScheduleId(scheduleId: string): Promise { + return this.findAll({ + where: { scheduleId }, + order: { removedAt: 'DESC' }, + }); + } +} diff --git a/apps/edr-freight-api/src/modules/train-schedules/train-schedule-bookings.repository.ts b/apps/edr-freight-api/src/modules/train-schedules/train-schedule-bookings.repository.ts new file mode 100644 index 000000000..4ccfcd469 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-schedules/train-schedule-bookings.repository.ts @@ -0,0 +1,50 @@ +import { BaseRepository } from '@edr/api-common'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { DeepPartial, EntityManager, In, Repository } from 'typeorm'; + +import { TrainScheduleBooking } from './entities/train-schedule-booking.entity'; + +@Injectable() +export class TrainScheduleBookingsRepository extends BaseRepository { + constructor( + @InjectRepository(TrainScheduleBooking) + repository: Repository, + ) { + super(repository); + } + + private repo(manager?: EntityManager) { + return manager ? manager.getRepository(TrainScheduleBooking) : this.repository; + } + + async createMany( + records: DeepPartial[], + manager?: EntityManager, + ): Promise { + if (!records.length) return []; + const repo = this.repo(manager); + return repo.save(repo.create(records)); + } + + async deleteByScheduleAndBooking( + trainScheduleId: string, + bookingId: string, + manager?: EntityManager, + ): Promise { + await this.repo(manager).delete({ trainScheduleId, bookingId }); + } + + async existsForBooking(bookingId: string, manager?: EntityManager): Promise { + const count = await this.repo(manager).count({ where: { bookingId } }); + return count > 0; + } + + findByBookingIds(bookingIds: string[], manager?: EntityManager): Promise { + if (!bookingIds.length) return Promise.resolve([]); + return this.repo(manager).find({ + where: { bookingId: In(bookingIds) }, + select: { id: true, bookingId: true, trainScheduleId: true }, + }); + } +} diff --git a/apps/edr-freight-api/src/modules/train-schedules/train-schedules.module.ts b/apps/edr-freight-api/src/modules/train-schedules/train-schedules.module.ts new file mode 100644 index 000000000..bb7b41d90 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-schedules/train-schedules.module.ts @@ -0,0 +1,45 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { TrainScheduleBooking } from './entities/train-schedule-booking.entity'; +import { TrainSchedule } from './entities/train-schedule.entity'; +import { TrainCompositionRemovalLog } from './entities/train-composition-removal-log.entity'; +import { WagonAllocationBulkLoad } from './entities/wagon-allocation-bulk-load.entity'; +import { WagonAllocationContainerItem } from './entities/wagon-allocation-container-item.entity'; +import { WagonBookingAllocation } from './entities/wagon-booking-allocation.entity'; +import { TrainScheduleBookingsRepository } from './train-schedule-bookings.repository'; +import { TrainSchedulesRepository } from './train-schedules.repository'; +import { TrainCompositionRemovalLogRepository } from './train-composition-removal-log.repository'; +import { WagonAllocationBulkLoadsRepository } from './wagon-allocation-bulk-loads.repository'; +import { WagonAllocationContainerItemsRepository } from './wagon-allocation-container-items.repository'; +import { WagonBookingAllocationsRepository } from './wagon-booking-allocations.repository'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([ + TrainSchedule, + TrainScheduleBooking, + TrainCompositionRemovalLog, + WagonBookingAllocation, + WagonAllocationContainerItem, + WagonAllocationBulkLoad, + ]), + ], + providers: [ + TrainSchedulesRepository, + TrainScheduleBookingsRepository, + TrainCompositionRemovalLogRepository, + WagonBookingAllocationsRepository, + WagonAllocationContainerItemsRepository, + WagonAllocationBulkLoadsRepository, + ], + exports: [ + TrainSchedulesRepository, + TrainScheduleBookingsRepository, + TrainCompositionRemovalLogRepository, + WagonBookingAllocationsRepository, + WagonAllocationContainerItemsRepository, + WagonAllocationBulkLoadsRepository, + ], +}) +export class TrainSchedulesModule {} diff --git a/apps/edr-freight-api/src/modules/train-schedules/train-schedules.repository.ts b/apps/edr-freight-api/src/modules/train-schedules/train-schedules.repository.ts new file mode 100644 index 000000000..8ec002d49 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-schedules/train-schedules.repository.ts @@ -0,0 +1,60 @@ +import { BaseRepository } from '@edr/api-common'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { EntityManager, Repository } from 'typeorm'; + +import { TrainSchedule, TrainScheduleStatus } from './entities/train-schedule.entity'; + +@Injectable() +export class TrainSchedulesRepository extends BaseRepository { + constructor( + @InjectRepository(TrainSchedule) + repository: Repository, + ) { + super(repository); + } + + private repo(manager?: EntityManager) { + return manager ? manager.getRepository(TrainSchedule) : this.repository; + } + + findByIdWithFullGraph(id: string, manager?: EntityManager): Promise { + return this.repo(manager).findOne({ + where: { id }, + relations: { + route: true, + trainSet: { + locomotive: true, + wagons: { + wagonType: true, + physicalWagon: true, + allocations: { + booking: { company: true, bookingContainers: { containerType: true } }, + containerItems: true, + }, + }, + }, + originStation: true, + destinationStation: true, + scheduleBookings: { + booking: { + company: true, + originYard: true, + destinationYard: true, + bookingContainers: { containerType: true }, + cargoType: true, + }, + }, + }, + }); + } + + async updateStatus( + id: string, + status: TrainScheduleStatus, + extra?: Partial, + manager?: EntityManager, + ): Promise { + await this.repo(manager).update(id, { status, ...extra } as never); + } +} diff --git a/apps/edr-freight-api/src/modules/train-schedules/wagon-allocation-bulk-loads.repository.ts b/apps/edr-freight-api/src/modules/train-schedules/wagon-allocation-bulk-loads.repository.ts new file mode 100644 index 000000000..dfaf602fa --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-schedules/wagon-allocation-bulk-loads.repository.ts @@ -0,0 +1,36 @@ +import { BaseRepository } from '@edr/api-common'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { DeepPartial, EntityManager, In, Repository } from 'typeorm'; + +import { WagonAllocationBulkLoad } from './entities/wagon-allocation-bulk-load.entity'; + +@Injectable() +export class WagonAllocationBulkLoadsRepository extends BaseRepository { + constructor( + @InjectRepository(WagonAllocationBulkLoad) + repository: Repository, + ) { + super(repository); + } + + private repo(manager?: EntityManager) { + return manager + ? manager.getRepository(WagonAllocationBulkLoad) + : this.repository; + } + + async createMany( + items: DeepPartial[], + manager?: EntityManager, + ): Promise { + if (!items.length) return []; + const repo = this.repo(manager); + return repo.save(repo.create(items)); + } + + async deleteByAllocationIds(allocationIds: string[], manager?: EntityManager): Promise { + if (!allocationIds.length) return; + await this.repo(manager).delete({ wagonBookingAllocationId: In(allocationIds) }); + } +} diff --git a/apps/edr-freight-api/src/modules/train-schedules/wagon-allocation-container-items.repository.ts b/apps/edr-freight-api/src/modules/train-schedules/wagon-allocation-container-items.repository.ts new file mode 100644 index 000000000..0ff7a0548 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-schedules/wagon-allocation-container-items.repository.ts @@ -0,0 +1,36 @@ +import { BaseRepository } from '@edr/api-common'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { DeepPartial, EntityManager, In, Repository } from 'typeorm'; + +import { WagonAllocationContainerItem } from './entities/wagon-allocation-container-item.entity'; + +@Injectable() +export class WagonAllocationContainerItemsRepository extends BaseRepository { + constructor( + @InjectRepository(WagonAllocationContainerItem) + repository: Repository, + ) { + super(repository); + } + + private repo(manager?: EntityManager) { + return manager + ? manager.getRepository(WagonAllocationContainerItem) + : this.repository; + } + + async createMany( + items: DeepPartial[], + manager?: EntityManager, + ): Promise { + if (!items.length) return []; + const repo = this.repo(manager); + return repo.save(repo.create(items)); + } + + async deleteByAllocationIds(allocationIds: string[], manager?: EntityManager): Promise { + if (!allocationIds.length) return; + await this.repo(manager).delete({ wagonBookingAllocationId: In(allocationIds) }); + } +} diff --git a/apps/edr-freight-api/src/modules/train-schedules/wagon-booking-allocations.repository.ts b/apps/edr-freight-api/src/modules/train-schedules/wagon-booking-allocations.repository.ts new file mode 100644 index 000000000..620fd1343 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-schedules/wagon-booking-allocations.repository.ts @@ -0,0 +1,55 @@ +import { BaseRepository } from '@edr/api-common'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { DeepPartial, EntityManager, Repository } from 'typeorm'; + +import { WagonBookingAllocation } from './entities/wagon-booking-allocation.entity'; + +@Injectable() +export class WagonBookingAllocationsRepository extends BaseRepository { + constructor( + @InjectRepository(WagonBookingAllocation) + repository: Repository, + ) { + super(repository); + } + + private repo(manager?: EntityManager) { + return manager ? manager.getRepository(WagonBookingAllocation) : this.repository; + } + + async createMany( + records: DeepPartial[], + manager?: EntityManager, + ): Promise { + if (!records.length) return []; + const repo = this.repo(manager); + return repo.save(repo.create(records)); + } + + findByScheduleId(trainScheduleId: string, manager?: EntityManager): Promise { + return this.repo(manager) + .createQueryBuilder('allocation') + .innerJoin('allocation.trainSetWagon', 'wagon') + .innerJoin('wagon.trainSet', 'trainSet') + .innerJoin('trainSet.trainSchedule', 'schedule') + .where('schedule.id = :trainScheduleId', { trainScheduleId }) + .leftJoinAndSelect('allocation.booking', 'booking') + .getMany(); + } + + async deleteByTrainSetId(trainSetId: string, manager?: EntityManager): Promise { + const allocations = await this.repo(manager) + .createQueryBuilder('allocation') + .innerJoin('allocation.trainSetWagon', 'wagon') + .where('wagon.train_set_id = :trainSetId', { trainSetId }) + .select(['allocation.id']) + .getMany(); + + const ids = allocations.map((a) => a.id); + if (ids.length) { + await this.repo(manager).delete(ids); + } + return ids; + } +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.spec.ts new file mode 100644 index 000000000..e765e5694 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.spec.ts @@ -0,0 +1,136 @@ +import { + getBatchWindowForTimestamp, + listBatchWindowsForDate, + listBatchWindowsForBookings, + BATCH_WINDOW_START_HOURS, + boardWindowForTimestamp, + listBoardWindowsForRange, + groupBookingsIntoBoardWindows, +} from './batch-window.util'; + +describe('batch-window.util', () => { + it('maps 20:15 EAT to the 19:00–22:00 window', () => { + // 20:15 EAT = 17:15 UTC on 11 Jun 2026 + const ts = new Date('2026-06-11T17:15:00.000Z'); + const window = getBatchWindowForTimestamp(ts); + + expect(window.label).toContain('19:00'); + expect(window.label).toContain('22:00'); + expect(window.label).toContain('11 Jun 2026'); + }); + + it('maps 08:30 EAT to the 07:00–10:00 window', () => { + const ts = new Date('2026-06-11T05:30:00.000Z'); // 08:30 EAT + const window = getBatchWindowForTimestamp(ts); + expect(window.label).toContain('07:00'); + expect(window.label).toContain('10:00'); + }); + + it('maps 02:00 EAT to the previous day 22:00–07:00 window', () => { + const ts = new Date('2026-06-11T23:00:00.000Z'); // 02:00 EAT on 12 Jun + const window = getBatchWindowForTimestamp(ts); + expect(window.label).toContain('22:00'); + expect(window.label).toContain('07:00'); + expect(window.label).toContain('11 Jun 2026'); + }); + + it('lists six windows for a calendar day', () => { + const ref = new Date('2026-06-11T12:00:00.000Z'); + const windows = listBatchWindowsForDate(ref); + expect(windows).toHaveLength(BATCH_WINDOW_START_HOURS.length); + expect(windows[0].label).toContain('07:00'); + expect(windows[windows.length - 1].label).toContain('22:00'); + }); + + it('includes cross-day overnight window when booking signed at 00:02 EAT', () => { + // 21:02 UTC = 00:02 EAT on 12 Jun → belongs to 11 Jun 22:00–07:00 window + const fullyExecutedAt = new Date('2026-06-11T21:02:05.153Z'); + const scheduleDate = new Date('2026-06-12T06:00:00.000Z'); + const windows = listBatchWindowsForBookings([fullyExecutedAt], scheduleDate); + const overnight = windows.find((w) => w.label.includes('22:00') && w.label.includes('07:00')); + expect(overnight).toBeDefined(); + expect(overnight!.label).toContain('11 Jun 2026'); + expect(getBatchWindowForTimestamp(fullyExecutedAt).key).toBe(overnight!.key); + }); +}); + +describe('batch-window board windows (midnight-based 3h slots)', () => { + it('maps 04:00 EAT to the 03:00–06:00 slot', () => { + // 01:00 UTC = 04:00 EAT on 11 Jun + const w = boardWindowForTimestamp(new Date('2026-06-11T01:00:00.000Z')); + expect(w.label).toContain('03:00'); + expect(w.label).toContain('06:00'); + expect(w.date).toBe('2026-06-11'); + expect(w.dateLabel).toContain('11 Jun'); + }); + + it('maps 00:30 EAT to the 00:00–03:00 slot of that EAT day', () => { + // 21:30 UTC on 10 Jun = 00:30 EAT on 11 Jun + const w = boardWindowForTimestamp(new Date('2026-06-10T21:30:00.000Z')); + expect(w.label).toContain('00:00'); + expect(w.label).toContain('03:00'); + expect(w.date).toBe('2026-06-11'); + }); + + it('maps 23:00 EAT to the final 21:00–24:00 slot', () => { + // 20:00 UTC = 23:00 EAT on 11 Jun + const w = boardWindowForTimestamp(new Date('2026-06-11T20:00:00.000Z')); + expect(w.label).toContain('21:00'); + expect(w.label).toContain('24:00'); + expect(w.date).toBe('2026-06-11'); + }); + + it('lists a continuous range open→departure clamped at both ends', () => { + // open 05 Jun 08:00 EAT (05:00 UTC) → departs 08 Jun 14:00 EAT (11:00 UTC) + const open = new Date('2026-06-05T05:00:00.000Z'); + const departure = new Date('2026-06-08T11:00:00.000Z'); + const windows = listBoardWindowsForRange(open, departure); + + // Day 5: 06,09,12,15,18,21 = 6 ; Days 6,7: 8 each ; Day 8: 00,03,06,09,12 = 5 + expect(windows).toHaveLength(6 + 8 + 8 + 5); + expect(windows[0].date).toBe('2026-06-05'); + expect(windows[0].label).toContain('06:00'); + expect(windows[0].label).toContain('09:00'); + const last = windows[windows.length - 1]; + expect(last.date).toBe('2026-06-08'); + expect(last.label).toContain('12:00'); + expect(last.label).toContain('15:00'); + // chronological + unique keys + const keys = windows.map((w) => w.key); + expect(new Set(keys).size).toBe(keys.length); + }); + + it('handles a same-day open→departure range', () => { + const open = new Date('2026-06-05T05:00:00.000Z'); // 08:00 EAT (06–09 slot) + const departure = new Date('2026-06-05T11:00:00.000Z'); // 14:00 EAT (12–15 slot) + const windows = listBoardWindowsForRange(open, departure); + // 06,09,12 = 3 slots + expect(windows).toHaveLength(3); + expect(windows.every((w) => w.date === '2026-06-05')).toBe(true); + }); + + it('buckets bookings by fullyExecutedAt and keeps empty + pending windows', () => { + const open = new Date('2026-06-05T05:00:00.000Z'); + const departure = new Date('2026-06-06T11:00:00.000Z'); + const items = [ + { id: 'a', ts: new Date('2026-06-05T05:30:00.000Z') }, // 08:30 EAT → 06–09 on 5th + { id: 'b', ts: null }, // pending + ]; + const map = groupBookingsIntoBoardWindows( + items, + (i) => i.ts, + open, + departure, + 'pending-contract', + ); + const pending = map.get('pending-contract'); + expect(pending?.items.map((i) => i.id)).toEqual(['b']); + const withA = [...map.values()].find((b) => b.items.some((i) => i.id === 'a')); + expect(withA?.window?.date).toBe('2026-06-05'); + // empty slots are retained for the UI + const emptyCount = [...map.values()].filter( + (b) => b.window && b.items.length === 0, + ).length; + expect(emptyCount).toBeGreaterThan(0); + }); +}); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.ts new file mode 100644 index 000000000..7316e1610 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.ts @@ -0,0 +1,350 @@ +import { BATCH_TIMEZONE } from './booking-batch.constants'; + +/** EAT intake boundaries — cron runs at these hours; each window spans to the next. */ +export const BATCH_WINDOW_START_HOURS = [7, 10, 13, 16, 19, 22] as const; + +export interface BatchWindow { + key: string; + label: string; + start: Date; + end: Date; +} + +type EatDateParts = { + year: number; + month: number; + day: number; + hour: number; + minute: number; +}; + +const dateFmt = new Intl.DateTimeFormat('en-GB', { + day: '2-digit', + month: 'short', + year: 'numeric', + timeZone: BATCH_TIMEZONE, +}); + +const timeFmt = new Intl.DateTimeFormat('en-GB', { + hour: '2-digit', + minute: '2-digit', + hour12: false, + timeZone: BATCH_TIMEZONE, +}); + +function eatParts(date: Date): EatDateParts { + const parts = new Intl.DateTimeFormat('en-US', { + timeZone: BATCH_TIMEZONE, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + hour12: false, + }).formatToParts(date); + + const get = (type: Intl.DateTimeFormatPartTypes) => + Number(parts.find((p) => p.type === type)?.value ?? 0); + + return { + year: get('year'), + month: get('month'), + day: get('day'), + hour: get('hour'), + minute: get('minute'), + }; +} + +/** + * The EAT calendar day a timestamp falls on, as `yyyy-MM-dd`. This is the day + * key for day-level booking pools — it must match the day the portal calendar + * renders, so always derive day keys through this (never `toISOString().slice`). + */ +export function eatDay(date: Date): string { + const { year, month, day } = eatParts(date); + return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`; +} + +/** Build a UTC Date for a given EAT local wall-clock time on a calendar day. */ +function eatToUtc( + year: number, + month: number, + day: number, + hour: number, + minute = 0, +): Date { + // EAT is UTC+3 year-round (no DST). Binary search would be safer across DST zones; + // for Africa/Addis_Ababa the offset is fixed. + const utcMs = Date.UTC(year, month - 1, day, hour - 3, minute, 0, 0); + return new Date(utcMs); +} + +function formatWindowLabel(start: Date, end: Date, endHourLabel?: string): string { + const endTime = endHourLabel ?? timeFmt.format(new Date(end.getTime() - 60_000)); + return `${dateFmt.format(start)} · ${timeFmt.format(start)} – ${endTime} EAT`; +} + +function windowFromEatStart( + year: number, + month: number, + day: number, + startHour: number, +): BatchWindow { + const start = eatToUtc(year, month, day, startHour); + let endYear = year; + let endMonth = month; + let endDay = day; + let endHour: number; + let endHourLabel: string; + + const idx = BATCH_WINDOW_START_HOURS.indexOf(startHour as (typeof BATCH_WINDOW_START_HOURS)[number]); + if (idx === BATCH_WINDOW_START_HOURS.length - 1) { + endHour = 7; + endHourLabel = '07:00'; + const next = new Date(eatToUtc(year, month, day, 0)); + next.setUTCDate(next.getUTCDate() + 1); + const nextParts = eatParts(next); + endYear = nextParts.year; + endMonth = nextParts.month; + endDay = nextParts.day; + } else { + endHour = BATCH_WINDOW_START_HOURS[idx + 1]; + endHourLabel = `${String(endHour).padStart(2, '0')}:00`; + } + + const end = eatToUtc(endYear, endMonth, endDay, endHour); + return { + key: start.toISOString(), + start, + end, + label: formatWindowLabel(start, end, endHourLabel), + }; +} + +/** Which 3h EAT intake window a timestamp (e.g. fullyExecutedAt) belongs to. */ +export function getBatchWindowForTimestamp(date: Date): BatchWindow { + const { year, month, day, hour } = eatParts(date); + + if (hour < 7) { + const prev = new Date(eatToUtc(year, month, day, 0)); + prev.setUTCDate(prev.getUTCDate() - 1); + const prevParts = eatParts(prev); + return windowFromEatStart(prevParts.year, prevParts.month, prevParts.day, 22); + } + + let startHour: (typeof BATCH_WINDOW_START_HOURS)[number] = 7; + for (const h of BATCH_WINDOW_START_HOURS) { + if (hour >= h) startHour = h; + } + + return windowFromEatStart(year, month, day, startHour); +} + +/** All six intake windows for an EAT calendar day (includes overnight 22:00–07:00). */ +export function listBatchWindowsForDate(reference: Date): BatchWindow[] { + const { year, month, day } = eatParts(reference); + return BATCH_WINDOW_START_HOURS.map((startHour) => + windowFromEatStart(year, month, day, startHour), + ); +} + +export function compareBatchWindows(a: BatchWindow, b: BatchWindow): number { + return a.start.getTime() - b.start.getTime(); +} + +/** Schedule-day windows plus any extra windows that contain booking timestamps (cross-day). */ +export function listBatchWindowsForBookings( + timestamps: Array, + referenceDate: Date, +): BatchWindow[] { + const byKey = new Map(); + for (const w of listBatchWindowsForDate(referenceDate)) { + byKey.set(w.key, w); + } + for (const ts of timestamps) { + if (!ts) continue; + const w = getBatchWindowForTimestamp(ts); + byKey.set(w.key, w); + } + return [...byKey.values()].sort(compareBatchWindows); +} + +// --------------------------------------------------------------------------- +// Board-display windows: full-day, midnight-based 3h slots over a date range. +// These are used ONLY for the batch-board UI grouping (not persisted, and +// independent of the cron intake hours above). +// --------------------------------------------------------------------------- + +/** Midnight-based 3-hour slot starts (00–03, 03–06, … 21–24). */ +export const BOARD_WINDOW_HOURS = [0, 3, 6, 9, 12, 15, 18, 21] as const; + +/** A board window carries an EAT calendar date in addition to the slot times. */ +export interface BoardWindow extends BatchWindow { + /** EAT calendar day as ISO `YYYY-MM-DD`. */ + date: string; + /** Human label for the day, e.g. `Thu, 05 Jun`. */ + dateLabel: string; +} + +const dayLabelFmt = new Intl.DateTimeFormat('en-GB', { + weekday: 'short', + day: '2-digit', + month: 'short', + timeZone: BATCH_TIMEZONE, +}); + +function pad2(n: number): string { + return String(n).padStart(2, '0'); +} + +/** Build a midnight-based 3h board window for an EAT calendar day + slot start hour. */ +function boardWindowFromEatStart( + year: number, + month: number, + day: number, + startHour: number, +): BoardWindow { + const start = eatToUtc(year, month, day, startHour); + const endHour = startHour + 3; // 21 -> 24 (handled by Date.UTC roll-over) + const end = eatToUtc(year, month, day, endHour); + const endLabel = endHour >= 24 ? '24:00' : `${pad2(endHour)}:00`; + return { + key: start.toISOString(), + start, + end, + label: formatWindowLabel(start, end, endLabel), + date: `${year}-${pad2(month)}-${pad2(day)}`, + dateLabel: dayLabelFmt.format(start), + }; +} + +/** Which midnight-based 3h EAT slot a timestamp falls in. */ +export function boardWindowForTimestamp(date: Date): BoardWindow { + const { year, month, day, hour } = eatParts(date); + let startHour: (typeof BOARD_WINDOW_HOURS)[number] = 0; + for (const h of BOARD_WINDOW_HOURS) { + if (hour >= h) startHour = h; + } + return boardWindowFromEatStart(year, month, day, startHour); +} + +/** + * Continuous list of board windows from `openDate` to `departureDate` (inclusive), + * clamped to the slot containing `openDate` on the first day and the slot + * containing `departureDate` on the last day. Returned in chronological order. + */ +export function listBoardWindowsForRange( + openDate: Date, + departureDate: Date, +): BoardWindow[] { + const startWin = boardWindowForTimestamp(openDate); + const endWin = boardWindowForTimestamp(departureDate); + // Guard against an inverted range (departure before open). + if (endWin.start.getTime() < startWin.start.getTime()) { + return [startWin]; + } + + const windows: BoardWindow[] = []; + const seen = new Set(); + // Walk day-by-day in EAT, emitting each day's slots, stepping via UTC noon to + // avoid any boundary ambiguity, then filter to [startWin.start, endWin.start]. + let cursor = new Date(eatToUtc( + Number(startWin.date.slice(0, 4)), + Number(startWin.date.slice(5, 7)), + Number(startWin.date.slice(8, 10)), + 12, + )); + const lastDayMs = eatToUtc( + Number(endWin.date.slice(0, 4)), + Number(endWin.date.slice(5, 7)), + Number(endWin.date.slice(8, 10)), + 12, + ).getTime(); + + while (cursor.getTime() <= lastDayMs) { + const { year, month, day } = eatParts(cursor); + for (const h of BOARD_WINDOW_HOURS) { + const w = boardWindowFromEatStart(year, month, day, h); + if ( + w.start.getTime() >= startWin.start.getTime() && + w.start.getTime() <= endWin.start.getTime() && + !seen.has(w.key) + ) { + seen.add(w.key); + windows.push(w); + } + } + cursor = new Date(cursor.getTime() + 24 * 60 * 60 * 1000); + } + + windows.sort(compareBatchWindows); + return windows; +} + +/** + * Group items into board windows spanning [openDate, departureDate]. Empty + * windows are kept so the UI shows every slot. Items whose timestamp falls + * outside the range still get their own window (nothing hidden). Items without + * a timestamp go to `pendingKey`. + */ +export function groupBookingsIntoBoardWindows( + items: T[], + getTimestamp: (item: T) => Date | null | undefined, + openDate: Date, + departureDate: Date, + pendingKey = 'pending-contract', +): Map { + const map = new Map(); + + for (const w of listBoardWindowsForRange(openDate, departureDate)) { + map.set(w.key, { window: w, items: [] }); + } + map.set(pendingKey, { window: null, items: [] }); + + for (const item of items) { + const ts = getTimestamp(item); + if (!ts) { + map.get(pendingKey)!.items.push(item); + continue; + } + const w = boardWindowForTimestamp(ts); + if (!map.has(w.key)) { + map.set(w.key, { window: w, items: [] }); + } + map.get(w.key)!.items.push(item); + } + + return map; +} + +/** Group items by batch window key; items without a timestamp go to `pendingKey`. */ +export function groupByBatchWindow( + items: T[], + getTimestamp: (item: T) => Date | null | undefined, + referenceDate: Date, + pendingKey = 'pending-contract', +): Map { + const timestamps = items.map(getTimestamp); + const windows = listBatchWindowsForBookings(timestamps, referenceDate); + const map = new Map(); + + for (const w of windows) { + map.set(w.key, { window: w, items: [] }); + } + map.set(pendingKey, { window: null, items: [] }); + + for (const item of items) { + const ts = getTimestamp(item); + if (!ts) { + map.get(pendingKey)!.items.push(item); + continue; + } + const w = getBatchWindowForTimestamp(ts); + if (!map.has(w.key)) { + map.set(w.key, { window: w, items: [] }); + } + map.get(w.key)!.items.push(item); + } + + return map; +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.constants.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.constants.ts new file mode 100644 index 000000000..eda168e03 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.constants.ts @@ -0,0 +1,31 @@ +/** + * Tunables for the demand-batching booking → allocation flow. + * Times run in EAT so the 07:00/10:00/… boundaries match the local operating clock. + */ + +/** Batch boundaries — every 3h from 07:00 (the 07:00–10:00 intake settles at 10:00, etc.). */ +// export const BATCH_CRON = '0 7,10,13,16,19,22 * * *'; +// export const BATCH_CRON = '*/3 * * * *'; +export const BATCH_CRON = '*/5 * * * *'; + +export const BATCH_TIMEZONE = 'Africa/Addis_Ababa'; + +/** How long a selected commercial customer has to pay before their slot expires. */ +// export const PAYMENT_WINDOW_MS = 60 * 60 * 1000; // 1 hour +export const PAYMENT_WINDOW_MS = 5 * 60 * 1000; // 5 minutes (test mode) + +/** Fallback wagons-per-booking when a booking has no computed `wagonsRequired`. */ +export const DEFAULT_WAGONS_PER_BOOKING = 1; + +/** + * Fallback per-wagon length (m) for the batch length budget when global rules don't yet + * define maxTrainLength / maxWagons to derive it from. Used only to estimate train length + * against the locomotive's max train length. + */ +export const DEFAULT_WAGON_LENGTH_METERS = 14; + +/** Default NW5 flat wagon length for container bookings (m). */ +export const DEFAULT_CONTAINER_WAGON_LENGTH_METERS = 14; + +/** Default CW3 covered wagon length for bulk bookings (m). */ +export const DEFAULT_BULK_WAGON_LENGTH_METERS = 14; diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts new file mode 100644 index 000000000..5cd06091a --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts @@ -0,0 +1,255 @@ +import { BookingBatchService } from './booking-batch.service'; +import { Booking } from '../bookings/entities/booking.entity'; + +describe('BookingBatchService — PAID reconcile', () => { + const scheduleId = 'schedule-1'; + const bookingId = 'booking-1'; + + const paidBooking = { + id: bookingId, + reference: 'BK-2026-000034', + trainScheduleId: scheduleId, + status: 'PAID', + paymentStatus: 'PAID', + isGovernment: false, + cargoTotalWeightVgm: 20, + bookingContainers: [], + } as unknown as Booking; + + let service: BookingBatchService; + let bookingsRepository: { + findPaidUnlinkedForSchedule: jest.Mock; + findBatchPool: jest.Mock; + findBatchPoolByRouteDay: jest.Mock; + findReservedForSchedule: jest.Mock; + update: jest.Mock; + }; + let trainScheduleBookingsRepository: { + existsForBooking: jest.Mock; + createMany: jest.Mock; + }; + let trainSchedulesRepository: { + findByIdWithFullGraph: jest.Mock; + findAll: jest.Mock; + }; + let trainSchedulingService: { + tryAutoWagonAllocation: jest.Mock; + getBookableSchedules: jest.Mock; + }; + let dataSource: { + getRepository: jest.Mock; + transaction: jest.Mock; + }; + let notifier: { + payNow: jest.Mock; + secured: jest.Mock; + expired: jest.Mock; + unplaced: jest.Mock; + }; + + beforeEach(() => { + bookingsRepository = { + findPaidUnlinkedForSchedule: jest.fn().mockResolvedValue([]), + findBatchPool: jest.fn().mockResolvedValue([]), + findBatchPoolByRouteDay: jest.fn().mockResolvedValue([]), + findReservedForSchedule: jest.fn().mockResolvedValue([]), + update: jest.fn().mockResolvedValue(undefined), + }; + trainScheduleBookingsRepository = { + existsForBooking: jest.fn().mockResolvedValue(false), + createMany: jest.fn().mockResolvedValue(undefined), + }; + trainSchedulesRepository = { + findByIdWithFullGraph: jest.fn().mockResolvedValue({ + id: scheduleId, + maxWagons: 10, + bookingWindowStatus: 'OPEN', + trainSet: { locomotive: { maxPullWeightTons: 3500, maxTrainLengthMeters: 760 } }, + scheduleBookings: [], + }), + findAll: jest.fn().mockResolvedValue([]), + }; + trainSchedulingService = { + tryAutoWagonAllocation: jest.fn().mockResolvedValue({ + assignedBookingIds: [], + deferred: [], + issues: [], + violations: [], + }), + getBookableSchedules: jest.fn().mockResolvedValue([]), + }; + + const bookingRepo = { + findOne: jest.fn().mockResolvedValue(paidBooking), + update: jest.fn().mockResolvedValue(undefined), + // WagonType.find() / global-rules find() fall back to defaults when empty. + find: jest.fn().mockResolvedValue([]), + }; + dataSource = { + getRepository: jest.fn().mockReturnValue(bookingRepo), + transaction: jest.fn(async (fn: (m: unknown) => Promise) => { + const manager = { + getRepository: () => bookingRepo, + }; + await fn(manager); + }), + }; + + notifier = { + payNow: jest.fn(), + secured: jest.fn(), + expired: jest.fn(), + unplaced: jest.fn(), + }; + + service = new BookingBatchService( + dataSource as never, + bookingsRepository as never, + trainSchedulesRepository as never, + trainScheduleBookingsRepository as never, + notifier as never, + { addTimeout: jest.fn(), deleteTimeout: jest.fn(), doesExist: jest.fn() } as never, + trainSchedulingService as never, + ); + }); + + it('reconcilePaidUnlinked links PAID bookings without a schedule row', async () => { + bookingsRepository.findPaidUnlinkedForSchedule.mockResolvedValue([paidBooking]); + + await service.reconcilePaidUnlinked(scheduleId); + + expect(bookingsRepository.findPaidUnlinkedForSchedule).toHaveBeenCalledWith(scheduleId); + expect(trainScheduleBookingsRepository.createMany).toHaveBeenCalledWith( + [{ trainScheduleId: scheduleId, bookingId }], + expect.anything(), + ); + }); + + it('ensurePaidBookingAllocated links PAID booking when not yet linked', async () => { + await service.ensurePaidBookingAllocated(bookingId); + + expect(trainScheduleBookingsRepository.createMany).toHaveBeenCalledTimes(1); + expect(trainSchedulingService.tryAutoWagonAllocation).toHaveBeenCalledWith(scheduleId); + }); + + it('ensurePaidBookingAllocated is idempotent when already linked', async () => { + trainScheduleBookingsRepository.existsForBooking.mockResolvedValue(true); + + await service.ensurePaidBookingAllocated(bookingId); + await service.ensurePaidBookingAllocated(bookingId); + + expect(trainScheduleBookingsRepository.createMany).not.toHaveBeenCalled(); + expect(trainSchedulingService.tryAutoWagonAllocation).toHaveBeenCalledTimes(2); + }); + + it('processSchedule reconciles PAID-unlinked before wagon allocation', async () => { + const fillSpy = jest.spyOn(service, 'fillSchedule').mockResolvedValue(undefined); + const settleSpy = jest.spyOn(service, 'settleDueReservations').mockResolvedValue(undefined); + const reconcileSpy = jest.spyOn(service, 'reconcilePaidUnlinked').mockResolvedValue(undefined); + + await service.processSchedule(scheduleId); + + expect(fillSpy).toHaveBeenCalledWith(scheduleId); + expect(settleSpy).toHaveBeenCalledWith(scheduleId); + expect(reconcileSpy).toHaveBeenCalledWith(scheduleId); + expect(trainSchedulingService.tryAutoWagonAllocation).toHaveBeenCalledWith(scheduleId); + + const fillOrder = fillSpy.mock.invocationCallOrder[0]; + const reconcileOrder = reconcileSpy.mock.invocationCallOrder[0]; + const wagonOrder = trainSchedulingService.tryAutoWagonAllocation.mock.invocationCallOrder[0]; + expect(fillOrder).toBeLessThan(reconcileOrder); + expect(reconcileOrder).toBeLessThan(wagonOrder); + }); + + describe('fillRouteDay — day-level distribution', () => { + const originYardId = 'yard-origin'; + const destinationYardId = 'yard-dest'; + const day = '2026-06-20'; + // 06:00Z and 09:00Z on 2026-06-20 both land on the same EAT day. + const trainA = 'train-a'; + const trainB = 'train-b'; + + // A tiny locomotive: default wagon = 14m / 70t → exactly 1 wagon slot fits. + const smallLoco = { maxPullWeightTons: 70, maxTrainLengthMeters: 14 }; + + const commercial = (id: string, priority: number): Booking => + ({ + id, + reference: id, + isGovernment: false, + priorityScore: priority, + status: 'FULLY_EXECUTED', + wagonsRequired: 1, + cargoTotalWeightVgm: 10, + freightType: 'CONTAINER', + bookingContainers: [], + }) as unknown as Booking; + + beforeEach(() => { + // Two OPEN trains on the same route + day, train A earlier than train B. + trainSchedulingService.getBookableSchedules.mockResolvedValue([ + { + id: trainA, + scheduleDate: '2026-06-20T06:00:00.000Z', + bookingWindowStatus: 'OPEN', + }, + { + id: trainB, + scheduleDate: '2026-06-20T09:00:00.000Z', + bookingWindowStatus: 'OPEN', + }, + ]); + trainSchedulesRepository.findByIdWithFullGraph.mockImplementation((id: string) => + Promise.resolve({ + id, + maxWagons: 1, + bookingWindowStatus: 'OPEN', + trainSetId: `set-${id}`, + trainSet: { locomotive: smallLoco }, + scheduleBookings: [], + }), + ); + }); + + it('spills overflow to the next train by priority, then reports unplaced', async () => { + // 3 commercial bookings, descending priority; only 1 fits per train (2 total). + bookingsRepository.findBatchPoolByRouteDay.mockResolvedValue([ + commercial('hi', 30), + commercial('mid', 20), + commercial('lo', 10), + ]); + + const touched = await service.fillRouteDay(originYardId, destinationYardId, day); + + expect(bookingsRepository.findBatchPoolByRouteDay).toHaveBeenCalledWith( + originYardId, + destinationYardId, + day, + ); + // Both trains were processed. + expect(touched).toEqual([trainA, trainB]); + // Highest priority reserved on train A, next on train B (commercial → reserve). + const reservedOn = notifier.payNow.mock.calls.map((c) => (c[0] as Booking).id); + expect(reservedOn).toEqual(['hi', 'mid']); + // The third booking fits no train and is reported unplaced (and only it). + expect(notifier.unplaced).toHaveBeenCalledTimes(1); + expect((notifier.unplaced.mock.calls[0][0] as Booking).id).toBe('lo'); + expect(notifier.unplaced.mock.calls[0][1]).toBe(day); + }); + + it('reserves the chosen train id on each commercial booking', async () => { + bookingsRepository.findBatchPoolByRouteDay.mockResolvedValue([commercial('hi', 30)]); + + await service.fillRouteDay(originYardId, destinationYardId, day); + + // reserve() persists trainScheduleId so the settle lifecycle can find the train. + expect(bookingsRepository.update).toHaveBeenCalledWith( + 'hi', + expect.objectContaining({ + trainScheduleId: trainA, + status: 'SELECTED_FOR_BATCH', + }), + ); + }); + }); +}); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts new file mode 100644 index 000000000..514f8572d --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts @@ -0,0 +1,1232 @@ +import { + BadRequestException, + Injectable, + Logger, + NotFoundException, + OnModuleInit, +} from '@nestjs/common'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { Cron, SchedulerRegistry } from '@nestjs/schedule'; +import { DataSource } from 'typeorm'; + +import { Booking } from '../bookings/entities/booking.entity'; +import { BookingsRepository } from '../bookings/bookings.repository'; +import { Locomotive } from '../locomotives/entities/locomotive.entity'; +import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; +import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity'; +import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository'; +import { TrainScheduleBookingsRepository } from '../train-schedules/train-schedule-bookings.repository'; +import { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-rules.entity'; +import { BookingNotifierService } from './booking-notifier.service'; +import { TrainSchedulingService } from './train-scheduling.service'; +import { eatDay, groupBookingsIntoBoardWindows } from './batch-window.util'; +import { + BATCH_CRON, + BATCH_TIMEZONE, + DEFAULT_BULK_WAGON_LENGTH_METERS, + DEFAULT_CONTAINER_WAGON_LENGTH_METERS, + DEFAULT_WAGONS_PER_BOOKING, + PAYMENT_WINDOW_MS, +} from './booking-batch.constants'; +import { + bookingTrainLengthMeters, + deriveTrainCapacityFromLocomotive, + wagonTypeDimensionsFromEntity, +} from './train-capacity.util'; +import { WagonType } from '../wagon-types/entities/wagon-type.entity'; + +/** A train's remaining capacity along the three physical limits the batch enforces. */ +interface Capacity { + wagons: number; + weightTons: number; + lengthMeters: number; +} + +/** A day-level pool key: all trains on this route departing on this EAT day. */ +interface RouteDayGroup { + originYardId: string; + destinationYardId: string; + /** EAT calendar day, `yyyy-MM-dd`. */ + day: string; +} + +type WagonLengths = { container: number; bulk: number }; + +export type BatchBoardBookingState = + | 'ALLOCATED' + | 'SELECTED_FOR_BATCH' + | 'READY' + | 'WAITING' + | 'PENDING_CONTRACT' + | 'EXPIRED'; + +export interface BatchBoardBooking { + id: string; + reference: string; + company: string; + isGovernment: boolean; + wagons: number; + weightTons: number; + lengthMeters: number; + paymentDeadline: string | null; + state: BatchBoardBookingState; +} + +export type BookingAllocationStatus = + | 'NOT_ATTEMPTED' + | 'ASSIGNED' + | 'DEFERRED' + | 'FAILED'; + +export interface BatchBoardBookingDetail extends BatchBoardBooking { + fullyExecutedAt: string | null; + selectedForBatchAt: string | null; + allocationStatus: BookingAllocationStatus; + allocationIssue: string | null; +} + +export interface BatchWindowGroup { + key: string; + label: string; + /** EAT calendar day as ISO `YYYY-MM-DD` (empty for the pending-contract bucket). */ + date: string; + /** Human label for the day, e.g. `Thu, 05 Jun` (empty for pending-contract). */ + dateLabel: string; + start: string; + end: string; + counts: { + allocated: number; + selectedForBatch: number; + ready: number; + waiting: number; + expired: number; + pendingContract: number; + }; + bookings: BatchBoardBookingDetail[]; +} + +export interface BatchBoardScheduleDetail { + scheduleId: string; + trainNumber: string | null; + routeName: string | null; + origin: string | null; + destination: string | null; + scheduleDate: string | null; + status: string; + bookingWindowStatus: string; + locomotive: BatchBoardSchedule['locomotive']; + capacity: BatchBoardSchedule['capacity']; + counts: BatchBoardSchedule['counts']; + windows: BatchWindowGroup[]; + pendingContract: BatchWindowGroup; + allocationViolations: string[]; +} + +export interface BatchBoardSchedule { + scheduleId: string; + trainNumber: string | null; + routeName: string | null; + origin: string | null; + destination: string | null; + scheduleDate: string | null; + status: string; + bookingWindowStatus: string; + locomotive: { + code: string; + name: string | null; + maxPullWeightTons: number; + maxTrainLengthMeters: number; + } | null; + capacity: { + /** Wagons on bookings already linked to the train (ALLOCATED only). */ + allocatedWagons: number; + /** Train length used by allocated bookings (from wagon-type dimensions). */ + allocatedLengthMeters: number; + maxLengthMeters: number | null; + /** Weight committed on the train (allocated + selected-for-batch). */ + usedWeightTons: number; + maxWeightTons: number | null; + }; + counts: { + allocated: number; + selectedForBatch: number; + ready: number; + waiting: number; + pendingContract: number; + expired: number; + }; + bookings: BatchBoardBooking[]; +} + +/** + * Demand-batching engine: every 3h (EAT) it ranks each OPEN schedule's ready pool + * by priority, greedily fills the train to capacity (skipping bookings that don't fit), + * reserves a 1h pay window for commercial customers (government allocated unpaid, + * preempting lower-priority commercial if needed), then settles each batch 1h later — + * allocating those who paid and expiring those who didn't, topping up from the waiting list. + * Capacity is bounded on three axes at once: wagon count (`schedule.maxWagons`), the + * locomotive's max pull weight, and its max train length (also capped by global rules). + */ +@Injectable() +export class BookingBatchService implements OnModuleInit { + private readonly logger = new Logger(BookingBatchService.name); + + constructor( + @InjectDataSource() private readonly dataSource: DataSource, + private readonly bookingsRepository: BookingsRepository, + private readonly trainSchedulesRepository: TrainSchedulesRepository, + private readonly trainScheduleBookingsRepository: TrainScheduleBookingsRepository, + private readonly notifier: BookingNotifierService, + private readonly scheduler: SchedulerRegistry, + private readonly trainSchedulingService: TrainSchedulingService, + ) {} + + /** On boot, reconcile OPEN route-days and re-arm settle timers. */ + async onModuleInit(): Promise { + const groups = await this.openRouteDayGroups(); + for (const group of groups) { + try { + await this.processRouteDay(group); + } catch (err) { + this.logger.warn( + `Boot reconcile failed for ${this.groupLabel(group)}: ${(err as Error).message}`, + ); + } + } + const reserved = await this.dataSource + .getRepository(Booking) + .createQueryBuilder('b') + .select('DISTINCT b.train_schedule_id', 'scheduleId') + .where(`b.status IN ('SELECTED_FOR_BATCH', 'AWAITING_PAYMENT')`) + .andWhere('b.train_schedule_id IS NOT NULL') + .getRawMany<{ scheduleId: string }>(); + for (const { scheduleId } of reserved) this.armSettle(scheduleId); + } + + /** + * Fire-and-forget batch pipeline for the (route, day) a schedule belongs to + * (contract sign, payment). Day-level pooling distributes across all of that + * day's trains, so a single schedule id maps to its whole route-day group. + */ + enqueueScheduleProcessing(scheduleId: string): void { + void this.processRouteDayForSchedule(scheduleId).catch((err) => + this.logger.error( + `processRouteDay for schedule ${scheduleId} failed: ${(err as Error).message}`, + ), + ); + } + + /** Resolve a schedule's (route, day) group and run the day-level pipeline. */ + private async processRouteDayForSchedule(scheduleId: string): Promise { + const schedule = await this.trainSchedulesRepository.findById(scheduleId); + if (!schedule?.scheduledDepartureDate) return; + await this.processRouteDay({ + originYardId: schedule.originStationId, + destinationYardId: schedule.destinationStationId, + day: eatDay(schedule.scheduledDepartureDate), + }); + } + + /** + * Day-level pipeline: distribute the (route, day) pool across all its trains, + * then settle / reconcile / assign wagons per schedule (those steps stay + * schedule-scoped — only the fill is day-level). + */ + async processRouteDay(group: RouteDayGroup): Promise { + const scheduleIds = await this.fillRouteDay( + group.originYardId, + group.destinationYardId, + group.day, + ); + for (const scheduleId of scheduleIds) { + await this.settleDueReservations(scheduleId); + await this.reconcilePaidUnlinked(scheduleId); + await this.trainSchedulingService.tryAutoWagonAllocation(scheduleId); + } + } + + /** Fill pool, settle due reservations, link orphaned PAID, then assign wagons. */ + async processSchedule(scheduleId: string): Promise { + await this.fillSchedule(scheduleId); + await this.settleDueReservations(scheduleId); + await this.reconcilePaidUnlinked(scheduleId); + await this.trainSchedulingService.tryAutoWagonAllocation(scheduleId); + } + + /** Distinct (origin, destination, EAT day) groups across all OPEN schedules. */ + private async openRouteDayGroups(): Promise { + const open = await this.trainSchedulesRepository.findAll({ + where: { bookingWindowStatus: 'OPEN' }, + }); + const groups = new Map(); + for (const s of open) { + if (!s.scheduledDepartureDate) continue; + const day = eatDay(s.scheduledDepartureDate); + const key = `${s.originStationId}|${s.destinationStationId}|${day}`; + if (!groups.has(key)) { + groups.set(key, { + originYardId: s.originStationId, + destinationYardId: s.destinationStationId, + day, + }); + } + } + return [...groups.values()]; + } + + private groupLabel(group: RouteDayGroup): string { + return `${group.originYardId}→${group.destinationYardId} on ${group.day}`; + } + + /** + * Idempotent: link a paid batch booking to its schedule and assign wagons. + * Handles SELECTED_FOR_BATCH, PAID-without-link, and PAID-already-linked cases. + */ + async ensurePaidBookingAllocated(bookingId: string): Promise { + const booking = await this.dataSource.getRepository(Booking).findOne({ + where: { id: bookingId }, + relations: { company: true }, + }); + if (!booking?.trainScheduleId) return; + + const isBatchPaid = + booking.status === 'SELECTED_FOR_BATCH' || + booking.status === 'AWAITING_PAYMENT' || + booking.status === 'PAID' || + booking.paymentStatus === 'PAID'; + if (!isBatchPaid) return; + + if (booking.status === 'SELECTED_FOR_BATCH' || booking.status === 'AWAITING_PAYMENT') { + await this.dataSource + .getRepository(Booking) + .update(bookingId, { paymentStatus: 'PAID', status: 'PAID' }); + } else if (booking.paymentStatus !== 'PAID') { + await this.dataSource + .getRepository(Booking) + .update(bookingId, { paymentStatus: 'PAID' }); + } + + const linked = await this.trainScheduleBookingsRepository.existsForBooking(bookingId); + if (!linked) { + await this.allocate(booking.trainScheduleId, booking, 'paid'); + this.logger.log( + `Linked PAID booking ${booking.reference ?? bookingId} to schedule ${booking.trainScheduleId}`, + ); + } + + const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph( + booking.trainScheduleId, + ); + if (schedule && (await this.remainingWagons(schedule)) <= 0) { + await this.setWindow(booking.trainScheduleId, 'FULL'); + } + + const result = await this.trainSchedulingService.tryAutoWagonAllocation( + booking.trainScheduleId, + ); + if (result.assignedBookingIds.length) { + this.logger.log( + `Wagon allocation for ${booking.reference ?? bookingId}: ${result.assignedBookingIds.length} assigned`, + ); + } + if (result.issues.some((i) => i.bookingId === bookingId && i.status !== 'ASSIGNED')) { + const issue = result.issues.find((i) => i.bookingId === bookingId); + this.logger.warn( + `Wagon allocation issue for ${booking.reference ?? bookingId}: ${issue?.issue ?? issue?.status}`, + ); + } + } + + /** Customer paid — delegate to ensurePaidBookingAllocated. */ + async confirmPaidAndAllocate(bookingId: string): Promise { + await this.ensurePaidBookingAllocated(bookingId); + } + + /** Link PAID bookings that have no train_schedule_bookings row (cron backstop). */ + async reconcilePaidUnlinked(scheduleId: string): Promise { + const unlinked = await this.bookingsRepository.findPaidUnlinkedForSchedule(scheduleId); + for (const booking of unlinked) { + await this.allocate(scheduleId, booking, 'paid'); + this.logger.log( + `Reconciled PAID booking ${booking.reference ?? booking.id} → schedule ${scheduleId}`, + ); + } + } + + // ---- cron entry point ----------------------------------------------------- + + @Cron(BATCH_CRON, { name: 'booking-batch-fill', timeZone: BATCH_TIMEZONE }) + async runBatchFill(): Promise { + const groups = await this.openRouteDayGroups(); + this.logger.log(`Batch fill: ${groups.length} OPEN route-day group(s).`); + for (const group of groups) { + try { + await this.processRouteDay(group); + } catch (err) { + this.logger.error( + `Batch fill failed for ${this.groupLabel(group)}: ${(err as Error).message}`, + ); + } + } + } + + // ---- monitoring board ----------------------------------------------------- + + /** + * Read model for the batch monitoring page: every still-relevant schedule (not arrived/ + * cancelled) with its locomotive, capacity usage and its bookings grouped by lifecycle + * state (allocated / awaiting payment / paid-waiting / pending contract / expired). + */ + async getBatchBoard(): Promise { + const schedules = await this.trainSchedulesRepository.findAll({ + relations: { + trainSet: { locomotive: true }, + originStation: true, + destinationStation: true, + route: true, + }, + order: { scheduledDepartureDate: 'ASC' }, + }); + + const wagonLengths = await this.loadWagonLengths(); + const linkRepo = this.dataSource.getRepository(TrainScheduleBooking); + + const board: BatchBoardSchedule[] = []; + for (const s of schedules) { + if (s.status === 'ARRIVED' || s.status === 'CANCELLED') continue; + + const links = await linkRepo.find({ where: { trainScheduleId: s.id } }); + const linkedIds = new Set(links.map((l) => l.bookingId)); + const bookings = await this.bookingsRepository.findAllBySchedule(s.id); + + const items: BatchBoardBooking[] = bookings.map((b) => { + const need = this.needFor(b, wagonLengths); + return { + id: b.id, + reference: b.reference ?? b.id.slice(0, 8), + company: b.isGovernment + ? (b.governmentInstitution ?? 'Government') + : (b.company?.name ?? '—'), + isGovernment: Boolean(b.isGovernment), + wagons: need.wagons, + weightTons: need.weightTons, + lengthMeters: need.lengthMeters, + paymentDeadline: b.paymentDeadline ? b.paymentDeadline.toISOString() : null, + state: this.boardState(b, linkedIds.has(b.id)), + }; + }); + + board.push(this.buildScheduleSummary(s, items)); + } + return board; + } + + /** Schedule-level batch board with EAT 3h windows grouped by fullyExecutedAt. */ + async getBatchBoardDetail(scheduleId: string): Promise { + const s = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); + if (!s) throw new NotFoundException(`Train schedule ${scheduleId} not found`); + if (s.status === 'ARRIVED' || s.status === 'CANCELLED') { + throw new BadRequestException('Schedule is no longer active'); + } + + const wagonLengths = await this.loadWagonLengths(); + const linkRepo = this.dataSource.getRepository(TrainScheduleBooking); + const links = await linkRepo.find({ where: { trainScheduleId: s.id } }); + const linkedIds = new Set(links.map((l) => l.bookingId)); + const bookings = await this.bookingsRepository.findAllBySchedule(s.id); + + let allocationPreview: Awaited< + ReturnType + >; + try { + allocationPreview = await this.trainSchedulingService.previewAllocationForSchedule(s.id); + } catch { + allocationPreview = { assignedBookingIds: [], deferred: [], issues: [], violations: [] }; + } + const allocationByBooking = new Map( + allocationPreview.issues.map((i) => [i.bookingId, i]), + ); + + const items: BatchBoardBookingDetail[] = bookings.map((b) => { + const need = this.needFor(b, wagonLengths); + const alloc = allocationByBooking.get(b.id); + return { + id: b.id, + reference: b.reference ?? b.id.slice(0, 8), + company: b.isGovernment + ? (b.governmentInstitution ?? 'Government') + : (b.company?.name ?? '—'), + isGovernment: Boolean(b.isGovernment), + wagons: need.wagons, + weightTons: need.weightTons, + lengthMeters: need.lengthMeters, + paymentDeadline: b.paymentDeadline ? b.paymentDeadline.toISOString() : null, + state: this.boardState(b, linkedIds.has(b.id)), + fullyExecutedAt: b.fullyExecutedAt ? b.fullyExecutedAt.toISOString() : null, + selectedForBatchAt: b.selectedForBatchAt ? b.selectedForBatchAt.toISOString() : null, + allocationStatus: alloc?.status ?? 'NOT_ATTEMPTED', + allocationIssue: alloc?.issue ?? null, + }; + }); + + const loco = s.trainSet?.locomotive ?? null; + + // Display windows span the whole booking window: from when it opened + // (schedule creation) through the scheduled departure, in 3-hour EAT slots. + const openDate = s.createdAt ?? s.scheduledDepartureDate ?? new Date(); + const departureDate = s.scheduledDepartureDate ?? new Date(); + const windowBuckets = groupBookingsIntoBoardWindows( + items, + (item) => (item.fullyExecutedAt ? new Date(item.fullyExecutedAt) : null), + openDate, + departureDate, + ); + + const emptyCounts = () => ({ + allocated: 0, + selectedForBatch: 0, + ready: 0, + waiting: 0, + expired: 0, + pendingContract: 0, + }); + + const countFor = (bookingsInWindow: BatchBoardBookingDetail[]) => { + const counts = emptyCounts(); + for (const b of bookingsInWindow) { + if (b.state === 'ALLOCATED') counts.allocated += 1; + else if (b.state === 'SELECTED_FOR_BATCH') counts.selectedForBatch += 1; + else if (b.state === 'READY') counts.ready += 1; + else if (b.state === 'WAITING') counts.waiting += 1; + else if (b.state === 'EXPIRED') counts.expired += 1; + else counts.pendingContract += 1; + } + return counts; + }; + + const windows: BatchWindowGroup[] = []; + for (const [key, bucket] of windowBuckets) { + if (key === 'pending-contract' || !bucket.window) continue; + const w = bucket.window; + windows.push({ + key: w.key, + label: w.label, + date: w.date, + dateLabel: w.dateLabel, + start: w.start.toISOString(), + end: w.end.toISOString(), + counts: countFor(bucket.items), + bookings: bucket.items, + }); + } + windows.sort((a, b) => new Date(a.start).getTime() - new Date(b.start).getTime()); + + const pendingBookings = windowBuckets.get('pending-contract')?.items ?? []; + + return { + scheduleId: s.id, + trainNumber: s.trainNumber ?? null, + routeName: s.route?.name ?? null, + origin: s.originStation?.label ?? s.originStation?.code ?? null, + destination: s.destinationStation?.label ?? s.destinationStation?.code ?? null, + scheduleDate: s.scheduledDepartureDate ? s.scheduledDepartureDate.toISOString() : null, + status: s.status, + bookingWindowStatus: s.bookingWindowStatus, + locomotive: loco + ? { + code: loco.code, + name: loco.name ?? null, + maxPullWeightTons: Number(loco.maxPullWeightTons), + maxTrainLengthMeters: Number(loco.maxTrainLengthMeters), + } + : null, + capacity: this.computeBoardCapacity(items, loco), + counts: { + allocated: items.filter((i) => i.state === 'ALLOCATED').length, + selectedForBatch: items.filter((i) => i.state === 'SELECTED_FOR_BATCH').length, + ready: items.filter((i) => i.state === 'READY').length, + waiting: items.filter((i) => i.state === 'WAITING').length, + pendingContract: items.filter((i) => i.state === 'PENDING_CONTRACT').length, + expired: items.filter((i) => i.state === 'EXPIRED').length, + }, + windows, + pendingContract: { + key: 'pending-contract', + label: 'Pending contract', + date: '', + dateLabel: '', + start: '', + end: '', + counts: countFor(pendingBookings), + bookings: pendingBookings, + }, + allocationViolations: allocationPreview.violations, + }; + } + + /** Run wagon-level allocation for all eligible linked bookings on a schedule. */ + async runWagonAllocation(scheduleId: string) { + return this.trainSchedulingService.tryAutoWagonAllocation(scheduleId); + } + + private computeBoardCapacity( + items: Array<{ + state: BatchBoardBookingState; + wagons: number; + weightTons: number; + lengthMeters: number; + }>, + loco: Locomotive | null, + ): BatchBoardSchedule['capacity'] { + const allocated = items.filter((i) => i.state === 'ALLOCATED'); + const committed = items.filter( + (i) => i.state === 'ALLOCATED' || i.state === 'SELECTED_FOR_BATCH', + ); + return { + allocatedWagons: allocated.reduce((sum, i) => sum + i.wagons, 0), + allocatedLengthMeters: + Math.round(allocated.reduce((sum, i) => sum + i.lengthMeters, 0) * 100) / 100, + maxLengthMeters: loco ? Number(loco.maxTrainLengthMeters) : null, + usedWeightTons: Math.round(committed.reduce((sum, i) => sum + i.weightTons, 0) * 100) / 100, + maxWeightTons: loco ? Number(loco.maxPullWeightTons) : null, + }; + } + + private buildScheduleSummary( + s: TrainSchedule, + items: BatchBoardBooking[], + ): BatchBoardSchedule { + const loco = s.trainSet?.locomotive ?? null; + + return { + scheduleId: s.id, + trainNumber: s.trainNumber ?? null, + routeName: s.route?.name ?? null, + origin: s.originStation?.label ?? s.originStation?.code ?? null, + destination: s.destinationStation?.label ?? s.destinationStation?.code ?? null, + scheduleDate: s.scheduledDepartureDate ? s.scheduledDepartureDate.toISOString() : null, + status: s.status, + bookingWindowStatus: s.bookingWindowStatus, + locomotive: loco + ? { + code: loco.code, + name: loco.name ?? null, + maxPullWeightTons: Number(loco.maxPullWeightTons), + maxTrainLengthMeters: Number(loco.maxTrainLengthMeters), + } + : null, + capacity: this.computeBoardCapacity(items, loco), + counts: { + allocated: items.filter((i) => i.state === 'ALLOCATED').length, + selectedForBatch: items.filter((i) => i.state === 'SELECTED_FOR_BATCH').length, + ready: items.filter((i) => i.state === 'READY').length, + waiting: items.filter((i) => i.state === 'WAITING').length, + pendingContract: items.filter((i) => i.state === 'PENDING_CONTRACT').length, + expired: items.filter((i) => i.state === 'EXPIRED').length, + }, + bookings: items.slice(0, 3), + }; + } + + private boardState(booking: Booking, linked: boolean): BatchBoardBookingState { + if (linked) return 'ALLOCATED'; + if (booking.status === 'SELECTED_FOR_BATCH' || booking.status === 'AWAITING_PAYMENT') { + return 'SELECTED_FOR_BATCH'; + } + if (booking.status === 'EXPIRED') return 'EXPIRED'; + if (booking.status === 'FULLY_EXECUTED' && booking.fullyExecutedAt) return 'READY'; + if (booking.status === 'PAID') return 'WAITING'; + return 'PENDING_CONTRACT'; + } + + // ---- core fill ------------------------------------------------------------ + + /** Fill one schedule from its priority-ordered pool until full. */ + async fillSchedule(scheduleId: string): Promise { + const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); + if (!schedule || schedule.bookingWindowStatus !== 'OPEN') return; + const locomotive = schedule.trainSet?.locomotive; + if (!schedule.trainSetId || !locomotive) { + this.logger.warn(`Schedule ${scheduleId} has no locomotive/train set — skipped.`); + return; + } + + const rules = await this.loadGlobalRules(); + const wagonLengths = await this.loadWagonLengths(); + const limits = await this.capacityLimits(locomotive, rules); + await this.syncScheduleMaxWagons(schedule, locomotive, rules); + let budget = await this.remainingCapacity(schedule, limits, wagonLengths); + if (budget.wagons <= 0) { + await this.setWindow(scheduleId, 'FULL'); + return; + } + + const pool = await this.bookingsRepository.findBatchPool(scheduleId); + let armed = false; + + for (const booking of pool) { + const need = this.needFor(booking, wagonLengths); + + if (!this.fits(need, budget)) { + if (booking.isGovernment) { + budget = await this.preemptForGovernment(scheduleId, need, budget, wagonLengths); + if (!this.fits(need, budget)) continue; // still doesn't fit even after preempt + } else { + continue; // skip a booking that exceeds weight/length/wagons, try the next + } + } + + if (booking.isGovernment) { + await this.allocate(scheduleId, booking, 'gov'); + } else { + await this.reserve(booking, scheduleId); + armed = true; + } + budget = this.subtract(budget, need); + if (budget.wagons <= 0) break; // no wagon slots left — nothing more can board + } + + if (budget.wagons <= 0) await this.setWindow(scheduleId, 'FULL'); + if (armed) this.armSettle(scheduleId); + void this.triggerWagonAllocation(scheduleId); + } + + /** + * Distribute one (route, day) pool across ALL of that day's OPEN trains, by + * priority, filling each train (earliest departure first) until it's full and + * spilling overflow to the next. Government bookings that fit no train preempt + * lower-priority commercial; bookings that fit no train at all stay pending and + * trigger a staff `unplaced` warning. Returns the schedule ids that were touched + * (or that had remaining pool work) so the caller can settle them per-schedule. + */ + async fillRouteDay( + originYardId: string, + destinationYardId: string, + day: string, + ): Promise { + // The day's OPEN bookable schedules on this exact corridor, earliest first. + const bookable = await this.trainSchedulingService.getBookableSchedules( + originYardId, + destinationYardId, + ); + const scheduleIds = bookable + .filter( + (s) => + s.bookingWindowStatus === 'OPEN' && + s.scheduleDate != null && + eatDay(new Date(s.scheduleDate)) === day, + ) + .sort( + (a, b) => + new Date(a.scheduleDate).getTime() - new Date(b.scheduleDate).getTime(), + ) + .map((s) => s.id); + + if (scheduleIds.length === 0) return []; + + const rules = await this.loadGlobalRules(); + const wagonLengths = await this.loadWagonLengths(); + + // Live per-schedule budget + arm flag, in departure order. + const trains: Array<{ id: string; budget: Capacity; armed: boolean }> = []; + for (const id of scheduleIds) { + const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(id); + const locomotive = schedule?.trainSet?.locomotive; + if (!schedule || !schedule.trainSetId || !locomotive) { + this.logger.warn(`Schedule ${id} has no locomotive/train set — skipped.`); + continue; + } + const limits = await this.capacityLimits(locomotive, rules); + await this.syncScheduleMaxWagons(schedule, locomotive, rules); + const budget = await this.remainingCapacity(schedule, limits, wagonLengths); + trains.push({ id, budget, armed: false }); + } + if (trains.length === 0) return []; + + const pool = await this.bookingsRepository.findBatchPoolByRouteDay( + originYardId, + destinationYardId, + day, + ); + + for (const booking of pool) { + const need = this.needFor(booking, wagonLengths); + + // First train (earliest departure) that fits this booking as-is. + let target = trains.find((t) => this.fits(need, t.budget)); + + if (!target && booking.isGovernment) { + // Government booking fits nowhere on its own — try to preempt commercial + // on each train (earliest first) until one frees enough room. + for (const t of trains) { + t.budget = await this.preemptForGovernment(t.id, need, t.budget, wagonLengths); + if (this.fits(need, t.budget)) { + target = t; + break; + } + } + } + + if (!target) { + // Fits no train this day — stays in the pool, retried next batch. + this.notifier.unplaced(booking, day); + continue; + } + + if (booking.isGovernment) { + await this.allocate(target.id, booking, 'gov'); + } else { + await this.reserve(booking, target.id); + target.armed = true; + } + target.budget = this.subtract(target.budget, need); + } + + for (const t of trains) { + if (t.budget.wagons <= 0) await this.setWindow(t.id, 'FULL'); + if (t.armed) this.armSettle(t.id); + void this.triggerWagonAllocation(t.id); + } + + return trains.map((t) => t.id); + } + + /** Durable settle: allocate paid / expire overdue reservations, then top up. */ + async settleDueReservations(scheduleId: string): Promise { + const reserved = await this.bookingsRepository.findReservedForSchedule(scheduleId); + const now = Date.now(); + let anySettled = false; + + for (const booking of reserved) { + const paid = booking.paymentStatus === 'PAID' || booking.status === 'PAID'; + const expired = booking.paymentDeadline + ? booking.paymentDeadline.getTime() <= now + : false; + + if (paid) { + await this.allocate(scheduleId, booking, 'paid'); + anySettled = true; + } else if (expired) { + await this.expire(booking); + anySettled = true; + } + } + + if (anySettled) await this.fillSchedule(scheduleId); + } + + // ---- settle (1h after a batch) ------------------------------------------- + + /** Allocate paid reservations, expire the rest, then top up. */ + async settleBatch(scheduleId: string): Promise { + this.removeTimeout(scheduleId); + const reserved = await this.bookingsRepository.findReservedForSchedule(scheduleId); + const now = Date.now(); + + for (const booking of reserved) { + const paid = booking.paymentStatus === 'PAID' || booking.status === 'PAID'; + const expired = booking.paymentDeadline + ? booking.paymentDeadline.getTime() <= now + : true; + + if (paid) { + await this.allocate(scheduleId, booking, 'paid'); + } else if (expired) { + await this.expire(booking); + } + // else: still within window (rare at settle) → leave for the re-armed timeout + } + + await this.fillSchedule(scheduleId); + void this.triggerWagonAllocation(scheduleId); + } + + private triggerWagonAllocation(scheduleId: string): void { + void this.trainSchedulingService.tryAutoWagonAllocation(scheduleId).catch((err) => + this.logger.warn( + `Auto wagon allocation failed for ${scheduleId}: ${(err as Error).message}`, + ), + ); + } + + // ---- staff override actions ---------------------------------------------- + + /** Staff "mark paid" override → set PAID and allocate immediately (don't wait for settle). */ + async markPaid(bookingId: string): Promise { + const booking = await this.dataSource + .getRepository(Booking) + .findOne({ where: { id: bookingId } }); + if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`); + if (!booking.trainScheduleId) { + throw new BadRequestException('Booking has no target schedule to allocate to'); + } + await this.dataSource + .getRepository(Booking) + .update(bookingId, { paymentStatus: 'PAID' }); + await this.allocate(booking.trainScheduleId, booking, 'paid'); + + const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph( + booking.trainScheduleId, + ); + if (schedule && (await this.remainingWagons(schedule)) <= 0) { + await this.setWindow(booking.trainScheduleId, 'FULL'); + } + void this.triggerWagonAllocation(booking.trainScheduleId!); + } + + /** + * Re-point a booking to another OPEN same-route schedule (keeps approval/contract + priority). + * Used for EXPIRED or full-schedule bookings — no re-approval. + */ + async moveToSchedule(bookingId: string, newScheduleId: string): Promise { + const booking = await this.dataSource + .getRepository(Booking) + .findOne({ where: { id: bookingId } }); + if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`); + + const schedule = await this.dataSource + .getRepository(TrainSchedule) + .findOne({ where: { id: newScheduleId } }); + if (!schedule) throw new NotFoundException(`Train schedule ${newScheduleId} not found`); + if (schedule.bookingWindowStatus !== 'OPEN') { + throw new BadRequestException('Target schedule is not accepting bookings'); + } + if ( + schedule.originStationId !== booking.originYardId || + schedule.destinationStationId !== booking.destinationYardId + ) { + throw new BadRequestException('Target schedule is not on the booking route'); + } + + await this.dataSource.transaction(async (manager) => { + if (booking.trainScheduleId) { + await this.trainScheduleBookingsRepository.deleteByScheduleAndBooking( + booking.trainScheduleId, + bookingId, + manager, + ); + } + const restoredStatus = + booking.status === 'EXPIRED' + ? booking.isGovernment + ? 'APPROVED' + : 'FULLY_EXECUTED' + : booking.status; + await manager.getRepository(Booking).update(bookingId, { + trainScheduleId: newScheduleId, + status: restoredStatus, + schedulingStatus: 'ELIGIBLE', + paymentDeadline: null, + selectedForBatchAt: null, + } as never); + }); + } + + /** Staff "expire" override → free a reservation now (booking becomes EXPIRED). */ + async expireReservation(bookingId: string): Promise { + const booking = await this.dataSource + .getRepository(Booking) + .findOne({ where: { id: bookingId } }); + if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`); + await this.expire(booking); + if (booking.trainScheduleId) await this.fillSchedule(booking.trainScheduleId); + } + + // ---- mutations ------------------------------------------------------------ + + /** + * Reserve capacity for a commercial booking on a specific train and open its + * pay window. `scheduleId` is persisted so the settle/allocate lifecycle + * (settleDueReservations, settleBatch, ensurePaidBookingAllocated, markPaid), + * which is all keyed off `booking.trainScheduleId`, can find the train — with + * day-level pooling the booking arrives here with `trainScheduleId` still null, + * so the engine sets it as it picks the train. + */ + private async reserve(booking: Booking, scheduleId: string): Promise { + const now = new Date(); + const deadline = new Date(now.getTime() + PAYMENT_WINDOW_MS); + await this.bookingsRepository.update(booking.id, { + trainScheduleId: scheduleId, + status: 'SELECTED_FOR_BATCH', + selectedForBatchAt: now, + paymentDeadline: deadline, + } as never); + booking.trainScheduleId = scheduleId; + await this.notifier.payNow(booking, deadline); + } + + /** Allocate a booking to the schedule's train (creates the TrainScheduleBooking link). */ + private async allocate( + scheduleId: string, + booking: Booking, + reason: 'paid' | 'gov', + ): Promise { + await this.dataSource.transaction(async (manager) => { + const exists = await this.trainScheduleBookingsRepository.existsForBooking( + booking.id, + manager, + ); + if (!exists) { + await this.trainScheduleBookingsRepository.createMany( + [{ trainScheduleId: scheduleId, bookingId: booking.id }], + manager, + ); + } + await manager.getRepository(Booking).update(booking.id, { + status: reason === 'paid' ? 'PAID' : booking.status, + schedulingStatus: 'SCHEDULED', + scheduledAt: new Date(), + paymentDeadline: null, + selectedForBatchAt: null, + } as never); + }); + this.notifier.secured(booking, reason); + void this.triggerWagonAllocation(scheduleId); + } + + /** + * Expire an unpaid reservation and free its capacity. With day-level pooling we + * also clear `trainScheduleId` so the booking is no longer pinned to the train + * it failed to pay for — it's back in the day pool for staff to act on. + */ + private async expire(booking: Booking): Promise { + await this.bookingsRepository.update(booking.id, { + trainScheduleId: null, + status: 'EXPIRED', + schedulingStatus: 'ELIGIBLE', + paymentDeadline: null, + selectedForBatchAt: null, + } as never); + booking.trainScheduleId = null; + this.notifier.expired(booking); + } + + /** + * Free capacity for a government booking by displacing the lowest-priority commercial + * bookings (reserved first, then allocated — including PAID). Displaced → EXPIRED + notified. + */ + private async preemptForGovernment( + scheduleId: string, + need: Capacity, + budget: Capacity, + wagonLengths: WagonLengths, + ): Promise { + const reservedCommercial = ( + await this.bookingsRepository.findReservedForSchedule(scheduleId) + ).filter((b) => !b.isGovernment); + const allocatedCommercial = + await this.bookingsRepository.findAllocatedCommercialForSchedule(scheduleId); + + // lowest priority first; reserved are cheaper to free than allocated + const candidates = [...reservedCommercial, ...allocatedCommercial].sort( + (a, b) => (a.priorityScore ?? 0) - (b.priorityScore ?? 0), + ); + + let freed = budget; + for (const victim of candidates) { + if (this.fits(need, freed)) break; + await this.dataSource.transaction(async (manager) => { + await this.trainScheduleBookingsRepository.deleteByScheduleAndBooking( + scheduleId, + victim.id, + manager, + ); + await manager.getRepository(Booking).update(victim.id, { + status: 'EXPIRED', + schedulingStatus: 'ELIGIBLE', + paymentDeadline: null, + selectedForBatchAt: null, + } as never); + }); + this.notifier.displaced(victim); + freed = this.add(freed, this.needFor(victim, wagonLengths)); + } + return freed; + } + + // ---- capacity helpers ----------------------------------------------------- + + private wagonsFor(booking: Booking): number { + if (booking.wagonsRequired && booking.wagonsRequired > 0) { + return Math.ceil(booking.wagonsRequired); + } + const fromContainers = (booking.bookingContainers ?? []).reduce( + (sum, c) => sum + Number(c.quantity ?? 0), + 0, + ); + return Math.max(DEFAULT_WAGONS_PER_BOOKING, fromContainers || DEFAULT_WAGONS_PER_BOOKING); + } + + /** What one booking consumes along all three capacity axes. */ + private needFor(booking: Booking, wagonLengths: WagonLengths): Capacity { + const wagons = this.wagonsFor(booking); + return { + wagons, + weightTons: Number(booking.cargoTotalWeightVgm ?? 0), + lengthMeters: bookingTrainLengthMeters(booking.freightType, wagons, { + container: wagonLengths.container, + bulk: wagonLengths.bulk, + }), + }; + } + + private fits(need: Capacity, budget: Capacity): boolean { + return ( + need.wagons <= budget.wagons && + need.weightTons <= budget.weightTons && + need.lengthMeters <= budget.lengthMeters + ); + } + + private subtract(budget: Capacity, need: Capacity): Capacity { + return { + wagons: budget.wagons - need.wagons, + weightTons: budget.weightTons - need.weightTons, + lengthMeters: budget.lengthMeters - need.lengthMeters, + }; + } + + private add(budget: Capacity, freed: Capacity): Capacity { + return { + wagons: budget.wagons + freed.wagons, + weightTons: budget.weightTons + freed.weightTons, + lengthMeters: budget.lengthMeters + freed.lengthMeters, + }; + } + + /** Locomotive + wagon-type-derived caps (weight, length, wagon slots — not a fixed 53). */ + private async capacityLimits( + locomotive: Locomotive, + rules: TrainSchedulingGlobalRules | null, + ): Promise { + const wagonTypes = await this.loadWagonTypeDimensions(); + const derived = deriveTrainCapacityFromLocomotive( + { + maxPullWeightTons: Number(locomotive.maxPullWeightTons), + maxTrainLengthMeters: Number(locomotive.maxTrainLengthMeters), + }, + wagonTypes, + { + maxTrainWeightTons: rules?.maxTrainWeightTons + ? Number(rules.maxTrainWeightTons) + : undefined, + maxTrainLengthMeters: rules?.maxTrainLengthMeters + ? Number(rules.maxTrainLengthMeters) + : undefined, + }, + ); + return { + wagons: derived.maxWagonSlots, + weightTons: derived.maxWeightTons, + lengthMeters: derived.maxLengthMeters, + }; + } + + /** Keep schedule.max_wagons aligned with locomotive physical limits. */ + private async syncScheduleMaxWagons( + schedule: TrainSchedule, + locomotive: Locomotive, + rules: TrainSchedulingGlobalRules | null, + ): Promise { + const limits = await this.capacityLimits(locomotive, rules); + if ((schedule.maxWagons ?? 0) !== limits.wagons) { + await this.dataSource + .getRepository(TrainSchedule) + .update(schedule.id, { maxWagons: limits.wagons }); + schedule.maxWagons = limits.wagons; + } + } + + private async loadWagonTypeDimensions(): Promise< + Array<{ lengthMeters: number; capacityTons: number }> + > { + const types = await this.dataSource.getRepository(WagonType).find({ + where: [{ code: 'NW5' }, { code: 'CW3' }], + }); + if (types.length) return types.map(wagonTypeDimensionsFromEntity); + return [ + { lengthMeters: DEFAULT_CONTAINER_WAGON_LENGTH_METERS, capacityTons: 70 }, + { lengthMeters: DEFAULT_BULK_WAGON_LENGTH_METERS, capacityTons: 60 }, + ]; + } + + private async loadWagonLengths(): Promise { + const types = await this.dataSource.getRepository(WagonType).find({ + where: [{ code: 'NW5' }, { code: 'CW3' }], + }); + const byCode = new Map(types.map((t) => [t.code, wagonTypeDimensionsFromEntity(t)])); + return { + container: byCode.get('NW5')?.lengthMeters ?? DEFAULT_CONTAINER_WAGON_LENGTH_METERS, + bulk: byCode.get('CW3')?.lengthMeters ?? DEFAULT_BULK_WAGON_LENGTH_METERS, + }; + } + + private async loadGlobalRules(): Promise { + return this.dataSource.getRepository(TrainSchedulingGlobalRules).findOne({ where: {} }); + } + + /** Remaining capacity = hard caps minus what allocated + reserved bookings already use. */ + private async remainingCapacity( + schedule: TrainSchedule, + limits: Capacity, + wagonLengths: WagonLengths, + ): Promise { + const allocated = (schedule.scheduleBookings ?? []) + .map((sb) => sb.booking) + .filter((b): b is Booking => Boolean(b)); + const reserved = await this.bookingsRepository.findReservedForSchedule(schedule.id); + const used = [...allocated, ...reserved].reduce( + (acc, b) => this.add(acc, this.needFor(b, wagonLengths)), + { wagons: 0, weightTons: 0, lengthMeters: 0 }, + ); + return this.subtract(limits, used); + } + + /** maxWagons minus wagons already taken by allocated + reserved bookings. */ + private async remainingWagons(schedule: TrainSchedule): Promise { + const allocated = (schedule.scheduleBookings ?? []) + .map((sb) => sb.booking) + .filter((b): b is Booking => Boolean(b)); + const reserved = await this.bookingsRepository.findReservedForSchedule(schedule.id); + const used = + allocated.reduce((s, b) => s + this.wagonsFor(b), 0) + + reserved.reduce((s, b) => s + this.wagonsFor(b), 0); + return (schedule.maxWagons ?? 0) - used; + } + + private async setWindow( + scheduleId: string, + status: 'OPEN' | 'FULL' | 'CLOSED', + ): Promise { + await this.dataSource + .getRepository(TrainSchedule) + .update(scheduleId, { bookingWindowStatus: status }); + } + + // ---- timer plumbing ------------------------------------------------------- + + private timeoutName(scheduleId: string): string { + return `settle:${scheduleId}`; + } + + private armSettle(scheduleId: string): void { + this.removeTimeout(scheduleId); + const handle = setTimeout(() => { + void this.settleBatch(scheduleId).catch((err) => + this.logger.error(`settleBatch ${scheduleId} failed: ${(err as Error).message}`), + ); + }, PAYMENT_WINDOW_MS); + this.scheduler.addTimeout(this.timeoutName(scheduleId), handle); + } + + private removeTimeout(scheduleId: string): void { + const name = this.timeoutName(scheduleId); + try { + if (this.scheduler.doesExist('timeout', name)) { + this.scheduler.deleteTimeout(name); + } + } catch { + // ignore — not armed + } + } +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts new file mode 100644 index 000000000..e8f272123 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts @@ -0,0 +1,85 @@ +import { Injectable, Logger } from '@nestjs/common'; + +import { Booking } from '../bookings/entities/booking.entity'; +import { NotificationsService } from '../notifications/notifications.service'; +import { PAYMENT_WINDOW_MS } from './booking-batch.constants'; + +@Injectable() +export class BookingNotifierService { + private readonly logger = new Logger(BookingNotifierService.name); + + constructor(private readonly notifications: NotificationsService) {} + + private ref(b: Booking): string { + return `${b.reference}${b.isGovernment ? ' (gov)' : ''}`; + } + + private async notifyContact( + b: Booking, + message: string, + logLabel: string, + ): Promise { + this.logger.log(`${logLabel} — ${this.ref(b)}`); + const phone = b.company?.contactPersonPhone ?? b.company?.phone ?? null; + const email = b.company?.email ?? b.company?.generalManagerEmail ?? null; + + if (phone) { + try { + await this.notifications.directSend('sms', phone, message); + } catch (err) { + this.logger.warn(`SMS failed for ${this.ref(b)}: ${(err as Error).message}`); + } + } + if (email) { + try { + await this.notifications.directSend('email', email, message); + } catch (err) { + this.logger.warn(`Email failed for ${this.ref(b)}: ${(err as Error).message}`); + } + } + if (!phone && !email) { + this.logger.warn(`No contact on file for ${this.ref(b)} — notification not sent`); + } + } + + async payNow(b: Booking, deadline: Date): Promise { + const payMinutes = Math.round(PAYMENT_WINDOW_MS / 60_000); + const eat = deadline.toLocaleString('en-GB', { timeZone: 'Africa/Addis_Ababa' }); + const msg = `Pay within ${payMinutes} minute${payMinutes === 1 ? '' : 's'} to secure train slot ${b.reference ?? b.id}. Deadline: ${eat} EAT.`; + await this.notifyContact(b, msg, 'PAY NOW'); + } + + secured(b: Booking, reason: 'paid' | 'gov'): void { + const msg = `Booking ${b.reference ?? b.id} allocated on train schedule ${b.trainScheduleId ?? ''}${ + reason === 'gov' ? ' (government)' : '' + }.`; + void this.notifyContact(b, msg, 'ALLOCATED'); + } + + expired(b: Booking): void { + const msg = `Payment window expired for booking ${b.reference ?? b.id}. Reschedule or cancel — no re-approval needed.`; + void this.notifyContact(b, msg, 'EXPIRED'); + } + + scheduleFull(b: Booking): void { + this.logger.warn( + `SCHEDULE FULL — ${this.ref(b)} could not be placed; change schedule, pick another day, or cancel.`, + ); + } + + /** + * Staff-facing warning when a pooled booking fits no train on its chosen day. + * It stays pending and is retried next batch; staff can add capacity or pin it + * to a train manually. Mirrors {@link scheduleFull} — no customer notification. + */ + unplaced(b: Booking, day: string): void { + this.logger.warn( + `UNPLACED — ${this.ref(b)} could not be placed on any train for ${day}; add capacity or assign it manually.`, + ); + } + + displaced(b: Booking): void { + const msg = `Booking ${b.reference ?? b.id} was displaced by a government booking. Move to another schedule or cancel.`; + void this.notifyContact(b, msg, 'DISPLACED'); + } +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/container-placement.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/container-placement.util.spec.ts new file mode 100644 index 000000000..f51199b8c --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/container-placement.util.spec.ts @@ -0,0 +1,59 @@ +import { + autoFillPlacements, + findMissingContainerNumberIssues, + type ContainerUnitForPlacement, +} from './container-placement.util'; + +describe('container-placement.util', () => { + const units: ContainerUnitForPlacement[] = [ + { + bookingId: 'b1', + bookingContainerId: 'c1', + unitIndex: 0, + label: 'REF · 1/1 · 20GP', + teuSlots: 1, + sizeFt: 20, + containerNumber: 'ABCD1234567', + }, + { + bookingId: 'b2', + bookingContainerId: 'c2', + unitIndex: 0, + label: 'REF2 · 1/1 · 40GP', + teuSlots: 2, + sizeFt: 40, + containerNumber: null, + }, + ]; + + it('auto-fills placements across slots', () => { + const placements = autoFillPlacements(units, [1, 2]); + expect(placements).toHaveLength(2); + expect(placements[0].sequenceNo).toBe(1); + expect(placements[1].sequenceNo).toBe(2); + }); + + it('reports missing container numbers only when placement is empty', () => { + const placements = autoFillPlacements(units, [1, 2]); + const issues = findMissingContainerNumberIssues(units, placements); + expect(issues).toHaveLength(0); + expect(placements[1].containerNumber).toMatch(/^TBD-/); + }); + + it('generates TBD placeholder for missing container numbers', () => { + const single: ContainerUnitForPlacement[] = [ + { + bookingId: 'b2', + bookingReference: 'BK-2026-000033', + bookingContainerId: 'c2', + unitIndex: 0, + label: 'REF2 · 1/1 · 40GP', + teuSlots: 2, + sizeFt: 40, + containerNumber: null, + }, + ]; + const placements = autoFillPlacements(single, [1]); + expect(placements[0].containerNumber).toBe('TBD-BK-2026-000033-1'); + }); +}); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/container-placement.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/container-placement.util.ts new file mode 100644 index 000000000..72ee1407e --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/container-placement.util.ts @@ -0,0 +1,99 @@ +import type { ContainerPlacementInput } from './wagon-plan.util'; + +export type ContainerUnitForPlacement = { + bookingId: string; + bookingReference?: string | null; + bookingContainerId: string; + unitIndex: number; + label: string; + teuSlots?: number; + sizeFt?: number; + containerNumber?: string | null; +}; + +export function placeholderContainerNumber(unit: ContainerUnitForPlacement): string { + const ref = unit.bookingReference ?? unit.bookingId.slice(0, 8); + return `TBD-${ref}-${unit.unitIndex + 1}`; +} + +export function isPlaceholderContainerNumber(value: string | null | undefined): boolean { + return Boolean(value?.trim().startsWith('TBD-')); +} + +export function resolveContainerNumber(unit: ContainerUnitForPlacement): string { + const trimmed = unit.containerNumber?.trim(); + return trimmed || placeholderContainerNumber(unit); +} + +export function autoFillPlacements( + units: ContainerUnitForPlacement[], + containerSlots: number[], +): ContainerPlacementInput[] { + if (!units.length || !containerSlots.length) return []; + + const placements: ContainerPlacementInput[] = []; + const MAX_TEU_PER_WAGON = 2; + let currentSlotIndex = 0; + let teuInCurrentSlot = 0; + + for (const unit of units) { + const teu = unit.teuSlots ?? (unit.sizeFt && unit.sizeFt >= 40 ? 2 : 1); + + if (teuInCurrentSlot > 0 && teuInCurrentSlot + teu > MAX_TEU_PER_WAGON) { + currentSlotIndex += 1; + teuInCurrentSlot = 0; + } + + const sequenceNo = + containerSlots[Math.min(currentSlotIndex, containerSlots.length - 1)] ?? + containerSlots[containerSlots.length - 1] ?? + containerSlots[0]; + + placements.push({ + bookingContainerId: unit.bookingContainerId, + unitIndex: unit.unitIndex, + sequenceNo, + containerNumber: resolveContainerNumber(unit), + }); + + teuInCurrentSlot += teu; + } + + return placements; +} + +export function findMissingContainerNumberIssues( + units: ContainerUnitForPlacement[], + placements: ContainerPlacementInput[], +): Array<{ bookingId: string; issue: string }> { + const issues: Array<{ bookingId: string; issue: string }> = []; + const byUnit = new Map( + placements.map((p) => [`${p.bookingContainerId}:${p.unitIndex}`, p]), + ); + + for (const unit of units) { + const placement = byUnit.get(`${unit.bookingContainerId}:${unit.unitIndex}`); + if (!placement?.containerNumber?.trim()) { + issues.push({ + bookingId: unit.bookingId, + issue: `Missing container number for ${unit.label}`, + }); + } + } + + return issues; +} + +export function placementsForBookings( + placements: ContainerPlacementInput[], + bookingIds: Set, + units: ContainerUnitForPlacement[], +): ContainerPlacementInput[] { + const unitBookingIds = new Map( + units.map((u) => [`${u.bookingContainerId}:${u.unitIndex}`, u.bookingId]), + ); + return placements.filter((p) => { + const bookingId = unitBookingIds.get(`${p.bookingContainerId}:${p.unitIndex}`); + return bookingId ? bookingIds.has(bookingId) : false; + }); +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/derive-schedule-direction.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/derive-schedule-direction.util.spec.ts new file mode 100644 index 000000000..330c4d4e9 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/derive-schedule-direction.util.spec.ts @@ -0,0 +1,21 @@ +import { deriveScheduleDirection } from './derive-schedule-direction.util'; + +describe('deriveScheduleDirection', () => { + it('returns IMPORT when origin is Djibouti', () => { + expect( + deriveScheduleDirection({ country: 'Djibouti' }, { country: 'Ethiopia' }), + ).toBe('IMPORT'); + }); + + it('returns EXPORT when destination is Djibouti and origin is not', () => { + expect( + deriveScheduleDirection({ country: 'Ethiopia' }, { country: 'Djibouti' }), + ).toBe('EXPORT'); + }); + + it('returns DOMESTIC for intra-Ethiopia routes', () => { + expect( + deriveScheduleDirection({ country: 'Ethiopia' }, { country: 'Ethiopia' }), + ).toBe('DOMESTIC'); + }); +}); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/derive-schedule-direction.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/derive-schedule-direction.util.ts new file mode 100644 index 000000000..7e7358358 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/derive-schedule-direction.util.ts @@ -0,0 +1,4 @@ +import { deriveTradeDirection } from '../../common/derive-trade-direction.util'; + +/** @deprecated Use deriveTradeDirection from common — kept as alias for train scheduling. */ +export const deriveScheduleDirection = deriveTradeDirection; diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/assign-bookings.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/assign-bookings.dto.ts new file mode 100644 index 000000000..b5e93f5da --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/assign-bookings.dto.ts @@ -0,0 +1,86 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { + ArrayMinSize, + IsArray, + IsBoolean, + IsInt, + IsNumber, + IsOptional, + IsString, + IsUUID, + Min, + ValidateNested, +} from 'class-validator'; + +export class ContainerPlacementDto { + @ApiProperty({ format: 'uuid' }) + @IsUUID() + bookingContainerId!: string; + + @ApiProperty({ minimum: 0 }) + @IsInt() + @Min(0) + unitIndex!: number; + + @ApiProperty({ minimum: 1 }) + @IsInt() + @Min(1) + sequenceNo!: number; + + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + containerId?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + containerNumber?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + sealNumber?: string; +} + +export class AssignBookingsDto { + @ApiProperty({ type: [String] }) + @IsArray() + @ArrayMinSize(1) + @IsUUID('4', { each: true }) + bookingIds!: string[]; + + @ApiPropertyOptional({ description: 'Bypass soft hold and overweight warnings' }) + @IsOptional() + @IsBoolean() + forceAssign?: boolean; + + @ApiPropertyOptional({ type: [ContainerPlacementDto] }) + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => ContainerPlacementDto) + containerPlacements?: ContainerPlacementDto[]; + + @ApiPropertyOptional({ description: 'Maximum total booking weight allowed on this train' }) + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(1) + maxTrainWeightTons?: number; + + @ApiPropertyOptional({ description: 'Maximum total wagon length allowed on this train' }) + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(1) + maxTrainLengthMeters?: number; + + @ApiPropertyOptional({ description: 'Maximum wagons allowed on this train' }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + maxWagonsPerTrain?: number; +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/assign-unassigned-booking.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/assign-unassigned-booking.dto.ts new file mode 100644 index 000000000..1db03c429 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/assign-unassigned-booking.dto.ts @@ -0,0 +1,8 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsUUID } from 'class-validator'; + +export class AssignUnassignedBookingDto { + @ApiProperty({ format: 'uuid' }) + @IsUUID() + bookingId!: string; +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/available-days-query.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/available-days-query.dto.ts new file mode 100644 index 000000000..bb55508e5 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/available-days-query.dto.ts @@ -0,0 +1,14 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsOptional, IsUUID } from 'class-validator'; + +export class AvailableDaysQueryDto { + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + originYardId?: string; + + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + destinationYardId?: string; +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/available-locomotives-query.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/available-locomotives-query.dto.ts new file mode 100644 index 000000000..705e505df --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/available-locomotives-query.dto.ts @@ -0,0 +1,8 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsUUID } from 'class-validator'; + +export class AvailableLocomotivesQueryDto { + @ApiProperty({ format: 'uuid', description: 'Route used to filter locomotives at the origin yard' }) + @IsUUID() + routeId!: string; +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/bookable-schedules-query.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/bookable-schedules-query.dto.ts new file mode 100644 index 000000000..1dc908639 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/bookable-schedules-query.dto.ts @@ -0,0 +1,14 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsOptional, IsUUID } from 'class-validator'; + +export class BookableSchedulesQueryDto { + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + originYardId?: string; + + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + destinationYardId?: string; +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/create-container-train-schedule.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/create-container-train-schedule.dto.ts new file mode 100644 index 000000000..5c2486fa3 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/create-container-train-schedule.dto.ts @@ -0,0 +1,38 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { IsDateString, IsInt, IsNumber, IsOptional, IsUUID, Min } from 'class-validator'; + +export class CreateContainerTrainScheduleDto { + @ApiProperty({ format: 'uuid' }) + @IsUUID() + routeId!: string; + + @ApiProperty({ example: '2026-06-20T08:00:00.000Z' }) + @IsDateString() + scheduleDate!: string; + + @ApiProperty({ format: 'uuid' }) + @IsUUID() + locomotiveId!: string; + + @ApiPropertyOptional({ description: 'Maximum total booking weight allowed on this train' }) + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(1) + maxTrainWeightTons?: number; + + @ApiPropertyOptional({ description: 'Maximum total wagon length allowed on this train' }) + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(1) + maxTrainLengthMeters?: number; + + @ApiPropertyOptional({ description: 'Maximum wagons allowed on this train' }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + maxWagonsPerTrain?: number; +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/get-eligible-bookings.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/get-eligible-bookings.dto.ts new file mode 100644 index 000000000..363e9b5e8 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/get-eligible-bookings.dto.ts @@ -0,0 +1,31 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsIn, IsOptional, IsUUID } from 'class-validator'; + +export class GetEligibleBookingsDto { + @ApiPropertyOptional({ enum: ['CONTAINER', 'BULK'] }) + @IsOptional() + @IsIn(['CONTAINER', 'BULK']) + freightType?: 'CONTAINER' | 'BULK'; + + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + originStationId?: string; + + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + destinationStationId?: string; + + @ApiPropertyOptional({ + format: 'uuid', + description: 'Scope to bookings that targeted this specific schedule (batch parity).', + }) + @IsOptional() + @IsUUID() + trainScheduleId?: string; + + @ApiPropertyOptional() + @IsOptional() + schedulingStatus?: string; +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/get-eligible-bulk-bookings.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/get-eligible-bulk-bookings.dto.ts new file mode 100644 index 000000000..c8fde0c07 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/get-eligible-bulk-bookings.dto.ts @@ -0,0 +1,23 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsOptional, IsUUID } from 'class-validator'; + +export class GetEligibleBulkBookingsDto { + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + originStationId?: string; + + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + destinationStationId?: string; + + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + trainScheduleId?: string; + + @ApiPropertyOptional({ example: 'HOLDING' }) + @IsOptional() + schedulingStatus?: string; +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/get-eligible-container-bookings.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/get-eligible-container-bookings.dto.ts new file mode 100644 index 000000000..5d11192d6 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/get-eligible-container-bookings.dto.ts @@ -0,0 +1,23 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsOptional, IsUUID } from 'class-validator'; + +export class GetEligibleContainerBookingsDto { + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + originStationId?: string; + + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + destinationStationId?: string; + + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + trainScheduleId?: string; + + @ApiPropertyOptional({ example: 'HOLDING' }) + @IsOptional() + schedulingStatus?: string; +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/pin-wagons.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/pin-wagons.dto.ts new file mode 100644 index 000000000..54f96e5c0 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/pin-wagons.dto.ts @@ -0,0 +1,22 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { ArrayMinSize, IsArray, IsUUID, ValidateNested } from 'class-validator'; +import { Type } from 'class-transformer'; + +export class PinWagonAssignmentDto { + @ApiProperty({ format: 'uuid' }) + @IsUUID() + trainSetWagonId!: string; + + @ApiProperty({ format: 'uuid' }) + @IsUUID() + physicalWagonId!: string; +} + +export class PinWagonsDto { + @ApiProperty({ type: [PinWagonAssignmentDto] }) + @IsArray() + @ArrayMinSize(1) + @ValidateNested({ each: true }) + @Type(() => PinWagonAssignmentDto) + assignments!: PinWagonAssignmentDto[]; +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/preview-bulk-train-schedule.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/preview-bulk-train-schedule.dto.ts new file mode 100644 index 000000000..d2efe75e4 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/preview-bulk-train-schedule.dto.ts @@ -0,0 +1,3 @@ +import { PreviewTrainScheduleDto } from './preview-train-schedule.dto'; + +export class PreviewBulkTrainScheduleDto extends PreviewTrainScheduleDto {} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/preview-container-train-schedule.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/preview-container-train-schedule.dto.ts new file mode 100644 index 000000000..28ee62070 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/preview-container-train-schedule.dto.ts @@ -0,0 +1,3 @@ +import { PreviewTrainScheduleDto } from './preview-train-schedule.dto'; + +export class PreviewContainerTrainScheduleDto extends PreviewTrainScheduleDto {} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/preview-train-schedule.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/preview-train-schedule.dto.ts new file mode 100644 index 000000000..56cd9592b --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/preview-train-schedule.dto.ts @@ -0,0 +1,61 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { + ArrayMinSize, + IsArray, + IsDateString, + IsInt, + IsNumber, + IsOptional, + IsUUID, + Min, +} from 'class-validator'; + +export class PreviewTrainScheduleDto { + @ApiProperty({ type: [String] }) + @IsArray() + @ArrayMinSize(1) + @IsUUID('4', { each: true }) + bookingIds!: string[]; + + @ApiProperty({ example: '2026-06-20T08:00:00.000Z' }) + @IsDateString() + scheduleDate!: string; + + @ApiProperty({ format: 'uuid' }) + @IsUUID() + originStationId!: string; + + @ApiProperty({ format: 'uuid' }) + @IsUUID() + destinationStationId!: string; + + @ApiPropertyOptional({ + format: 'uuid', + description: 'Allow bookings already assigned to this schedule (re-assign / reschedule)', + }) + @IsOptional() + @IsUUID() + targetScheduleId?: string; + + @ApiPropertyOptional({ description: 'Maximum total booking weight allowed on this train' }) + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(1) + maxTrainWeightTons?: number; + + @ApiPropertyOptional({ description: 'Maximum total wagon length allowed on this train' }) + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(1) + maxTrainLengthMeters?: number; + + @ApiPropertyOptional({ description: 'Maximum wagons allowed on this train' }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + maxWagonsPerTrain?: number; +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/record-checkpoint.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/record-checkpoint.dto.ts new file mode 100644 index 000000000..7f778760d --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/record-checkpoint.dto.ts @@ -0,0 +1,34 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { TrainCheckpointKind } from '@edr/types'; +import { + IsEnum, + IsInt, + IsISO8601, + IsOptional, + IsString, + MaxLength, + Min, +} from 'class-validator'; + +export class RecordCheckpointDto { + @ApiProperty({ description: 'Station position along the route (0 = origin).' }) + @IsInt() + @Min(0) + sequenceNo!: number; + + @ApiProperty({ enum: TrainCheckpointKind, required: false }) + @IsOptional() + @IsEnum(TrainCheckpointKind) + kind?: TrainCheckpointKind; + + @ApiProperty({ required: false, description: 'ISO timestamp; defaults to now.' }) + @IsOptional() + @IsISO8601() + occurredAt?: string; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + @MaxLength(500) + note?: string; +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/update-container-item.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/update-container-item.dto.ts new file mode 100644 index 000000000..37710e003 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/update-container-item.dto.ts @@ -0,0 +1,7 @@ +import { IsOptional, IsString } from 'class-validator'; + +export class UpdateContainerItemDto { + @IsString() + @IsOptional() + containerNumber?: string | null; +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/update-train-scheduling-global-rules.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/update-train-scheduling-global-rules.dto.ts new file mode 100644 index 000000000..d47195976 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/update-train-scheduling-global-rules.dto.ts @@ -0,0 +1,40 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { IsInt, IsNumber, IsOptional, Min } from 'class-validator'; + +export class UpdateTrainSchedulingGlobalRulesDto { + @ApiPropertyOptional({ example: 760 }) + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(1) + maxTrainLengthMeters?: number; + + @ApiPropertyOptional({ example: 3500 }) + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(1) + maxTrainWeightTons?: number; + + @ApiPropertyOptional({ example: 53 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + maxWagonsPerTrain?: number; + + @ApiPropertyOptional({ example: 30 }) + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(0.001) + max20ftContainerWeightTons?: number; + + @ApiPropertyOptional({ example: 10 }) + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(0) + max20ftPairWeightDiffTons?: number; +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/entities/train-checkpoint-event.entity.ts b/apps/edr-freight-api/src/modules/train-scheduling/entities/train-checkpoint-event.entity.ts new file mode 100644 index 000000000..5fa90a2e1 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/entities/train-checkpoint-event.entity.ts @@ -0,0 +1,45 @@ +import { BaseEntity } from '@edr/api-common'; +import { TrainCheckpointKind } from '@edr/types'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; + +import { Yard } from '../../rule-engine/entities/yard.entity'; +import { TrainSchedule } from '../../train-schedules/entities/train-schedule.entity'; + +/** + * One staff-logged tracking checkpoint for a dispatched train as it passes a + * station along its route (origin → milestones → destination). + */ +@Entity({ schema: 'freight', name: 'train_checkpoint_events' }) +@Index(['trainScheduleId']) +@Index(['trainScheduleId', 'sequenceNo']) +export class TrainCheckpointEvent extends BaseEntity { + @Column({ name: 'train_schedule_id', type: 'uuid' }) + trainScheduleId!: string; + + @ManyToOne(() => TrainSchedule, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'train_schedule_id' }) + trainSchedule?: TrainSchedule; + + @Column({ name: 'yard_id', type: 'uuid' }) + yardId!: string; + + @ManyToOne(() => Yard) + @JoinColumn({ name: 'yard_id' }) + yard?: Yard; + + /** Position along the corridor: 0 = origin, N+1 = destination. */ + @Column({ name: 'sequence_no', type: 'int' }) + sequenceNo!: number; + + @Column({ name: 'kind', type: 'varchar', length: 20 }) + kind!: TrainCheckpointKind; + + @Column({ name: 'occurred_at', type: 'timestamptz' }) + occurredAt!: Date; + + @Column({ name: 'note', type: 'text', nullable: true }) + note?: string | null; + + @Column({ name: 'recorded_by_user_id', type: 'uuid', nullable: true }) + recordedByUserId?: string | null; +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/entities/train-scheduling-global-rules.entity.ts b/apps/edr-freight-api/src/modules/train-scheduling/entities/train-scheduling-global-rules.entity.ts new file mode 100644 index 000000000..326915933 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/entities/train-scheduling-global-rules.entity.ts @@ -0,0 +1,44 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity } from 'typeorm'; + +@Entity({ schema: 'freight', name: 'train_scheduling_global_rules' }) +export class TrainSchedulingGlobalRules extends BaseEntity { + @Column({ + name: 'max_train_length_meters', + type: 'numeric', + precision: 10, + scale: 2, + default: 760, + }) + maxTrainLengthMeters!: number; + + @Column({ + name: 'max_train_weight_tons', + type: 'numeric', + precision: 10, + scale: 3, + default: 3500, + }) + maxTrainWeightTons!: number; + + @Column({ name: 'max_wagons_per_train', type: 'int', default: 53 }) + maxWagonsPerTrain!: number; + + @Column({ + name: 'max_20ft_container_weight_tons', + type: 'numeric', + precision: 8, + scale: 3, + default: 30, + }) + max20ftContainerWeightTons!: number; + + @Column({ + name: 'max_20ft_pair_weight_diff_tons', + type: 'numeric', + precision: 8, + scale: 3, + default: 10, + }) + max20ftPairWeightDiffTons!: number; +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.spec.ts new file mode 100644 index 000000000..76ea00422 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.spec.ts @@ -0,0 +1,127 @@ +import { Booking } from '../bookings/entities/booking.entity'; +import { WagonType } from '../wagon-types/entities/wagon-type.entity'; +import { + computeFleetAvailability, + selectBookingsWithinFleetCap, + sortBookingsForScheduling, + summarizeFleetWarnings, + wagonsRequiredForBooking, +} from './fleet-plan.util'; +import { buildContainerWagonPlan, type WagonPlanSlot } from './wagon-plan.util'; + +const nw5: WagonType = { + id: 'wt-nw5', + code: 'NW5', + name: 'Flat Wagon', + capacityTons: 70, + lengthMeters: 14, + maxWagonsPerTrain: 53, + supportedLoadTypes: ['CONTAINER'], + isActive: true, + supportsContainer: true, +} as WagonType; + +const makeBooking = ( + id: string, + extra: Partial = {}, +): Booking => + ({ + id, + reference: id, + freightType: 'CONTAINER', + isGovernment: false, + priorityScore: 0, + scheduledDate: new Date('2026-06-20T08:00:00.000Z'), + cargoTotalWeightVgm: 50, + bookingContainers: [{ id: `${id}-line`, quantity: 2, wagonsRequired: 1, vgmPerUnitTons: 25 }], + ...extra, + }) as Booking; + +describe('fleet-plan.util', () => { + it('sorts bookings government first, then priority, then date', () => { + const bookings = [ + makeBooking('late', { scheduledDate: new Date('2026-06-22T08:00:00.000Z') }), + makeBooking('gov', { isGovernment: true, priorityScore: 0 }), + makeBooking('prio', { priorityScore: 10 }), + ]; + + const sorted = sortBookingsForScheduling(bookings); + expect(sorted.map((b) => b.id)).toEqual(['gov', 'prio', 'late']); + }); + + it('computes fleet availability with shortfall', () => { + const plan: WagonPlanSlot[] = buildContainerWagonPlan( + [ + makeBooking('b1', { + bookingContainers: [ + { id: 'b1-line', quantity: 4, wagonsRequired: 2, vgmPerUnitTons: 25 } as never, + ], + }), + ], + nw5, + ); + const fleetByTypeId = new Map([[nw5.id, 1]]); + + const rows = computeFleetAvailability(plan, fleetByTypeId, new Map([[nw5.id, 'NW5']])); + const nw5Row = rows.find((r) => r.wagonTypeCode === 'NW5'); + + expect(nw5Row?.needed).toBe(2); + expect(nw5Row?.available).toBe(1); + expect(nw5Row?.shortfall).toBe(1); + }); + + it('defers lower-priority bookings when fleet is insufficient', () => { + const high = makeBooking('high', { + priorityScore: 100, + bookingContainers: [ + { id: 'high-line', quantity: 2, wagonsRequired: 2, vgmPerUnitTons: 25 } as never, + ], + }); + const low = makeBooking('low', { + priorityScore: 1, + bookingContainers: [ + { id: 'low-line', quantity: 2, wagonsRequired: 2, vgmPerUnitTons: 25 } as never, + ], + }); + const fleet = new Map([[nw5.id, 2]]); + + const { fitting, deferred } = selectBookingsWithinFleetCap( + [low, high], + fleet, + () => nw5.id, + ); + + expect(fitting.map((b) => b.id)).toEqual(['high']); + expect(deferred).toHaveLength(1); + expect(deferred[0]?.id).toBe('low'); + expect(deferred[0]?.reason).toContain('2'); + }); + + it('summarizes fleet shortage warnings', () => { + const warnings = summarizeFleetWarnings( + [ + { + wagonTypeId: nw5.id, + wagonTypeCode: 'NW5', + needed: 5, + available: 2, + shortfall: 3, + }, + ], + [{ id: 'b1', reference: 'BKG-1', reason: 'No wagons' }], + ); + + expect(warnings.some((w) => w.includes('Fleet shortage'))).toBe(true); + expect(warnings.some((w) => w.includes('deferred'))).toBe(true); + }); + + it('counts wagons required per booking from container lines', () => { + const booking = makeBooking('b1', { + bookingContainers: [ + { id: 'b1-line-0', quantity: 2, wagonsRequired: 1, vgmPerUnitTons: 25 } as never, + { id: 'b1-line-1', quantity: 1, wagonsRequired: 1, vgmPerUnitTons: 25 } as never, + ], + }); + expect(wagonsRequiredForBooking(booking)).toBe(2); + }); +}); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.ts new file mode 100644 index 000000000..2e721825e --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.ts @@ -0,0 +1,168 @@ +import type { Booking } from '../bookings/entities/booking.entity'; +import type { WagonType } from '../wagon-types/entities/wagon-type.entity'; +import { + buildBulkWagonPlan, + buildContainerWagonPlan, + buildMixedWagonPlan, + roundTons, + type WagonPlanSlot, +} from './wagon-plan.util'; + +export type FleetAvailabilityRow = { + wagonTypeId: string; + wagonTypeCode: string; + needed: number; + available: number; + shortfall: number; +}; + +export type DeferredBookingRow = { + id: string; + reference: string; + reason: string; +}; + +export function sortBookingsForScheduling(bookings: Booking[]): Booking[] { + return [...bookings].sort((a, b) => { + const govDiff = Number(Boolean(b.isGovernment)) - Number(Boolean(a.isGovernment)); + if (govDiff !== 0) return govDiff; + + const priorityDiff = (b.priorityScore ?? 0) - (a.priorityScore ?? 0); + if (priorityDiff !== 0) return priorityDiff; + + return new Date(a.scheduledDate).getTime() - new Date(b.scheduledDate).getTime(); + }); +} + +export function wagonsRequiredForBooking(booking: Booking, bulkWagonCapacity?: number): number { + if (booking.freightType === 'BULK') { + const weight = Number(booking.cargoTotalWeightVgm ?? 0); + const capacity = bulkWagonCapacity && bulkWagonCapacity > 0 ? bulkWagonCapacity : 1; + return Math.max(1, Math.ceil(weight / capacity)); + } + + const lineSlots = (booking.bookingContainers ?? []).reduce( + (sum, line) => sum + Number(line.wagonsRequired ?? 0), + 0, + ); + return Math.max(1, lineSlots); +} + +export function countSlotsByType(wagonPlan: WagonPlanSlot[]): Map { + const map = new Map(); + for (const slot of wagonPlan) { + const existing = map.get(slot.wagonTypeId) ?? { code: slot.wagonTypeCode, count: 0 }; + existing.count += 1; + map.set(slot.wagonTypeId, existing); + } + return map; +} + +export function computeFleetAvailability( + demandPlan: WagonPlanSlot[], + fleetByTypeId: Map, + fleetTypeCodes: Map, +): FleetAvailabilityRow[] { + const neededByType = countSlotsByType(demandPlan); + const typeIds = new Set([...neededByType.keys(), ...fleetByTypeId.keys()]); + + return [...typeIds].map((wagonTypeId) => { + const needed = neededByType.get(wagonTypeId)?.count ?? 0; + const available = fleetByTypeId.get(wagonTypeId) ?? 0; + return { + wagonTypeId, + wagonTypeCode: + neededByType.get(wagonTypeId)?.code ?? + fleetTypeCodes.get(wagonTypeId) ?? + wagonTypeId, + needed, + available, + shortfall: Math.max(0, needed - available), + }; + }).filter((row) => row.needed > 0 || row.available > 0); +} + +export function selectBookingsWithinFleetCap( + bookings: Booking[], + fleetByTypeId: Map, + resolveWagonTypeId: (booking: Booking) => string, + bulkWagonCapacity?: number, +): { fitting: Booking[]; deferred: DeferredBookingRow[] } { + const remaining = new Map(fleetByTypeId); + const fitting: Booking[] = []; + const deferred: DeferredBookingRow[] = []; + + for (const booking of sortBookingsForScheduling(bookings)) { + const typeId = resolveWagonTypeId(booking); + const needed = wagonsRequiredForBooking(booking, bulkWagonCapacity); + const available = remaining.get(typeId) ?? 0; + + if (available >= needed) { + remaining.set(typeId, available - needed); + fitting.push(booking); + continue; + } + + deferred.push({ + id: booking.id, + reference: booking.reference, + reason: + available > 0 + ? `Needs ${needed} wagons but only ${available} available for this type` + : `No available wagons for required type (${needed} needed)`, + }); + } + + return { fitting, deferred }; +} + +export function buildCappedWagonPlan(params: { + bookings: Booking[]; + resolvedMode: 'CONTAINER' | 'BULK' | 'MIXED'; + containerWagonType: WagonType; + bulkWagonType: WagonType; +}): WagonPlanSlot[] { + const { bookings, resolvedMode, containerWagonType, bulkWagonType } = params; + + if (resolvedMode === 'MIXED') { + const containerBookings = bookings.filter((b) => b.freightType === 'CONTAINER'); + const bulkBookings = bookings.filter((b) => b.freightType === 'BULK'); + return buildMixedWagonPlan( + containerBookings, + bulkBookings, + containerWagonType, + bulkWagonType, + ); + } + + if (resolvedMode === 'BULK') { + return buildBulkWagonPlan(bookings, bulkWagonType); + } + + return buildContainerWagonPlan(bookings, containerWagonType); +} + +export function summarizeFleetWarnings( + fleetAvailability: FleetAvailabilityRow[], + deferred: DeferredBookingRow[], +): string[] { + const warnings: string[] = []; + + for (const row of fleetAvailability.filter((r) => r.shortfall > 0)) { + warnings.push( + `Fleet shortage: need ${row.needed} ${row.wagonTypeCode}, only ${row.available} available (short ${row.shortfall})`, + ); + } + + if (deferred.length) { + warnings.push( + `${deferred.length} booking(s) deferred to next train due to insufficient fleet wagons`, + ); + } + + return warnings; +} + +export function totalAssignedWeight(bookings: Booking[]): number { + return roundTons(bookings.reduce((sum, b) => sum + Number(b.cargoTotalWeightVgm ?? 0), 0)); +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.spec.ts new file mode 100644 index 000000000..d72f3d311 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.spec.ts @@ -0,0 +1,41 @@ +import { + bookingTrainLengthMeters, + deriveTrainCapacityFromLocomotive, +} from './train-capacity.util'; + +describe('train-capacity.util', () => { + const nw5 = { lengthMeters: 14, capacityTons: 70 }; + + it('derives wagon slots from locomotive length and weight, not a fixed 53', () => { + const shortLoco = deriveTrainCapacityFromLocomotive( + { maxPullWeightTons: 2000, maxTrainLengthMeters: 280 }, + [nw5], + ); + expect(shortLoco.maxWagonSlots).toBe(20); // 280 / 14 + expect(shortLoco.maxWagonSlots).not.toBe(53); + + const heavyLoco = deriveTrainCapacityFromLocomotive( + { maxPullWeightTons: 2100, maxTrainLengthMeters: 760 }, + [nw5], + ); + expect(heavyLoco.maxWagonSlots).toBe(30); // min(54, 30) from weight 2100/70 + }); + + it('uses shortest wagon type when mixed types are present', () => { + const longBulk = { lengthMeters: 18, capacityTons: 80 }; + const mixed = deriveTrainCapacityFromLocomotive( + { maxPullWeightTons: 3500, maxTrainLengthMeters: 760 }, + [nw5, longBulk], + ); + expect(mixed.maxWagonSlots).toBe( + Math.min(Math.floor(760 / 14), Math.floor(3500 / 70)), + ); + }); + + it('computes booking length by freight type', () => { + expect( + bookingTrainLengthMeters('CONTAINER', 2, { container: 14, bulk: 14 }), + ).toBe(28); + expect(bookingTrainLengthMeters('BULK', 3, { container: 14, bulk: 18 })).toBe(54); + }); +}); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.ts new file mode 100644 index 000000000..593bb7bee --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.ts @@ -0,0 +1,90 @@ +/** Physical dimensions used when deriving how many wagons a locomotive can pull. */ +export type WagonTypeDimensions = { + lengthMeters: number; + capacityTons: number; +}; + +export type LocomotiveLimits = { + maxPullWeightTons: number; + maxTrainLengthMeters: number; +}; + +export type DerivedTrainCapacity = { + maxWeightTons: number; + maxLengthMeters: number; + maxWagonSlots: number; +}; + +const DEFAULT_WAGON_LENGTH_M = 14; +const DEFAULT_WAGON_CAPACITY_T = 70; + +/** + * Derive train capacity from locomotive pull weight and train length. + * Wagon count is NOT a fixed 53 — it is the minimum of: + * - floor(maxLength / shortest wagon type length) + * - floor(maxWeight / lightest wagon type capacity) + */ +export function deriveTrainCapacityFromLocomotive( + locomotive: LocomotiveLimits, + wagonTypes: WagonTypeDimensions[], + ruleCaps?: { maxTrainWeightTons?: number; maxTrainLengthMeters?: number }, +): DerivedTrainCapacity { + const maxWeightTons = Math.min( + Number(locomotive.maxPullWeightTons) || Infinity, + ruleCaps?.maxTrainWeightTons ?? Infinity, + ); + const maxLengthMeters = Math.min( + Number(locomotive.maxTrainLengthMeters) || Infinity, + ruleCaps?.maxTrainLengthMeters ?? Infinity, + ); + + const types = + wagonTypes.length > 0 + ? wagonTypes + : [{ lengthMeters: DEFAULT_WAGON_LENGTH_M, capacityTons: DEFAULT_WAGON_CAPACITY_T }]; + + const minLength = Math.min(...types.map((w) => Number(w.lengthMeters) || DEFAULT_WAGON_LENGTH_M)); + const minCapacity = Math.min( + ...types.map((w) => Number(w.capacityTons) || DEFAULT_WAGON_CAPACITY_T), + ); + + const byLength = + minLength > 0 && Number.isFinite(maxLengthMeters) + ? Math.floor(maxLengthMeters / minLength) + : 0; + const byWeight = + minCapacity > 0 && Number.isFinite(maxWeightTons) + ? Math.floor(maxWeightTons / minCapacity) + : byLength; + + const maxWagonSlots = Math.max(0, Math.min(byLength, byWeight)); + + return { + maxWeightTons: Number.isFinite(maxWeightTons) ? maxWeightTons : MAX_FALLBACK_WEIGHT, + maxLengthMeters: Number.isFinite(maxLengthMeters) ? maxLengthMeters : MAX_FALLBACK_LENGTH, + maxWagonSlots, + }; +} + +export const MAX_FALLBACK_WEIGHT = 3500; +export const MAX_FALLBACK_LENGTH = 760; + +/** Per-booking train length from wagon count and freight-specific wagon type length. */ +export function bookingTrainLengthMeters( + freightType: string | null | undefined, + wagonCount: number, + lengths: { container: number; bulk: number }, +): number { + const perWagon = freightType === 'BULK' ? lengths.bulk : lengths.container; + return wagonCount * perWagon; +} + +export function wagonTypeDimensionsFromEntity(wt: { + lengthMeters?: number | string | null; + capacityTons?: number | string | null; +}): WagonTypeDimensions { + return { + lengthMeters: Number(wt.lengthMeters) || DEFAULT_WAGON_LENGTH_M, + capacityTons: Number(wt.capacityTons) || DEFAULT_WAGON_CAPACITY_T, + }; +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-checkpoint-events.repository.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-checkpoint-events.repository.ts new file mode 100644 index 000000000..210de382e --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-checkpoint-events.repository.ts @@ -0,0 +1,24 @@ +import { BaseRepository } from '@edr/api-common'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity'; + +@Injectable() +export class TrainCheckpointEventsRepository extends BaseRepository { + constructor( + @InjectRepository(TrainCheckpointEvent) + repository: Repository, + ) { + super(repository); + } + + findBySchedule(trainScheduleId: string): Promise { + return this.findAll({ + where: { trainScheduleId }, + relations: { yard: true }, + order: { sequenceNo: 'ASC', occurredAt: 'ASC' }, + }); + } +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts new file mode 100644 index 000000000..0c01dd219 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts @@ -0,0 +1,445 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + ParseUUIDPipe, + Patch, + Post, + Query, +} from "@nestjs/common"; +import { CurrentUser } from "@edr/api-common"; +import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger"; +import type { AuthUserPayload } from "../../common/resolve-auth-user-id"; +import { resolveAuthUserId } from "../../common/resolve-auth-user-id"; + +import { + TrainSchedulingManage, + TrainSchedulingView, +} from "../../common/booking-guards"; +import { AssignBookingsDto } from "./dto/assign-bookings.dto"; +import { AssignUnassignedBookingDto } from "./dto/assign-unassigned-booking.dto"; +import { CreateContainerTrainScheduleDto } from "./dto/create-container-train-schedule.dto"; +import { GetEligibleBookingsDto } from "./dto/get-eligible-bookings.dto"; +import { GetEligibleBulkBookingsDto } from "./dto/get-eligible-bulk-bookings.dto"; +import { GetEligibleContainerBookingsDto } from "./dto/get-eligible-container-bookings.dto"; +import { PinWagonsDto } from "./dto/pin-wagons.dto"; +import { UpdateContainerItemDto } from "./dto/update-container-item.dto"; +import { PreviewBulkTrainScheduleDto } from "./dto/preview-bulk-train-schedule.dto"; +import { PreviewContainerTrainScheduleDto } from "./dto/preview-container-train-schedule.dto"; +import { PreviewTrainScheduleDto } from "./dto/preview-train-schedule.dto"; +import { RecordCheckpointDto } from "./dto/record-checkpoint.dto"; +import { AvailableLocomotivesQueryDto } from "./dto/available-locomotives-query.dto"; +import { BookableSchedulesQueryDto } from "./dto/bookable-schedules-query.dto"; +import { AvailableDaysQueryDto } from "./dto/available-days-query.dto"; +import { UpdateTrainSchedulingGlobalRulesDto } from "./dto/update-train-scheduling-global-rules.dto"; +import { TrainSchedulingService } from "./train-scheduling.service"; +import { BookingBatchService } from "./booking-batch.service"; + +@ApiTags("train-scheduling") +@ApiBearerAuth() +@Controller("train-scheduling") +export class TrainSchedulingController { + constructor( + private readonly trainSchedulingService: TrainSchedulingService, + private readonly bookingBatchService: BookingBatchService, + ) { } + + @Get("global-rules") + @TrainSchedulingView() + @ApiOperation({ summary: "Get global train scheduling rules (singleton)" }) + getGlobalRules() { + return this.trainSchedulingService.getTrainSchedulingGlobalRules(); + } + + @Patch("global-rules") + @TrainSchedulingManage() + @ApiOperation({ summary: "Update global train scheduling rules (singleton)" }) + updateGlobalRules(@Body() dto: UpdateTrainSchedulingGlobalRulesDto) { + return this.trainSchedulingService.updateTrainSchedulingGlobalRules(dto); + } + + @Get("eligible-bookings") + @TrainSchedulingView() + @ApiOperation({ summary: "List eligible bookings (container and/or bulk)" }) + getEligibleBookings(@Query() query: GetEligibleBookingsDto) { + return this.trainSchedulingService.getEligibleBookings(query); + } + + @Get("batch-board") + @TrainSchedulingView() + @ApiOperation({ + summary: "Batch monitoring board: schedules with bookings grouped by state", + }) + getBatchBoard() { + return this.bookingBatchService.getBatchBoard(); + } + + @Get("batch-board/:scheduleId") + @TrainSchedulingView() + @ApiOperation({ + summary: "Batch board detail for one schedule with EAT 3h windows", + }) + getBatchBoardDetail(@Param("scheduleId", ParseUUIDPipe) scheduleId: string) { + return this.bookingBatchService.getBatchBoardDetail(scheduleId); + } + + @Get("available-locomotives") + @TrainSchedulingView() + @ApiOperation({ + summary: "List AVAILABLE locomotives at the route origin yard", + }) + getAvailableLocomotives(@Query() query: AvailableLocomotivesQueryDto) { + return this.trainSchedulingService.getAvailableLocomotivesForRoute( + query.routeId, + ); + } + + @Get("bookable-schedules") + // No staff guard: customers hit this while creating a booking to find OPEN + // same-route schedules. Do not attach train_scheduling permissions here. + @ApiOperation({ + summary: "OPEN same-route schedules a new booking can target", + }) + getBookableSchedules(@Query() query: BookableSchedulesQueryDto) { + return this.trainSchedulingService.getBookableSchedules( + query.originYardId, + query.destinationYardId, + ); + } + + @Get("available-days") + // No staff guard: customers hit this while creating a booking to find which + // DAYS have a departure on their route. Day-level pooling — no capacity is + // returned, only the list of bookable days. + @ApiOperation({ + summary: "Distinct days with an OPEN same-route departure (day-level pool)", + }) + getAvailableDays(@Query() query: AvailableDaysQueryDto) { + return this.trainSchedulingService.getAvailableDays( + query.originYardId, + query.destinationYardId, + ); + } + + @Get("container/eligible-bookings") + @TrainSchedulingView() + @ApiOperation({ summary: "List eligible container bookings" }) + getEligibleContainerBookings( + @Query() query: GetEligibleContainerBookingsDto, + ) { + return this.trainSchedulingService.getEligibleContainerBookings(query); + } + + @Get("bulk/eligible-bookings") + @TrainSchedulingView() + @ApiOperation({ summary: "List eligible bulk bookings" }) + getEligibleBulkBookings(@Query() query: GetEligibleBulkBookingsDto) { + return this.trainSchedulingService.getEligibleBulkBookings(query); + } + + @Post("preview") + @TrainSchedulingView() + @ApiOperation({ summary: "Preview a mixed-capable train schedule" }) + previewTrainSchedule(@Body() dto: PreviewTrainScheduleDto) { + return this.trainSchedulingService.previewTrainSchedule(dto); + } + + @Post("container/preview") + @TrainSchedulingView() + @ApiOperation({ summary: "Preview a container train schedule" }) + previewContainerTrainSchedule(@Body() dto: PreviewContainerTrainScheduleDto) { + return this.trainSchedulingService.previewContainerTrainSchedule(dto); + } + + @Post("bulk/preview") + @TrainSchedulingView() + @ApiOperation({ summary: "Preview a bulk train schedule" }) + previewBulkTrainSchedule(@Body() dto: PreviewBulkTrainScheduleDto) { + return this.trainSchedulingService.previewBulkTrainSchedule(dto); + } + + @Post("container/schedules") + @TrainSchedulingManage() + @ApiOperation({ summary: "Create a container train schedule" }) + createContainerTrainSchedule(@Body() dto: CreateContainerTrainScheduleDto) { + return this.trainSchedulingService.createContainerTrainSchedule(dto); + } + + @Post("bulk/schedules") + @TrainSchedulingManage() + @ApiOperation({ summary: "Create a bulk train schedule" }) + createBulkTrainSchedule(@Body() dto: CreateContainerTrainScheduleDto) { + return this.trainSchedulingService.createContainerTrainSchedule(dto); + } + + @Post("schedules/:id/assign-bookings") + @TrainSchedulingManage() + @ApiOperation({ + summary: "Assign bookings to a train schedule (mixed-capable)", + }) + assignBookings( + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: AssignBookingsDto, + ) { + return this.trainSchedulingService.assignBookingsToSchedule(id, dto); + } + + @Post("container/schedules/:id/assign-bookings") + @TrainSchedulingManage() + @ApiOperation({ summary: "Assign container bookings to a train schedule" }) + assignContainerBookings( + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: AssignBookingsDto, + ) { + return this.trainSchedulingService.assignBookingsToSchedule( + id, + dto, + "CONTAINER", + ); + } + + @Post("bulk/schedules/:id/assign-bookings") + @TrainSchedulingManage() + @ApiOperation({ summary: "Assign bulk bookings to a train schedule" }) + assignBulkBookings( + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: AssignBookingsDto, + ) { + return this.trainSchedulingService.assignBookingsToSchedule( + id, + dto, + "BULK", + ); + } + + @Delete("schedules/:id/bookings/:bookingId") + @TrainSchedulingManage() + @ApiOperation({ summary: "Unassign a booking from a train schedule" }) + unassignBooking( + @Param("id", ParseUUIDPipe) id: string, + @Param("bookingId", ParseUUIDPipe) bookingId: string, + @CurrentUser() user: AuthUserPayload, + ) { + return this.trainSchedulingService.unassignBooking( + id, + bookingId, + resolveAuthUserId(user), + ); + } + + @Delete("schedules/:id/wagons/:trainSetWagonId") + @TrainSchedulingManage() + @ApiOperation({ summary: "Remove an empty wagon slot from a train" }) + removeWagonSlot( + @Param("id", ParseUUIDPipe) id: string, + @Param("trainSetWagonId", ParseUUIDPipe) trainSetWagonId: string, + ) { + return this.trainSchedulingService.removeTrainSetWagonSlot( + id, + trainSetWagonId, + ); + } + + @Patch("schedules/:id/container-items/:itemId") + @TrainSchedulingManage() + @ApiOperation({ summary: "Update a container number on a wagon slot" }) + updateContainerItem( + @Param("id", ParseUUIDPipe) id: string, + @Param("itemId", ParseUUIDPipe) itemId: string, + @Body() dto: UpdateContainerItemDto, + ) { + return this.trainSchedulingService.updateContainerItem(id, itemId, dto); + } + + @Get("schedules/:id/unassigned-bookings") + @TrainSchedulingView() + @ApiOperation({ summary: "Get unassigned bookings for a schedule" }) + getUnassignedBookings(@Param("id", ParseUUIDPipe) id: string) { + return this.trainSchedulingService.getUnassignedBookings(id); + } + + @Post("schedules/:id/assign-unassigned-booking") + @TrainSchedulingManage() + @ApiOperation({ + summary: + "Assign one linked unallocated booking to wagons (preserves existing assignments)", + }) + assignUnassignedBooking( + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: AssignUnassignedBookingDto, + ) { + return this.trainSchedulingService.assignUnassignedBookingToWagons( + id, + dto.bookingId, + ); + } + + @Get("schedules/:id/composition-removals") + @TrainSchedulingView() + @ApiOperation({ summary: "Get removal log for a schedule" }) + getCompositionRemovals(@Param("id", ParseUUIDPipe) id: string) { + return this.trainSchedulingService.getCompositionRemovals(id); + } + + @Post("schedules/:id/pin-wagons") + @TrainSchedulingManage() + @ApiOperation({ summary: "Pin physical wagons to train set slots" }) + pinWagons(@Param("id", ParseUUIDPipe) id: string, @Body() dto: PinWagonsDto) { + return this.trainSchedulingService.pinWagons(id, dto); + } + + @Post("schedules/:id/finalize") + @TrainSchedulingManage() + @ApiOperation({ summary: "Finalize a draft train schedule" }) + finalizeSchedule(@Param("id", ParseUUIDPipe) id: string) { + return this.trainSchedulingService.finalizeSchedule(id); + } + + @Post("schedules/:id/dispatch") + @TrainSchedulingManage() + @ApiOperation({ summary: "Dispatch a scheduled train" }) + dispatchSchedule(@Param("id", ParseUUIDPipe) id: string) { + return this.trainSchedulingService.dispatchSchedule(id); + } + + // ---- batch / booking-window staff actions ---- + + @Post("schedules/:id/run-batch") + @TrainSchedulingManage() + @ApiOperation({ summary: "Manually run the batch fill for a schedule" }) + async runBatch(@Param("id", ParseUUIDPipe) id: string) { + await this.bookingBatchService.fillSchedule(id); + return this.bookingBatchService.getBatchBoardDetail(id); + } + + @Post("schedules/:id/run-allocation") + @TrainSchedulingManage() + @ApiOperation({ + summary: "Run wagon-level allocation for all eligible linked bookings", + }) + async runAllocation(@Param("id", ParseUUIDPipe) id: string) { + return this.bookingBatchService.runWagonAllocation(id); + } + + @Patch("schedules/:id/booking-window") + @TrainSchedulingManage() + @ApiOperation({ summary: "Open or close a schedule booking window" }) + async setBookingWindow( + @Param("id", ParseUUIDPipe) id: string, + @Body("status") status: "OPEN" | "CLOSED", + ) { + await this.trainSchedulingService.setBookingWindow( + id, + status === "CLOSED" ? "CLOSED" : "OPEN", + ); + return this.trainSchedulingService.getContainerTrainScheduleById(id); + } + + @Post("bookings/:bookingId/mark-paid") + @TrainSchedulingManage() + @ApiOperation({ + summary: "Staff: mark a reserved booking paid and allocate it now", + }) + async markBookingPaid(@Param("bookingId", ParseUUIDPipe) bookingId: string) { + await this.bookingBatchService.markPaid(bookingId); + return { ok: true }; + } + + @Post("bookings/:bookingId/expire") + @TrainSchedulingManage() + @ApiOperation({ + summary: "Staff: expire a reservation and free its capacity", + }) + async expireBooking(@Param("bookingId", ParseUUIDPipe) bookingId: string) { + await this.bookingBatchService.expireReservation(bookingId); + return { ok: true }; + } + + @Post("bookings/:bookingId/move-schedule") + @TrainSchedulingManage() + @ApiOperation({ + summary: "Re-point a booking to another OPEN same-route schedule", + }) + async moveBookingSchedule( + @Param("bookingId", ParseUUIDPipe) bookingId: string, + @Body("trainScheduleId", ParseUUIDPipe) trainScheduleId: string, + ) { + await this.bookingBatchService.moveToSchedule(bookingId, trainScheduleId); + return { ok: true }; + } + + @Get("schedules/:id/checkpoints") + @TrainSchedulingView() + @ApiOperation({ + summary: "Get the tracking corridor + logged checkpoints for a train", + }) + getScheduleCheckpoints(@Param("id", ParseUUIDPipe) id: string) { + return this.trainSchedulingService.getScheduleCheckpoints(id); + } + + @Post("schedules/:id/checkpoints") + @TrainSchedulingManage() + @ApiOperation({ + summary: "Log the train passing a station (final station triggers arrival)", + }) + recordCheckpoint( + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: RecordCheckpointDto, + ) { + return this.trainSchedulingService.recordCheckpoint(id, dto); + } + + @Post("schedules/:id/arrive") + @TrainSchedulingManage() + @ApiOperation({ + summary: + "Mark a dispatched train arrived (move assets to destination yard, free assets)", + }) + arriveSchedule(@Param("id", ParseUUIDPipe) id: string) { + return this.trainSchedulingService.arriveSchedule(id); + } + + @Get("container/schedules") + @TrainSchedulingView() + @ApiOperation({ summary: "List container train schedules" }) + getContainerTrainSchedules() { + return this.trainSchedulingService.getContainerTrainSchedules(); + } + + @Get("bulk/schedules") + @TrainSchedulingView() + @ApiOperation({ summary: "List bulk train schedules" }) + getBulkTrainSchedules() { + return this.trainSchedulingService.getContainerTrainSchedules(); + } + + @Get("container/schedules/:id") + @TrainSchedulingView() + @ApiOperation({ summary: "Get container train schedule detail" }) + getContainerTrainScheduleById(@Param("id", ParseUUIDPipe) id: string) { + return this.trainSchedulingService.getContainerTrainScheduleById(id); + } + + @Get("bulk/schedules/:id") + @TrainSchedulingView() + @ApiOperation({ summary: "Get bulk train schedule detail" }) + getBulkTrainScheduleById(@Param("id", ParseUUIDPipe) id: string) { + return this.trainSchedulingService.getContainerTrainScheduleById(id); + } + + @Post("container/schedules/:id/cancel") + @TrainSchedulingManage() + @ApiOperation({ summary: "Cancel container train schedule" }) + cancelTrainSchedule(@Param("id", ParseUUIDPipe) id: string) { + return this.trainSchedulingService.cancelTrainSchedule(id); + } + + @Post("bulk/schedules/:id/cancel") + @TrainSchedulingManage() + @ApiOperation({ summary: "Cancel bulk train schedule" }) + cancelBulkTrainSchedule(@Param("id", ParseUUIDPipe) id: string) { + return this.trainSchedulingService.cancelTrainSchedule(id); + } +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts new file mode 100644 index 000000000..64112d720 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts @@ -0,0 +1,56 @@ +import { Module, forwardRef } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { BookingsModule } from '../bookings/bookings.module'; +import { Container } from '../container-management/entities/container.entity'; +import { LocomotivesModule } from '../locomotives/locomotives.module'; +import { RuleEngineModule } from '../rule-engine/rule-engine.module'; +import { Locomotive } from '../locomotives/entities/locomotive.entity'; +import { Route } from '../routes/entities/route.entity'; +import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity'; +import { TrainSet } from '../train-sets/entities/train-set.entity'; +import { TrainSetsModule } from '../train-sets/train-sets.module'; +import { TrainSchedulesModule } from '../train-schedules/train-schedules.module'; +import { WagonType } from '../wagon-types/entities/wagon-type.entity'; +import { WagonTypesModule } from '../wagon-types/wagon-types.module'; +import { Wagon } from '../wagons/entities/wagon.entity'; +import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity'; +import { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-rules.entity'; +import { TrainCheckpointEventsRepository } from './train-checkpoint-events.repository'; +import { TrainSchedulingController } from './train-scheduling.controller'; +import { TrainSchedulingService } from './train-scheduling.service'; +import { BookingBatchService } from './booking-batch.service'; +import { BookingNotifierService } from './booking-notifier.service'; +import { NotificationsModule } from '../notifications/notifications.module'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([ + Locomotive, + WagonType, + TrainSet, + TrainSetWagon, + Route, + Wagon, + Container, + TrainSchedulingGlobalRules, + TrainCheckpointEvent, + ]), + forwardRef(() => BookingsModule), + NotificationsModule, + LocomotivesModule, + WagonTypesModule, + TrainSetsModule, + TrainSchedulesModule, + RuleEngineModule, + ], + controllers: [TrainSchedulingController], + providers: [ + TrainSchedulingService, + TrainCheckpointEventsRepository, + BookingBatchService, + BookingNotifierService, + ], + exports: [TrainSchedulingService, BookingBatchService], +}) +export class TrainSchedulingModule {} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts new file mode 100644 index 000000000..163008ecc --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts @@ -0,0 +1,933 @@ +import { BadRequestException, ConflictException } from '@nestjs/common'; +import { WagonStatus } from '@edr/types'; + +import { Wagon } from '../wagons/entities/wagon.entity'; +import { WagonType } from '../wagon-types/entities/wagon-type.entity'; +import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity'; +import { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-rules.entity'; +import { WagonBookingAllocation } from '../train-schedules/entities/wagon-booking-allocation.entity'; +import { TrainSchedulingService } from './train-scheduling.service'; + +const nw5 = { + id: 'wagon-type-1', + code: 'NW5', + name: 'Flat Wagon', + capacityTons: 70, + lengthMeters: 14, + maxWagonsPerTrain: 53, + supportedLoadTypes: ['CONTAINER'], + isActive: true, + supportsContainer: true, +}; + +const locomotive = { + id: 'loc-1', + code: 'LOC-001', + maxPullWeightTons: 3500, + maxTrainLengthMeters: 760, + status: 'AVAILABLE', + currentYardId: 'yard-origin', +}; + +const cw3 = { + id: 'wagon-type-bulk', + code: 'CW3', + name: 'Covered Wagon', + capacityTons: 60, + lengthMeters: 14, + maxWagonsPerTrain: 53, + supportedLoadTypes: ['BULK'], + isActive: true, + supportsContainer: false, +}; + +const makeBooking = ( + id: string, + reference: string, + weight: number, + quantity: number, + containerCode: string, + wagonsRequired: number, + scheduledDate = '2026-06-20T08:00:00.000Z', + originYardId = 'yard-origin', + destinationYardId = 'yard-destination', + extra: Record = {}, +) => ({ + id, + reference, + freightType: 'CONTAINER', + cargoTotalWeightVgm: weight, + scheduledDate: new Date(scheduledDate), + originYardId, + destinationYardId, + status: 'PAID', + schedulingStatus: 'HOLDING', + holdExpiresAt: new Date(Date.now() + 60 * 60 * 1000), + company: { companyName: 'Demo Customer' }, + originYard: { label: 'Djibouti', code: 'DJIBOUTI' }, + destinationYard: { label: 'Addis Ababa', code: 'ADDIS_ABABA' }, + bookingContainers: [ + { + id: `${id}-line`, + containerTypeId: 'ct-1', + quantity, + wagonsRequired, + vgmPerUnitTons: weight / quantity, + isOverweight: false, + containerType: { code: containerCode, label: containerCode }, + }, + ], + ...extra, +}); + +describe('TrainSchedulingService', () => { + let service: TrainSchedulingService; + let dataSource: { getRepository: jest.Mock; transaction: jest.Mock }; + let bookingsRepository: Record; + let locomotivesRepository: { findById: jest.Mock; findAll: jest.Mock }; + let wagonTypesRepository: { findAll: jest.Mock }; + let trainSchedulesRepository: Record; + let trainScheduleBookingsRepository: Record; + let wagonBookingAllocationsRepository: Record; + let wagonAllocationContainerItemsRepository: Record; + let wagonAllocationBulkLoadsRepository: Record; + + beforeEach(() => { + dataSource = { getRepository: jest.fn(), transaction: jest.fn() }; + bookingsRepository = { + findEligibleForScheduling: jest.fn(), + findByIdsForScheduling: jest.fn(), + findAll: jest.fn(), + updateSchedulingFields: jest.fn(), + }; + locomotivesRepository = { findById: jest.fn(), findAll: jest.fn() }; + wagonTypesRepository = { findAll: jest.fn() }; + trainSchedulesRepository = { + findById: jest.fn(), + findByIdWithFullGraph: jest.fn(), + findAll: jest.fn(), + updateStatus: jest.fn(), + }; + trainScheduleBookingsRepository = { + findByBookingIds: jest.fn(), + createMany: jest.fn(), + deleteByScheduleAndBooking: jest.fn(), + }; + wagonBookingAllocationsRepository = { + deleteByTrainSetId: jest.fn().mockResolvedValue([]), + createMany: jest.fn(), + }; + wagonAllocationContainerItemsRepository = { + createMany: jest.fn(), + deleteByAllocationIds: jest.fn(), + findAll: jest.fn().mockResolvedValue([]), + }; + wagonAllocationBulkLoadsRepository = { + createMany: jest.fn(), + deleteByAllocationIds: jest.fn(), + findAll: jest.fn().mockResolvedValue([]), + }; + + const trainCheckpointEventsRepository = { + findBySchedule: jest.fn().mockResolvedValue([]), + findAll: jest.fn().mockResolvedValue([]), + create: jest.fn(), + update: jest.fn(), + }; + + service = new TrainSchedulingService( + dataSource as never, + bookingsRepository as never, + locomotivesRepository as never, + wagonTypesRepository as never, + trainSchedulesRepository as never, + trainScheduleBookingsRepository as never, + wagonBookingAllocationsRepository as never, + wagonAllocationContainerItemsRepository as never, + wagonAllocationBulkLoadsRepository as never, + trainCheckpointEventsRepository as never, + {} as never, // trainCompositionRemovalLogRepository + ); + + const defaultFleetWagons = [ + ...Array.from({ length: 100 }, (_, index) => ({ + id: `wagon-nw5-${index}`, + wagonTypeId: nw5.id, + status: WagonStatus.Available, + currentYardId: 'yard-origin', + currentTrainScheduleId: null, + })), + ...Array.from({ length: 50 }, (_, index) => ({ + id: `wagon-cw3-${index}`, + wagonTypeId: cw3.id, + status: WagonStatus.Available, + currentYardId: 'yard-origin', + currentTrainScheduleId: null, + })), + ]; + + dataSource.getRepository.mockImplementation((entity: unknown) => { + if (entity === TrainSchedulingGlobalRules) { + return { find: jest.fn().mockResolvedValue([]) }; + } + if (entity === Wagon) { + return { find: jest.fn().mockResolvedValue(defaultFleetWagons) }; + } + if (entity === WagonType) { + return { find: jest.fn().mockResolvedValue([nw5, cw3]) }; + } + return { find: jest.fn().mockResolvedValue([]), findOne: jest.fn().mockResolvedValue(null) }; + }); + }); + + it('returns fleet availability and defers bookings when fleet is insufficient', async () => { + const bookings = [ + makeBooking('b1', 'BKG-CONT-001', 500, 20, '40FT', 20), + makeBooking('b2', 'BKG-CONT-002', 300, 10, '20FT', 10), + ]; + + wagonTypesRepository.findAll.mockResolvedValue([nw5]); + bookingsRepository.findByIdsForScheduling.mockResolvedValue(bookings); + trainScheduleBookingsRepository.findByBookingIds.mockResolvedValue([]); + locomotivesRepository.findAll.mockResolvedValue([locomotive]); + + const availableWagons = Array.from({ length: 15 }, (_, index) => ({ + id: `wagon-${index}`, + wagonTypeId: nw5.id, + status: WagonStatus.Available, + currentYardId: 'yard-origin', + currentTrainScheduleId: null, + })); + + dataSource.getRepository.mockImplementation((entity: unknown) => { + if (entity === TrainSchedulingGlobalRules) { + return { find: jest.fn().mockResolvedValue([]) }; + } + if (entity === Wagon) { + return { find: jest.fn().mockResolvedValue(availableWagons) }; + } + if (entity === WagonType) { + return { find: jest.fn().mockResolvedValue([nw5]) }; + } + return { find: jest.fn().mockResolvedValue([]), findOne: jest.fn().mockResolvedValue(null) }; + }); + + const result = await service.previewContainerTrainSchedule({ + bookingIds: bookings.map((b) => b.id), + scheduleDate: '2026-06-20T08:00:00.000Z', + originStationId: 'yard-origin', + destinationStationId: 'yard-destination', + }); + + expect(result.fleetAvailability?.length).toBeGreaterThan(0); + expect(result.fleetAvailability?.[0]?.shortfall).toBeGreaterThan(0); + expect(result.deferredBookings?.length).toBeGreaterThan(0); + expect(result.summary.wagonsNeeded).toBeLessThan(30); + expect(result.warnings.some((w) => w.includes('Fleet shortage') || w.includes('deferred'))).toBe( + true, + ); + }); + + it('computes slot-based preview for Group A', async () => { + const bookings = [ + makeBooking('b1', 'BKG-CONT-001', 500, 20, '40FT', 20), + makeBooking('b2', 'BKG-CONT-002', 300, 10, '20FT', 10), + makeBooking('b3', 'BKG-CONT-003', 450, 15, '40FT', 15), + ]; + + wagonTypesRepository.findAll.mockResolvedValue([nw5]); + bookingsRepository.findByIdsForScheduling.mockResolvedValue(bookings); + trainScheduleBookingsRepository.findByBookingIds.mockResolvedValue([]); + locomotivesRepository.findAll.mockResolvedValue([locomotive]); + + const result = await service.previewContainerTrainSchedule({ + bookingIds: bookings.map((b) => b.id), + scheduleDate: '2026-06-20T08:00:00.000Z', + originStationId: 'yard-origin', + destinationStationId: 'yard-destination', + }); + + expect(result.valid).toBe(true); + expect(result.violations).toEqual([]); + expect(result.summary.wagonsNeeded).toBe(45); + expect(result.wagonPlan).toHaveLength(45); + }); + + it('returns soft hold warnings without forceAssign', async () => { + const bookings = [makeBooking('b7', 'BKG-CONT-007', 120, 2, '40FT', 2)]; + + wagonTypesRepository.findAll.mockResolvedValue([nw5]); + bookingsRepository.findByIdsForScheduling.mockResolvedValue(bookings); + trainScheduleBookingsRepository.findByBookingIds.mockResolvedValue([]); + locomotivesRepository.findAll.mockResolvedValue([locomotive]); + + const result = await service.previewContainerTrainSchedule({ + bookingIds: ['b7'], + scheduleDate: '2026-06-20T08:00:00.000Z', + originStationId: 'yard-origin', + destinationStationId: 'yard-destination', + }); + + expect(result.warnings.length).toBeGreaterThan(0); + expect(result.warnings[0]).toContain('soft hold window'); + }); + + it('flags the overweight booking as invalid', async () => { + const bookings = [ + makeBooking('b6', 'BKG-CONT-006', 3600, 80, '40FT', 80, undefined, undefined, undefined, { + bookingContainers: [ + { + id: 'b6-line', + containerTypeId: 'ct-1', + quantity: 80, + wagonsRequired: 80, + vgmPerUnitTons: 45, + isOverweight: true, + containerType: { code: '40FT', label: '40FT' }, + }, + ], + }), + ]; + + wagonTypesRepository.findAll.mockResolvedValue([nw5]); + bookingsRepository.findByIdsForScheduling.mockResolvedValue(bookings); + trainScheduleBookingsRepository.findByBookingIds.mockResolvedValue([]); + locomotivesRepository.findAll.mockResolvedValue([locomotive]); + + const result = await service.previewContainerTrainSchedule({ + bookingIds: ['b6'], + scheduleDate: '2026-06-20T08:00:00.000Z', + originStationId: 'yard-origin', + destinationStationId: 'yard-destination', + }); + + expect(result.valid).toBe(false); + expect(result.violations.some((v) => v.includes('overweight'))).toBe(true); + }); + + it('allows preview when bookings are already on the target schedule', async () => { + const bookings = [makeBooking('b1', 'BKG-CONT-001', 500, 20, '40FT', 20)]; + + wagonTypesRepository.findAll.mockResolvedValue([nw5]); + bookingsRepository.findByIdsForScheduling.mockResolvedValue(bookings); + trainScheduleBookingsRepository.findByBookingIds.mockResolvedValue([ + { bookingId: 'b1', trainScheduleId: 'sched-target' }, + ]); + trainSchedulesRepository.findById.mockResolvedValue({ + id: 'sched-target', + direction: 'IMPORT', + }); + locomotivesRepository.findAll.mockResolvedValue([locomotive]); + + const result = await service.previewContainerTrainSchedule({ + bookingIds: ['b1'], + scheduleDate: '2026-06-20T08:00:00.000Z', + originStationId: 'yard-origin', + destinationStationId: 'yard-destination', + targetScheduleId: 'sched-target', + }); + + expect(result.violations).not.toContain( + 'One or more selected bookings are already assigned to a train schedule', + ); + expect(result.valid).toBe(true); + }); + + it('allows preview when selected bookings are on different schedule dates', async () => { + const bookings = [ + makeBooking('b1', 'BKG-CONT-001', 500, 20, '40FT', 20, '2026-06-20T08:00:00.000Z'), + makeBooking('b2', 'BKG-CONT-002', 300, 10, '20FT', 10, '2026-06-21T14:00:00.000Z'), + ]; + + wagonTypesRepository.findAll.mockResolvedValue([nw5]); + bookingsRepository.findByIdsForScheduling.mockResolvedValue(bookings); + trainScheduleBookingsRepository.findByBookingIds.mockResolvedValue([]); + locomotivesRepository.findAll.mockResolvedValue([locomotive]); + + const result = await service.previewContainerTrainSchedule({ + bookingIds: bookings.map((b) => b.id), + scheduleDate: '2026-06-20T08:00:00.000Z', + originStationId: 'yard-origin', + destinationStationId: 'yard-destination', + }); + + expect(result.violations).not.toContain( + 'Selected bookings must share the same schedule date', + ); + expect(result.valid).toBe(true); + }); + + it('rejects bookings that are not in schedulable status', async () => { + const bookings = [ + { ...makeBooking('b7', 'BKG-CONT-007', 120, 2, '40FT', 2), status: 'APPROVED' }, + ]; + + wagonTypesRepository.findAll.mockResolvedValue([nw5]); + bookingsRepository.findByIdsForScheduling.mockResolvedValue(bookings); + trainScheduleBookingsRepository.findByBookingIds.mockResolvedValue([]); + locomotivesRepository.findAll.mockResolvedValue([locomotive]); + + const result = await service.previewContainerTrainSchedule({ + bookingIds: ['b7'], + scheduleDate: '2026-06-20T08:00:00.000Z', + originStationId: 'yard-origin', + destinationStationId: 'yard-destination', + }); + + expect(result.valid).toBe(false); + expect(result.violations).toContain( + 'Only PAID bookings can be scheduled; received: APPROVED', + ); + }); + + it('creates a schedule transactionally when validation passes', async () => { + const route = { + id: 'route-1', + name: 'Djibouti to Addis', + originYardId: 'yard-origin', + destinationYardId: 'yard-destination', + isActive: true, + }; + + const lockedLocomotiveRepo = { + findOne: jest.fn().mockResolvedValue(locomotive), + update: jest.fn().mockResolvedValue(undefined), + }; + const trainScheduleRepo = { + create: jest.fn().mockImplementation((value) => value), + save: jest.fn().mockResolvedValue({ id: 'schedule-1' }), + }; + const trainSetRepo = { + create: jest.fn().mockImplementation((value) => value), + save: jest.fn().mockResolvedValue({ id: 'train-set-1' }), + }; + const manager = { + getRepository: jest.fn((entity: { name?: string }) => { + switch (entity?.name) { + case 'Locomotive': + return lockedLocomotiveRepo; + case 'TrainSchedule': + return trainScheduleRepo; + case 'TrainSet': + return trainSetRepo; + default: + throw new Error(`Unexpected transaction repository ${entity?.name}`); + } + }), + }; + + jest.spyOn(service, 'selectOrValidateLocomotive').mockResolvedValue(locomotive as never); + dataSource.getRepository.mockImplementation((entity: unknown) => { + if ((entity as { name?: string })?.name === 'Route') { + return { findOne: jest.fn().mockResolvedValue(route) }; + } + if (entity === TrainSchedulingGlobalRules) { + return { find: jest.fn().mockResolvedValue([]) }; + } + if (entity === WagonType) { + return { find: jest.fn().mockResolvedValue([nw5, cw3]) }; + } + throw new Error(`Unexpected repository ${(entity as { name?: string })?.name}`); + }); + trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue({ id: 'schedule-1' }); + dataSource.transaction.mockImplementation(async (callback: (tx: typeof manager) => Promise) => + callback(manager), + ); + + const result = await service.createContainerTrainSchedule({ + routeId: 'route-1', + scheduleDate: '2026-06-20T08:00:00.000Z', + locomotiveId: 'loc-1', + }); + + expect(trainSetRepo.save).toHaveBeenCalled(); + expect(trainScheduleRepo.save).toHaveBeenCalled(); + expect(lockedLocomotiveRepo.update).toHaveBeenCalledWith('loc-1', { status: 'ASSIGNED' }); + expect(result.id).toBe('schedule-1'); + }); + + it('previews mixed container and bulk bookings', async () => { + const containerBooking = makeBooking('c1', 'BKG-CONT', 100, 2, '40FT', 2); + const bulkBooking = { + id: 'b1', + reference: 'BKG-BULK', + freightType: 'BULK', + cargoTotalWeightVgm: 120, + scheduledDate: new Date('2026-06-20T08:00:00.000Z'), + originYardId: 'yard-origin', + destinationYardId: 'yard-destination', + status: 'PAID', + bookingContainers: [], + cargoType: { code: 'COFFEE' }, + }; + + wagonTypesRepository.findAll.mockImplementation(async ({ where }: { where?: { code?: string } }) => { + if (where?.code === 'NW5') return [nw5]; + return [nw5, cw3]; + }); + bookingsRepository.findByIdsForScheduling.mockResolvedValue([containerBooking, bulkBooking]); + trainScheduleBookingsRepository.findByBookingIds.mockResolvedValue([]); + locomotivesRepository.findAll.mockResolvedValue([locomotive]); + + const result = await service.previewTrainSchedule({ + bookingIds: ['c1', 'b1'], + scheduleDate: '2026-06-20T08:00:00.000Z', + originStationId: 'yard-origin', + destinationStationId: 'yard-destination', + }); + + expect(result.valid).toBe(true); + expect(result.summary.wagonType).toBe('MIXED'); + expect(result.wagonPlan.length).toBeGreaterThan(2); + expect(result.containerUnits).toHaveLength(2); + }); + + it('previews container bookings without requiring placements', async () => { + const bookings = [makeBooking('c2', 'BKG-CONT-2', 50, 1, '40FT', 1)]; + + wagonTypesRepository.findAll.mockResolvedValue([nw5]); + bookingsRepository.findByIdsForScheduling.mockResolvedValue(bookings); + trainScheduleBookingsRepository.findByBookingIds.mockResolvedValue([]); + locomotivesRepository.findAll.mockResolvedValue([locomotive]); + + const result = await service.previewTrainSchedule({ + bookingIds: ['c2'], + scheduleDate: '2026-06-20T08:00:00.000Z', + originStationId: 'yard-origin', + destinationStationId: 'yard-destination', + }); + + expect(result.valid).toBe(true); + expect(result.containerUnits).toHaveLength(1); + }); + + it('rejects create when the locked locomotive is no longer available', async () => { + const manager = { + getRepository: jest.fn(() => ({ + findOne: jest.fn().mockResolvedValue({ ...locomotive, status: 'ASSIGNED' }), + })), + }; + + jest.spyOn(service, 'selectOrValidateLocomotive').mockResolvedValue(locomotive as never); + dataSource.getRepository.mockImplementation((entity: { name?: string }) => { + if (entity?.name === 'Route') { + return { + findOne: jest.fn().mockResolvedValue({ + id: 'route-1', + name: 'Djibouti to Addis', + originYardId: 'yard-origin', + destinationYardId: 'yard-destination', + isActive: true, + }), + }; + } + throw new Error(`Unexpected repository ${entity?.name}`); + }); + dataSource.transaction.mockImplementation(async (callback: (tx: typeof manager) => Promise) => + callback(manager), + ); + + await expect( + service.createContainerTrainSchedule({ + routeId: 'route-1', + scheduleDate: '2026-06-20T08:00:00.000Z', + locomotiveId: 'loc-1', + }), + ).rejects.toBeInstanceOf(ConflictException); + }); + + it('rejects pin when wagon is not at the schedule origin yard', async () => { + const scheduleId = 'sched-1'; + const slotId = 'slot-1'; + + trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue({ + id: scheduleId, + status: 'DRAFT', + originStationId: 'yard-origin', + trainSet: { + wagons: [{ id: slotId, physicalWagonId: null }], + }, + }); + + const manager = { + getRepository: jest.fn((entity: { name?: string }) => { + if (entity === Wagon) { + return { + findOne: jest.fn().mockResolvedValue({ + id: 'wagon-1', + wagonNumber: 'WGN-001', + status: WagonStatus.Available, + currentYardId: 'yard-other', + currentTrainScheduleId: null, + }), + update: jest.fn(), + }; + } + if (entity === TrainSetWagon) { + return { update: jest.fn() }; + } + throw new Error(`Unexpected repository ${entity?.name}`); + }), + }; + dataSource.transaction.mockImplementation(async (callback: (tx: typeof manager) => Promise) => + callback(manager), + ); + + await expect( + service.pinWagons(scheduleId, { + assignments: [{ trainSetWagonId: slotId, physicalWagonId: 'wagon-1' }], + }), + ).rejects.toBeInstanceOf(ConflictException); + }); + + it('flags physical fleet shortfall when wagons are not at the origin yard', async () => { + const exportBooking = makeBooking( + 'exp-1', + 'BKG-EXP', + 50, + 1, + '40FT', + 1, + '2026-06-20T08:00:00.000Z', + 'yard-addis', + 'yard-djibouti', + { + originYard: { label: 'Addis Ababa', code: 'ADDIS', country: 'Ethiopia' }, + destinationYard: { label: 'Djibouti', code: 'DJIBOUTI', country: 'Djibouti' }, + }, + ); + + wagonTypesRepository.findAll.mockResolvedValue([nw5]); + bookingsRepository.findByIdsForScheduling.mockResolvedValue([exportBooking]); + trainScheduleBookingsRepository.findByBookingIds.mockResolvedValue([]); + locomotivesRepository.findAll.mockResolvedValue([ + { ...locomotive, currentYardId: 'yard-addis' }, + ]); + + const wrongYardFleet = Array.from({ length: 5 }, (_, index) => ({ + id: `wagon-nw5-${index}`, + wagonTypeId: nw5.id, + wagonNumber: `WGN-${index}`, + status: WagonStatus.Available, + currentYardId: 'yard-djibouti', + currentTrainScheduleId: null, + })); + + dataSource.getRepository.mockImplementation((entity: unknown) => { + if (entity === TrainSchedulingGlobalRules) { + return { find: jest.fn().mockResolvedValue([]) }; + } + if (entity === Wagon) { + return { find: jest.fn().mockResolvedValue(wrongYardFleet) }; + } + if (entity === WagonType) { + return { find: jest.fn().mockResolvedValue([nw5]) }; + } + return { find: jest.fn().mockResolvedValue([]), findOne: jest.fn().mockResolvedValue(null) }; + }); + + const result = await service.previewContainerTrainSchedule({ + bookingIds: ['exp-1'], + scheduleDate: '2026-06-20T08:00:00.000Z', + originStationId: 'yard-addis', + destinationStationId: 'yard-djibouti', + }); + + expect(result.valid).toBe(false); + expect( + result.violations.some((v) => v.includes('available at yard') && v.includes('NW5')), + ).toBe(true); + }); + + it('assignBookingsToSchedule rejects when physical wagons cannot be pinned', async () => { + const scheduleId = 'sched-assign-1'; + const trainSetId = 'train-set-1'; + const booking = makeBooking('b-pin', 'BKG-PIN', 50, 1, '40FT', 1); + + wagonTypesRepository.findAll.mockResolvedValue([nw5]); + bookingsRepository.findByIdsForScheduling.mockResolvedValue([{ ...booking, trainScheduleId: scheduleId }]); + trainScheduleBookingsRepository.findByBookingIds.mockResolvedValue([]); + locomotivesRepository.findAll.mockResolvedValue([locomotive]); + trainSchedulesRepository.findById.mockResolvedValue({ + id: scheduleId, + direction: 'IMPORT', + }); + trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue({ + id: scheduleId, + status: 'DRAFT', + direction: 'IMPORT', + trainSetId, + originStationId: 'yard-origin', + destinationStationId: 'yard-destination', + scheduledDepartureDate: new Date('2026-06-20T08:00:00.000Z'), + trainSet: { + id: trainSetId, + locomotive, + wagons: [], + }, + scheduleBookings: [], + }); + + dataSource.getRepository.mockImplementation((entity: unknown) => { + if (entity === TrainSchedulingGlobalRules) { + return { find: jest.fn().mockResolvedValue([]) }; + } + if (entity === Wagon) { + return { find: jest.fn().mockResolvedValue([]) }; + } + if (entity === WagonType) { + return { find: jest.fn().mockResolvedValue([nw5]) }; + } + return { find: jest.fn().mockResolvedValue([]), findOne: jest.fn().mockResolvedValue(null) }; + }); + + const wagonRepo = { + find: jest.fn().mockResolvedValue([]), + update: jest.fn(), + }; + const trainSetWagonRepo = { + delete: jest.fn(), + create: jest.fn((v) => v), + save: jest.fn(async (rows) => + rows.map((r: { sequenceNo: number; wagonTypeId: string }, i: number) => ({ + ...r, + id: `slot-${i + 1}`, + })), + ), + update: jest.fn(), + }; + + const manager = { + getRepository: jest.fn((entity: unknown) => { + if (entity === Wagon) return wagonRepo; + if (entity === WagonType) return { find: jest.fn().mockResolvedValue([nw5]) }; + if (entity === TrainSetWagon) return trainSetWagonRepo; + if ((entity as { name?: string })?.name === 'TrainSet') return { update: jest.fn() }; + if ((entity as { name?: string })?.name === 'TrainScheduleBooking') return { delete: jest.fn() }; + if ((entity as { name?: string })?.name === 'WagonBookingAllocation') { + return { + create: jest.fn((v) => v), + save: jest.fn(async (v) => ({ ...v, id: 'alloc-1' })), + delete: jest.fn(), + }; + } + return { delete: jest.fn(), update: jest.fn(), find: jest.fn().mockResolvedValue([]) }; + }), + }; + dataSource.transaction.mockImplementation(async (cb: (m: typeof manager) => Promise) => + cb(manager), + ); + + await expect( + service.assignBookingsToSchedule( + scheduleId, + { bookingIds: ['b-pin'], containerPlacements: [] }, + 'CONTAINER', + ), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + describe('getUnassignedBookings', () => { + const scheduleId = 'sched-unassigned-1'; + const trainSetId = 'train-set-unassigned'; + const assignedBooking = makeBooking('b-assigned', 'BKG-ASSIGNED', 50, 1, '40FT', 1); + const unassignedBooking = makeBooking('b-unassigned', 'BKG-UNASSIGNED', 60, 1, '40FT', 1); + + const buildScheduleGraph = () => ({ + id: scheduleId, + status: 'DRAFT', + originStationId: 'yard-origin', + destinationStationId: 'yard-destination', + scheduledDepartureDate: new Date('2026-06-20T08:00:00.000Z'), + trainSet: { + id: trainSetId, + locomotive: { ...locomotive, status: 'ASSIGNED', currentYardId: 'yard-origin' }, + wagons: [{ id: 'slot-1', sequenceNo: 1, wagonTypeId: nw5.id, allocations: [] }], + }, + scheduleBookings: [], + }); + + beforeEach(() => { + wagonTypesRepository.findAll.mockResolvedValue([nw5]); + trainScheduleBookingsRepository.findByBookingIds.mockResolvedValue([]); + locomotivesRepository.findAll.mockResolvedValue([locomotive]); + bookingsRepository.findAll.mockResolvedValue([ + { + ...assignedBooking, + trainScheduleId: scheduleId, + paymentStatus: 'PAID', + isGovernment: false, + }, + { + ...unassignedBooking, + trainScheduleId: scheduleId, + paymentStatus: 'PAID', + isGovernment: false, + }, + ]); + trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue(buildScheduleGraph()); + }); + + it('allows assign when train slots are full but origin yard has matching wagons', async () => { + const yardFleet = [ + { + id: 'wagon-pinned', + wagonTypeId: nw5.id, + status: WagonStatus.Assigned, + currentYardId: 'yard-origin', + currentTrainScheduleId: scheduleId, + }, + ...Array.from({ length: 2 }, (_, index) => ({ + id: `wagon-yard-${index}`, + wagonTypeId: nw5.id, + status: WagonStatus.Available, + currentYardId: 'yard-origin', + currentTrainScheduleId: null, + })), + ]; + + bookingsRepository.findByIdsForScheduling.mockImplementation(async (ids: string[]) => { + const map = new Map([ + [assignedBooking.id, { ...assignedBooking, trainScheduleId: scheduleId }], + [unassignedBooking.id, { ...unassignedBooking, trainScheduleId: scheduleId }], + ]); + return ids.map((id) => map.get(id)).filter(Boolean); + }); + + dataSource.getRepository.mockImplementation((entity: unknown) => { + if (entity === TrainSchedulingGlobalRules) { + return { find: jest.fn().mockResolvedValue([]) }; + } + if (entity === Wagon) { + return { find: jest.fn().mockResolvedValue(yardFleet) }; + } + if (entity === WagonType) { + return { find: jest.fn().mockResolvedValue([nw5]) }; + } + if (entity === WagonBookingAllocation) { + return { + find: jest.fn().mockResolvedValue([{ bookingId: assignedBooking.id }]), + }; + } + return { find: jest.fn().mockResolvedValue([]), findOne: jest.fn().mockResolvedValue(null) }; + }); + + const result = await service.getUnassignedBookings(scheduleId); + + expect(result.bookings).toHaveLength(1); + expect(result.bookings[0].id).toBe(unassignedBooking.id); + expect(result.bookings[0].canAssign).toBe(true); + expect(result.bookings[0].blockReason).toBeNull(); + expect( + result.fleetAtOrigin.some( + (row: { wagonTypeCode: string; available: number }) => + row.wagonTypeCode === 'NW5' && row.available >= 2, + ), + ).toBe(true); + }); + + it('blocks assign when origin yard lacks wagons of the required type', async () => { + const yardFleet = [ + { + id: 'wagon-pinned', + wagonTypeId: nw5.id, + status: WagonStatus.Assigned, + currentYardId: 'yard-origin', + currentTrainScheduleId: scheduleId, + }, + ]; + + bookingsRepository.findByIdsForScheduling.mockImplementation(async (ids: string[]) => { + const map = new Map([ + [assignedBooking.id, { ...assignedBooking, trainScheduleId: scheduleId }], + [unassignedBooking.id, { ...unassignedBooking, trainScheduleId: scheduleId }], + ]); + return ids.map((id) => map.get(id)).filter(Boolean); + }); + + dataSource.getRepository.mockImplementation((entity: unknown) => { + if (entity === TrainSchedulingGlobalRules) { + return { find: jest.fn().mockResolvedValue([]) }; + } + if (entity === Wagon) { + return { find: jest.fn().mockResolvedValue(yardFleet) }; + } + if (entity === WagonType) { + return { find: jest.fn().mockResolvedValue([nw5]) }; + } + if (entity === WagonBookingAllocation) { + return { + find: jest.fn().mockResolvedValue([{ bookingId: assignedBooking.id }]), + }; + } + return { find: jest.fn().mockResolvedValue([]), findOne: jest.fn().mockResolvedValue(null) }; + }); + + const result = await service.getUnassignedBookings(scheduleId); + + expect(result.bookings).toHaveLength(1); + expect(result.bookings[0].canAssign).toBe(false); + expect(result.bookings[0].blockReason).toBeTruthy(); + }); + }); + + describe('getAvailableLocomotivesForRoute', () => { + it('returns locomotives at the route origin yard', async () => { + const routeId = 'route-export'; + const originYardId = 'yard-addis'; + const routeRepo = { + findOne: jest.fn().mockResolvedValue({ + id: routeId, + name: 'Addis → Djibouti', + isActive: true, + originYardId, + originYard: { country: 'Ethiopia' }, + destinationYard: { country: 'Djibouti' }, + }), + }; + dataSource.getRepository.mockImplementation((entity: unknown) => { + if ((entity as { name?: string })?.name === 'Route') return routeRepo; + return { findOne: jest.fn(), update: jest.fn() }; + }); + locomotivesRepository.findAll.mockResolvedValue([ + { id: 'l2', code: 'EXP', status: 'AVAILABLE', currentYardId: originYardId }, + ]); + + const result = await service.getAvailableLocomotivesForRoute(routeId); + + expect(locomotivesRepository.findAll).toHaveBeenCalledWith({ + where: { status: 'AVAILABLE', currentYardId: originYardId }, + order: { code: 'ASC' }, + }); + expect(result).toHaveLength(1); + expect(result[0].code).toBe('EXP'); + }); + + it('returns all locomotives returned by the repository for domestic routes', async () => { + const routeId = 'route-domestic'; + const originYardId = 'yard-addis'; + const routeRepo = { + findOne: jest.fn().mockResolvedValue({ + id: routeId, + name: 'Addis → Dire Dawa', + isActive: true, + originYardId, + originYard: { country: 'Ethiopia' }, + destinationYard: { country: 'Ethiopia' }, + }), + }; + dataSource.getRepository.mockImplementation((entity: unknown) => { + if ((entity as { name?: string })?.name === 'Route') return routeRepo; + return { findOne: jest.fn(), update: jest.fn() }; + }); + locomotivesRepository.findAll.mockResolvedValue([ + { id: 'l1', code: 'IMP', status: 'AVAILABLE', currentYardId: originYardId }, + { id: 'l2', code: 'EXP', status: 'AVAILABLE', currentYardId: originYardId }, + ]); + + const result = await service.getAvailableLocomotivesForRoute(routeId); + + expect(result).toHaveLength(2); + }); + }); +}); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts new file mode 100644 index 000000000..7bfe5811e --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -0,0 +1,2781 @@ +import { + AllocationLoadType, + SchedulingStatus, + TrainCheckpointKind, + TrainScheduleStatus as TrainScheduleStatusEnum, + WagonStatus, +} from '@edr/types'; +import { + BadRequestException, + ConflictException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { DataSource, EntityManager, In } from 'typeorm'; + +import { BookingsRepository } from '../bookings/bookings.repository'; +import { Booking } from '../bookings/entities/booking.entity'; +import { BookingContainer } from '../bookings/entities/booking-container.entity'; +import { Container } from '../container-management/entities/container.entity'; +import { Locomotive } from '../locomotives/entities/locomotive.entity'; +import { LocomotivesRepository } from '../locomotives/locomotives.repository'; +import { Route } from '../routes/entities/route.entity'; +import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity'; +import { TrainSet } from '../train-sets/entities/train-set.entity'; +import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity'; +import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; +import { WagonAllocationContainerItem } from '../train-schedules/entities/wagon-allocation-container-item.entity'; +import { WagonBookingAllocation } from '../train-schedules/entities/wagon-booking-allocation.entity'; +import { TrainScheduleBookingsRepository } from '../train-schedules/train-schedule-bookings.repository'; +import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository'; +import { TrainCompositionRemovalLogRepository } from '../train-schedules/train-composition-removal-log.repository'; +import { WagonAllocationBulkLoadsRepository } from '../train-schedules/wagon-allocation-bulk-loads.repository'; +import { WagonAllocationContainerItemsRepository } from '../train-schedules/wagon-allocation-container-items.repository'; +import { WagonBookingAllocationsRepository } from '../train-schedules/wagon-booking-allocations.repository'; +import { WagonType } from '../wagon-types/entities/wagon-type.entity'; +import { WagonTypesRepository } from '../wagon-types/wagon-types.repository'; +import { Wagon } from '../wagons/entities/wagon.entity'; +import { AssignBookingsDto } from './dto/assign-bookings.dto'; +import { CreateContainerTrainScheduleDto } from './dto/create-container-train-schedule.dto'; +import { GetEligibleBookingsDto } from './dto/get-eligible-bookings.dto'; +import { GetEligibleBulkBookingsDto } from './dto/get-eligible-bulk-bookings.dto'; +import { GetEligibleContainerBookingsDto } from './dto/get-eligible-container-bookings.dto'; +import { PinWagonsDto } from './dto/pin-wagons.dto'; +import { UpdateContainerItemDto } from './dto/update-container-item.dto'; +import { PreviewBulkTrainScheduleDto } from './dto/preview-bulk-train-schedule.dto'; +import { PreviewContainerTrainScheduleDto } from './dto/preview-container-train-schedule.dto'; +import { PreviewTrainScheduleDto } from './dto/preview-train-schedule.dto'; +import { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-rules.entity'; +import { UpdateTrainSchedulingGlobalRulesDto } from './dto/update-train-scheduling-global-rules.dto'; +import { + buildCappedWagonPlan, + computeFleetAvailability, + selectBookingsWithinFleetCap, + summarizeFleetWarnings, + totalAssignedWeight, + wagonsRequiredForBooking, + type DeferredBookingRow, + type FleetAvailabilityRow, +} from './fleet-plan.util'; +import { + buildBulkWagonPlan, + buildContainerWagonPlan, + buildMixedWagonPlan, + expandBookingContainerUnits, + getContainerSlotSequenceNos, + roundTons, + sumWagonsRequired, + type TrainLimitConfig, + validateContainerPlacements, + validateMixedTrainLimits, + validateTrainLimits, + type ContainerPlacementInput, + type WagonPlanSlot, +} from './wagon-plan.util'; +import { + getDefaultContainerWagonTypeCode, + pickBulkWagonType, +} from './wagon-type-resolver.util'; +import { deriveScheduleDirection } from './derive-schedule-direction.util'; +import { + deriveTrainCapacityFromLocomotive, + wagonTypeDimensionsFromEntity, +} from './train-capacity.util'; +import { + DEFAULT_BULK_WAGON_LENGTH_METERS, + DEFAULT_CONTAINER_WAGON_LENGTH_METERS, +} from './booking-batch.constants'; +import { eatDay } from './batch-window.util'; +import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity'; +import { TrainCheckpointEventsRepository } from './train-checkpoint-events.repository'; +import { RecordCheckpointDto } from './dto/record-checkpoint.dto'; +import { RouteMilestone } from '../routes/entities/route-milestone.entity'; +import { + autoFillPlacements, + findMissingContainerNumberIssues, + isPlaceholderContainerNumber, + placementsForBookings, + type ContainerUnitForPlacement, +} from './container-placement.util'; + +const SCHEDULABLE_BOOKING_STATUSES = ['PAID'] as const; + +export type BookingWagonAllocationStatus = + | 'NOT_ATTEMPTED' + | 'ASSIGNED' + | 'DEFERRED' + | 'FAILED'; + +export interface BookingWagonAllocationIssue { + bookingId: string; + status: BookingWagonAllocationStatus; + issue: string | null; +} + +export interface WagonAllocationAttemptResult { + assignedBookingIds: string[]; + deferred: DeferredBookingRow[]; + issues: BookingWagonAllocationIssue[]; + violations: string[]; +} + +export interface CompositionUnassignedBookingRow { + id: string; + reference: string | null; + freightType: string | null; + priorityScore: number; + cargoTotalWeightVgm: number; + status: string | null; + schedulingStatus: string | null; + wagonsRequired: number; + requiredWagonTypeCode: string; + yardWagonsAvailable: number; + canAssign: boolean; + blockReason: string | null; +} + +export interface UnassignedBookingsResponse { + fleetAtOrigin: FleetAvailabilityRow[]; + bookings: CompositionUnassignedBookingRow[]; +} + +const DEFAULT_TRAIN_LIMITS: Required = { + maxWeightTons: 3500, + maxLengthMeters: 760, + maxWagonsPerTrain: Math.floor(760 / 14), + max20ftContainerWeightTons: 30, + max20ftPairWeightDiffTons: 10, +}; + +@Injectable() +export class TrainSchedulingService { + constructor( + @InjectDataSource() + private readonly dataSource: DataSource, + private readonly bookingsRepository: BookingsRepository, + private readonly locomotivesRepository: LocomotivesRepository, + private readonly wagonTypesRepository: WagonTypesRepository, + private readonly trainSchedulesRepository: TrainSchedulesRepository, + private readonly trainScheduleBookingsRepository: TrainScheduleBookingsRepository, + private readonly wagonBookingAllocationsRepository: WagonBookingAllocationsRepository, + private readonly wagonAllocationContainerItemsRepository: WagonAllocationContainerItemsRepository, + private readonly wagonAllocationBulkLoadsRepository: WagonAllocationBulkLoadsRepository, + private readonly trainCheckpointEventsRepository: TrainCheckpointEventsRepository, + private readonly trainCompositionRemovalLogRepository: TrainCompositionRemovalLogRepository, + private readonly configService?: ConfigService, + ) {} + + async getEligibleBookings(query: GetEligibleBookingsDto) { + // Day-level pooling: when the wizard targets a schedule, surface the whole + // (route, EAT day) pool — not just bookings pre-pinned to that train — by + // resolving the schedule's route + day and filtering on the day instead. + let day: string | undefined; + let originStationId = query.originStationId; + let destinationStationId = query.destinationStationId; + if (query.trainScheduleId) { + const schedule = await this.trainSchedulesRepository.findById(query.trainScheduleId); + if (schedule?.scheduledDepartureDate) { + day = eatDay(schedule.scheduledDepartureDate); + originStationId = originStationId ?? schedule.originStationId; + destinationStationId = destinationStationId ?? schedule.destinationStationId; + } + } + + const bookings = await this.bookingsRepository.findEligibleForScheduling({ + freightType: query.freightType, + originStationId, + destinationStationId, + schedulingStatus: query.schedulingStatus, + trainScheduleId: query.trainScheduleId, + day, + }); + return { count: bookings.length, items: bookings.map((b) => this.mapEligibleBooking(b)) }; + } + + async getEligibleContainerBookings(query: GetEligibleContainerBookingsDto) { + return this.getEligibleBookings({ ...query, freightType: 'CONTAINER' }); + } + + async getEligibleBulkBookings(query: GetEligibleBulkBookingsDto) { + return this.getEligibleBookings({ ...query, freightType: 'BULK' }); + } + + async getTrainSchedulingGlobalRules() { + return this.loadGlobalRulesRow(); + } + + async updateTrainSchedulingGlobalRules(dto: UpdateTrainSchedulingGlobalRulesDto) { + const row = await this.loadGlobalRulesRow(); + if (!row) { + throw new NotFoundException('Train scheduling global rules not configured'); + } + if (dto.maxTrainLengthMeters != null) row.maxTrainLengthMeters = dto.maxTrainLengthMeters; + if (dto.maxTrainWeightTons != null) row.maxTrainWeightTons = dto.maxTrainWeightTons; + if (dto.maxWagonsPerTrain != null) row.maxWagonsPerTrain = dto.maxWagonsPerTrain; + if (dto.max20ftContainerWeightTons != null) { + row.max20ftContainerWeightTons = dto.max20ftContainerWeightTons; + } + if (dto.max20ftPairWeightDiffTons != null) { + row.max20ftPairWeightDiffTons = dto.max20ftPairWeightDiffTons; + } + return this.dataSource.getRepository(TrainSchedulingGlobalRules).save(row); + } + + async previewTrainSchedule(dto: PreviewTrainScheduleDto) { + const limits = await this.resolveTrainLimitConfig(dto); + return this.buildPreviewResponse( + await this.validateBookingsForScheduling( + dto, + null, + false, + [], + false, + limits, + dto.targetScheduleId, + ), + ); + } + + async previewContainerTrainSchedule(dto: PreviewContainerTrainScheduleDto) { + const limits = await this.resolveTrainLimitConfig(dto); + return this.buildPreviewResponse( + await this.validateBookingsForScheduling( + dto, + 'CONTAINER', + false, + [], + false, + limits, + dto.targetScheduleId, + ), + ); + } + + async previewBulkTrainSchedule(dto: PreviewBulkTrainScheduleDto) { + const limits = await this.resolveTrainLimitConfig(dto); + return this.buildPreviewResponse( + await this.validateBookingsForScheduling( + dto, + 'BULK', + false, + [], + false, + limits, + dto.targetScheduleId, + ), + ); + } + + private buildPreviewResponse(validation: Awaited>) { + const containerBookings = validation.bookings.filter((b) => b.freightType === 'CONTAINER'); + return { + valid: validation.valid, + violations: validation.violations, + warnings: validation.warnings, + summary: validation.summary, + fleetAvailability: validation.fleetAvailability, + deferredBookings: validation.deferredBookings, + bookingIds: validation.bookings.map((b) => b.id), + wagonPlan: validation.wagonPlan, + containerUnits: containerBookings.length + ? expandBookingContainerUnits(containerBookings) + : [], + containerSlotSequenceNos: getContainerSlotSequenceNos(validation.wagonPlan), + }; + } + + async createContainerTrainSchedule(dto: CreateContainerTrainScheduleDto) { + const route = await this.getActiveRoute(dto.routeId); + const locomotive = await this.selectOrValidateLocomotive(dto.locomotiveId, 0, 0); + + const createdScheduleId = await this.dataSource.transaction(async (manager) => { + const lockedLocomotive = await manager.getRepository(Locomotive).findOne({ + where: { id: locomotive.id }, + lock: { mode: 'pessimistic_write' }, + }); + if (!lockedLocomotive) { + throw new NotFoundException(`Locomotive ${locomotive.id} not found`); + } + if (lockedLocomotive.status !== 'AVAILABLE') { + throw new ConflictException(`Locomotive ${lockedLocomotive.code} is not available`); + } + + const direction = deriveScheduleDirection( + route.originYard ?? { country: null }, + route.destinationYard ?? { country: null }, + ); + if (lockedLocomotive.currentYardId !== route.originYardId) { + throw new ConflictException( + `Locomotive ${lockedLocomotive.code} is at yard ${lockedLocomotive.currentYardId} but schedule originates from ${route.originYardId}`, + ); + } + + const trainSet = await this.buildEmptyTrainSet(manager, lockedLocomotive); + const schedule = manager.getRepository(TrainSchedule).create({ + trainSetId: trainSet.id, + routeId: route.id, + originStationId: route.originYardId, + destinationStationId: route.destinationYardId, + scheduledDepartureDate: new Date(dto.scheduleDate), + status: TrainScheduleStatusEnum.Draft, + direction, + maxWagons: ( + await this.resolveTrainLimitConfig(dto, lockedLocomotive) + ).maxWagonsPerTrain, + }); + const saved = await manager.getRepository(TrainSchedule).save(schedule); + await manager.getRepository(Locomotive).update(lockedLocomotive.id, { status: 'ASSIGNED' }); + return saved.id; + }); + + return this.getTrainScheduleById(createdScheduleId); + } + + async assignBookingsToSchedule( + scheduleId: string, + dto: AssignBookingsDto, + freightType?: 'CONTAINER' | 'BULK', + ) { + const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); + if (!schedule) { + throw new NotFoundException(`Train schedule ${scheduleId} not found`); + } + if (!['DRAFT', 'SCHEDULED'].includes(schedule.status)) { + throw new BadRequestException( + `Cannot assign bookings to schedule in status ${schedule.status}`, + ); + } + if (!schedule.trainSet) { + throw new BadRequestException('Schedule has no train set'); + } + + // Batch parity: a schedule may only allocate bookings that targeted it. This mirrors + // the automatic fill, which only pulls bookings whose train_schedule_id is this schedule. + if (dto.bookingIds.length) { + const targeted = await this.bookingsRepository.findByIdsForScheduling(dto.bookingIds); + const stray = targeted.filter((b) => b.trainScheduleId !== scheduleId); + if (stray.length) { + throw new BadRequestException( + `These bookings are not assigned to this schedule: ${stray + .map((b) => b.reference ?? b.id) + .join(', ')}`, + ); + } + } + + const previewDto = { + bookingIds: dto.bookingIds, + scheduleDate: schedule.scheduledDepartureDate.toISOString(), + originStationId: schedule.originStationId, + destinationStationId: schedule.destinationStationId, + maxTrainWeightTons: dto.maxTrainWeightTons, + maxTrainLengthMeters: dto.maxTrainLengthMeters, + maxWagonsPerTrain: dto.maxWagonsPerTrain, + }; + + const locomotive = schedule.trainSet.locomotive; + const limits = await this.resolveTrainLimitConfig(previewDto, locomotive ?? undefined); + const validation = await this.validateBookingsForScheduling( + previewDto, + freightType ?? null, + dto.forceAssign, + dto.containerPlacements, + true, + limits, + scheduleId, + ); + + if (!validation.valid) { + throw new BadRequestException({ + message: 'Booking validation failed', + violations: validation.violations, + warnings: validation.warnings, + }); + } + + if (!validation.bookings.length) { + throw new BadRequestException({ + message: 'No bookings fit on available fleet wagons', + violations: ['Insufficient fleet wagons for the selected bookings'], + warnings: validation.warnings, + deferredBookings: validation.deferredBookings, + }); + } + + const { bookings, wagonType, wagonPlan, warnings, deferredBookings } = validation; + const totalWeightTons = validation.summary.totalWeightTons; + const totalLengthMeters = validation.summary.totalLengthMeters; + + if (!locomotive) { + throw new BadRequestException('Schedule train set has no locomotive'); + } + if (Number(locomotive.maxPullWeightTons) < totalWeightTons) { + throw new BadRequestException( + `Locomotive ${locomotive.code} cannot pull ${totalWeightTons}T`, + ); + } + if (Number(locomotive.maxTrainLengthMeters) < totalLengthMeters) { + throw new BadRequestException( + `Locomotive ${locomotive.code} cannot support ${totalLengthMeters}m`, + ); + } + + await this.dataSource.transaction(async (manager) => { + const trainSetId = schedule.trainSetId; + + await this.releasePinnedWagonsForTrainSet(manager, trainSetId); + + const deletedAllocationIds = + await this.wagonBookingAllocationsRepository.deleteByTrainSetId(trainSetId, manager); + + if (deletedAllocationIds.length) { + await this.wagonAllocationContainerItemsRepository.deleteByAllocationIds( + deletedAllocationIds, + manager, + ); + await this.wagonAllocationBulkLoadsRepository.deleteByAllocationIds( + deletedAllocationIds, + manager, + ); + } + + await manager.getRepository(TrainSetWagon).delete({ trainSetId }); + await manager.getRepository(TrainScheduleBooking).delete({ trainScheduleId: scheduleId }); + + await manager.getRepository(TrainSet).update(trainSetId, { + totalWeightTons, + totalLengthMeters, + wagonCount: wagonPlan.length, + status: 'ASSIGNED', + }); + + const savedWagons = await this.persistTrainSetWagons( + manager, + trainSetId, + wagonType, + wagonPlan, + ); + + const scheduleBookingRecords = bookings.map((booking) => ({ + trainScheduleId: scheduleId, + bookingId: booking.id, + })); + await this.trainScheduleBookingsRepository.createMany(scheduleBookingRecords, manager); + + await this.persistAllocationsAndLoads( + manager, + savedWagons, + wagonPlan, + bookings, + dto.containerPlacements ?? [], + ); + + for (const booking of bookings) { + await this.bookingsRepository.updateSchedulingFields( + booking.id, + { + schedulingStatus: SchedulingStatus.Eligible, + wagonsRequired: sumWagonsRequired(booking), + }, + manager, + ); + } + + if (schedule.status === TrainScheduleStatusEnum.Draft && bookings.length > 0) { + await this.trainSchedulesRepository.updateStatus( + scheduleId, + TrainScheduleStatusEnum.Draft, + {}, + manager, + ); + } + + await this.autoPinWagonsForSchedule( + manager, + scheduleId, + schedule.originStationId, + savedWagons, + ); + }); + + const detail = await this.getTrainScheduleById(scheduleId); + return { ...detail, warnings, deferredBookings }; + } + + async unassignBooking(scheduleId: string, bookingId: string, userId?: string) { + const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); + if (!schedule) { + throw new NotFoundException(`Train schedule ${scheduleId} not found`); + } + if (!['DRAFT', 'SCHEDULED'].includes(schedule.status)) { + throw new BadRequestException('Cannot unassign from a finalized or dispatched schedule'); + } + + const link = schedule.scheduleBookings?.find((sb) => sb.bookingId === bookingId); + if (!link) { + throw new NotFoundException(`Booking ${bookingId} is not assigned to this schedule`); + } + + const booking = await this.bookingsRepository.findById(bookingId); + const bookingReference = booking?.reference ?? null; + + await this.dataSource.transaction(async (manager) => { + const allocationIds = (schedule.trainSet?.wagons ?? []) + .flatMap((w) => w.allocations ?? []) + .filter((a) => a.bookingId === bookingId) + .map((a) => a.id); + + if (allocationIds.length) { + await this.wagonAllocationContainerItemsRepository.deleteByAllocationIds( + allocationIds, + manager, + ); + await this.wagonAllocationBulkLoadsRepository.deleteByAllocationIds(allocationIds, manager); + await manager.getRepository(WagonBookingAllocation).delete(allocationIds); + } + + await this.trainScheduleBookingsRepository.deleteByScheduleAndBooking( + scheduleId, + bookingId, + manager, + ); + + const booking = await this.bookingsRepository.findById(bookingId); + const schedulingStatus = this.resolvePostUnassignStatus(booking); + await this.bookingsRepository.updateSchedulingFields( + bookingId, + { schedulingStatus, wagonsRequired: null }, + manager, + ); + + const remainingBookings = (schedule.scheduleBookings ?? []).filter( + (sb) => sb.bookingId !== bookingId, + ); + if (remainingBookings.length === 0) { + await this.releasePinnedWagonsForTrainSet(manager, schedule.trainSetId); + await this.wagonBookingAllocationsRepository.deleteByTrainSetId( + schedule.trainSetId, + manager, + ); + await manager.getRepository(TrainSetWagon).delete({ trainSetId: schedule.trainSetId }); + await manager.getRepository(TrainSet).update(schedule.trainSetId, { + totalWeightTons: 0, + totalLengthMeters: 0, + wagonCount: 0, + status: 'DRAFT', + }); + } + }); + + await this.trainCompositionRemovalLogRepository.create({ + scheduleId, + bookingId, + bookingReference, + removedByUserId: userId ?? null, + removedAt: new Date(), + }); + + console.log( + `[NOTIFY] Booking ${bookingReference} removed from schedule ${scheduleId} by user ${userId ?? 'unknown'} — customer should be notified to reschedule or cancel.`, + ); + + return this.getTrainScheduleById(scheduleId); + } + + async pinWagons(scheduleId: string, dto: PinWagonsDto) { + const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); + if (!schedule) { + throw new NotFoundException(`Train schedule ${scheduleId} not found`); + } + if (!['DRAFT', 'SCHEDULED'].includes(schedule.status)) { + throw new BadRequestException('Cannot pin wagons on a dispatched or cancelled schedule'); + } + + const slotIds = new Set((schedule.trainSet?.wagons ?? []).map((w) => w.id)); + + await this.dataSource.transaction(async (manager) => { + for (const assignment of dto.assignments) { + if (!slotIds.has(assignment.trainSetWagonId)) { + throw new BadRequestException( + `Train set wagon ${assignment.trainSetWagonId} does not belong to this schedule`, + ); + } + + const physicalWagon = await manager.getRepository(Wagon).findOne({ + where: { id: assignment.physicalWagonId }, + }); + if (!physicalWagon) { + throw new NotFoundException(`Wagon ${assignment.physicalWagonId} not found`); + } + if ( + physicalWagon.status !== WagonStatus.Available && + physicalWagon.currentTrainScheduleId !== scheduleId + ) { + throw new ConflictException( + `Wagon ${physicalWagon.wagonNumber} is not available`, + ); + } + if (physicalWagon.currentYardId !== schedule.originStationId) { + throw new ConflictException( + `Wagon ${physicalWagon.wagonNumber} is at yard ${physicalWagon.currentYardId} but schedule originates from ${schedule.originStationId}`, + ); + } + + await manager.getRepository(TrainSetWagon).update(assignment.trainSetWagonId, { + physicalWagonId: assignment.physicalWagonId, + status: 'RESERVED', + }); + await manager.getRepository(Wagon).update(assignment.physicalWagonId, { + trainSetWagonId: assignment.trainSetWagonId, + currentTrainScheduleId: scheduleId, + status: WagonStatus.Assigned, + }); + } + }); + + return this.getTrainScheduleById(scheduleId); + } + + async finalizeSchedule(scheduleId: string) { + const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); + if (!schedule) { + throw new NotFoundException(`Train schedule ${scheduleId} not found`); + } + if (schedule.status !== TrainScheduleStatusEnum.Draft) { + throw new BadRequestException('Only DRAFT schedules can be finalized'); + } + if (!schedule.scheduleBookings?.length) { + throw new BadRequestException('Cannot finalize a schedule with no bookings'); + } + + const now = new Date(); + await this.dataSource.transaction(async (manager) => { + await this.trainSchedulesRepository.updateStatus( + scheduleId, + TrainScheduleStatusEnum.Scheduled, + {}, + manager, + ); + for (const sb of schedule.scheduleBookings ?? []) { + await this.bookingsRepository.updateSchedulingFields( + sb.bookingId, + { schedulingStatus: SchedulingStatus.Scheduled, scheduledAt: now }, + manager, + ); + } + }); + + return this.getTrainScheduleById(scheduleId); + } + + async dispatchSchedule(scheduleId: string) { + const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); + if (!schedule) { + throw new NotFoundException(`Train schedule ${scheduleId} not found`); + } + if (schedule.status !== TrainScheduleStatusEnum.Scheduled) { + throw new BadRequestException('Only SCHEDULED trains can be dispatched'); + } + + const now = new Date(); + await this.dataSource.transaction(async (manager) => { + await this.trainSchedulesRepository.updateStatus( + scheduleId, + TrainScheduleStatusEnum.Dispatched, + { actualDepartureAt: now }, + manager, + ); + if (schedule.trainSetId) { + await manager.getRepository(TrainSet).update(schedule.trainSetId, { status: 'DISPATCHED' }); + } + for (const sb of schedule.scheduleBookings ?? []) { + await this.bookingsRepository.updateSchedulingFields( + sb.bookingId, + { schedulingStatus: SchedulingStatus.Dispatched }, + manager, + ); + } + // Close the booking window; any still-pending (unallocated) reservations don't ride this train. + await manager + .getRepository(TrainSchedule) + .update(scheduleId, { bookingWindowStatus: 'CLOSED' }); + await manager + .getRepository(Booking) + .createQueryBuilder() + .update() + .set({ + status: 'EXPIRED', + schedulingStatus: SchedulingStatus.Eligible, + paymentDeadline: null, + }) + .where('train_schedule_id = :scheduleId', { scheduleId }) + .andWhere(`status IN ('SELECTED_FOR_BATCH', 'AWAITING_PAYMENT')`) + .execute(); + }); + + return this.getTrainScheduleById(scheduleId); + } + + /** Open or close a schedule's booking window (staff override). */ + async setBookingWindow(scheduleId: string, status: 'OPEN' | 'CLOSED'): Promise { + await this.dataSource + .getRepository(TrainSchedule) + .update(scheduleId, { bookingWindowStatus: status }); + } + + /** Build the ordered station list for a schedule's corridor (origin → milestones → destination). */ + private async buildScheduleStations(schedule: TrainSchedule) { + type Station = { sequenceNo: number; yardId: string; label: string; code: string }; + const stations: Station[] = []; + + const route = schedule.routeId + ? await this.dataSource.getRepository(Route).findOne({ + where: { id: schedule.routeId }, + relations: { originYard: true, destinationYard: true, milestones: { yard: true } }, + }) + : null; + + if (route) { + // `route.milestones` is the complete ordered corridor and already includes + // the origin (first) and destination (last) yards — `route.originYardId` + // and `route.destinationYardId` are derived from them. Use the milestones + // directly so the endpoints aren't double-counted (Addis…Addis, Dire…Dire). + const milestones = [...(route.milestones ?? [])].sort( + (a: RouteMilestone, b: RouteMilestone) => a.sequenceNo - b.sequenceNo, + ); + + if (milestones.length > 0) { + milestones.forEach((m, i) => + stations.push({ + sequenceNo: i, + yardId: m.yardId, + label: m.yard?.label ?? m.yard?.code ?? `Stop ${i + 1}`, + code: m.yard?.code ?? '', + }), + ); + return stations; + } + + // Route with no milestones recorded — fall back to its origin/destination. + const origin = route.originYard; + const destination = route.destinationYard; + stations.push({ + sequenceNo: 0, + yardId: route.originYardId, + label: origin?.label ?? origin?.code ?? 'Origin', + code: origin?.code ?? '', + }); + stations.push({ + sequenceNo: 1, + yardId: route.destinationYardId, + label: destination?.label ?? destination?.code ?? 'Destination', + code: destination?.code ?? '', + }); + return stations; + } + + // Fallback: no route milestones — just origin → destination from the schedule stations. + stations.push({ + sequenceNo: 0, + yardId: schedule.originStationId, + label: schedule.originStation?.label ?? schedule.originStation?.code ?? 'Origin', + code: schedule.originStation?.code ?? '', + }); + stations.push({ + sequenceNo: 1, + yardId: schedule.destinationStationId, + label: + schedule.destinationStation?.label ?? schedule.destinationStation?.code ?? 'Destination', + code: schedule.destinationStation?.code ?? '', + }); + return stations; + } + + /** Track payload for a schedule: ordered stations, logged checkpoints, current position. */ + async getScheduleCheckpoints(scheduleId: string) { + const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); + if (!schedule) { + throw new NotFoundException(`Train schedule ${scheduleId} not found`); + } + + const stations = await this.buildScheduleStations(schedule); + const events = await this.trainCheckpointEventsRepository.findBySchedule(scheduleId); + + // Resolve each checkpoint's position by its yard against the canonical + // corridor rather than the stored sequenceNo, so legacy checkpoints logged + // under an older station numbering still line up with the current stations. + const seqByYard = new Map(stations.map((s) => [s.yardId, s.sequenceNo])); + const resolvedSeq = (e: TrainCheckpointEvent) => + seqByYard.get(e.yardId) ?? e.sequenceNo; + + const currentSequenceNo = events.length + ? Math.max(...events.map(resolvedSeq)) + : -1; + + return { + scheduleId, + status: schedule.status, + direction: schedule.direction ?? null, + trainNumber: schedule.trainNumber ?? null, + actualDepartureAt: schedule.actualDepartureAt + ? schedule.actualDepartureAt.toISOString() + : null, + actualArrivalAt: schedule.actualArrivalAt + ? schedule.actualArrivalAt.toISOString() + : null, + scheduledDepartureAt: schedule.scheduledDepartureDate + ? schedule.scheduledDepartureDate.toISOString() + : null, + scheduledArrivalAt: schedule.scheduledArrivalDate + ? schedule.scheduledArrivalDate.toISOString() + : null, + origin: stations[0]?.label ?? null, + destination: stations[stations.length - 1]?.label ?? null, + stations, + currentSequenceNo, + checkpoints: events.map((e) => ({ + id: e.id, + sequenceNo: resolvedSeq(e), + yardId: e.yardId, + label: e.yard?.label ?? e.yard?.code ?? null, + kind: e.kind, + occurredAt: e.occurredAt.toISOString(), + note: e.note ?? null, + })), + }; + } + + /** Log the train passing a station. Logging the destination station triggers arrival. */ + async recordCheckpoint(scheduleId: string, dto: RecordCheckpointDto) { + const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); + if (!schedule) { + throw new NotFoundException(`Train schedule ${scheduleId} not found`); + } + if (schedule.status !== TrainScheduleStatusEnum.Dispatched) { + throw new BadRequestException('Only DISPATCHED trains can be tracked'); + } + + const stations = await this.buildScheduleStations(schedule); + const finalSeq = stations[stations.length - 1].sequenceNo; + const station = stations.find((s) => s.sequenceNo === dto.sequenceNo); + if (!station) { + throw new BadRequestException(`Station ${dto.sequenceNo} is not on this route`); + } + + const kind = + dto.kind ?? + (dto.sequenceNo === 0 + ? TrainCheckpointKind.Departed + : dto.sequenceNo === finalSeq + ? TrainCheckpointKind.Arrived + : TrainCheckpointKind.Passed); + const occurredAt = dto.occurredAt ? new Date(dto.occurredAt) : new Date(); + + // Upsert by (scheduleId, sequenceNo) so re-logging a station updates rather than duplicates. + const [existing] = await this.trainCheckpointEventsRepository.findAll({ + where: { trainScheduleId: scheduleId, sequenceNo: dto.sequenceNo }, + }); + if (existing) { + await this.trainCheckpointEventsRepository.update(existing.id, { + kind, + occurredAt, + note: dto.note ?? null, + yardId: station.yardId, + }); + } else { + await this.trainCheckpointEventsRepository.create({ + trainScheduleId: scheduleId, + yardId: station.yardId, + sequenceNo: dto.sequenceNo, + kind, + occurredAt, + note: dto.note ?? null, + }); + } + + if (dto.sequenceNo === finalSeq) { + await this.arriveSchedule(scheduleId); + } + + return this.getScheduleCheckpoints(scheduleId); + } + + /** + * Mark a dispatched train arrived: close out the schedule, move the locomotive + * and wagons to the destination yard, and free the assets for re-use. + */ + async arriveSchedule(scheduleId: string) { + const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); + if (!schedule) { + throw new NotFoundException(`Train schedule ${scheduleId} not found`); + } + if (schedule.status !== TrainScheduleStatusEnum.Dispatched) { + throw new BadRequestException('Only DISPATCHED trains can arrive'); + } + + const now = new Date(); + + await this.dataSource.transaction(async (manager) => { + await this.trainSchedulesRepository.updateStatus( + scheduleId, + TrainScheduleStatusEnum.Arrived, + { actualArrivalAt: now }, + manager, + ); + + if (schedule.trainSetId) { + await manager.getRepository(TrainSet).update(schedule.trainSetId, { + status: 'COMPLETED', + }); + } + + if (schedule.trainSet?.locomotiveId) { + const loco = await manager + .getRepository(Locomotive) + .findOne({ where: { id: schedule.trainSet.locomotiveId } }); + if (loco) { + await manager.getRepository(Locomotive).update(loco.id, { + status: 'AVAILABLE', + currentYardId: schedule.destinationStationId, + }); + } + } + + for (const slot of schedule.trainSet?.wagons ?? []) { + if (!slot.physicalWagonId) continue; + const wagon = await manager + .getRepository(Wagon) + .findOne({ where: { id: slot.physicalWagonId } }); + if (!wagon) continue; + await manager.getRepository(Wagon).update(wagon.id, { + currentTrainScheduleId: null, + trainSetWagonId: null, + status: WagonStatus.Available, + currentYardId: schedule.destinationStationId, + }); + } + + // Ensure a destination checkpoint exists so the timeline shows ARRIVED. + const stations = await this.buildScheduleStations(schedule); + const finalStation = stations[stations.length - 1]; + const [existingFinal] = await this.trainCheckpointEventsRepository.findAll({ + where: { trainScheduleId: scheduleId, sequenceNo: finalStation.sequenceNo }, + }); + if (!existingFinal) { + await manager.getRepository(TrainCheckpointEvent).save( + manager.getRepository(TrainCheckpointEvent).create({ + trainScheduleId: scheduleId, + yardId: finalStation.yardId, + sequenceNo: finalStation.sequenceNo, + kind: TrainCheckpointKind.Arrived, + occurredAt: now, + }), + ); + } + }); + + return this.getTrainScheduleById(scheduleId); + } + + async getContainerTrainSchedules() { + const schedules = await this.trainSchedulesRepository.findAll({ + relations: { + trainSet: { locomotive: true }, + route: true, + originStation: true, + destinationStation: true, + scheduleBookings: { booking: true }, + }, + order: { scheduledDepartureDate: 'DESC', createdAt: 'DESC' }, + }); + return schedules.map((s) => this.mapScheduleListItem(s)); + } + + async getContainerTrainScheduleById(id: string) { + return this.getTrainScheduleById(id); + } + + async cancelTrainSchedule(id: string) { + const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(id); + if (!schedule) { + throw new NotFoundException(`Train schedule ${id} not found`); + } + + await this.dataSource.transaction(async (manager) => { + await this.trainSchedulesRepository.updateStatus( + id, + TrainScheduleStatusEnum.Cancelled, + {}, + manager, + ); + if (schedule.trainSetId) { + await manager.getRepository(TrainSet).update(schedule.trainSetId, { status: 'CANCELLED' }); + } + if (schedule.trainSet?.locomotiveId) { + await manager.getRepository(Locomotive).update(schedule.trainSet.locomotiveId, { + status: 'AVAILABLE', + }); + } + for (const wagon of schedule.trainSet?.wagons ?? []) { + if (wagon.physicalWagonId) { + await manager.getRepository(Wagon).update(wagon.physicalWagonId, { + currentTrainScheduleId: null, + trainSetWagonId: null, + status: WagonStatus.Available, + }); + } + } + for (const sb of schedule.scheduleBookings ?? []) { + const booking = await this.bookingsRepository.findById(sb.bookingId); + await this.bookingsRepository.updateSchedulingFields( + sb.bookingId, + { schedulingStatus: this.resolvePostUnassignStatus(booking) }, + manager, + ); + } + }); + + return this.getTrainScheduleById(id); + } + + private async getTrainScheduleById(id: string) { + const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(id); + if (!schedule) { + throw new NotFoundException(`Train schedule ${id} not found`); + } + return this.mapScheduleDetail(schedule); + } + + private async validateBookingsForScheduling( + dto: PreviewContainerTrainScheduleDto | PreviewBulkTrainScheduleDto | PreviewTrainScheduleDto, + freightType: 'CONTAINER' | 'BULK' | null, + forceAssign = false, + containerPlacements: ContainerPlacementInput[] = [], + requireContainerPlacements = false, + trainLimits: Required, + targetScheduleId?: string, + ) { + const bookingIds = [...new Set(dto.bookingIds)]; + if (!bookingIds.length) { + throw new BadRequestException('At least one booking is required'); + } + + const bookings = await this.bookingsRepository.findByIdsForScheduling(bookingIds); + const violations: string[] = []; + const warnings: string[] = []; + + if (bookings.length !== bookingIds.length) { + const foundIds = new Set(bookings.map((b) => b.id)); + violations.push(`Bookings not found: ${bookingIds.filter((id) => !foundIds.has(id)).join(', ')}`); + } + + const scheduledLinks = await this.trainScheduleBookingsRepository.findByBookingIds(bookingIds); + const conflictingLinks = targetScheduleId + ? scheduledLinks.filter((link) => link.trainScheduleId !== targetScheduleId) + : scheduledLinks; + if (conflictingLinks.length > 0) { + violations.push('One or more selected bookings are already assigned to a train schedule'); + } + + const bookingTypes = new Set(bookings.map((b) => b.freightType)); + const isMixed = bookingTypes.size > 1; + const resolvedMode: 'CONTAINER' | 'BULK' | 'MIXED' = + freightType ?? (isMixed ? 'MIXED' : ([...bookingTypes][0] as 'CONTAINER' | 'BULK')); + + if (freightType === 'CONTAINER' || freightType === 'BULK') { + const wrongType = bookings.filter((b) => b.freightType !== freightType); + if (wrongType.length) { + violations.push(`Only ${freightType} bookings are supported`); + } + } + + const invalidStatus = bookings.filter( + (b) => + !(targetScheduleId && b.trainScheduleId === targetScheduleId) && + !SCHEDULABLE_BOOKING_STATUSES.includes(b.status as 'PAID') && + !b.isGovernment, + ); + if (invalidStatus.length) { + const statuses = [...new Set(invalidStatus.map((b) => b.status))]; + violations.push( + `Only ${SCHEDULABLE_BOOKING_STATUSES.join(', ')} bookings can be scheduled; received: ${statuses.join(', ')}`, + ); + } + + if ( + bookings.some((b) => { + if (targetScheduleId && b.trainScheduleId === targetScheduleId) { + return false; + } + return ( + b.originYardId !== dto.originStationId || + b.destinationYardId !== dto.destinationStationId + ); + }) + ) { + violations.push('Selected bookings must share the same origin and destination as the schedule'); + } + + if (!forceAssign) { + for (const booking of bookings) { + if (this.isHoldActive(booking)) { + warnings.push( + `Booking ${booking.reference} is within the soft hold window (expires ${booking.holdExpiresAt?.toISOString()})`, + ); + } + const overweightLines = (booking.bookingContainers ?? []).filter((c) => c.isOverweight); + if (overweightLines.length) { + violations.push( + `Booking ${booking.reference} has overweight container lines; use forceAssign to override`, + ); + } + } + } + + let wagonType: WagonType; + let containerWagonType: WagonType; + let bulkWagonType: WagonType; + let demandPlan: WagonPlanSlot[]; + let fittingBookings = bookings; + let deferredBookings: DeferredBookingRow[] = []; + let fleetAvailability: FleetAvailabilityRow[] = []; + + if (resolvedMode === 'MIXED') { + const containerBookings = bookings.filter((b) => b.freightType === 'CONTAINER'); + const bulkBookings = bookings.filter((b) => b.freightType === 'BULK'); + containerWagonType = await this.resolveWagonType('CONTAINER', bookingIds); + bulkWagonType = await this.resolveWagonType('BULK', bookingIds); + wagonType = containerWagonType; + demandPlan = buildMixedWagonPlan( + containerBookings, + bulkBookings, + containerWagonType, + bulkWagonType, + ); + } else { + wagonType = await this.resolveWagonType(resolvedMode, bookingIds); + containerWagonType = wagonType; + bulkWagonType = wagonType; + demandPlan = + resolvedMode === 'CONTAINER' + ? buildContainerWagonPlan(bookings, wagonType) + : buildBulkWagonPlan(bookings, wagonType); + } + + const originYardId = dto.originStationId; + const fleetCounts = await this.countFleetAvailability(originYardId, targetScheduleId); + const fleetByTypeId = new Map(fleetCounts.map((row) => [row.wagonTypeId, row.available])); + fleetAvailability = computeFleetAvailability( + demandPlan, + fleetByTypeId, + new Map(fleetCounts.map((row) => [row.wagonTypeId, row.wagonTypeCode])), + ); + + const selection = selectBookingsWithinFleetCap( + bookings, + fleetByTypeId, + (booking) => + booking.freightType === 'BULK' ? bulkWagonType.id : containerWagonType.id, + Number(bulkWagonType.capacityTons), + ); + fittingBookings = selection.fitting; + deferredBookings = selection.deferred; + warnings.push(...summarizeFleetWarnings(fleetAvailability, deferredBookings)); + + const wagonPlan = buildCappedWagonPlan({ + bookings: fittingBookings, + resolvedMode, + containerWagonType, + bulkWagonType, + }); + + violations.push( + ...(await this.validatePhysicalFleetForPlan( + wagonPlan, + originYardId, + targetScheduleId, + )), + ); + + const placementRules = { + max20ftContainerWeightTons: trainLimits.max20ftContainerWeightTons, + max20ftPairWeightDiffTons: trainLimits.max20ftPairWeightDiffTons, + }; + + if (resolvedMode === 'MIXED') { + violations.push( + ...validateMixedTrainLimits(wagonPlan, [containerWagonType, bulkWagonType], trainLimits), + ); + if (requireContainerPlacements) { + const containerBookings = fittingBookings.filter((b) => b.freightType === 'CONTAINER'); + violations.push( + ...validateContainerPlacements( + containerBookings, + wagonPlan, + containerPlacements, + placementRules, + ), + ); + violations.push( + ...(await this.validateFleetContainers(containerPlacements, containerBookings)), + ); + } + } else { + violations.push(...validateTrainLimits(wagonPlan, wagonType, trainLimits)); + + if (requireContainerPlacements && resolvedMode === 'CONTAINER') { + violations.push( + ...validateContainerPlacements( + fittingBookings, + wagonPlan, + containerPlacements, + placementRules, + ), + ); + violations.push( + ...(await this.validateFleetContainers(containerPlacements, fittingBookings)), + ); + } + } + + const totalWeightTons = totalAssignedWeight(fittingBookings); + const totalLengthMeters = roundTons( + wagonPlan.reduce((sum, w) => sum + w.lengthMeters, 0), + ); + if (totalWeightTons > trainLimits.maxWeightTons) { + const message = `Total booking weight ${totalWeightTons}T exceeds max train weight ${trainLimits.maxWeightTons}T`; + if (!violations.includes(message)) { + violations.push(message); + } + } + + let assignedLocomotive: Locomotive | null = null; + if (targetScheduleId) { + const targetSchedule = + await this.trainSchedulesRepository.findByIdWithFullGraph(targetScheduleId); + assignedLocomotive = targetSchedule?.trainSet?.locomotive ?? null; + } + + if (assignedLocomotive) { + if (assignedLocomotive.currentYardId !== originYardId) { + violations.push( + `Locomotive ${assignedLocomotive.code} is not at the schedule origin yard`, + ); + } else if ( + Number(assignedLocomotive.maxPullWeightTons) < totalWeightTons || + Number(assignedLocomotive.maxTrainLengthMeters) < totalLengthMeters + ) { + violations.push( + 'Assigned locomotive cannot support the total train weight and length', + ); + } + } else { + const availableLocomotives = ( + await this.locomotivesRepository.findAll({ + where: { status: 'AVAILABLE' }, + }) + ).filter((l) => l.currentYardId === originYardId); + if (!availableLocomotives.length) { + violations.push('No available locomotive at the schedule origin yard'); + } else if ( + !availableLocomotives.some( + (l) => + Number(l.maxPullWeightTons) >= totalWeightTons && + Number(l.maxTrainLengthMeters) >= totalLengthMeters, + ) + ) { + violations.push('No available locomotive can support the total train weight and length'); + } + } + + return { + valid: violations.length === 0, + violations, + warnings, + bookings: fittingBookings, + wagonType, + wagonPlan, + fleetAvailability, + deferredBookings, + summary: { + totalBookings: fittingBookings.length, + totalWeightTons, + wagonType: + resolvedMode === 'MIXED' ? 'MIXED' : wagonType.code, + wagonsNeeded: wagonPlan.length, + totalLengthMeters, + freightMode: resolvedMode, + }, + }; + } + + private async loadGlobalRulesRow(): Promise { + try { + const rows = await this.dataSource.getRepository(TrainSchedulingGlobalRules).find({ + order: { createdAt: 'ASC' }, + take: 1, + }); + return rows[0] ?? null; + } catch { + return null; + } + } + + private async resolveTrainLimitConfig( + dto?: { + maxTrainWeightTons?: number; + maxTrainLengthMeters?: number; + maxWagonsPerTrain?: number; + }, + locomotive?: Pick, + ): Promise> { + const row = await this.loadGlobalRulesRow(); + const configured = this.configService?.get<{ + maxTrainWeightTons?: number; + maxTrainLengthMeters?: number; + maxWagonsPerTrain?: number; + }>('app.trainScheduling'); + + const ruleWeightCap = + dto?.maxTrainWeightTons ?? + (row?.maxTrainWeightTons != null + ? Number(row.maxTrainWeightTons) + : configured?.maxTrainWeightTons); + const ruleLengthCap = + dto?.maxTrainLengthMeters ?? + (row?.maxTrainLengthMeters != null + ? Number(row.maxTrainLengthMeters) + : configured?.maxTrainLengthMeters); + + const wagonTypes = await this.loadSchedulingWagonTypeDimensions(); + + if (locomotive) { + const derived = deriveTrainCapacityFromLocomotive( + { + maxPullWeightTons: Number(locomotive.maxPullWeightTons), + maxTrainLengthMeters: Number(locomotive.maxTrainLengthMeters), + }, + wagonTypes, + { + maxTrainWeightTons: ruleWeightCap, + maxTrainLengthMeters: ruleLengthCap, + }, + ); + return { + maxWeightTons: derived.maxWeightTons, + maxLengthMeters: derived.maxLengthMeters, + maxWagonsPerTrain: + dto?.maxWagonsPerTrain != null + ? Math.floor(this.positiveNumber(dto.maxWagonsPerTrain, derived.maxWagonSlots)) + : derived.maxWagonSlots, + max20ftContainerWeightTons: this.positiveNumber( + undefined, + Number(row?.max20ftContainerWeightTons) || + DEFAULT_TRAIN_LIMITS.max20ftContainerWeightTons, + ), + max20ftPairWeightDiffTons: this.positiveNumber( + undefined, + Number(row?.max20ftPairWeightDiffTons) || + DEFAULT_TRAIN_LIMITS.max20ftPairWeightDiffTons, + ), + }; + } + + const maxWeightTons = this.positiveNumber( + dto?.maxTrainWeightTons, + ruleWeightCap ?? DEFAULT_TRAIN_LIMITS.maxWeightTons, + ); + const maxLengthMeters = this.positiveNumber( + dto?.maxTrainLengthMeters, + ruleLengthCap ?? DEFAULT_TRAIN_LIMITS.maxLengthMeters, + ); + const derivedWithoutLoco = deriveTrainCapacityFromLocomotive( + { maxPullWeightTons: maxWeightTons, maxTrainLengthMeters: maxLengthMeters }, + wagonTypes, + ); + + return { + maxWeightTons, + maxLengthMeters, + maxWagonsPerTrain: Math.floor( + this.positiveNumber( + dto?.maxWagonsPerTrain, + row?.maxWagonsPerTrain != null + ? Number(row.maxWagonsPerTrain) + : configured?.maxWagonsPerTrain ?? derivedWithoutLoco.maxWagonSlots, + ), + ), + max20ftContainerWeightTons: this.positiveNumber( + undefined, + Number(row?.max20ftContainerWeightTons) || DEFAULT_TRAIN_LIMITS.max20ftContainerWeightTons, + ), + max20ftPairWeightDiffTons: this.positiveNumber( + undefined, + Number(row?.max20ftPairWeightDiffTons) || + DEFAULT_TRAIN_LIMITS.max20ftPairWeightDiffTons, + ), + }; + } + + private async loadSchedulingWagonTypeDimensions(): Promise< + Array<{ lengthMeters: number; capacityTons: number }> + > { + const types = await this.dataSource.getRepository(WagonType).find({ + where: [{ code: 'NW5' }, { code: 'CW3' }], + }); + if (types.length) return types.map(wagonTypeDimensionsFromEntity); + return [ + { lengthMeters: DEFAULT_CONTAINER_WAGON_LENGTH_METERS, capacityTons: 70 }, + { lengthMeters: DEFAULT_BULK_WAGON_LENGTH_METERS, capacityTons: 60 }, + ]; + } + + private async countFleetAvailability( + originYardId: string, + targetScheduleId?: string, + ): Promise> { + const [wagons, wagonTypes] = await Promise.all([ + this.dataSource.getRepository(Wagon).find(), + this.dataSource.getRepository(WagonType).find(), + ]); + const typeCodeById = new Map(wagonTypes.map((type) => [type.id, type.code])); + const counts = new Map(); + + for (const wagon of wagons) { + const pinnedOnTarget = targetScheduleId + ? wagon.currentTrainScheduleId === targetScheduleId + : false; + if (wagon.status !== WagonStatus.Available && !pinnedOnTarget) continue; + if (wagon.currentYardId !== originYardId) continue; + + const typeId = wagon.wagonTypeId; + const code = typeCodeById.get(typeId) ?? typeId; + const existing = counts.get(typeId) ?? { code, available: 0 }; + existing.available += 1; + counts.set(typeId, existing); + } + + return [...counts.entries()].map(([wagonTypeId, value]) => ({ + wagonTypeId, + wagonTypeCode: value.code, + available: value.available, + })); + } + + private async releasePinnedWagonsForTrainSet(manager: EntityManager, trainSetId: string) { + const slots = await manager.getRepository(TrainSetWagon).find({ where: { trainSetId } }); + for (const slot of slots) { + if (!slot.physicalWagonId) continue; + await manager.getRepository(Wagon).update(slot.physicalWagonId, { + status: WagonStatus.Available, + trainSetWagonId: null, + currentTrainScheduleId: null, + }); + } + } + + private async autoPinWagonsForSchedule( + manager: EntityManager, + scheduleId: string, + originYardId: string, + slots: TrainSetWagon[], + ) { + const wagons = await manager.getRepository(Wagon).find(); + const wagonTypes = await manager.getRepository(WagonType).find(); + const typeCodeById = new Map(wagonTypes.map((wt) => [wt.id, wt.code])); + + const planSlots = [...slots] + .sort((a, b) => a.sequenceNo - b.sequenceNo) + .map((slot) => ({ + sequenceNo: slot.sequenceNo, + wagonTypeId: slot.wagonTypeId, + wagonTypeCode: typeCodeById.get(slot.wagonTypeId) ?? slot.wagonTypeId, + trainSetWagonId: slot.id, + })); + + const unpinnable = this.findUnpinnableWagonSlots( + planSlots, + wagons, + scheduleId, + originYardId, + ); + if (unpinnable.length) { + throw new BadRequestException({ + message: 'Insufficient physical wagons to pin all train slots', + violations: unpinnable, + }); + } + + const assignedPhysicalIds = new Set(); + for (const slot of planSlots) { + const physical = this.pickPhysicalWagonForSlot( + slot, + wagons, + scheduleId, + originYardId, + assignedPhysicalIds, + ); + if (!physical) continue; + + await manager.getRepository(TrainSetWagon).update(slot.trainSetWagonId!, { + physicalWagonId: physical.id, + status: 'RESERVED', + }); + await manager.getRepository(Wagon).update(physical.id, { + trainSetWagonId: slot.trainSetWagonId, + currentTrainScheduleId: scheduleId, + status: WagonStatus.Assigned, + }); + assignedPhysicalIds.add(physical.id); + } + } + + /** Pre-assign check: every planned slot must have a matching physical wagon. */ + private async validatePhysicalFleetForPlan( + wagonPlan: WagonPlanSlot[], + originYardId: string, + targetScheduleId?: string, + ): Promise { + if (!wagonPlan.length) return []; + + const wagons = await this.dataSource.getRepository(Wagon).find(); + return this.findUnpinnableWagonSlots( + wagonPlan.map((slot) => ({ + sequenceNo: slot.sequenceNo, + wagonTypeId: slot.wagonTypeId, + wagonTypeCode: slot.wagonTypeCode, + })), + wagons, + targetScheduleId, + originYardId, + ); + } + + private findUnpinnableWagonSlots( + slots: Array<{ sequenceNo: number; wagonTypeId: string; wagonTypeCode: string }>, + wagons: Wagon[], + scheduleId: string | undefined, + originYardId: string, + ): string[] { + const violations: string[] = []; + const assignedPhysicalIds = new Set(); + + for (const slot of [...slots].sort((a, b) => a.sequenceNo - b.sequenceNo)) { + const physical = this.pickPhysicalWagonForSlot( + slot, + wagons, + scheduleId, + originYardId, + assignedPhysicalIds, + ); + if (!physical) { + violations.push( + `No ${slot.wagonTypeCode} wagon available at yard for slot #${slot.sequenceNo}`, + ); + continue; + } + assignedPhysicalIds.add(physical.id); + } + + return violations; + } + + private pickPhysicalWagonForSlot( + slot: { wagonTypeId: string }, + wagons: Wagon[], + scheduleId: string | undefined, + originYardId: string, + assignedPhysicalIds: Set, + ): Wagon | undefined { + return wagons.find((wagon) => { + if (wagon.wagonTypeId !== slot.wagonTypeId) return false; + if (assignedPhysicalIds.has(wagon.id)) return false; + const pinnedOnSchedule = scheduleId + ? wagon.currentTrainScheduleId === scheduleId + : false; + if (wagon.status !== WagonStatus.Available && !pinnedOnSchedule) return false; + return wagon.currentYardId === originYardId; + }); + } + + private positiveNumber(value: number | undefined, fallback: number): number { + const numeric = Number(value); + return Number.isFinite(numeric) && numeric > 0 ? numeric : fallback; + } + + private async validateFleetContainers( + placements: ContainerPlacementInput[], + containerBookings: Booking[], + ): Promise { + const violations: string[] = []; + const inventoryIds = [ + ...new Set(placements.map((p) => p.containerId).filter((id): id is string => Boolean(id))), + ]; + if (!inventoryIds.length) return violations; + + const lineById = new Map( + containerBookings.flatMap((b) => + (b.bookingContainers ?? []).map((line) => [line.id, line] as const), + ), + ); + + const containers = await this.dataSource.getRepository(Container).find({ + where: { id: In(inventoryIds) }, + }); + const containerById = new Map(containers.map((c) => [c.id, c])); + + for (const placement of placements) { + if (!placement.containerId) continue; + const fleet = containerById.get(placement.containerId); + if (!fleet) { + violations.push(`Fleet container ${placement.containerId} not found`); + continue; + } + if (fleet.status !== 'AVAILABLE') { + violations.push(`Container ${fleet.containerNumber} is not available`); + } + const line = lineById.get(placement.bookingContainerId); + if (line && fleet.containerTypeId !== line.containerTypeId) { + violations.push( + `Container ${fleet.containerNumber} type does not match booking line`, + ); + } + if ( + placement.containerNumber && + fleet.containerNumber.toUpperCase() !== placement.containerNumber.trim().toUpperCase() + ) { + violations.push( + `Container number ${placement.containerNumber} does not match fleet record ${fleet.containerNumber}`, + ); + } + } + + return violations; + } + + private async resolveWagonType( + freightType: 'CONTAINER' | 'BULK', + bookingIds: string[], + ): Promise { + if (freightType === 'CONTAINER') { + const [wagonType] = await this.wagonTypesRepository.findAll({ + where: { code: getDefaultContainerWagonTypeCode(), isActive: true }, + }); + if (!wagonType) { + throw new NotFoundException(`Wagon type ${getDefaultContainerWagonTypeCode()} not found`); + } + return wagonType; + } + + const bookings = await this.bookingsRepository.findByIdsForScheduling(bookingIds); + const cargoCode = bookings[0]?.cargoType?.code ?? null; + const wagonTypes = await this.wagonTypesRepository.findAll({ where: { isActive: true } }); + const picked = pickBulkWagonType(wagonTypes, cargoCode); + if (!picked) { + throw new NotFoundException('No suitable bulk wagon type found'); + } + return picked; + } + + private async persistTrainSetWagons( + manager: EntityManager, + trainSetId: string, + wagonType: WagonType, + wagonPlan: WagonPlanSlot[], + ) { + const wagons = wagonPlan.map((slot) => + manager.getRepository(TrainSetWagon).create({ + trainSetId, + wagonTypeId: slot.wagonTypeId ?? wagonType.id, + sequenceNo: slot.sequenceNo, + capacityTons: slot.capacityTons, + lengthMeters: slot.lengthMeters, + assignedWeightTons: slot.assignedWeightTons, + status: 'PLANNED', + }), + ); + return manager.getRepository(TrainSetWagon).save(wagons); + } + + private async persistAllocationsAndLoads( + manager: EntityManager, + savedWagons: TrainSetWagon[], + wagonPlan: WagonPlanSlot[], + bookings: Booking[], + containerPlacements: ContainerPlacementInput[] = [], + ) { + const bookingById = new Map(bookings.map((b) => [b.id, b])); + const lineById = new Map( + bookings.flatMap((b) => + (b.bookingContainers ?? []).map((line) => [line.id, { line, bookingId: b.id }] as const), + ), + ); + const allocationBySlotBooking = new Map(); + + const containerItems: Array<{ + wagonBookingAllocationId: string; + bookingContainerId: string; + containerTypeId: string | null; + grossWeightTons: number; + positionOnWagon: number | null; + containerId?: string | null; + containerNumber?: string | null; + sealNumber?: string | null; + }> = []; + const bulkLoads: Array<{ + wagonBookingAllocationId: string; + bookingId: string; + cargoTypeId: string | null; + cargoDescription: string | null; + weightTons: number; + quantity: number; + }> = []; + + for (let i = 0; i < savedWagons.length; i += 1) { + const slot = wagonPlan[i]; + const trainSetWagon = savedWagons[i]; + if (!slot || !trainSetWagon) continue; + + for (const alloc of slot.allocations) { + const savedAllocation = await manager.getRepository(WagonBookingAllocation).save( + manager.getRepository(WagonBookingAllocation).create({ + trainSetWagonId: trainSetWagon.id, + bookingId: alloc.bookingId, + allocatedWeightTons: alloc.allocatedWeightTons, + loadType: alloc.loadType, + status: 'PLANNED', + }), + ); + + allocationBySlotBooking.set( + `${slot.sequenceNo}:${alloc.bookingId}`, + savedAllocation.id, + ); + + const booking = bookingById.get(alloc.bookingId); + if (!booking) continue; + + if (alloc.loadType === AllocationLoadType.Bulk) { + bulkLoads.push({ + wagonBookingAllocationId: savedAllocation.id, + bookingId: booking.id, + cargoTypeId: booking.cargoTypeId ?? null, + cargoDescription: booking.cargoFreeText ?? null, + weightTons: alloc.allocatedWeightTons, + quantity: 1, + }); + } + } + } + + for (const placement of containerPlacements) { + const lineEntry = lineById.get(placement.bookingContainerId); + if (!lineEntry) continue; + + // Durably persist the container number on the booking container line first, so it + // survives a refresh regardless of whether a wagon allocation slot can be matched + // below. booking_container is the source of truth re-read into the preview units. + if (placement.containerNumber && placement.containerNumber.trim()) { + await manager.getRepository(BookingContainer).update(placement.bookingContainerId, { + containerNumber: placement.containerNumber.trim(), + }); + } + + const allocationId = allocationBySlotBooking.get( + `${placement.sequenceNo}:${lineEntry.bookingId}`, + ); + if (!allocationId) continue; + + const { line } = lineEntry; + containerItems.push({ + wagonBookingAllocationId: allocationId, + bookingContainerId: placement.bookingContainerId, + containerTypeId: line.containerTypeId ?? null, + grossWeightTons: Number(line.vgmPerUnitTons), + positionOnWagon: placement.unitIndex + 1, + containerId: placement.containerId ?? null, + containerNumber: placement.containerNumber?.trim() ?? null, + sealNumber: placement.sealNumber ?? null, + }); + + if (placement.containerId) { + await manager.getRepository(Container).update(placement.containerId, { + status: 'LOADED', + bookingId: lineEntry.bookingId, + wagonBookingAllocationId: allocationId, + bookingContainerId: placement.bookingContainerId, + }); + } + } + + if (containerItems.length) { + await this.wagonAllocationContainerItemsRepository.createMany(containerItems, manager); + } + if (bulkLoads.length) { + await this.wagonAllocationBulkLoadsRepository.createMany(bulkLoads, manager); + } + } + + async selectOrValidateLocomotive( + locomotiveId: string, + totalWeightTons: number, + totalLengthMeters: number, + ) { + const locomotive = await this.locomotivesRepository.findById(locomotiveId); + if (!locomotive) { + throw new NotFoundException(`Locomotive ${locomotiveId} not found`); + } + if (locomotive.status !== 'AVAILABLE') { + throw new BadRequestException(`Locomotive ${locomotive.code} is not available`); + } + if (Number(locomotive.maxPullWeightTons) < totalWeightTons) { + throw new BadRequestException(`Locomotive ${locomotive.code} cannot pull ${totalWeightTons}T`); + } + if (Number(locomotive.maxTrainLengthMeters) < totalLengthMeters) { + throw new BadRequestException( + `Locomotive ${locomotive.code} cannot support ${totalLengthMeters}m`, + ); + } + return locomotive; + } + + private async buildEmptyTrainSet(manager: EntityManager, locomotive: Locomotive) { + const trainSet = manager.getRepository(TrainSet).create({ + locomotiveId: locomotive.id, + totalWeightTons: 0, + totalLengthMeters: 0, + wagonCount: 0, + status: 'DRAFT', + }); + return manager.getRepository(TrainSet).save(trainSet); + } + + private async getActiveRoute(routeId: string) { + const route = await this.dataSource.getRepository(Route).findOne({ + where: { id: routeId }, + relations: { originYard: true, destinationYard: true }, + }); + if (!route) throw new NotFoundException(`Route ${routeId} not found`); + if (!route.isActive) throw new BadRequestException(`Route ${route.name} is inactive`); + return route; + } + + private mapEligibleBooking(booking: Booking) { + return { + id: booking.id, + reference: booking.reference, + freightType: booking.freightType, + customer: booking.company?.name ?? booking.company?.email ?? 'Unknown customer', + priorityScore: booking.priorityScore, + schedulingStatus: booking.schedulingStatus, + containerType: + booking.bookingContainers + ?.map((c) => c.containerType?.label ?? c.containerType?.code ?? 'Container') + .join(', ') ?? (booking.cargoType?.cargoTypeName ?? 'Bulk'), + quantity: + booking.bookingContainers?.reduce((sum, c) => sum + Number(c.quantity ?? 0), 0) ?? 0, + weightTons: roundTons(booking.cargoTotalWeightVgm), + origin: booking.originYard?.label ?? booking.originYard?.code ?? 'Unknown origin', + destination: + booking.destinationYard?.label ?? booking.destinationYard?.code ?? 'Unknown destination', + preferredDepartureDate: booking.scheduledDate.toISOString(), + status: booking.status, + }; + } + + private resolveScheduleFreightType( + schedule: import('../train-schedules/entities/train-schedule.entity').TrainSchedule, + ): 'CONTAINER' | 'BULK' | 'MIXED' | null { + const types = new Set( + (schedule.scheduleBookings ?? []) + .map((sb) => sb.booking?.freightType) + .filter((t): t is string => Boolean(t)), + ); + if (types.size === 1) return [...types][0] as 'CONTAINER' | 'BULK'; + if (types.size > 1) return 'MIXED'; + return null; + } + + private mapScheduleListItem(schedule: import('../train-schedules/entities/train-schedule.entity').TrainSchedule) { + return { + id: schedule.id, + scheduleDate: schedule.scheduledDepartureDate, + trainNumber: schedule.trainNumber ?? null, + routeName: schedule.route?.name ?? null, + origin: schedule.originStation?.label ?? schedule.originStation?.code ?? null, + destination: + schedule.destinationStation?.label ?? schedule.destinationStation?.code ?? null, + locomotive: schedule.trainSet?.locomotive + ? { + id: schedule.trainSet.locomotive.id, + code: schedule.trainSet.locomotive.code, + name: schedule.trainSet.locomotive.name ?? null, + currentYardId: schedule.trainSet.locomotive.currentYardId ?? null, + } + : null, + wagonCount: schedule.trainSet?.wagonCount ?? 0, + totalWeightTons: roundTons(Number(schedule.trainSet?.totalWeightTons ?? 0)), + totalLengthMeters: roundTons(Number(schedule.trainSet?.totalLengthMeters ?? 0)), + bookingsCount: schedule.scheduleBookings?.length ?? 0, + freightType: this.resolveScheduleFreightType(schedule), + status: schedule.status, + bookingWindowStatus: schedule.bookingWindowStatus ?? 'OPEN', + maxWagons: schedule.maxWagons ?? 0, + remainingWagons: Math.max( + 0, + (schedule.maxWagons ?? 0) - (schedule.trainSet?.wagonCount ?? 0), + ), + }; + } + + /** AVAILABLE locomotives at the route's origin yard. */ + async getAvailableLocomotivesForRoute(routeId: string): Promise { + const route = await this.getActiveRoute(routeId); + + const locomotives = await this.locomotivesRepository.findAll({ + where: { status: 'AVAILABLE', currentYardId: route.originYardId }, + order: { code: 'ASC' }, + }); + + return locomotives; + } + + /** OPEN schedules a new booking may target (with rough remaining capacity). + * Supports sub-route matching: if originYardId and/or destinationYardId are provided, + * returns schedules whose route passes through both yards in the correct order. + */ + async getBookableSchedules(originYardId?: string, destinationYardId?: string) { + const schedules = await this.trainSchedulesRepository.findAll({ + where: { + bookingWindowStatus: 'OPEN', + }, + relations: { + trainSet: { locomotive: true }, + route: { milestones: true }, + originStation: true, + destinationStation: true, + scheduleBookings: { booking: true }, + }, + order: { scheduledDepartureDate: 'ASC' }, + }); + + const filteredSchedules = schedules + .filter((s) => ['DRAFT', 'SCHEDULED'].includes(s.status)) + .filter((s) => { + // Build the full stop list: origin -> milestones (ordered) -> destination + const milestones = s.route?.milestones ?? []; + const sortedMilestones = [...milestones].sort((a, b) => a.sequenceNo - b.sequenceNo); + const stopYardIds = [s.originStationId, ...sortedMilestones.map((m) => m.yardId), s.destinationStationId]; + + // Remove duplicates while preserving order (in case origin/destination appears in milestones) + const uniqueStopYardIds: string[] = []; + for (const yardId of stopYardIds) { + if (!uniqueStopYardIds.includes(yardId)) { + uniqueStopYardIds.push(yardId); + } + } + + // Check origin yard filter + if (originYardId) { + if (!uniqueStopYardIds.includes(originYardId)) { + return false; + } + } + + // Check destination yard filter + if (destinationYardId) { + if (!uniqueStopYardIds.includes(destinationYardId)) { + return false; + } + // Ensure destination comes after origin (if both are specified) + if (originYardId) { + const originIndex = uniqueStopYardIds.indexOf(originYardId); + const destIndex = uniqueStopYardIds.indexOf(destinationYardId); + if (destIndex <= originIndex) { + return false; + } + } + } + + return true; + }) + .map((s) => this.mapScheduleListItem(s)); + + return filteredSchedules; + } + + /** + * Day-level pool: the distinct EAT calendar days that have ≥1 OPEN bookable + * departure on the route. Customers pick a DAY (not a train) — so this returns + * only the day strings, no capacity, counts or train info. + */ + async getAvailableDays( + originYardId?: string, + destinationYardId?: string, + ): Promise<{ days: string[] }> { + const schedules = await this.getBookableSchedules(originYardId, destinationYardId); + const days = new Set(); + for (const s of schedules) { + if (s.scheduleDate) days.add(eatDay(new Date(s.scheduleDate))); + } + return { days: [...days].sort() }; + } + + /** Whether a route has ≥1 OPEN bookable departure on a given EAT day. */ + async existsOpenScheduleOnRouteDay( + originYardId: string, + destinationYardId: string, + day: string, + ): Promise { + const { days } = await this.getAvailableDays(originYardId, destinationYardId); + return days.includes(day); + } + + private async mapScheduleDetail( + schedule: import('../train-schedules/entities/train-schedule.entity').TrainSchedule, + ) { + const allocationIds = (schedule.trainSet?.wagons ?? []) + .flatMap((w) => w.allocations ?? []) + .map((a) => a.id); + + const [containerItems, bulkLoads] = await Promise.all([ + allocationIds.length + ? this.wagonAllocationContainerItemsRepository.findAll({ + where: { wagonBookingAllocationId: In(allocationIds) }, + relations: { containerType: true, bookingContainer: true }, + }) + : [], + allocationIds.length + ? this.wagonAllocationBulkLoadsRepository.findAll({ + where: { wagonBookingAllocationId: In(allocationIds) }, + relations: { cargoType: true }, + }) + : [], + ]); + + const containerItemsByAllocation = new Map(); + for (const item of containerItems) { + const list = containerItemsByAllocation.get(item.wagonBookingAllocationId) ?? []; + list.push(item); + containerItemsByAllocation.set(item.wagonBookingAllocationId, list); + } + const bulkLoadsByAllocation = new Map( + bulkLoads.map((load) => [load.wagonBookingAllocationId, load]), + ); + + return { + id: schedule.id, + status: schedule.status, + freightType: this.resolveScheduleFreightType(schedule), + trainNumber: schedule.trainNumber ?? null, + direction: schedule.direction ?? null, + route: schedule.route ? { id: schedule.route.id, name: schedule.route.name } : null, + scheduledDepartureDate: schedule.scheduledDepartureDate, + scheduledArrivalDate: schedule.scheduledArrivalDate, + actualDepartureAt: schedule.actualDepartureAt ?? null, + originStation: schedule.originStation, + destinationStation: schedule.destinationStation, + trainSet: schedule.trainSet + ? { + id: schedule.trainSet.id, + status: schedule.trainSet.status, + wagonCount: schedule.trainSet.wagonCount, + totalWeightTons: roundTons(Number(schedule.trainSet.totalWeightTons)), + totalLengthMeters: roundTons(Number(schedule.trainSet.totalLengthMeters)), + locomotive: schedule.trainSet.locomotive + ? { + id: schedule.trainSet.locomotive.id, + code: schedule.trainSet.locomotive.code, + name: schedule.trainSet.locomotive.name, + status: schedule.trainSet.locomotive.status, + currentYardId: schedule.trainSet.locomotive.currentYardId ?? null, + maxPullWeightTons: roundTons( + Number(schedule.trainSet.locomotive.maxPullWeightTons), + ), + maxTrainLengthMeters: roundTons( + Number(schedule.trainSet.locomotive.maxTrainLengthMeters), + ), + } + : null, + wagons: [...(schedule.trainSet.wagons ?? [])] + .sort((a, b) => a.sequenceNo - b.sequenceNo) + .map((wagon) => ({ + id: wagon.id, + sequenceNo: wagon.sequenceNo, + capacityTons: roundTons(Number(wagon.capacityTons)), + lengthMeters: roundTons(Number(wagon.lengthMeters)), + assignedWeightTons: roundTons(Number(wagon.assignedWeightTons)), + status: wagon.status, + physicalWagonId: wagon.physicalWagonId ?? null, + physicalWagonNumber: wagon.physicalWagon?.wagonNumber ?? null, + wagonType: wagon.wagonType + ? { id: wagon.wagonType.id, code: wagon.wagonType.code, name: wagon.wagonType.name } + : null, + allocations: + wagon.allocations?.map((allocation) => ({ + id: allocation.id, + bookingId: allocation.bookingId, + bookingReference: allocation.booking?.reference ?? null, + allocatedWeightTons: roundTons(Number(allocation.allocatedWeightTons)), + loadType: allocation.loadType ?? null, + status: allocation.status, + containerItems: (containerItemsByAllocation.get(allocation.id) ?? []).map( + (item) => ({ + id: item.id, + containerNumber: item.containerNumber ?? null, + containerTypeId: item.containerTypeId, + grossWeightTons: item.grossWeightTons ?? null, + containerId: item.containerId ?? null, + positionOnWagon: item.positionOnWagon ?? null, + bookingContainerId: item.bookingContainerId ?? null, + }), + ), + bulkLoad: bulkLoadsByAllocation.get(allocation.id) + ? { + id: bulkLoadsByAllocation.get(allocation.id)!.id, + weightTons: bulkLoadsByAllocation.get(allocation.id)!.weightTons, + cargoDescription: + bulkLoadsByAllocation.get(allocation.id)!.cargoDescription ?? null, + } + : null, + })) ?? [], + })), + } + : null, + bookings: + schedule.scheduleBookings?.map((sb) => ({ + id: sb.booking?.id ?? sb.bookingId, + reference: sb.booking?.reference ?? null, + customer: sb.booking?.company?.name ?? sb.booking?.company?.email ?? null, + weightTons: roundTons(Number(sb.booking?.cargoTotalWeightVgm ?? 0)), + status: sb.booking?.status ?? null, + schedulingStatus: sb.booking?.schedulingStatus ?? null, + })) ?? [], + }; + } + + private isHoldActive(booking: Booking): boolean { + if (!booking.holdExpiresAt) return false; + return booking.holdExpiresAt.getTime() > Date.now(); + } + + private resolvePostUnassignStatus(booking: Booking | null): string { + if (!booking) return SchedulingStatus.NotScheduled; + if (booking.holdExpiresAt && booking.holdExpiresAt.getTime() > Date.now()) { + return SchedulingStatus.Holding; + } + return SchedulingStatus.Eligible; + } + + /** Assign one linked-but-unallocated booking onto wagons, preserving existing wagon assignments. */ + async assignUnassignedBookingToWagons(scheduleId: string, bookingId: string) { + const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); + if (!schedule) { + throw new NotFoundException(`Train schedule ${scheduleId} not found`); + } + if (!schedule.trainSet?.locomotive) { + throw new BadRequestException('Schedule has no locomotive — cannot assign booking'); + } + if (!['DRAFT', 'SCHEDULED'].includes(schedule.status)) { + throw new BadRequestException( + `Cannot assign bookings to schedule in status ${schedule.status}`, + ); + } + + const [booking] = await this.bookingsRepository.findByIdsForScheduling([bookingId]); + if (!booking) { + throw new NotFoundException(`Booking ${bookingId} not found`); + } + if (booking.trainScheduleId !== scheduleId) { + throw new BadRequestException('Booking is not linked to this schedule'); + } + if (!this.isReadyToLoadBooking(booking)) { + throw new BadRequestException('Booking is not paid and ready to load'); + } + + const wagonAssignedIds = await this.getWagonAssignedBookingIds(scheduleId); + if (wagonAssignedIds.has(bookingId)) { + throw new BadRequestException('Booking is already assigned to a wagon'); + } + + const allBookingIds = [...wagonAssignedIds, bookingId]; + const previewDto = { + bookingIds: allBookingIds, + scheduleDate: schedule.scheduledDepartureDate.toISOString(), + originStationId: schedule.originStationId, + destinationStationId: schedule.destinationStationId, + }; + const limits = await this.resolveTrainLimitConfig(undefined, schedule.trainSet.locomotive); + + const validation = await this.validateBookingsForScheduling( + previewDto, + null, + false, + [], + false, + limits, + scheduleId, + ); + + if (!validation.valid) { + throw new BadRequestException({ + message: 'Booking validation failed', + violations: validation.violations, + warnings: validation.warnings, + }); + } + + if (!validation.bookings.some((b) => b.id === bookingId)) { + const deferred = validation.deferredBookings.find((d) => d.id === bookingId); + throw new BadRequestException({ + message: deferred?.reason ?? 'Booking does not fit on available fleet wagons', + violations: validation.violations, + warnings: validation.warnings, + deferredBookings: validation.deferredBookings, + }); + } + + const containerBookings = validation.bookings.filter((b) => b.freightType === 'CONTAINER'); + const units: ContainerUnitForPlacement[] = expandBookingContainerUnits(containerBookings); + const slots = getContainerSlotSequenceNos(validation.wagonPlan); + const placements = autoFillPlacements(units, slots); + const missingForBooking = findMissingContainerNumberIssues(units, placements).find( + (m) => m.bookingId === bookingId, + ); + if (missingForBooking) { + throw new BadRequestException({ + message: missingForBooking.issue, + violations: [missingForBooking.issue], + }); + } + + const assignableSet = new Set(validation.bookings.map((b) => b.id)); + const assignPlacements = placementsForBookings(placements, assignableSet, units); + const needsPlacements = containerBookings.length > 0; + + return this.assignBookingsToSchedule( + scheduleId, + { + bookingIds: validation.bookings.map((b) => b.id), + containerPlacements: needsPlacements ? assignPlacements : undefined, + }, + undefined, + ); + } + + /** Preview wagon allocation issues per linked booking without mutating the schedule. */ + async previewAllocationForSchedule( + scheduleId: string, + ): Promise { + const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); + if (!schedule) { + throw new NotFoundException(`Train schedule ${scheduleId} not found`); + } + return this.buildAllocationAttempt(schedule, false); + } + + /** Assign all eligible linked bookings to wagons; returns per-booking issues. */ + async tryAutoWagonAllocation( + scheduleId: string, + ): Promise { + const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); + if (!schedule) { + throw new NotFoundException(`Train schedule ${scheduleId} not found`); + } + return this.buildAllocationAttempt(schedule, true); + } + + private async buildAllocationAttempt( + schedule: TrainSchedule, + performAssign: boolean, + ): Promise { + const empty: WagonAllocationAttemptResult = { + assignedBookingIds: [], + deferred: [], + issues: [], + violations: [], + }; + + if (!schedule.trainSet?.locomotive) { + return { ...empty, violations: ['Schedule has no locomotive — cannot allocate wagons'] }; + } + if (!['DRAFT', 'SCHEDULED'].includes(schedule.status)) { + return { + ...empty, + violations: [`Cannot allocate wagons for schedule in status ${schedule.status}`], + }; + } + + const linkedBookings = (schedule.scheduleBookings ?? []) + .map((sb) => sb.booking) + .filter((b): b is Booking => Boolean(b)); + const eligible = linkedBookings.filter( + (b) => SCHEDULABLE_BOOKING_STATUSES.includes(b.status as 'PAID') || b.isGovernment, + ); + if (!eligible.length) return empty; + + const wagonAssignedIds = await this.getWagonAssignedBookingIds(schedule.id); + const previewDto = { + bookingIds: eligible.map((b) => b.id), + scheduleDate: schedule.scheduledDepartureDate.toISOString(), + originStationId: schedule.originStationId, + destinationStationId: schedule.destinationStationId, + }; + const limits = await this.resolveTrainLimitConfig( + undefined, + schedule.trainSet.locomotive, + ); + + let validation: Awaited>; + try { + validation = await this.validateBookingsForScheduling( + previewDto, + null, + false, + [], + false, + limits, + schedule.id, + ); + } catch (err) { + const message = err instanceof Error ? err.message : 'Validation failed'; + return { + ...empty, + violations: [message], + issues: eligible.map((b) => ({ + bookingId: b.id, + status: 'FAILED' as const, + issue: message, + })), + }; + } + + const fittingIds = new Set(validation.bookings.map((b) => b.id)); + const deferredMap = new Map( + validation.deferredBookings.map((d) => [d.id, d.reason]), + ); + const containerBookings = validation.bookings.filter((b) => b.freightType === 'CONTAINER'); + const units: ContainerUnitForPlacement[] = expandBookingContainerUnits(containerBookings); + const slots = getContainerSlotSequenceNos(validation.wagonPlan); + const placements = autoFillPlacements(units, slots); + const missingNumbers = findMissingContainerNumberIssues(units, placements); + const missingByBooking = new Map(); + for (const m of missingNumbers) { + if (!missingByBooking.has(m.bookingId)) missingByBooking.set(m.bookingId, m.issue); + } + const placeholderWarnings = new Map(); + for (const p of placements) { + if (!isPlaceholderContainerNumber(p.containerNumber)) continue; + const unit = units.find( + (u) => u.bookingContainerId === p.bookingContainerId && u.unitIndex === p.unitIndex, + ); + if (unit && !placeholderWarnings.has(unit.bookingId)) { + placeholderWarnings.set( + unit.bookingId, + 'Container number auto-assigned — verify before dispatch.', + ); + } + } + + const assignableIds = validation.bookings + .filter((b) => !missingByBooking.has(b.id)) + .map((b) => b.id); + const assignableSet = new Set(assignableIds); + const assignPlacements = placementsForBookings( + placements, + assignableSet, + units, + ); + + const issues: BookingWagonAllocationIssue[] = eligible.map((b) => { + const placeholderIssue = placeholderWarnings.get(b.id) ?? null; + if (wagonAssignedIds.has(b.id) && assignableSet.has(b.id)) { + return { bookingId: b.id, status: 'ASSIGNED', issue: placeholderIssue }; + } + if (missingByBooking.has(b.id)) { + return { bookingId: b.id, status: 'FAILED', issue: missingByBooking.get(b.id)! }; + } + if (deferredMap.has(b.id)) { + return { bookingId: b.id, status: 'DEFERRED', issue: deferredMap.get(b.id)! }; + } + if (!fittingIds.has(b.id)) { + const refIssue = validation.violations.find((v) => v.includes(b.reference ?? b.id)); + return { + bookingId: b.id, + status: 'FAILED', + issue: refIssue ?? 'Does not fit train capacity or fleet constraints', + }; + } + if (wagonAssignedIds.has(b.id)) { + return { bookingId: b.id, status: 'ASSIGNED', issue: null }; + } + return { bookingId: b.id, status: 'NOT_ATTEMPTED', issue: null }; + }); + + const result: WagonAllocationAttemptResult = { + assignedBookingIds: [], + deferred: validation.deferredBookings, + issues, + violations: validation.violations, + }; + + if (!performAssign || !assignableIds.length) return result; + + const needsPlacements = containerBookings.some((b) => assignableSet.has(b.id)); + if (needsPlacements && !assignPlacements.length) { + return { + ...result, + violations: [...result.violations, 'Container placements could not be generated'], + }; + } + + try { + await this.assignBookingsToSchedule( + schedule.id, + { + bookingIds: assignableIds, + containerPlacements: needsPlacements ? assignPlacements : undefined, + }, + undefined, + ); + result.assignedBookingIds = assignableIds; + for (const issue of result.issues) { + if (assignableSet.has(issue.bookingId)) { + issue.status = 'ASSIGNED'; + issue.issue = placeholderWarnings.get(issue.bookingId) ?? null; + } + } + } catch (err) { + const message = + err instanceof BadRequestException + ? ((err.getResponse() as { message?: string; violations?: string[] }).violations?.join( + '; ', + ) ?? + (err.getResponse() as { message?: string }).message ?? + err.message) + : err instanceof Error + ? err.message + : 'Allocation failed'; + result.violations = [...result.violations, message]; + for (const issue of result.issues) { + if (assignableSet.has(issue.bookingId) && issue.status !== 'ASSIGNED') { + issue.status = 'FAILED'; + issue.issue = message; + } + } + } + + return result; + } + + async removeTrainSetWagonSlot(scheduleId: string, trainSetWagonId: string): Promise { + const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); + if (!schedule) { + throw new NotFoundException(`Train schedule ${scheduleId} not found`); + } + if (!['DRAFT', 'SCHEDULED'].includes(schedule.status)) { + throw new BadRequestException('Cannot remove wagon slots from a finalized or dispatched schedule'); + } + + const wagon = (schedule.trainSet?.wagons ?? []).find((w) => w.id === trainSetWagonId); + if (!wagon) { + throw new NotFoundException(`Train set wagon ${trainSetWagonId} not found in this schedule`); + } + + if ((wagon.allocations ?? []).length > 0) { + throw new BadRequestException( + 'Cannot remove a wagon slot that has active allocations; remove the booking first', + ); + } + + await this.dataSource.transaction(async (manager) => { + await manager.getRepository(TrainSetWagon).delete(trainSetWagonId); + await manager.getRepository(TrainSet).update(schedule.trainSetId, { + wagonCount: Math.max(0, (schedule.trainSet?.wagonCount ?? 0) - 1), + totalLengthMeters: Math.max(0, (schedule.trainSet?.totalLengthMeters ?? 0) - (wagon.lengthMeters ?? 0)), + }); + }); + + return this.getTrainScheduleById(scheduleId); + } + + async updateContainerItem( + scheduleId: string, + itemId: string, + dto: UpdateContainerItemDto, + ): Promise<{ id: string; containerNumber: string | null }> { + const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); + if (!schedule) { + throw new NotFoundException(`Train schedule ${scheduleId} not found`); + } + if (schedule.status === 'DISPATCHED') { + throw new BadRequestException('Cannot edit a dispatched schedule'); + } + + const item = await this.dataSource.getRepository(WagonAllocationContainerItem).findOne({ + where: { id: itemId }, + relations: ['wagonBookingAllocation', 'wagonBookingAllocation.trainSetWagon'], + }); + + if (!item) { + throw new NotFoundException(`Container item ${itemId} not found`); + } + + const wagonId = item.wagonBookingAllocationId; + const wagonAllocation = await this.dataSource.getRepository(WagonBookingAllocation).findOne({ + where: { id: wagonId }, + relations: ['trainSetWagon'], + }); + + if (!wagonAllocation?.trainSetWagon) { + throw new NotFoundException(`Container item ${itemId} does not belong to this schedule`); + } + + const trainSetWagonId = wagonAllocation.trainSetWagon.id; + const wagonIds = (schedule.trainSet?.wagons ?? []).map((w) => w.id); + if (!wagonIds.includes(trainSetWagonId)) { + throw new NotFoundException(`Container item ${itemId} does not belong to this schedule`); + } + + await this.dataSource.getRepository(WagonAllocationContainerItem).update(itemId, { + containerNumber: dto.containerNumber ?? null, + }); + + return { id: itemId, containerNumber: dto.containerNumber ?? null }; + } + + async getUnassignedBookings(scheduleId: string): Promise { + const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); + if (!schedule) { + throw new NotFoundException(`Train schedule ${scheduleId} not found`); + } + + const allBookings = await this.bookingsRepository.findAll({ + where: { trainScheduleId: scheduleId }, + select: [ + 'id', + 'reference', + 'freightType', + 'priorityScore', + 'cargoTotalWeightVgm', + 'status', + 'schedulingStatus', + 'paymentStatus', + 'isGovernment', + ], + }); + + const wagonAssignedIds = await this.getWagonAssignedBookingIds(scheduleId); + + const unassigned = allBookings + .filter((b) => !wagonAssignedIds.has(b.id) && this.isReadyToLoadBooking(b)) + .sort((a, b) => (b.priorityScore ?? 0) - (a.priorityScore ?? 0)); + + const fleetCounts = await this.countFleetAvailability( + schedule.originStationId, + scheduleId, + ); + const fleetByTypeId = new Map( + fleetCounts.map((row) => [ + row.wagonTypeId, + { code: row.wagonTypeCode, available: row.available }, + ]), + ); + const fleetAtOrigin: FleetAvailabilityRow[] = fleetCounts.map((row) => ({ + wagonTypeId: row.wagonTypeId, + wagonTypeCode: row.wagonTypeCode, + needed: 0, + available: row.available, + shortfall: 0, + })); + + const bookings = await Promise.all( + unassigned.map(async (b) => { + const assignability = await this.previewUnassignedBookingAssignability( + schedule, + wagonAssignedIds, + b as Booking, + fleetByTypeId, + ); + return { + id: b.id, + reference: b.reference ?? null, + freightType: b.freightType ?? null, + priorityScore: b.priorityScore ?? 0, + cargoTotalWeightVgm: Number(b.cargoTotalWeightVgm ?? 0), + status: b.status ?? null, + schedulingStatus: b.schedulingStatus ?? null, + ...assignability, + }; + }), + ); + + return { fleetAtOrigin, bookings }; + } + + private async previewUnassignedBookingAssignability( + schedule: TrainSchedule, + wagonAssignedIds: Set, + booking: Booking, + fleetByTypeId: Map, + ): Promise<{ + wagonsRequired: number; + requiredWagonTypeCode: string; + yardWagonsAvailable: number; + canAssign: boolean; + blockReason: string | null; + }> { + if (!schedule.trainSet?.locomotive) { + return { + wagonsRequired: 0, + requiredWagonTypeCode: '', + yardWagonsAvailable: 0, + canAssign: false, + blockReason: 'Schedule has no locomotive', + }; + } + + const freightType = booking.freightType === 'BULK' ? 'BULK' : 'CONTAINER'; + let wagonType: WagonType; + try { + wagonType = await this.resolveWagonType(freightType, [booking.id]); + } catch { + return { + wagonsRequired: 0, + requiredWagonTypeCode: '', + yardWagonsAvailable: 0, + canAssign: false, + blockReason: 'No suitable wagon type found', + }; + } + + const bulkCapacity = + freightType === 'BULK' ? Number(wagonType.capacityTons) : undefined; + const [fullBooking] = await this.bookingsRepository.findByIdsForScheduling([booking.id]); + const resolvedBooking = fullBooking ?? booking; + const wagonsRequired = wagonsRequiredForBooking(resolvedBooking, bulkCapacity); + const yardWagonsAvailable = fleetByTypeId.get(wagonType.id)?.available ?? 0; + + const allBookingIds = [...wagonAssignedIds, booking.id]; + const previewDto = { + bookingIds: allBookingIds, + scheduleDate: schedule.scheduledDepartureDate.toISOString(), + originStationId: schedule.originStationId, + destinationStationId: schedule.destinationStationId, + }; + const limits = await this.resolveTrainLimitConfig( + undefined, + schedule.trainSet.locomotive, + ); + + let validation: Awaited>; + try { + validation = await this.validateBookingsForScheduling( + previewDto, + null, + false, + [], + false, + limits, + schedule.id, + ); + } catch (err) { + return { + wagonsRequired, + requiredWagonTypeCode: wagonType.code, + yardWagonsAvailable, + canAssign: false, + blockReason: err instanceof Error ? err.message : 'Validation failed', + }; + } + + if (!validation.valid) { + return { + wagonsRequired, + requiredWagonTypeCode: wagonType.code, + yardWagonsAvailable, + canAssign: false, + blockReason: validation.violations[0] ?? 'Booking validation failed', + }; + } + + const fittingIds = new Set(validation.bookings.map((b) => b.id)); + if (!fittingIds.has(booking.id)) { + const deferred = validation.deferredBookings.find((d) => d.id === booking.id); + const yardShortfall = + yardWagonsAvailable < wagonsRequired + ? `No ${wagonType.code} wagons at origin yard (need ${wagonsRequired}, ${yardWagonsAvailable} available)` + : null; + return { + wagonsRequired, + requiredWagonTypeCode: wagonType.code, + yardWagonsAvailable, + canAssign: false, + blockReason: + deferred?.reason ?? + yardShortfall ?? + `Need ${wagonsRequired} ${wagonType.code} wagon(s) at origin yard`, + }; + } + + const containerBookings = validation.bookings.filter((b) => b.freightType === 'CONTAINER'); + if (containerBookings.some((b) => b.id === booking.id)) { + const units = expandBookingContainerUnits(containerBookings); + const slots = getContainerSlotSequenceNos(validation.wagonPlan); + const placements = autoFillPlacements(units, slots); + const missing = findMissingContainerNumberIssues(units, placements).find( + (m) => m.bookingId === booking.id, + ); + if (missing) { + return { + wagonsRequired, + requiredWagonTypeCode: wagonType.code, + yardWagonsAvailable, + canAssign: false, + blockReason: missing.issue, + }; + } + } + + return { + wagonsRequired, + requiredWagonTypeCode: wagonType.code, + yardWagonsAvailable, + canAssign: true, + blockReason: null, + }; + } + + /** Paid (or government) bookings that may be loaded onto wagons — excludes expired / awaiting payment. */ + private isReadyToLoadBooking(booking: { + status: string; + paymentStatus?: string | null; + isGovernment?: boolean; + }): boolean { + if (booking.status === 'EXPIRED') return false; + if (booking.status === 'SELECTED_FOR_BATCH' || booking.status === 'AWAITING_PAYMENT') { + return false; + } + if (booking.status === 'PAID' || booking.paymentStatus === 'PAID') return true; + if (booking.isGovernment) return true; + return false; + } + + async getCompositionRemovals(scheduleId: string): Promise { + return this.trainCompositionRemovalLogRepository.findByScheduleId(scheduleId); + } + + private async getWagonAssignedBookingIds(scheduleId: string): Promise> { + const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); + const wagonIds = (schedule?.trainSet?.wagons ?? []).map((w) => w.id); + if (!wagonIds.length) return new Set(); + + const allocations = await this.dataSource.getRepository(WagonBookingAllocation).find({ + where: { trainSetWagonId: In(wagonIds) }, + select: ['bookingId'], + }); + return new Set(allocations.map((a) => a.bookingId)); + } +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.spec.ts new file mode 100644 index 000000000..824c45e6e --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.spec.ts @@ -0,0 +1,202 @@ +import { AllocationLoadType } from '@edr/types'; + +import { Booking } from '../bookings/entities/booking.entity'; +import { WagonType } from '../wagon-types/entities/wagon-type.entity'; +import { + buildBulkWagonPlan, + buildContainerWagonPlan, + buildMixedWagonPlan, + expandBookingContainerUnits, + expandContainerItems, + roundTons, + sumWagonsRequired, + validate20ftContainerRules, + validateContainerPlacements, +} from './wagon-plan.util'; + +const nw5: WagonType = { + id: 'wt-nw5', + code: 'NW5', + name: 'Flat Wagon', + capacityTons: 70, + lengthMeters: 14, + maxWagonsPerTrain: 53, + supportedLoadTypes: ['CONTAINER'], + isActive: true, + supportsContainer: true, +} as WagonType; + +const cw3: WagonType = { + id: 'wt-cw3', + code: 'CW3', + name: 'Covered Wagon', + capacityTons: 60, + lengthMeters: 14, + maxWagonsPerTrain: 53, + supportedLoadTypes: ['BULK'], + isActive: true, + supportsContainer: false, +} as WagonType; + +const makeContainerBooking = ( + id: string, + lines: Array<{ quantity: number; wagonsRequired: number; vgmPerUnitTons?: number }>, +): Booking => + ({ + id, + reference: id, + freightType: 'CONTAINER', + cargoTotalWeightVgm: lines.reduce( + (sum, line) => sum + line.quantity * (line.vgmPerUnitTons ?? 25), + 0, + ), + bookingContainers: lines.map((line, index) => ({ + id: `${id}-line-${index}`, + containerTypeId: `ct-${index}`, + quantity: line.quantity, + wagonsRequired: line.wagonsRequired, + vgmPerUnitTons: line.vgmPerUnitTons ?? 25, + })), + }) as Booking; + +describe('wagon-plan.util', () => { + it('uses slot-based planning: 2×20ft = 1 wagon slot', () => { + const booking = makeContainerBooking('b1', [{ quantity: 2, wagonsRequired: 1 }]); + const plan = buildContainerWagonPlan([booking], nw5); + expect(plan).toHaveLength(1); + expect(plan[0]?.allocations[0]?.loadType).toBe(AllocationLoadType.Container); + }); + + it('uses slot-based planning: 1×40ft = 1 wagon slot', () => { + const booking = makeContainerBooking('b2', [{ quantity: 1, wagonsRequired: 1 }]); + const plan = buildContainerWagonPlan([booking], nw5); + expect(plan).toHaveLength(1); + }); + + it('sums wagons across multiple container lines', () => { + const booking = makeContainerBooking('b3', [ + { quantity: 2, wagonsRequired: 1 }, + { quantity: 1, wagonsRequired: 1 }, + ]); + expect(sumWagonsRequired(booking)).toBe(2); + const plan = buildContainerWagonPlan([booking], nw5); + expect(plan).toHaveLength(2); + }); + + it('6×20ft containers = 3 wagon slots (2 per wagon)', () => { + // 20ft containers have wagonsPerUnit = 0.5, so 6 * 0.5 = 3 wagons + const booking = makeContainerBooking('b6x20', [{ quantity: 6, wagonsRequired: 3 }]); + expect(sumWagonsRequired(booking)).toBe(3); + const plan = buildContainerWagonPlan([booking], nw5); + expect(plan).toHaveLength(3); + // Verify sequence numbers are 1, 2, 3 + expect(plan.map((s) => s.sequenceNo)).toEqual([1, 2, 3]); + }); + + it('expands container items per quantity', () => { + const booking = makeContainerBooking('b4', [{ quantity: 3, wagonsRequired: 3 }]); + const items = expandContainerItems(booking, 'alloc-1'); + expect(items).toHaveLength(3); + expect(items[0]?.wagonBookingAllocationId).toBe('alloc-1'); + }); + + it('rounds tons to three decimal places', () => { + expect(roundTons(1.23456)).toBe(1.235); + expect(roundTons('bad')).toBe(0); + }); + + it('builds mixed plan with container block before bulk', () => { + const containerBooking = makeContainerBooking('c1', [{ quantity: 2, wagonsRequired: 2 }]); + const bulkBooking = { + id: 'b1', + reference: 'BKG-BULK', + freightType: 'BULK', + cargoTotalWeightVgm: 120, + bookingContainers: [], + } as unknown as Booking; + + const plan = buildMixedWagonPlan([containerBooking], [bulkBooking], nw5, cw3); + expect(plan).toHaveLength(4); + expect(plan[0]?.slotLoadType).toBe('CONTAINER'); + expect(plan[2]?.slotLoadType).toBe('BULK'); + expect(plan.map((s) => s.sequenceNo)).toEqual([1, 2, 3, 4]); + }); + + it('expands booking container units for UI rows', () => { + const booking = makeContainerBooking('c2', [{ quantity: 3, wagonsRequired: 3 }]); + const units = expandBookingContainerUnits([booking]); + expect(units).toHaveLength(3); + expect(units[1]?.unitIndex).toBe(1); + expect(units[1]?.bookingContainerId).toBe('c2-line-0'); + }); + + it('validates required placements per container unit', () => { + const booking = makeContainerBooking('c3', [{ quantity: 2, wagonsRequired: 2 }]); + const plan = buildContainerWagonPlan([booking], nw5); + const violations = validateContainerPlacements([booking], plan, []); + expect(violations.some((v) => v.includes('required'))).toBe(true); + + const units = expandBookingContainerUnits([booking]); + const placements = units.map((unit, index) => ({ + bookingContainerId: unit.bookingContainerId, + unitIndex: unit.unitIndex, + sequenceNo: plan[index]?.sequenceNo ?? 1, + containerNumber: `CNTR-${index + 1}`, + })); + expect(validateContainerPlacements([booking], plan, placements)).toEqual([]); + }); + + it('rejects 20ft container over max individual weight', () => { + const booking = makeContainerBooking('c20', [{ quantity: 2, wagonsRequired: 1, vgmPerUnitTons: 35 }]); + const units = expandBookingContainerUnits([booking]); + const placements = units.map((unit, index) => ({ + bookingContainerId: unit.bookingContainerId, + unitIndex: unit.unitIndex, + sequenceNo: 1, + containerNumber: `CNTR-${index + 1}`, + })); + + const violations = validate20ftContainerRules(units, placements, { + max20ftContainerWeightTons: 30, + max20ftPairWeightDiffTons: 10, + }); + + expect(violations.some((v) => v.includes('exceeds max 30T'))).toBe(true); + }); + + it('rejects 20ft pair when weight difference exceeds limit', () => { + const booking = makeContainerBooking('c21', [ + { quantity: 2, wagonsRequired: 1, vgmPerUnitTons: 25 }, + ]); + booking.bookingContainers![0]!.vgmPerUnitTons = 25; + const units = expandBookingContainerUnits([booking]); + units[1]!.grossWeightTons = 10; + const placements = units.map((unit) => ({ + bookingContainerId: unit.bookingContainerId, + unitIndex: unit.unitIndex, + sequenceNo: 1, + containerNumber: `CNTR-${unit.unitIndex}`, + })); + + const violations = validate20ftContainerRules(units, placements, { + max20ftContainerWeightTons: 30, + max20ftPairWeightDiffTons: 10, + }); + + expect(violations.some((v) => v.includes('weight difference'))).toBe(true); + }); + + it('builds bulk-only plan as degenerate mixed case', () => { + const bulkBooking = { + id: 'b2', + reference: 'BKG-BULK-2', + freightType: 'BULK', + cargoTotalWeightVgm: 60, + bookingContainers: [], + } as unknown as Booking; + const plan = buildMixedWagonPlan([], [bulkBooking], nw5, cw3); + expect(plan).toHaveLength(1); + expect(plan[0]?.slotLoadType).toBe('BULK'); + expect(buildBulkWagonPlan([bulkBooking], cw3)).toHaveLength(1); + }); +}); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts new file mode 100644 index 000000000..8c3199461 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts @@ -0,0 +1,618 @@ +import { AllocationLoadType } from '@edr/types'; + +import { Booking } from '../bookings/entities/booking.entity'; +import { WagonType } from '../wagon-types/entities/wagon-type.entity'; + +export const MAX_TRAIN_WEIGHT_TONS = 3500; +export const MAX_TRAIN_LENGTH_METERS = 760; +export const MAX_TEU_SLOTS_PER_WAGON = 2; + +export type TrainLimitConfig = { + maxWeightTons?: number; + maxLengthMeters?: number; + maxWagonsPerTrain?: number; + max20ftContainerWeightTons?: number; + max20ftPairWeightDiffTons?: number; +}; + +export type ContainerPlacementRules = { + max20ftContainerWeightTons?: number; + max20ftPairWeightDiffTons?: number; +}; + +export type WagonAllocationRecord = { + bookingId: string; + bookingReference: string; + allocatedWeightTons: number; + loadType: AllocationLoadType; +}; + +export type SlotLoadType = 'CONTAINER' | 'BULK'; + +export type WagonPlanSlot = { + sequenceNo: number; + wagonTypeId: string; + wagonTypeCode: string; + capacityTons: number; + lengthMeters: number; + assignedWeightTons: number; + allocations: WagonAllocationRecord[]; + slotLoadType?: SlotLoadType; +}; + +export type ContainerUnitRow = { + bookingId: string; + bookingReference: string; + bookingContainerId: string; + unitIndex: number; + containerTypeId: string; + containerTypeCode: string; + label: string; + grossWeightTons: number; + sizeFt?: number; + wagonsPerUnit?: number; + containersPerWagon?: number; + teuSlots?: number; + containerNumber?: string | null; +}; + +export type ContainerPlacementInput = { + bookingContainerId: string; + unitIndex: number; + sequenceNo: number; + containerId?: string; + containerNumber?: string; + sealNumber?: string; +}; + +export function roundTons(value: number | string | null | undefined): number { + const numericValue = typeof value === 'number' ? value : Number(value ?? 0); + if (!Number.isFinite(numericValue)) return 0; + return Number(numericValue.toFixed(3)); +} + +/** TEU slots on a wagon: 40ft = 2, 20ft = 1 (max 2 TEU / wagon). */ +export function teuSlotsForSizeFt(sizeFt: number): number { + return sizeFt >= 40 ? 2 : 1; +} + +export function containersPerWagonFromType(wagonsPerUnit: number): number { + const wpu = Number(wagonsPerUnit); + if (!wpu || wpu <= 0) return 1; + return Math.max(1, Math.round(1 / wpu)); +} + +function lineWagonsRequired(line: { + quantity?: number | null; + wagonsRequired?: number | null; + containerType?: { wagonsPerUnit?: number | null; sizeFt?: number | null } | null; +}): number { + const qty = Number(line.quantity ?? 0); + if (qty <= 0) return 0; + const wpu = Number(line.containerType?.wagonsPerUnit); + if (Number.isFinite(wpu) && wpu > 0) { + return Math.ceil(qty * wpu); + } + return Math.max(1, Math.ceil(Number(line.wagonsRequired ?? 1))); +} + +/** + * Build slot-based wagon plan for CONTAINER bookings using booking_container.wagons_required. + */ +export function buildContainerWagonPlan( + bookings: Booking[], + wagonType: WagonType, +): WagonPlanSlot[] { + const totalSlots = bookings.reduce((sum, booking) => { + const lineSlots = (booking.bookingContainers ?? []).reduce( + (lineSum, line) => lineSum + lineWagonsRequired(line), + 0, + ); + return sum + Math.max(lineSlots, 1); + }, 0); + + const slots = Math.max(1, Math.ceil(totalSlots)); + const basePlan: WagonPlanSlot[] = Array.from({ length: slots }, (_, index) => ({ + sequenceNo: index + 1, + wagonTypeId: wagonType.id, + wagonTypeCode: wagonType.code, + capacityTons: Number(wagonType.capacityTons), + lengthMeters: Number(wagonType.lengthMeters), + assignedWeightTons: 0, + allocations: [], + })); + + return allocateContainersToSlots(bookings, basePlan).map((slot) => ({ + ...slot, + slotLoadType: 'CONTAINER' as SlotLoadType, + })); +} + +/** + * Build weight-based wagon plan for BULK bookings. + */ +export function buildBulkWagonPlan( + bookings: Booking[], + wagonType: WagonType, +): WagonPlanSlot[] { + const totalWeight = roundTons( + bookings.reduce((sum, b) => sum + Number(b.cargoTotalWeightVgm ?? 0), 0), + ); + const capacity = Number(wagonType.capacityTons); + const slots = Math.max(1, Math.ceil(totalWeight / capacity)); + + const basePlan: WagonPlanSlot[] = Array.from({ length: slots }, (_, index) => ({ + sequenceNo: index + 1, + wagonTypeId: wagonType.id, + wagonTypeCode: wagonType.code, + capacityTons: capacity, + lengthMeters: Number(wagonType.lengthMeters), + assignedWeightTons: 0, + allocations: [], + })); + + return allocateBookingsToSlots(bookings, basePlan, AllocationLoadType.Bulk).map((slot) => ({ + ...slot, + slotLoadType: 'BULK' as SlotLoadType, + })); +} + +/** + * Build a mixed consist: container slots first, then bulk slots, with unified sequence numbers. + */ +export function buildMixedWagonPlan( + containerBookings: Booking[], + bulkBookings: Booking[], + containerWagonType: WagonType, + bulkWagonType: WagonType, +): WagonPlanSlot[] { + const containerPlan = containerBookings.length + ? buildContainerWagonPlan(containerBookings, containerWagonType) + : []; + const bulkPlan = bulkBookings.length + ? buildBulkWagonPlan(bulkBookings, bulkWagonType) + : []; + + const tagged: WagonPlanSlot[] = [ + ...containerPlan.map((slot) => ({ ...slot, slotLoadType: 'CONTAINER' as SlotLoadType })), + ...bulkPlan.map((slot) => ({ ...slot, slotLoadType: 'BULK' as SlotLoadType })), + ]; + + if (!tagged.length) { + return [ + { + sequenceNo: 1, + wagonTypeId: containerWagonType.id, + wagonTypeCode: containerWagonType.code, + capacityTons: Number(containerWagonType.capacityTons), + lengthMeters: Number(containerWagonType.lengthMeters), + assignedWeightTons: 0, + allocations: [], + slotLoadType: 'CONTAINER', + }, + ]; + } + + return tagged.map((slot, index) => ({ + ...slot, + sequenceNo: index + 1, + })); +} + +export function expandBookingContainerUnits(bookings: Booking[]): ContainerUnitRow[] { + const rows: ContainerUnitRow[] = []; + + for (const booking of bookings.filter((b) => b.freightType === 'CONTAINER')) { + for (const line of booking.bookingContainers ?? []) { + const qty = Number(line.quantity ?? 0); + const code = line.containerType?.code ?? line.containerType?.label ?? 'Container'; + const sizeFt = Number(line.containerType?.sizeFt ?? (code.includes('40') ? 40 : 20)); + const wagonsPerUnit = Number(line.containerType?.wagonsPerUnit ?? (sizeFt >= 40 ? 1 : 0.5)); + const perWagon = containersPerWagonFromType(wagonsPerUnit); + const teuSlots = teuSlotsForSizeFt(sizeFt); + for (let i = 0; i < qty; i += 1) { + rows.push({ + bookingId: booking.id, + bookingReference: booking.reference, + bookingContainerId: line.id, + unitIndex: i, + containerTypeId: line.containerTypeId ?? '', + containerTypeCode: code, + label: `${booking.reference} · ${i + 1}/${qty} · ${code}`, + grossWeightTons: Number(line.vgmPerUnitTons), + sizeFt, + wagonsPerUnit, + containersPerWagon: perWagon, + teuSlots, + containerNumber: line.containerNumber ?? null, + }); + } + } + } + + return rows; +} + +export function getContainerSlotSequenceNos(wagonPlan: WagonPlanSlot[]): number[] { + return wagonPlan + .filter((slot) => slot.slotLoadType === 'CONTAINER' || slot.allocations.some( + (a) => a.loadType === AllocationLoadType.Container, + )) + .map((slot) => slot.sequenceNo); +} + +function allocateBookingsToSlots( + bookings: Booking[], + basePlan: WagonPlanSlot[], + loadType: AllocationLoadType, +): WagonPlanSlot[] { + const remaining = bookings.map((booking) => ({ + bookingId: booking.id, + bookingReference: booking.reference, + remainingWeightTons: roundTons(Number(booking.cargoTotalWeightVgm ?? 0)), + })); + + let bookingIndex = 0; + + return basePlan.map((slot) => { + let wagonRemaining = roundTons(slot.capacityTons); + const allocations: WagonAllocationRecord[] = []; + let assignedWeightTons = 0; + + while (wagonRemaining > 0 && bookingIndex < remaining.length) { + const booking = remaining[bookingIndex]; + const allocatedWeightTons = roundTons( + Math.min(wagonRemaining, booking.remainingWeightTons), + ); + + if (allocatedWeightTons <= 0) { + bookingIndex += 1; + continue; + } + + allocations.push({ + bookingId: booking.bookingId, + bookingReference: booking.bookingReference, + allocatedWeightTons, + loadType, + }); + + booking.remainingWeightTons = roundTons( + booking.remainingWeightTons - allocatedWeightTons, + ); + wagonRemaining = roundTons(wagonRemaining - allocatedWeightTons); + assignedWeightTons = roundTons(assignedWeightTons + allocatedWeightTons); + + if (booking.remainingWeightTons <= 0) { + bookingIndex += 1; + } + } + + return { ...slot, assignedWeightTons, allocations }; + }); +} + +/** + * Allocate container bookings across wagon slots by TEU capacity. A wagon holds at most + * 2 TEU, so it carries either one 40ft container (2 TEU) or two 20ft containers (1 TEU + * each) — a 40ft is NEVER mixed onto the same wagon as a 20ft. Every physical container + * maps to a real wagon allocation, and this mirrors the frontend auto-fill packing + * exactly so a placement's sequenceNo always lands on a slot that holds an allocation + * for its booking. + * + * Weight-based packing (allocateBookingsToSlots) is wrong for containers: it collapses + * several light containers into the first wagons by tonnage and leaves later container + * units without an allocation slot, which silently drops their container items on assign. + */ +function allocateContainersToSlots( + bookings: Booking[], + basePlan: WagonPlanSlot[], +): WagonPlanSlot[] { + const slots = basePlan.map((slot) => ({ + ...slot, + assignedWeightTons: 0, + allocations: [] as WagonAllocationRecord[], + })); + if (!slots.length) return slots; + + const units = expandBookingContainerUnits(bookings); + let currentSlotIndex = 0; + let teuInCurrentSlot = 0; + + for (const unit of units) { + const teu = unit.teuSlots ?? teuSlotsForSizeFt(unit.sizeFt ?? 20); + + // Move to the next wagon once this one can't fit the container's TEU. This keeps a + // 40ft (2 TEU) alone on its wagon and never pairs it with a 20ft. + if (teuInCurrentSlot > 0 && teuInCurrentSlot + teu > MAX_TEU_SLOTS_PER_WAGON) { + currentSlotIndex += 1; + teuInCurrentSlot = 0; + } + + const slot = slots[Math.min(currentSlotIndex, slots.length - 1)]!; + + let allocation = slot.allocations.find((a) => a.bookingId === unit.bookingId); + if (!allocation) { + allocation = { + bookingId: unit.bookingId, + bookingReference: unit.bookingReference, + allocatedWeightTons: 0, + loadType: AllocationLoadType.Container, + }; + slot.allocations.push(allocation); + } + allocation.allocatedWeightTons = roundTons( + allocation.allocatedWeightTons + unit.grossWeightTons, + ); + slot.assignedWeightTons = roundTons(slot.assignedWeightTons + unit.grossWeightTons); + teuInCurrentSlot += teu; + } + + return slots; +} + +export function expandContainerItems( + booking: Booking, + allocationId: string, +): Array<{ + wagonBookingAllocationId: string; + bookingContainerId: string; + containerTypeId: string; + grossWeightTons: number; + positionOnWagon: number | null; +}> { + const items: Array<{ + wagonBookingAllocationId: string; + bookingContainerId: string; + containerTypeId: string; + grossWeightTons: number; + positionOnWagon: number | null; + }> = []; + + for (const line of booking.bookingContainers ?? []) { + const qty = Number(line.quantity ?? 0); + for (let i = 0; i < qty; i += 1) { + items.push({ + wagonBookingAllocationId: allocationId, + bookingContainerId: line.id, + containerTypeId: line.containerTypeId ?? '', + grossWeightTons: Number(line.vgmPerUnitTons), + positionOnWagon: qty > 1 ? i + 1 : null, + }); + } + } + + return items; +} + +export function sumWagonsRequired(booking: Booking): number { + if (booking.freightType === 'BULK') { + return 1; + } + return (booking.bookingContainers ?? []).reduce( + (sum, line) => sum + Number(line.wagonsRequired ?? 0), + 0, + ); +} + +export function validateBulkWagonSlotWeights(wagonPlan: WagonPlanSlot[]): string[] { + const violations: string[] = []; + for (const slot of wagonPlan.filter((s) => s.slotLoadType === 'BULK')) { + if (slot.assignedWeightTons > slot.capacityTons) { + violations.push( + `Bulk wagon #${slot.sequenceNo} load ${slot.assignedWeightTons}T exceeds capacity ${slot.capacityTons}T`, + ); + } + } + return violations; +} + +export function validateTrainLimits( + wagonPlan: WagonPlanSlot[], + wagonType: WagonType, + limits?: TrainLimitConfig, +): string[] { + const violations: string[] = []; + const maxWeightTons = limits?.maxWeightTons ?? MAX_TRAIN_WEIGHT_TONS; + const maxLengthMeters = limits?.maxLengthMeters ?? MAX_TRAIN_LENGTH_METERS; + const wagonLength = Number(wagonType.lengthMeters) || 14; + const maxWagonsPerTrain = + limits?.maxWagonsPerTrain ?? + Math.floor(maxLengthMeters / wagonLength); + + const totalWeightTons = roundTons( + wagonPlan.reduce((sum, w) => sum + w.assignedWeightTons, 0), + ); + const totalLengthMeters = roundTons( + wagonPlan.reduce((sum, w) => sum + w.lengthMeters, 0), + ); + + if (totalWeightTons > maxWeightTons) { + violations.push( + `Total booking weight ${totalWeightTons}T exceeds max train weight ${maxWeightTons}T`, + ); + } + if (totalLengthMeters > maxLengthMeters) { + violations.push( + `Total wagon length ${totalLengthMeters}m exceeds max train length ${maxLengthMeters}m`, + ); + } + if (wagonPlan.length > maxWagonsPerTrain) { + violations.push( + `Wagon count ${wagonPlan.length} exceeds max wagons per train (${maxWagonsPerTrain})`, + ); + } + + violations.push(...validateBulkWagonSlotWeights(wagonPlan)); + + return violations; +} + +export function validateMixedTrainLimits( + wagonPlan: WagonPlanSlot[], + wagonTypes: WagonType[], + limits?: TrainLimitConfig, +): string[] { + const maxLengthMeters = limits?.maxLengthMeters ?? MAX_TRAIN_LENGTH_METERS; + const minWagonLength = Math.min( + ...wagonTypes.map((wt) => Number(wt.lengthMeters) || 14), + 14, + ); + const maxWagonsPerTrain = + limits?.maxWagonsPerTrain ?? Math.floor(maxLengthMeters / minWagonLength); + + return validateTrainLimits( + wagonPlan, + { maxWagonsPerTrain } as WagonType, + { ...limits, maxWagonsPerTrain }, + ); +} + +export function validate20ftContainerRules( + units: ContainerUnitRow[], + placements: ContainerPlacementInput[], + rules?: ContainerPlacementRules, +): string[] { + const violations: string[] = []; + const maxEach = rules?.max20ftContainerWeightTons; + const maxDiff = rules?.max20ftPairWeightDiffTons; + if (maxEach == null && maxDiff == null) return violations; + + const placementByUnit = new Map( + placements.map((p) => [`${p.bookingContainerId}:${p.unitIndex}`, p]), + ); + + const weightsBySlot = new Map(); + + for (const unit of units) { + const sizeFt = unit.sizeFt ?? (unit.containerTypeCode.includes('40') ? 40 : 20); + if (sizeFt >= 40) continue; + + if (maxEach != null && unit.grossWeightTons > maxEach) { + violations.push( + `${unit.label} weight ${unit.grossWeightTons}T exceeds max ${maxEach}T for 20ft containers`, + ); + } + + const placement = placementByUnit.get(`${unit.bookingContainerId}:${unit.unitIndex}`); + if (!placement?.sequenceNo) continue; + + const list = weightsBySlot.get(placement.sequenceNo) ?? []; + list.push(unit.grossWeightTons); + weightsBySlot.set(placement.sequenceNo, list); + } + + if (maxDiff != null) { + for (const [sequenceNo, weights] of weightsBySlot.entries()) { + if (weights.length < 2) continue; + const diff = Math.abs(weights[0]! - weights[1]!); + if (diff > maxDiff) { + violations.push( + `Wagon #${sequenceNo} 20ft pair weight difference ${roundTons(diff)}T exceeds max ${maxDiff}T`, + ); + } + } + } + + return violations; +} + +export function validateContainerPlacements( + containerBookings: Booking[], + wagonPlan: WagonPlanSlot[], + placements: ContainerPlacementInput[], + rules?: ContainerPlacementRules, +): string[] { + const violations: string[] = []; + const units = expandBookingContainerUnits(containerBookings); + if (!units.length) return violations; + + const containerSlots = new Set(getContainerSlotSequenceNos(wagonPlan)); + const unitKeys = new Set(units.map((u) => `${u.bookingContainerId}:${u.unitIndex}`)); + const placementKeys = new Set(); + const containerNumbers = new Set(); + + if (!placements.length) { + violations.push('Container placements are required for container bookings'); + return violations; + } + + for (const placement of placements) { + const unitKey = `${placement.bookingContainerId}:${placement.unitIndex}`; + if (!unitKeys.has(unitKey)) { + violations.push( + `Unknown container unit ${placement.bookingContainerId}#${placement.unitIndex}`, + ); + continue; + } + if (placementKeys.has(unitKey)) { + violations.push(`Duplicate placement for container unit ${unitKey}`); + } + placementKeys.add(unitKey); + + if (!containerSlots.has(placement.sequenceNo)) { + violations.push(`Slot #${placement.sequenceNo} is not a container wagon slot`); + } + + const hasInventory = Boolean(placement.containerId); + const hasManual = Boolean(placement.containerNumber?.trim()); + if (!hasInventory && !hasManual) { + violations.push( + `Container unit ${unitKey} requires an existing container or a new container number`, + ); + } + + if (hasManual) { + const normalized = placement.containerNumber!.trim().toUpperCase(); + if (containerNumbers.has(normalized)) { + violations.push(`Duplicate container number ${normalized}`); + } + containerNumbers.add(normalized); + } + } + + for (const unit of units) { + const unitKey = `${unit.bookingContainerId}:${unit.unitIndex}`; + if (!placementKeys.has(unitKey)) { + violations.push(`Missing placement for ${unit.label}`); + } + } + + const slotTeuUsed = new Map(); + const slotWeightUsed = new Map(); + const slotBySeq = new Map(wagonPlan.map((s) => [s.sequenceNo, s])); + + for (const placement of placements) { + const unit = units.find( + (u) => + u.bookingContainerId === placement.bookingContainerId && + u.unitIndex === placement.unitIndex, + ); + if (!unit) continue; + + const teu = unit.teuSlots ?? teuSlotsForSizeFt(unit.sizeFt ?? 20); + const usedTeu = slotTeuUsed.get(placement.sequenceNo) ?? 0; + if (usedTeu + teu > MAX_TEU_SLOTS_PER_WAGON) { + violations.push( + `Wagon #${placement.sequenceNo} cannot fit another ${unit.containerTypeCode} (max 1×40ft or 2×20ft per wagon)`, + ); + } else { + slotTeuUsed.set(placement.sequenceNo, usedTeu + teu); + } + + const slot = slotBySeq.get(placement.sequenceNo); + if (slot) { + const weight = roundTons(slotWeightUsed.get(placement.sequenceNo) ?? 0) + unit.grossWeightTons; + slotWeightUsed.set(placement.sequenceNo, weight); + if (weight > slot.capacityTons) { + violations.push( + `Wagon #${placement.sequenceNo} total container weight ${weight}T exceeds capacity ${slot.capacityTons}T`, + ); + } + } + } + + violations.push(...validate20ftContainerRules(units, placements, rules)); + + return violations; +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-readiness.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-readiness.util.spec.ts new file mode 100644 index 000000000..b39c12e89 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/wagon-readiness.util.spec.ts @@ -0,0 +1,35 @@ +import { WagonReadiness } from '@edr/types'; + +import { + requiredWagonReadiness, + wagonReadinessMatchesSchedule, +} from './wagon-readiness.util'; + +describe('wagonReadinessMatchesSchedule', () => { + it('requires IMPORT_READY for IMPORT schedules', () => { + expect(requiredWagonReadiness('IMPORT')).toBe(WagonReadiness.ImportReady); + expect( + wagonReadinessMatchesSchedule(WagonReadiness.ImportReady, 'IMPORT'), + ).toBe(true); + expect( + wagonReadinessMatchesSchedule(WagonReadiness.ExportReady, 'IMPORT'), + ).toBe(false); + }); + + it('requires EXPORT_READY for EXPORT schedules', () => { + expect(requiredWagonReadiness('EXPORT')).toBe(WagonReadiness.ExportReady); + expect( + wagonReadinessMatchesSchedule(WagonReadiness.ExportReady, 'EXPORT'), + ).toBe(true); + expect( + wagonReadinessMatchesSchedule(WagonReadiness.ImportReady, 'EXPORT'), + ).toBe(false); + }); + + it('allows any readiness for DOMESTIC schedules', () => { + expect(requiredWagonReadiness('DOMESTIC')).toBeNull(); + expect( + wagonReadinessMatchesSchedule(WagonReadiness.ExportReady, 'DOMESTIC'), + ).toBe(true); + }); +}); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-readiness.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-readiness.util.ts new file mode 100644 index 000000000..e4ee03a2c --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/wagon-readiness.util.ts @@ -0,0 +1,31 @@ +import { WagonReadiness, type ScheduleTradeDirection } from '@edr/types'; + +/** @deprecated Replaced by yard-based fleet filtering via `currentYardId`. */ +export function requiredWagonReadiness( + direction: ScheduleTradeDirection | string | null | undefined, +): WagonReadiness | null { + if (direction === 'IMPORT') return WagonReadiness.ImportReady; + if (direction === 'EXPORT') return WagonReadiness.ExportReady; + return null; +} + +/** @deprecated Replaced by `wagon.currentYardId === originYardId` checks. */ +export function wagonReadinessMatchesSchedule( + wagonReadiness: WagonReadiness | string, + direction: ScheduleTradeDirection | string | null | undefined, +): boolean { + const required = requiredWagonReadiness(direction); + if (!required) return true; + return wagonReadiness === required; +} + +/** + * @deprecated Replaced by setting `currentYardId = schedule.destinationStationId` on arrival. + */ +export function flipReadiness( + readiness: WagonReadiness | string, +): WagonReadiness { + return readiness === WagonReadiness.ImportReady + ? WagonReadiness.ExportReady + : WagonReadiness.ImportReady; +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-type-resolver.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-type-resolver.util.ts new file mode 100644 index 000000000..bac0330f2 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/wagon-type-resolver.util.ts @@ -0,0 +1,49 @@ +import { WagonType } from '../wagon-types/entities/wagon-type.entity'; + +const CARGO_CODE_TO_WAGON_TYPE: Record = { + COFFEE: 'KW2', + GRAIN: 'KW2', + WHEAT: 'KW2', + SORGHUM: 'KW2', + CORN: 'KW2', + FERTILIZER: 'PW2', + SUGAR: 'PW2', + COAL: 'KW3', + STEEL: 'CW3', + ORE: 'CW3', +}; + +const DEFAULT_BULK_WAGON_TYPE = 'CW3'; +const DEFAULT_CONTAINER_WAGON_TYPE = 'NW5'; + +/** + * Resolve wagon type code from cargo type code for bulk freight. + */ +export function resolveBulkWagonTypeCode(cargoTypeCode?: string | null): string { + if (!cargoTypeCode) return DEFAULT_BULK_WAGON_TYPE; + const normalized = cargoTypeCode.trim().toUpperCase(); + return CARGO_CODE_TO_WAGON_TYPE[normalized] ?? DEFAULT_BULK_WAGON_TYPE; +} + +/** + * Pick the best matching wagon type entity for bulk cargo. + */ +export function pickBulkWagonType( + wagonTypes: WagonType[], + cargoTypeCode?: string | null, +): WagonType | undefined { + const preferredCode = resolveBulkWagonTypeCode(cargoTypeCode); + const direct = wagonTypes.find((wt) => wt.code === preferredCode && wt.isActive); + if (direct) return direct; + + return wagonTypes.find( + (wt) => + wt.isActive && + !wt.supportsContainer && + wt.code !== DEFAULT_CONTAINER_WAGON_TYPE, + ); +} + +export function getDefaultContainerWagonTypeCode(): string { + return DEFAULT_CONTAINER_WAGON_TYPE; +} diff --git a/apps/edr-freight-api/src/modules/train-sets/entities/train-set-wagon.entity.ts b/apps/edr-freight-api/src/modules/train-sets/entities/train-set-wagon.entity.ts new file mode 100644 index 000000000..a220218e0 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-sets/entities/train-set-wagon.entity.ts @@ -0,0 +1,60 @@ +import { BaseEntity } from '@edr/api-common'; +import { TrainSetWagonStatus } from '@edr/types'; +import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm'; + +import { WagonBookingAllocation } from '../../train-schedules/entities/wagon-booking-allocation.entity'; +import { Wagon } from '../../wagons/entities/wagon.entity'; +import { WagonType } from '../../wagon-types/entities/wagon-type.entity'; +import { TrainSet } from './train-set.entity'; + +export const TRAIN_SET_WAGON_STATUSES = [ + TrainSetWagonStatus.Planned, + TrainSetWagonStatus.Reserved, + TrainSetWagonStatus.Loaded, + TrainSetWagonStatus.Departed, +] as const; + +export type TrainSetWagonStatusType = (typeof TRAIN_SET_WAGON_STATUSES)[number]; + +@Entity({ schema: 'freight', name: 'train_set_wagons' }) +@Index(['trainSetId', 'sequenceNo'], { unique: true }) +export class TrainSetWagon extends BaseEntity { + @Column({ name: 'train_set_id', type: 'uuid' }) + trainSetId!: string; + + @ManyToOne(() => TrainSet, (trainSet) => trainSet.wagons, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'train_set_id' }) + trainSet?: TrainSet; + + @Column({ name: 'wagon_type_id', type: 'uuid' }) + wagonTypeId!: string; + + @Column({ name: 'physical_wagon_id', type: 'uuid', nullable: true }) + physicalWagonId!: string | null; + + @ManyToOne(() => Wagon, { nullable: true, onDelete: 'SET NULL' }) + @JoinColumn({ name: 'physical_wagon_id' }) + physicalWagon?: Wagon | null; + + @ManyToOne(() => WagonType, (wagonType) => wagonType.trainSetWagons) + @JoinColumn({ name: 'wagon_type_id' }) + wagonType?: WagonType; + + @Column({ name: 'sequence_no', type: 'int' }) + sequenceNo!: number; + + @Column({ name: 'capacity_tons', type: 'numeric', precision: 10, scale: 3 }) + capacityTons!: number; + + @Column({ name: 'length_meters', type: 'numeric', precision: 10, scale: 3 }) + lengthMeters!: number; + + @Column({ name: 'assigned_weight_tons', type: 'numeric', precision: 10, scale: 3, default: 0 }) + assignedWeightTons!: number; + + @Column({ name: 'status', type: 'varchar', length: 20, default: 'PLANNED' }) + status!: string; + + @OneToMany(() => WagonBookingAllocation, (allocation) => allocation.trainSetWagon) + allocations?: WagonBookingAllocation[]; +} diff --git a/apps/edr-freight-api/src/modules/train-sets/entities/train-set.entity.ts b/apps/edr-freight-api/src/modules/train-sets/entities/train-set.entity.ts new file mode 100644 index 000000000..9099824d5 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-sets/entities/train-set.entity.ts @@ -0,0 +1,46 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany, OneToOne } from 'typeorm'; + +import { Locomotive } from '../../locomotives/entities/locomotive.entity'; +import { TrainSchedule } from '../../train-schedules/entities/train-schedule.entity'; +import { TrainSetWagon } from './train-set-wagon.entity'; + +export const TRAIN_SET_STATUSES = [ + 'DRAFT', + 'ASSIGNED', + 'DISPATCHED', + 'COMPLETED', + 'CANCELLED', +] as const; + +export type TrainSetStatus = (typeof TRAIN_SET_STATUSES)[number]; + +@Entity({ schema: 'freight', name: 'train_sets' }) +@Index(['locomotiveId']) +@Index(['status']) +export class TrainSet extends BaseEntity { + @Column({ name: 'locomotive_id', type: 'uuid' }) + locomotiveId!: string; + + @ManyToOne(() => Locomotive, (locomotive) => locomotive.trainSets) + @JoinColumn({ name: 'locomotive_id' }) + locomotive?: Locomotive; + + @Column({ name: 'total_weight_tons', type: 'numeric', precision: 10, scale: 3 }) + totalWeightTons!: number; + + @Column({ name: 'total_length_meters', type: 'numeric', precision: 10, scale: 3 }) + totalLengthMeters!: number; + + @Column({ name: 'wagon_count', type: 'int' }) + wagonCount!: number; + + @Column({ name: 'status', type: 'varchar', length: 20, default: 'DRAFT' }) + status!: TrainSetStatus; + + @OneToMany(() => TrainSetWagon, (wagon) => wagon.trainSet) + wagons?: TrainSetWagon[]; + + @OneToOne(() => TrainSchedule, (schedule) => schedule.trainSet) + trainSchedule?: TrainSchedule; +} diff --git a/apps/edr-freight-api/src/modules/train-sets/train-set-wagons.repository.ts b/apps/edr-freight-api/src/modules/train-sets/train-set-wagons.repository.ts new file mode 100644 index 000000000..5296b04a6 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-sets/train-set-wagons.repository.ts @@ -0,0 +1,16 @@ +import { BaseRepository } from '@edr/api-common'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { TrainSetWagon } from './entities/train-set-wagon.entity'; + +@Injectable() +export class TrainSetWagonsRepository extends BaseRepository { + constructor( + @InjectRepository(TrainSetWagon) + repository: Repository, + ) { + super(repository); + } +} diff --git a/apps/edr-freight-api/src/modules/train-sets/train-sets.module.ts b/apps/edr-freight-api/src/modules/train-sets/train-sets.module.ts new file mode 100644 index 000000000..f11052727 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-sets/train-sets.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { TrainSet } from './entities/train-set.entity'; +import { TrainSetWagon } from './entities/train-set-wagon.entity'; +import { TrainSetWagonsRepository } from './train-set-wagons.repository'; +import { TrainSetsRepository } from './train-sets.repository'; + +@Module({ + imports: [TypeOrmModule.forFeature([TrainSet, TrainSetWagon])], + providers: [TrainSetsRepository, TrainSetWagonsRepository], + exports: [TrainSetsRepository, TrainSetWagonsRepository], +}) +export class TrainSetsModule {} diff --git a/apps/edr-freight-api/src/modules/train-sets/train-sets.repository.ts b/apps/edr-freight-api/src/modules/train-sets/train-sets.repository.ts new file mode 100644 index 000000000..a6cadbf2d --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-sets/train-sets.repository.ts @@ -0,0 +1,16 @@ +import { BaseRepository } from '@edr/api-common'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { TrainSet } from './entities/train-set.entity'; + +@Injectable() +export class TrainSetsRepository extends BaseRepository { + constructor( + @InjectRepository(TrainSet) + repository: Repository, + ) { + super(repository); + } +} diff --git a/apps/edr-freight-api/src/modules/trains/dto/create-train.dto.ts b/apps/edr-freight-api/src/modules/trains/dto/create-train.dto.ts index f41dfa275..f166254a9 100644 --- a/apps/edr-freight-api/src/modules/trains/dto/create-train.dto.ts +++ b/apps/edr-freight-api/src/modules/trains/dto/create-train.dto.ts @@ -1,5 +1,5 @@ -import { Freight } from "@edr/types"; -import { IsEnum, IsNumber, IsOptional, IsString, Min } from "class-validator"; +import { IsString, IsNumber, IsOptional, IsUUID, IsDateString, Min, IsEnum } from 'class-validator'; +import { Freight } from '@edr/types'; export class CreateTrainDto { @IsString() @@ -11,9 +11,45 @@ export class CreateTrainDto { @IsOptional() @IsEnum(Freight.TrainStatus) - status?: Freight.TrainStatus; + status?: Freight.TrainStatus; // ✅ uses enum, not string @IsOptional() @IsString() notes?: string; -} + + @IsOptional() + @IsString() + trainNumber?: string; + + @IsOptional() + @IsString() + trainName?: string; + + @IsOptional() + @IsUUID() + routeId?: string; + + @IsOptional() + @IsUUID() + originStationId?: string; + + @IsOptional() + @IsUUID() + destinationStationId?: string; + + @IsOptional() + @IsDateString() + departureTime?: string; + + @IsOptional() + @IsDateString() + arrivalTime?: string; + + @IsOptional() + @IsString() + locomotiveNumber?: string; + + @IsOptional() + @IsString() + remarks?: string; +} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/trains/dto/update-train.dto.ts b/apps/edr-freight-api/src/modules/trains/dto/update-train.dto.ts new file mode 100644 index 000000000..cbd36eed9 --- /dev/null +++ b/apps/edr-freight-api/src/modules/trains/dto/update-train.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/swagger'; +import { CreateTrainDto } from './create-train.dto'; + +export class UpdateTrainDto extends PartialType(CreateTrainDto) {} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/trains/entities/train.entity.ts b/apps/edr-freight-api/src/modules/trains/entities/train.entity.ts index c14b66ad0..ab6b49b1d 100644 --- a/apps/edr-freight-api/src/modules/trains/entities/train.entity.ts +++ b/apps/edr-freight-api/src/modules/trains/entities/train.entity.ts @@ -1,23 +1,62 @@ -import { BaseEntity } from "@edr/api-common"; -import { Freight } from "@edr/types"; -import { Column, Entity } from "typeorm"; +// apps/edr-freight-api/src/modules/trains/entities/train.entity.ts +import { BaseEntity } from '@edr/api-common'; +import { Freight } from '@edr/types'; +import { Column, Entity, OneToMany } from 'typeorm'; +import { Wagon } from '../../wagons/entities/wagon.entity'; -@Entity({ name: "trains" }) +/** + * Fleet master data — named wagon consist in inventory (POST /trains). + * Operational departures use train_schedules + locomotives; scheduling never creates trains rows. + */ +@Entity({ schema: 'freight', name: 'trains' }) export class Train extends BaseEntity { - @Column({ name: "code", type: "varchar", length: 32, unique: true }) + // --- existing fields (keep for backward compatibility) --- + @Column({ name: 'code', type: 'varchar', length: 32, unique: true }) code!: string; - @Column({ name: "capacity_tons", type: "numeric", precision: 10, scale: 2 }) + @Column({ name: 'capacity_tons', type: 'numeric', precision: 10, scale: 2 }) capacityTons!: number; @Column({ - name: "status", - type: "enum", + name: 'status', + type: 'enum', enum: Freight.TrainStatus, default: Freight.TrainStatus.Available, }) status!: Freight.TrainStatus; - @Column({ name: "notes", type: "text", nullable: true }) + @Column({ name: 'notes', type: 'text', nullable: true }) notes?: string | null; -} + + // --- new required fields --- + @Column({ name: 'train_number', type: 'varchar', length: 20, unique: true, nullable: true }) + trainNumber?: string; + + @Column({ name: 'train_name', type: 'varchar', length: 100, nullable: true }) + trainName?: string; + + @Column({ name: 'route_id', type: 'uuid', nullable: true }) + routeId?: string; + + @Column({ name: 'origin_station_id', type: 'uuid', nullable: true }) + originStationId?: string; + + @Column({ name: 'destination_station_id', type: 'uuid', nullable: true }) + destinationStationId?: string; + + @Column({ name: 'departure_time', type: 'timestamp', nullable: true }) + departureTime?: Date; + + @Column({ name: 'arrival_time', type: 'timestamp', nullable: true }) + arrivalTime?: Date; + + @Column({ name: 'locomotive_number', type: 'varchar', length: 50, nullable: true }) + locomotiveNumber?: string; + + @Column({ name: 'remarks', type: 'text', nullable: true }) + remarks?: string; + + // --- relationships --- + @OneToMany(() => Wagon, (wagon) => wagon.train) + wagons!: Wagon[]; // fixed typo: was 'wagens' +} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/trains/trains.controller.ts b/apps/edr-freight-api/src/modules/trains/trains.controller.ts index c087d59e3..0217bc161 100644 --- a/apps/edr-freight-api/src/modules/trains/trains.controller.ts +++ b/apps/edr-freight-api/src/modules/trains/trains.controller.ts @@ -1,23 +1,29 @@ import { Body, Controller, + Delete, Get, Param, ParseUUIDPipe, + Patch, Post, + Query, } from "@nestjs/common"; import { ApiOperation, ApiTags } from "@nestjs/swagger"; +import { FleetManage, FleetView } from "../../common/booking-guards"; import { CreateTrainDto } from "./dto/create-train.dto"; +import { UpdateTrainDto } from "./dto/update-train.dto"; import { TrainsService } from "./trains.service"; @ApiTags("trains") -// @UseGuards(JwtAuthGuard) — TODO: integrate @edr/auth @Controller("trains") +@FleetView() export class TrainsController { constructor(private readonly trainsService: TrainsService) {} @Post() + @FleetManage() @ApiOperation({ summary: "Register a new train" }) create(@Body() dto: CreateTrainDto) { return this.trainsService.create(dto); @@ -25,8 +31,8 @@ export class TrainsController { @Get() @ApiOperation({ summary: "List all trains" }) - findAll() { - return this.trainsService.findAll(); + findAll(@Query() query: Record) { + return this.trainsService.findAll(query); } @Get(":id") @@ -34,4 +40,18 @@ export class TrainsController { findOne(@Param("id", ParseUUIDPipe) id: string) { return this.trainsService.findById(id); } + + @Patch(":id") + @FleetManage() + @ApiOperation({ summary: "Update a train" }) + update(@Param("id", ParseUUIDPipe) id: string, @Body() dto: UpdateTrainDto) { + return this.trainsService.update(id, dto); + } + + @Delete(":id") + @FleetManage() + @ApiOperation({ summary: "Delete a train" }) + remove(@Param("id", ParseUUIDPipe) id: string) { + return this.trainsService.remove(id); + } } diff --git a/apps/edr-freight-api/src/modules/trains/trains.module.ts b/apps/edr-freight-api/src/modules/trains/trains.module.ts index 094120f33..61098ff40 100644 --- a/apps/edr-freight-api/src/modules/trains/trains.module.ts +++ b/apps/edr-freight-api/src/modules/trains/trains.module.ts @@ -1,15 +1,14 @@ -import { Module } from "@nestjs/common"; -import { TypeOrmModule } from "@nestjs/typeorm"; - -import { Train } from "./entities/train.entity"; -import { TrainsController } from "./trains.controller"; -import { TrainsRepository } from "./trains.repository"; -import { TrainsService } from "./trains.service"; +// apps/edr-freight-api/src/modules/trains/trains.module.ts +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Train } from './entities/train.entity'; +import { TrainsController } from './trains.controller'; +import { TrainsService } from './trains.service'; @Module({ imports: [TypeOrmModule.forFeature([Train])], controllers: [TrainsController], - providers: [TrainsService, TrainsRepository], - exports: [TrainsService], + providers: [TrainsService], + exports: [TrainsService], // if other modules need it }) -export class TrainsModule {} +export class TrainsModule {} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/trains/trains.service.ts b/apps/edr-freight-api/src/modules/trains/trains.service.ts index db689a19a..9cf37760e 100644 --- a/apps/edr-freight-api/src/modules/trains/trains.service.ts +++ b/apps/edr-freight-api/src/modules/trains/trains.service.ts @@ -1,29 +1,61 @@ -import { Injectable, NotFoundException } from "@nestjs/common"; - -import { CreateTrainDto } from "./dto/create-train.dto"; -import { Train } from "./entities/train.entity"; -import { TrainsRepository } from "./trains.repository"; +import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { FindOptionsOrder, FindOptionsWhere, ILike, Repository } from 'typeorm'; +import { CreateTrainDto } from './dto/create-train.dto'; +import { UpdateTrainDto } from './dto/update-train.dto'; +import { Train } from './entities/train.entity'; @Injectable() export class TrainsService { - constructor(private readonly trainsRepository: TrainsRepository) {} + constructor( + @InjectRepository(Train) + private readonly trainRepo: Repository, + ) {} - /** Register a new train in the fleet. */ create(dto: CreateTrainDto): Promise { - return this.trainsRepository.create(dto); + const train = this.trainRepo.create(dto); + return this.trainRepo.save(train); } - /** List every active train. */ - findAll(): Promise { - return this.trainsRepository.findAll({ order: { code: "ASC" } }); - } + findAll(query: Record = {}): Promise { + const where: FindOptionsWhere[] | FindOptionsWhere = []; + const search = query.search?.trim(); + const status = query.status?.trim(); - /** Get a single train by ID. */ - async findById(id: string): Promise { - const train = await this.trainsRepository.findById(id); - if (!train) { - throw new NotFoundException(`Train ${id} not found`); + if (search) { + where.push({ code: ILike(`%${search}%`), ...(status ? { status: status as Train['status'] } : {}) }); + where.push({ trainNumber: ILike(`%${search}%`), ...(status ? { status: status as Train['status'] } : {}) }); + where.push({ trainName: ILike(`%${search}%`), ...(status ? { status: status as Train['status'] } : {}) }); } + + const sortBy = ['code', 'trainNumber', 'trainName', 'capacityTons', 'status'].includes(query.sortBy ?? '') + ? (query.sortBy as keyof Train) + : 'code'; + const sortOrder = query.sortOrder?.toUpperCase() === 'DESC' ? 'DESC' : 'ASC'; + + return this.trainRepo.find({ + where: search ? where : status ? { status: status as Train['status'] } : {}, + order: { [sortBy]: sortOrder } as FindOptionsOrder, + skip: query.page && query.limit ? (Number(query.page) - 1) * Number(query.limit) : undefined, + take: query.limit ? Number(query.limit) : undefined, + }); + } + + async findById(id: string): Promise { + const train = await this.trainRepo.findOne({ where: { id } }); + if (!train) throw new NotFoundException(`Train ${id} not found`); return train; } + + async update(id: string, dto: UpdateTrainDto): Promise { + const train = await this.findById(id); + Object.assign(train, dto); + // Convert undefined to null for optional fields if needed + return this.trainRepo.save(train); + } + + async remove(id: string): Promise { + const train = await this.findById(id); + await this.trainRepo.remove(train); + } } diff --git a/apps/edr-freight-api/src/modules/vehicles/dto/create-vehicle.dto.ts b/apps/edr-freight-api/src/modules/vehicles/dto/create-vehicle.dto.ts new file mode 100644 index 000000000..fb5c4e92b --- /dev/null +++ b/apps/edr-freight-api/src/modules/vehicles/dto/create-vehicle.dto.ts @@ -0,0 +1,32 @@ +import { IsString, IsEnum, IsNumber, IsOptional } from 'class-validator'; +import { VehicleType, FuelType, VehicleStatus } from '../entities/vehicle.entity'; + +export class CreateVehicleDto { + @IsString() + plateNumber!: string; + + @IsEnum(VehicleType) + vehicleType!: VehicleType; + + @IsString() + manufacturer!: string; + + @IsString() + model!: string; + + @IsNumber() + year!: number; + + @IsEnum(FuelType) + fuelType!: FuelType; + + @IsNumber() + capacity!: number; + + @IsEnum(VehicleStatus) + status!: VehicleStatus; + + @IsOptional() + @IsString() + description?: string; +} diff --git a/apps/edr-freight-api/src/modules/vehicles/dto/update-vehicle.dto.ts b/apps/edr-freight-api/src/modules/vehicles/dto/update-vehicle.dto.ts new file mode 100644 index 000000000..953917b2f --- /dev/null +++ b/apps/edr-freight-api/src/modules/vehicles/dto/update-vehicle.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/mapped-types'; +import { CreateVehicleDto } from './create-vehicle.dto'; + +export class UpdateVehicleDto extends PartialType(CreateVehicleDto) {} diff --git a/apps/edr-freight-api/src/modules/vehicles/entities/vehicle.entity.ts b/apps/edr-freight-api/src/modules/vehicles/entities/vehicle.entity.ts new file mode 100644 index 000000000..773e8051a --- /dev/null +++ b/apps/edr-freight-api/src/modules/vehicles/entities/vehicle.entity.ts @@ -0,0 +1,64 @@ +import { Entity, Column, Index } from 'typeorm'; +import { BaseEntity } from '@edr/api-common'; + +export enum VehicleType { + TRUCK = 'TRUCK', + VAN = 'VAN', + CAR = 'CAR', + BUS = 'BUS', + TRAILER = 'TRAILER', + TANKER = 'TANKER', + FLATBED = 'FLATBED', +} + +export enum FuelType { + PETROL = 'PETROL', + DIESEL = 'DIESEL', + ELECTRIC = 'ELECTRIC', + HYBRID = 'HYBRID', +} + +export enum VehicleStatus { + ACTIVE = 'ACTIVE', + MAINTENANCE = 'MAINTENANCE', + RETIRED = 'RETIRED', + OUT_OF_SERVICE = 'OUT_OF_SERVICE', +} + +@Entity({ name: 'vehicles', schema: 'freight' }) +@Index(['plateNumber']) +@Index(['registrationNumber']) +@Index(['status']) +@Index(['vehicleType']) +@Index(['manufacturer']) +export class Vehicle extends BaseEntity { + @Column({ name: 'plate_number', unique: true }) + plateNumber!: string; + + @Column({ name: 'registration_number', unique: true }) + registrationNumber!: string; + + @Column({ name: 'vehicle_type', type: 'varchar' }) + vehicleType!: VehicleType; + + @Column() + manufacturer!: string; + + @Column() + model!: string; + + @Column() + year!: number; + + @Column({ name: 'fuel_type', type: 'varchar' }) + fuelType!: FuelType; + + @Column() + capacity!: number; + + @Column({ name: 'status', type: 'varchar', default: VehicleStatus.ACTIVE }) + status!: VehicleStatus; + + @Column({ type: 'text', nullable: true }) + description!: string | null; +} diff --git a/apps/edr-freight-api/src/modules/vehicles/vehicles.controller.ts b/apps/edr-freight-api/src/modules/vehicles/vehicles.controller.ts new file mode 100644 index 000000000..24ff2d022 --- /dev/null +++ b/apps/edr-freight-api/src/modules/vehicles/vehicles.controller.ts @@ -0,0 +1,74 @@ +import { + Controller, + Get, + Post, + Patch, + Delete, + Param, + Body, + Query, + ParseUUIDPipe, +} from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { FleetManage, FleetView } from '../../common/booking-guards'; +import { VehiclesService } from './vehicles.service'; +import { CreateVehicleDto } from './dto/create-vehicle.dto'; +import { UpdateVehicleDto } from './dto/update-vehicle.dto'; + +@ApiTags('vehicles') +@ApiBearerAuth() +@Controller('vehicles') +@FleetView() +export class VehiclesController { + constructor(private readonly vehiclesService: VehiclesService) {} + + @Post() + @FleetManage() + @ApiOperation({ summary: 'Create a new vehicle' }) + create(@Body() createVehicleDto: CreateVehicleDto) { + return this.vehiclesService.create(createVehicleDto); + } + + @Get() + @ApiOperation({ summary: 'Get all vehicles with filters' }) + findAll( + @Query('search') search?: string, + @Query('status') status?: string, + @Query('page') page?: string, + @Query('limit') limit?: string, + @Query('sortBy') sortBy?: string, + @Query('sortOrder') sortOrder?: 'ASC' | 'DESC', + ) { + return this.vehiclesService.findAll({ + search, + status: status as any, + page: page ? parseInt(page) : undefined, + limit: limit ? parseInt(limit) : undefined, + sortBy, + sortOrder, + }); + } + + @Get(':id') + @ApiOperation({ summary: 'Get vehicle by id' }) + findOne(@Param('id', ParseUUIDPipe) id: string) { + return this.vehiclesService.findById(id); + } + + @Patch(':id') + @FleetManage() + @ApiOperation({ summary: 'Update a vehicle' }) + update( + @Param('id', ParseUUIDPipe) id: string, + @Body() updateVehicleDto: UpdateVehicleDto, + ) { + return this.vehiclesService.update(id, updateVehicleDto); + } + + @Delete(':id') + @FleetManage() + @ApiOperation({ summary: 'Delete a vehicle' }) + remove(@Param('id', ParseUUIDPipe) id: string) { + return this.vehiclesService.remove(id); + } +} diff --git a/apps/edr-freight-api/src/modules/vehicles/vehicles.module.ts b/apps/edr-freight-api/src/modules/vehicles/vehicles.module.ts new file mode 100644 index 000000000..07aa4bd2f --- /dev/null +++ b/apps/edr-freight-api/src/modules/vehicles/vehicles.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Vehicle } from './entities/vehicle.entity'; +import { VehiclesService } from './vehicles.service'; +import { VehiclesController } from './vehicles.controller'; + +@Module({ + imports: [TypeOrmModule.forFeature([Vehicle])], + providers: [VehiclesService], + controllers: [VehiclesController], + exports: [VehiclesService], +}) +export class VehiclesModule {} diff --git a/apps/edr-freight-api/src/modules/vehicles/vehicles.repository.ts b/apps/edr-freight-api/src/modules/vehicles/vehicles.repository.ts new file mode 100644 index 000000000..9c5bad1e9 --- /dev/null +++ b/apps/edr-freight-api/src/modules/vehicles/vehicles.repository.ts @@ -0,0 +1,15 @@ +import { BaseRepository } from '@edr/api-common'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Vehicle } from './entities/vehicle.entity'; + +@Injectable() +export class VehiclesRepository extends BaseRepository { + constructor( + @InjectRepository(Vehicle) + repository: Repository, + ) { + super(repository); + } +} diff --git a/apps/edr-freight-api/src/modules/vehicles/vehicles.service.ts b/apps/edr-freight-api/src/modules/vehicles/vehicles.service.ts new file mode 100644 index 000000000..12970a8a8 --- /dev/null +++ b/apps/edr-freight-api/src/modules/vehicles/vehicles.service.ts @@ -0,0 +1,109 @@ +import { Injectable, NotFoundException, ConflictException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { CreateVehicleDto } from './dto/create-vehicle.dto'; +import { UpdateVehicleDto } from './dto/update-vehicle.dto'; +import { Vehicle, VehicleStatus } from './entities/vehicle.entity'; + +@Injectable() +export class VehiclesService { + constructor( + @InjectRepository(Vehicle) + private readonly vehicleRepo: Repository, + ) {} + + async create(dto: CreateVehicleDto): Promise { + const existing = await this.vehicleRepo.findOne({ + where: { plateNumber: dto.plateNumber }, + }); + + if (existing) { + throw new ConflictException( + `Vehicle with plate number ${dto.plateNumber} already exists`, + ); + } + + const registrationNumber = `REG-${dto.vehicleType}-${Date.now()}`; + const vehicle = this.vehicleRepo.create({ + ...dto, + registrationNumber, + }); + + return this.vehicleRepo.save(vehicle); + } + + async findAll(query: { + search?: string; + status?: VehicleStatus | string; + page?: number; + limit?: number; + sortBy?: string; + sortOrder?: 'ASC' | 'DESC'; + } = {}): Promise<{ data: Vehicle[]; total: number; page: number; limit: number }> { + const page = query.page || 1; + const limit = query.limit || 10; + const skip = (page - 1) * limit; + + const where: any = {}; + if (query.status) where.status = query.status; + + let qb = this.vehicleRepo.createQueryBuilder('v'); + + if (query.search) { + qb = qb.where( + 'v.plateNumber ILIKE :search OR v.manufacturer ILIKE :search', + { search: `%${query.search}%` }, + ); + } + + if (query.status) { + qb = qb.andWhere('v.status = :status', { status: query.status }); + } + + const sortBy = ['plateNumber', 'status', 'year', 'createdAt'].includes( + query.sortBy ?? '', + ) + ? query.sortBy + : 'createdAt'; + const sortOrder = (query.sortOrder ?? 'DESC').toUpperCase(); + + const [data, total] = await qb + .orderBy(`v.${sortBy}`, sortOrder as 'ASC' | 'DESC') + .skip(skip) + .take(limit) + .getManyAndCount(); + + return { data, total, page, limit }; + } + + async findById(id: string): Promise { + const vehicle = await this.vehicleRepo.findOne({ where: { id } }); + if (!vehicle) { + throw new NotFoundException(`Vehicle ${id} not found`); + } + return vehicle; + } + + async update(id: string, dto: UpdateVehicleDto): Promise { + const vehicle = await this.findById(id); + + if (dto.plateNumber && dto.plateNumber !== vehicle.plateNumber) { + const existing = await this.vehicleRepo.findOne({ + where: { plateNumber: dto.plateNumber }, + }); + if (existing) { + throw new ConflictException( + `Vehicle with plate number ${dto.plateNumber} already exists`, + ); + } + } + + Object.assign(vehicle, dto); + return this.vehicleRepo.save(vehicle); + } + + async remove(id: string): Promise { + await this.findById(id); + await this.vehicleRepo.softDelete(id); + } +} diff --git a/apps/edr-freight-api/src/modules/wagon-types/dto/create-wagon-type.dto.ts b/apps/edr-freight-api/src/modules/wagon-types/dto/create-wagon-type.dto.ts new file mode 100644 index 000000000..6de5debda --- /dev/null +++ b/apps/edr-freight-api/src/modules/wagon-types/dto/create-wagon-type.dto.ts @@ -0,0 +1,86 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Transform } from 'class-transformer'; +import { + IsArray, + IsBoolean, + IsInt, + IsNumber, + IsOptional, + IsString, + MaxLength, + Min, +} from 'class-validator'; + +const toNumber = ({ value }: { value: unknown }) => + value === '' || value == null ? value : Number(value); + +const toOptionalNumber = ({ value }: { value: unknown }) => + value === '' || value == null ? undefined : Number(value); + +const toBoolean = ({ value }: { value: unknown }) => { + if (typeof value === 'boolean') return value; + if (value === 'true') return true; + if (value === 'false') return false; + return value; +}; + +const toStringArray = ({ value }: { value: unknown }) => { + if (Array.isArray(value)) { + return value.map((entry) => String(entry).trim()).filter(Boolean); + } + + if (typeof value !== 'string') return []; + + return value + .split(',') + .map((entry) => entry.trim()) + .filter(Boolean); +}; + +export class CreateWagonTypeDto { + @ApiProperty({ maxLength: 32, example: 'NW5' }) + @IsString() + @MaxLength(32) + code!: string; + + @ApiProperty({ maxLength: 100, example: 'Flat wagon container' }) + @IsString() + @MaxLength(100) + name!: string; + + @ApiProperty({ description: 'Maximum payload capacity in metric tons', example: 70 }) + @Transform(toNumber) + @IsNumber() + @Min(0.001) + capacityTons!: number; + + @ApiProperty({ description: 'Wagon length in meters', example: 14 }) + @Transform(toNumber) + @IsNumber() + @Min(0.001) + lengthMeters!: number; + + @ApiPropertyOptional({ description: 'Maximum wagons of this type per train', example: 53 }) + @IsOptional() + @Transform(toOptionalNumber) + @IsInt() + @Min(1) + maxWagonsPerTrain?: number; + + @ApiPropertyOptional({ + description: 'Supported load types, e.g. CONTAINER,BULK', + type: [String], + default: [], + }) + @IsOptional() + @Transform(toStringArray) + @IsArray() + @IsString({ each: true }) + supportedLoadTypes?: string[]; + + @ApiPropertyOptional({ default: true }) + @IsOptional() + @Transform(toBoolean) + @IsBoolean() + isActive?: boolean; +} diff --git a/apps/edr-freight-api/src/modules/wagon-types/dto/update-wagon-type.dto.ts b/apps/edr-freight-api/src/modules/wagon-types/dto/update-wagon-type.dto.ts new file mode 100644 index 000000000..846987556 --- /dev/null +++ b/apps/edr-freight-api/src/modules/wagon-types/dto/update-wagon-type.dto.ts @@ -0,0 +1,5 @@ +import { PartialType } from '@nestjs/mapped-types'; + +import { CreateWagonTypeDto } from './create-wagon-type.dto'; + +export class UpdateWagonTypeDto extends PartialType(CreateWagonTypeDto) {} diff --git a/apps/edr-freight-api/src/modules/wagon-types/entities/wagon-type.entity.ts b/apps/edr-freight-api/src/modules/wagon-types/entities/wagon-type.entity.ts new file mode 100644 index 000000000..2181a2bd1 --- /dev/null +++ b/apps/edr-freight-api/src/modules/wagon-types/entities/wagon-type.entity.ts @@ -0,0 +1,45 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, OneToMany } from 'typeorm'; + +import { TrainSetWagon } from '../../train-sets/entities/train-set-wagon.entity'; + +@Entity({ schema: 'freight', name: 'wagon_types' }) +@Index(['code']) +@Index(['isActive']) +export class WagonType extends BaseEntity { + @Column({ name: 'code', type: 'varchar', length: 32, unique: true }) + code!: string; + + @Column({ name: 'name', type: 'varchar', length: 100 }) + name!: string; + + @Column({ name: 'capacity_tons', type: 'numeric', precision: 10, scale: 3 }) + capacityTons!: number; + + @Column({ name: 'length_meters', type: 'numeric', precision: 10, scale: 3 }) + lengthMeters!: number; + + @Column({ name: 'max_wagons_per_train', type: 'int', nullable: true }) + maxWagonsPerTrain?: number | null; + + @Column({ name: 'supported_load_types', type: 'text', array: true, default: '{}' }) + supportedLoadTypes!: string[]; + + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive!: boolean; + + @Column({ name: 'equated_length_m', type: 'numeric', precision: 10, scale: 3, nullable: true }) + equatedLengthM?: number | null; + + @Column({ name: 'tare_weight_tons', type: 'numeric', precision: 10, scale: 3, nullable: true }) + tareWeightTons?: number | null; + + @Column({ name: 'supports_container', type: 'boolean', default: false }) + supportsContainer!: boolean; + + @Column({ name: 'max_container_gross_t', type: 'numeric', precision: 10, scale: 3, nullable: true }) + maxContainerGrossT?: number | null; + + @OneToMany(() => TrainSetWagon, (wagon) => wagon.wagonType) + trainSetWagons?: TrainSetWagon[]; +} diff --git a/apps/edr-freight-api/src/modules/wagon-types/wagon-types.controller.ts b/apps/edr-freight-api/src/modules/wagon-types/wagon-types.controller.ts new file mode 100644 index 000000000..8f6417220 --- /dev/null +++ b/apps/edr-freight-api/src/modules/wagon-types/wagon-types.controller.ts @@ -0,0 +1,74 @@ +import { + Body, + Controller, + Delete, + Get, + HttpCode, + HttpStatus, + Param, + ParseUUIDPipe, + Patch, + Post, + Query, +} from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { RuleEngineManage, RuleEngineView } from '../../common/rule-engine-guards'; + +import { CreateWagonTypeDto } from './dto/create-wagon-type.dto'; +import { UpdateWagonTypeDto } from './dto/update-wagon-type.dto'; +import { WagonTypesService } from './wagon-types.service'; + +@ApiTags('wagon-types') +@Controller('wagon-types') +@ApiBearerAuth() +export class WagonTypesController { + constructor(private readonly wagonTypesService: WagonTypesService) {} + + @Get() + @RuleEngineView('wagon-types') + @ApiOperation({ summary: 'List wagon types' }) + findAll(@Query() query: Record) { + return this.wagonTypesService.findAll({ + isActive: + query.isActive === 'all' + ? undefined + : query.isActive !== undefined + ? query.isActive === 'true' + : true, + page: query.page ? parseInt(query.page, 10) : undefined, + pageSize: query.pageSize ? parseInt(query.pageSize, 10) : undefined, + sortBy: query.sortBy, + sortOrder: query.sortOrder, + }); + } + + @Get(':id') + @RuleEngineView('wagon-types') + @ApiOperation({ summary: 'Get a wagon type by ID' }) + findOne(@Param('id', ParseUUIDPipe) id: string) { + return this.wagonTypesService.findById(id); + } + + @Post() + @RuleEngineManage('wagon-types') + @ApiOperation({ summary: 'Create a wagon type' }) + create(@Body() dto: CreateWagonTypeDto) { + return this.wagonTypesService.create(dto); + } + + @Patch(':id') + @RuleEngineManage('wagon-types') + @ApiOperation({ summary: 'Update a wagon type' }) + update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateWagonTypeDto) { + return this.wagonTypesService.update(id, dto); + } + + @Delete(':id') + @RuleEngineManage('wagon-types') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'Soft-delete a wagon type' }) + remove(@Param('id', ParseUUIDPipe) id: string) { + return this.wagonTypesService.remove(id); + } +} diff --git a/apps/edr-freight-api/src/modules/wagon-types/wagon-types.module.ts b/apps/edr-freight-api/src/modules/wagon-types/wagon-types.module.ts new file mode 100644 index 000000000..d2d770585 --- /dev/null +++ b/apps/edr-freight-api/src/modules/wagon-types/wagon-types.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { WagonType } from './entities/wagon-type.entity'; +import { WagonTypesController } from './wagon-types.controller'; +import { WagonTypesRepository } from './wagon-types.repository'; +import { WagonTypesService } from './wagon-types.service'; + +@Module({ + imports: [TypeOrmModule.forFeature([WagonType])], + controllers: [WagonTypesController], + providers: [WagonTypesRepository, WagonTypesService], + exports: [WagonTypesRepository, WagonTypesService], +}) +export class WagonTypesModule {} diff --git a/apps/edr-freight-api/src/modules/wagon-types/wagon-types.repository.ts b/apps/edr-freight-api/src/modules/wagon-types/wagon-types.repository.ts new file mode 100644 index 000000000..ce7166e67 --- /dev/null +++ b/apps/edr-freight-api/src/modules/wagon-types/wagon-types.repository.ts @@ -0,0 +1,20 @@ +import { BaseRepository } from '@edr/api-common'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { WagonType } from './entities/wagon-type.entity'; + +@Injectable() +export class WagonTypesRepository extends BaseRepository { + constructor( + @InjectRepository(WagonType) + repository: Repository, + ) { + super(repository); + } + + findByCode(code: string): Promise { + return this.repository.findOne({ where: { code } }); + } +} diff --git a/apps/edr-freight-api/src/modules/wagon-types/wagon-types.service.ts b/apps/edr-freight-api/src/modules/wagon-types/wagon-types.service.ts new file mode 100644 index 000000000..ec69bb76a --- /dev/null +++ b/apps/edr-freight-api/src/modules/wagon-types/wagon-types.service.ts @@ -0,0 +1,120 @@ +import { ConflictException, Injectable, NotFoundException } from '@nestjs/common'; +import { FindOptionsOrder } from 'typeorm'; + +import { CreateWagonTypeDto } from './dto/create-wagon-type.dto'; +import { UpdateWagonTypeDto } from './dto/update-wagon-type.dto'; +import { WagonType } from './entities/wagon-type.entity'; +import { WagonTypesRepository } from './wagon-types.repository'; + +type WagonTypeListFilter = { + isActive?: boolean; + page?: number; + pageSize?: number; + sortBy?: string; + sortOrder?: string; +}; + +@Injectable() +export class WagonTypesService { + constructor(private readonly wagonTypesRepository: WagonTypesRepository) {} + + async findAll(filter: WagonTypeListFilter = {}): Promise<{ + data: WagonType[]; + meta: { total: number; page: number; pageSize: number; totalPages: number }; + }> { + const page = filter.page ?? 1; + const pageSize = filter.pageSize ?? 500; + const sortBy = ['code', 'name', 'capacityTons', 'lengthMeters', 'isActive'].includes( + filter.sortBy ?? '', + ) + ? (filter.sortBy as keyof WagonType) + : 'code'; + const sortOrder = filter.sortOrder?.toUpperCase() === 'DESC' ? 'DESC' : 'ASC'; + + const [data, total] = await this.wagonTypesRepository.findAndCount({ + where: filter.isActive === undefined ? {} : { isActive: filter.isActive }, + order: { [sortBy]: sortOrder } as FindOptionsOrder, + skip: (page - 1) * pageSize, + take: pageSize, + }); + + return { + data, + meta: { + total, + page, + pageSize, + totalPages: Math.max(1, Math.ceil(total / pageSize)), + }, + }; + } + + async findById(id: string): Promise { + const wagonType = await this.wagonTypesRepository.findById(id); + + if (!wagonType) { + throw new NotFoundException(`Wagon type ${id} not found`); + } + + return wagonType; + } + + async findByCode(code: string): Promise { + const wagonType = await this.wagonTypesRepository.findByCode(code); + if (!wagonType) { + throw new NotFoundException(`Wagon type ${code} not found`); + } + return wagonType; + } + + async create(dto: CreateWagonTypeDto): Promise { + const code = dto.code.trim().toUpperCase(); + const existing = await this.wagonTypesRepository.findByCode(code); + + if (existing) { + throw new ConflictException(`Wagon type code "${code}" already exists`); + } + + return this.wagonTypesRepository.create({ + code, + name: dto.name.trim(), + capacityTons: dto.capacityTons, + lengthMeters: dto.lengthMeters, + maxWagonsPerTrain: dto.maxWagonsPerTrain ?? null, + supportedLoadTypes: dto.supportedLoadTypes ?? [], + isActive: dto.isActive ?? true, + }); + } + + async update(id: string, dto: UpdateWagonTypeDto): Promise { + const wagonType = await this.findById(id); + const nextCode = dto.code?.trim().toUpperCase(); + + if (nextCode && nextCode !== wagonType.code) { + const existing = await this.wagonTypesRepository.findByCode(nextCode); + if (existing) { + throw new ConflictException(`Wagon type code "${nextCode}" already exists`); + } + } + + const updated = await this.wagonTypesRepository.update(id, { + ...dto, + ...(nextCode ? { code: nextCode } : {}), + ...(dto.name ? { name: dto.name.trim() } : {}), + maxWagonsPerTrain: + dto.maxWagonsPerTrain === undefined ? undefined : dto.maxWagonsPerTrain ?? null, + supportedLoadTypes: dto.supportedLoadTypes ?? undefined, + }); + + if (!updated) { + throw new NotFoundException(`Wagon type ${id} not found`); + } + + return updated; + } + + async remove(id: string): Promise { + await this.findById(id); + await this.wagonTypesRepository.softDelete(id); + } +} diff --git a/apps/edr-freight-api/src/modules/wagons/dto/assign-wagon-to-train.dto.ts b/apps/edr-freight-api/src/modules/wagons/dto/assign-wagon-to-train.dto.ts new file mode 100644 index 000000000..66a837e68 --- /dev/null +++ b/apps/edr-freight-api/src/modules/wagons/dto/assign-wagon-to-train.dto.ts @@ -0,0 +1,11 @@ +import { IsUUID, IsOptional, IsInt, Min } from 'class-validator'; + +export class AssignWagonToTrainDto { + @IsUUID() + trainId!: string; + + @IsOptional() + @IsInt() + @Min(1) + sequenceNumber?: number; +} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/wagons/dto/create-wagon.dto.ts b/apps/edr-freight-api/src/modules/wagons/dto/create-wagon.dto.ts new file mode 100644 index 000000000..03a930b11 --- /dev/null +++ b/apps/edr-freight-api/src/modules/wagons/dto/create-wagon.dto.ts @@ -0,0 +1,39 @@ +import { WagonStatus } from '@edr/types'; +import { IsString, IsUUID, IsOptional, IsInt, Min, IsNumber, IsEnum } from 'class-validator'; + +export class CreateWagonDto { + @IsString() + wagonNumber!: string; + + @IsUUID() + wagonTypeId!: string; + + @IsOptional() + @IsUUID() + trainId?: string; + + @IsOptional() + @IsInt() + @Min(1) + sequenceNumber?: number; + + @IsNumber() + @Min(0) + tareWeight!: number; + + @IsNumber() + @Min(0) + maxPayloadWeight!: number; + + @IsOptional() + @IsEnum(WagonStatus) + status?: WagonStatus; + + @IsOptional() + @IsUUID() + currentYardId?: string; + + @IsOptional() + @IsString() + notes?: string; +} diff --git a/apps/edr-freight-api/src/modules/wagons/dto/list-wagons-query.dto.ts b/apps/edr-freight-api/src/modules/wagons/dto/list-wagons-query.dto.ts new file mode 100644 index 000000000..23b517785 --- /dev/null +++ b/apps/edr-freight-api/src/modules/wagons/dto/list-wagons-query.dto.ts @@ -0,0 +1,56 @@ +import { WagonStatus } from '@edr/types'; +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { IsEnum, IsInt, IsOptional, IsString, IsUUID, Max, Min } from 'class-validator'; + +export class ListWagonsQueryDto { + @ApiPropertyOptional({ description: 'Search wagon number (partial match)' }) + @IsOptional() + @IsString() + search?: string; + + @ApiPropertyOptional({ enum: WagonStatus }) + @IsOptional() + @IsEnum(WagonStatus) + status?: WagonStatus; + + @ApiPropertyOptional({ description: 'Filter by current yard' }) + @IsOptional() + @IsUUID() + currentYardId?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsUUID() + wagonTypeId?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsUUID() + trainId?: string; + + @ApiPropertyOptional({ default: 'wagonNumber' }) + @IsOptional() + @IsString() + sortBy?: string; + + @ApiPropertyOptional({ enum: ['ASC', 'DESC'], default: 'ASC' }) + @IsOptional() + @IsString() + sortOrder?: 'ASC' | 'DESC'; + + @ApiPropertyOptional({ minimum: 1 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page?: number; + + @ApiPropertyOptional({ minimum: 1, maximum: 500 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(500) + limit?: number; +} diff --git a/apps/edr-freight-api/src/modules/wagons/dto/reorder-wagons.dto.ts b/apps/edr-freight-api/src/modules/wagons/dto/reorder-wagons.dto.ts new file mode 100644 index 000000000..0395adb8f --- /dev/null +++ b/apps/edr-freight-api/src/modules/wagons/dto/reorder-wagons.dto.ts @@ -0,0 +1,7 @@ +import { IsArray, IsUUID } from 'class-validator'; + +export class ReorderWagonsDto { + @IsArray() + @IsUUID(4, { each: true }) + wagonIds!: string[]; +} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/wagons/dto/update-wagon.dto.ts b/apps/edr-freight-api/src/modules/wagons/dto/update-wagon.dto.ts new file mode 100644 index 000000000..3414d1f2c --- /dev/null +++ b/apps/edr-freight-api/src/modules/wagons/dto/update-wagon.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/swagger'; +import { CreateWagonDto } from './create-wagon.dto'; + +export class UpdateWagonDto extends PartialType(CreateWagonDto) {} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/wagons/entities/wagon.entity.ts b/apps/edr-freight-api/src/modules/wagons/entities/wagon.entity.ts new file mode 100644 index 000000000..42db52231 --- /dev/null +++ b/apps/edr-freight-api/src/modules/wagons/entities/wagon.entity.ts @@ -0,0 +1,76 @@ +// apps/edr-freight-api/src/modules/wagons/entities/wagon.entity.ts +import { WagonStatus } from '@edr/types'; +import { Entity, Column, ManyToOne, OneToMany, JoinColumn, Index } from 'typeorm'; +import { BaseEntity } from '@edr/api-common'; +import { Train } from '../../trains/entities/train.entity'; +import { TrainSchedule } from '../../train-schedules/entities/train-schedule.entity'; +import { TrainSetWagon } from '../../train-sets/entities/train-set-wagon.entity'; +import { Container } from '../../container-management/entities/container.entity'; +import { Yard } from '../../rule-engine/entities/yard.entity'; + +export const WAGON_STATUSES = [ + WagonStatus.Available, + WagonStatus.Assigned, + WagonStatus.Maintenance, + WagonStatus.Retired, +] as const; + +export type WagonStatusType = (typeof WAGON_STATUSES)[number]; + +@Entity({ name: 'wagons', schema: 'freight' }) +@Index(['currentYardId']) +export class Wagon extends BaseEntity { + @Column({ unique: true, name: 'wagon_number' }) + wagonNumber!: string; + + @Column({ name: 'wagon_type_id', type: 'uuid' }) + wagonTypeId!: string; + + @Column({ name: 'train_id', type: 'uuid', nullable: true }) + trainId!: string | null; + + @Column({ name: 'sequence_number', type: 'int', nullable: true }) + sequenceNumber!: number | null; + + @Column({ name: 'tare_weight', type: 'decimal', precision: 10, scale: 2 }) + tareWeight!: number; + + @Column({ name: 'max_payload_weight', type: 'decimal', precision: 10, scale: 2 }) + maxPayloadWeight!: number; + + @Column({ type: 'varchar', length: 20, default: WagonStatus.Available }) + status!: WagonStatusType; + + @Column({ name: 'current_yard_id', type: 'uuid', nullable: true }) + currentYardId!: string | null; + + @ManyToOne(() => Yard, { nullable: true, onDelete: 'SET NULL' }) + @JoinColumn({ name: 'current_yard_id' }) + currentYard?: Yard | null; + + @Column({ type: 'text', nullable: true }) + notes!: string | null; + + @Column({ name: 'train_set_wagon_id', type: 'uuid', nullable: true }) + trainSetWagonId!: string | null; + + @ManyToOne(() => TrainSetWagon, { nullable: true, onDelete: 'SET NULL' }) + @JoinColumn({ name: 'train_set_wagon_id' }) + trainSetWagon?: TrainSetWagon | null; + + @Column({ name: 'current_train_schedule_id', type: 'uuid', nullable: true }) + currentTrainScheduleId!: string | null; + + @ManyToOne(() => TrainSchedule, { nullable: true, onDelete: 'SET NULL' }) + @JoinColumn({ name: 'current_train_schedule_id' }) + currentTrainSchedule?: TrainSchedule | null; + + /** Fleet master consist grouping — separate from operational train_schedules. */ + @ManyToOne(() => Train, (train) => train.wagons, { onDelete: 'SET NULL' }) + @JoinColumn({ name: 'train_id' }) + train!: Train | null; + + // Relationship to Container + @OneToMany(() => Container, (container) => container.wagon) + containers!: Container[]; +} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/wagons/wagons.controller.ts b/apps/edr-freight-api/src/modules/wagons/wagons.controller.ts new file mode 100644 index 000000000..ec98a4a4b --- /dev/null +++ b/apps/edr-freight-api/src/modules/wagons/wagons.controller.ts @@ -0,0 +1,87 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + ParseUUIDPipe, + Patch, + Post, + Query, +} from '@nestjs/common'; +import { ApiOperation, ApiTags } from '@nestjs/swagger'; +import { FleetManage, FleetView } from '../../common/booking-guards'; +import { CreateWagonDto } from './dto/create-wagon.dto'; +import { ListWagonsQueryDto } from './dto/list-wagons-query.dto'; +import { UpdateWagonDto } from './dto/update-wagon.dto'; +import { AssignWagonToTrainDto } from './dto/assign-wagon-to-train.dto'; +import { ReorderWagonsDto } from './dto/reorder-wagons.dto'; +import { WagonsService } from './wagons.service'; + +@ApiTags('wagons') +@Controller('wagons') +@FleetView() +export class WagonsController { + constructor(private readonly wagonsService: WagonsService) {} + + @Post() + @FleetManage() + @ApiOperation({ summary: 'Create a new wagon' }) + create(@Body() dto: CreateWagonDto) { + return this.wagonsService.create(dto); + } + + @Get() + @ApiOperation({ summary: 'List all wagons' }) + findAll(@Query() query: ListWagonsQueryDto) { + return this.wagonsService.findAll(query); + } + + @Get(':id') + @ApiOperation({ summary: 'Get a wagon by ID' }) + findOne(@Param('id', ParseUUIDPipe) id: string) { + return this.wagonsService.findById(id); + } + + @Patch(':id') + @FleetManage() + @ApiOperation({ summary: 'Update a wagon' }) + update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateWagonDto) { + return this.wagonsService.update(id, dto); + } + + @Delete(':id') + @FleetManage() + @ApiOperation({ summary: 'Delete a wagon' }) + remove(@Param('id', ParseUUIDPipe) id: string) { + return this.wagonsService.remove(id); + } + + @Post(':id/assign-train') + @FleetManage() + @ApiOperation({ summary: 'Assign wagon to a train' }) + assignToTrain(@Param('id', ParseUUIDPipe) id: string, @Body() dto: AssignWagonToTrainDto) { + return this.wagonsService.assignToTrain(id, dto); + } + + @Post(':id/unassign-train') + @FleetManage() + @ApiOperation({ summary: 'Unassign wagon from train' }) + unassignFromTrain(@Param('id', ParseUUIDPipe) id: string) { + return this.wagonsService.unassignFromTrain(id); + } +} + +// Separate controller for train‑specific reorder (registered in module) +@Controller('trains/:trainId/reorder-wagons') +@FleetView() +export class TrainWagonsReorderController { + constructor(private readonly wagonsService: WagonsService) {} + + @Post() + @FleetManage() + @ApiOperation({ summary: 'Reorder wagons of a train' }) + reorder(@Param('trainId', ParseUUIDPipe) trainId: string, @Body() dto: ReorderWagonsDto) { + return this.wagonsService.reorderWagons(trainId, dto); + } +} diff --git a/apps/edr-freight-api/src/modules/wagons/wagons.module.ts b/apps/edr-freight-api/src/modules/wagons/wagons.module.ts new file mode 100644 index 000000000..bffe28860 --- /dev/null +++ b/apps/edr-freight-api/src/modules/wagons/wagons.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Wagon } from './entities/wagon.entity'; +import { Train } from '../trains/entities/train.entity'; +import { Yard } from '../rule-engine/entities/yard.entity'; +import { WagonsController, TrainWagonsReorderController } from './wagons.controller'; +import { WagonsService } from './wagons.service'; + +@Module({ + imports: [TypeOrmModule.forFeature([Wagon, Train, Yard])], + controllers: [WagonsController, TrainWagonsReorderController], + providers: [WagonsService], + exports: [WagonsService], +}) +export class WagonsModule {} diff --git a/apps/edr-freight-api/src/modules/wagons/wagons.repository.ts b/apps/edr-freight-api/src/modules/wagons/wagons.repository.ts new file mode 100644 index 000000000..f0e12842e --- /dev/null +++ b/apps/edr-freight-api/src/modules/wagons/wagons.repository.ts @@ -0,0 +1,15 @@ +import { BaseRepository } from '@edr/api-common'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Wagon } from './entities/wagon.entity'; + +@Injectable() +export class WagonsRepository extends BaseRepository { + constructor( + @InjectRepository(Wagon) + repository: Repository, + ) { + super(repository); + } +} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/wagons/wagons.service.ts b/apps/edr-freight-api/src/modules/wagons/wagons.service.ts new file mode 100644 index 000000000..b2b1df275 --- /dev/null +++ b/apps/edr-freight-api/src/modules/wagons/wagons.service.ts @@ -0,0 +1,137 @@ +import { WagonStatus } from '@edr/types'; +import { Injectable, NotFoundException, ConflictException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, DataSource, FindOptionsOrder, FindOptionsWhere, ILike } from 'typeorm'; +import { CreateWagonDto } from './dto/create-wagon.dto'; +import { ListWagonsQueryDto } from './dto/list-wagons-query.dto'; +import { UpdateWagonDto } from './dto/update-wagon.dto'; +import { AssignWagonToTrainDto } from './dto/assign-wagon-to-train.dto'; +import { ReorderWagonsDto } from './dto/reorder-wagons.dto'; +import { Wagon } from './entities/wagon.entity'; +import { Train } from '../trains/entities/train.entity'; + +@Injectable() +export class WagonsService { + constructor( + @InjectRepository(Wagon) + private readonly wagonRepo: Repository, + @InjectRepository(Train) + private readonly trainRepo: Repository, + private readonly dataSource: DataSource, + ) {} + + async create(dto: CreateWagonDto): Promise { + const wagon = this.wagonRepo.create({ + ...dto, + status: dto.status ?? WagonStatus.Available, + }); + // Convert undefined to null for nullable fields + if (dto.trainId === undefined) wagon.trainId = null; + if (dto.sequenceNumber === undefined) wagon.sequenceNumber = null; + if (dto.currentYardId === undefined) wagon.currentYardId = null; + return this.wagonRepo.save(wagon); + } + + async findAll(query: ListWagonsQueryDto = {}): Promise { + const where: FindOptionsWhere[] | FindOptionsWhere = []; + const search = query.search?.trim(); + const trainId = query.trainId?.trim(); + const wagonTypeId = query.wagonTypeId?.trim(); + const filters: FindOptionsWhere = { + ...(query.status ? { status: query.status } : {}), + ...(query.currentYardId ? { currentYardId: query.currentYardId } : {}), + ...(trainId ? { trainId } : {}), + ...(wagonTypeId ? { wagonTypeId } : {}), + }; + + if (search) { + where.push({ + wagonNumber: ILike(`%${search}%`), + ...filters, + }); + } + + const sortBy = ['wagonNumber', 'tareWeight', 'maxPayloadWeight', 'status', 'currentYardId', 'sequenceNumber'].includes(query.sortBy ?? '') + ? (query.sortBy as keyof Wagon) + : 'wagonNumber'; + const sortOrder = query.sortOrder?.toUpperCase() === 'DESC' ? 'DESC' : 'ASC'; + + return this.wagonRepo.find({ + where: search ? where : filters, + relations: { currentYard: true }, + order: { [sortBy]: sortOrder } as FindOptionsOrder, + skip: query.page && query.limit ? (Number(query.page) - 1) * Number(query.limit) : undefined, + take: query.limit ? Number(query.limit) : undefined, + }); + } + + async findById(id: string): Promise { + const wagon = await this.wagonRepo.findOne({ + where: { id }, + relations: { currentYard: true }, + }); + if (!wagon) throw new NotFoundException(`Wagon ${id} not found`); + return wagon; + } + + async update(id: string, dto: UpdateWagonDto): Promise { + const wagon = await this.findById(id); + Object.assign(wagon, dto); + return this.wagonRepo.save(wagon); + } + + async remove(id: string): Promise { + const wagon = await this.findById(id); + await this.wagonRepo.remove(wagon); + } + + async assignToTrain(wagonId: string, dto: AssignWagonToTrainDto): Promise { + const wagon = await this.findById(wagonId); + if (wagon.status === WagonStatus.Assigned) { + throw new ConflictException('Wagon already assigned to a train'); + } + + const train = await this.trainRepo.findOne({ where: { id: dto.trainId } }); + if (!train) throw new NotFoundException('Train not found'); + + let sequence: number | null = dto.sequenceNumber ?? null; + if (sequence === null) { + const maxSeq = await this.wagonRepo + .createQueryBuilder('w') + .select('MAX(w.sequenceNumber)', 'max') + .where('w.trainId = :trainId', { trainId: train.id }) + .getRawOne(); + sequence = (maxSeq?.max ?? 0) + 1; + } + + wagon.trainId = train.id; + wagon.sequenceNumber = sequence; + wagon.status = WagonStatus.Assigned; + return this.wagonRepo.save(wagon); + } + + async unassignFromTrain(wagonId: string): Promise { + const wagon = await this.findById(wagonId); + wagon.trainId = null; + wagon.sequenceNumber = null; + wagon.status = WagonStatus.Available; + return this.wagonRepo.save(wagon); + } + + async reorderWagons(_trainId: string, dto: ReorderWagonsDto): Promise { + const queryRunner = this.dataSource.createQueryRunner(); + await queryRunner.connect(); + await queryRunner.startTransaction(); + try { + for (let i = 0; i < dto.wagonIds.length; i++) { + await queryRunner.manager.update(Wagon, dto.wagonIds[i], { sequenceNumber: i + 1 }); + } + await queryRunner.commitTransaction(); + } catch (err) { + await queryRunner.rollbackTransaction(); + throw err; + } finally { + await queryRunner.release(); + } + } +} diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/allocation-rule.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/allocation-rule.dto.ts new file mode 100644 index 000000000..43cd6f61a --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/dto/allocation-rule.dto.ts @@ -0,0 +1,96 @@ +import { ApiProperty, ApiPropertyOptional, PartialType } from '@nestjs/swagger'; +import { IsBoolean, IsInt, IsOptional, IsString } from 'class-validator'; + +export class CreateAllocationRuleDto { + @ApiProperty() + @IsString() + name!: string; + + @ApiPropertyOptional({ default: 100 }) + @IsOptional() + @IsInt() + priority?: number; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + freightType?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + tradeDirection?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + cargoTypeCode?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + containerStatus?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsBoolean() + requiresInspection?: boolean; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + targetFacilityCode?: string; + + @ApiProperty() + @IsString() + targetYardCode!: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + targetWarehouseCode?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + targetZoneCode?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + storageType?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsBoolean() + isActive?: boolean; +} + +export class UpdateAllocationRuleDto extends PartialType(CreateAllocationRuleDto) {} + +export class AllocationPreviewDto { + @ApiPropertyOptional() + @IsOptional() + @IsString() + freightType?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + tradeDirection?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + cargoTypeCode?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + containerStatus?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsBoolean() + requiresInspection?: boolean; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/create-inspection-report.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/create-inspection-report.dto.ts new file mode 100644 index 000000000..cc1350796 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/dto/create-inspection-report.dto.ts @@ -0,0 +1,64 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsBoolean, IsEnum, IsNumber, IsOptional, IsString, IsUUID } from 'class-validator'; + +import { + INSPECTION_REPORT_TYPES, + INSPECTION_STATUSES, + InspectionReportType, + InspectionStatus, +} from '../entities/warehouse-inspection-report.entity'; + +export class CreateInspectionReportDto { + @ApiProperty({ enum: INSPECTION_REPORT_TYPES }) + @IsEnum(INSPECTION_REPORT_TYPES) + reportType!: InspectionReportType; + + @ApiProperty({ enum: INSPECTION_STATUSES }) + @IsEnum(INSPECTION_STATUSES) + inspectionStatus!: InspectionStatus; + + @ApiPropertyOptional() + @IsOptional() + @IsBoolean() + hasDamage?: boolean; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + damageDescription?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsBoolean() + hasWeightLoss?: boolean; + + @ApiPropertyOptional() + @IsOptional() + @IsNumber() + expectedWeight?: number; + + @ApiPropertyOptional() + @IsOptional() + @IsNumber() + actualWeight?: number; + + @ApiPropertyOptional() + @IsOptional() + @IsBoolean() + hasMissingItems?: boolean; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + missingItemsDescription?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + remarks?: string; + + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + inspectedById?: string; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse-yard.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse-yard.dto.ts new file mode 100644 index 000000000..ccdda90d8 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse-yard.dto.ts @@ -0,0 +1,49 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsEnum, IsNumber, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator'; + +import { WAREHOUSE_YARD_TYPES, WarehouseYardType } from '../entities/warehouse-yard.entity'; + +export class CreateWarehouseYardDto { + @ApiPropertyOptional({ format: 'uuid', description: 'Optional — taken from the route param when omitted' }) + @IsOptional() + @IsUUID() + warehouseId?: string; + + @ApiProperty() + @IsString() + @MaxLength(160) + name!: string; + + @ApiProperty() + @IsString() + @MaxLength(40) + code!: string; + + @ApiProperty({ enum: WAREHOUSE_YARD_TYPES }) + @IsEnum(WAREHOUSE_YARD_TYPES) + type!: WarehouseYardType; + + @ApiPropertyOptional() + @IsOptional() + @IsNumber() + @Min(0) + capacityWeight?: number; + + @ApiPropertyOptional() + @IsOptional() + @IsNumber() + @Min(0) + capacityContainers?: number; + + @ApiPropertyOptional({ description: 'Max weight capacity (kg). Defaults to capacityWeight.' }) + @IsOptional() + @IsNumber() + @Min(0) + maxWeight?: number; + + @ApiPropertyOptional({ description: 'Max volume capacity (m³).' }) + @IsOptional() + @IsNumber() + @Min(0) + maxVolume?: number; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse-zone.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse-zone.dto.ts new file mode 100644 index 000000000..fbb057fd5 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse-zone.dto.ts @@ -0,0 +1,49 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsEnum, IsNumber, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator'; + +import { WAREHOUSE_ZONE_TYPES, WarehouseZoneType } from '../entities/warehouse-zone.entity'; + +export class CreateWarehouseZoneDto { + @ApiPropertyOptional({ format: 'uuid', description: 'Optional — taken from the route param when omitted' }) + @IsOptional() + @IsUUID() + yardId?: string; + + @ApiProperty() + @IsString() + @MaxLength(160) + name!: string; + + @ApiProperty() + @IsString() + @MaxLength(40) + code!: string; + + @ApiProperty({ enum: WAREHOUSE_ZONE_TYPES }) + @IsEnum(WAREHOUSE_ZONE_TYPES) + type!: WarehouseZoneType; + + @ApiPropertyOptional() + @IsOptional() + @IsNumber() + @Min(0) + capacityWeight?: number; + + @ApiPropertyOptional() + @IsOptional() + @IsNumber() + @Min(0) + capacityContainers?: number; + + @ApiPropertyOptional({ description: 'Max weight capacity (kg). Defaults to capacityWeight.' }) + @IsOptional() + @IsNumber() + @Min(0) + maxWeight?: number; + + @ApiPropertyOptional({ description: 'Max volume capacity (m³).' }) + @IsOptional() + @IsNumber() + @Min(0) + maxVolume?: number; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse.dto.ts new file mode 100644 index 000000000..788c798bf --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse.dto.ts @@ -0,0 +1,60 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsEnum, IsNumber, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator'; + +import { WAREHOUSE_TYPES, WarehouseType } from '../entities/warehouse.entity'; + +export class CreateWarehouseDto { + @ApiProperty() + @IsString() + @MaxLength(160) + name!: string; + + @ApiProperty() + @IsString() + @MaxLength(40) + code!: string; + + @ApiProperty({ enum: WAREHOUSE_TYPES }) + @IsEnum(WAREHOUSE_TYPES) + type!: WarehouseType; + + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + stationId?: string; + + @ApiPropertyOptional({ format: 'uuid', description: 'Parent facility / port this warehouse belongs to.' }) + @IsOptional() + @IsUUID() + facilityId?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + @MaxLength(200) + locationName?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsNumber() + @Min(0) + capacityWeight?: number; + + @ApiPropertyOptional() + @IsOptional() + @IsNumber() + @Min(0) + capacityContainers?: number; + + @ApiPropertyOptional({ description: 'Max weight capacity (kg). Defaults to capacityWeight.' }) + @IsOptional() + @IsNumber() + @Min(0) + maxWeight?: number; + + @ApiPropertyOptional({ description: 'Max volume capacity (m³).' }) + @IsOptional() + @IsNumber() + @Min(0) + maxVolume?: number; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/fee-rule.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/fee-rule.dto.ts new file mode 100644 index 000000000..873f97a6b --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/dto/fee-rule.dto.ts @@ -0,0 +1,76 @@ +import { ApiProperty, ApiPropertyOptional, PartialType } from '@nestjs/swagger'; +import { IsEnum, IsInt, IsNumber, IsOptional, IsString, IsUUID, Min } from 'class-validator'; + +import { FEE_RULE_TYPES, FeeRuleType } from '../entities/warehouse-fee-rule.entity'; + +export class CreateFeeRuleDto { + @ApiProperty() + @IsString() + name!: string; + + @ApiProperty({ enum: FEE_RULE_TYPES }) + @IsEnum(FEE_RULE_TYPES) + ruleType!: FeeRuleType; + + @ApiPropertyOptional({ default: 100 }) + @IsOptional() + @IsInt() + priority?: number; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + freightType?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + tradeDirection?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + cargoTypeCode?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + containerType?: string; + + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + facilityId?: string; + + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + warehouseId?: string; + + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + yardId?: string; + + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + zoneId?: string; + + @ApiProperty({ description: 'Grace period in days before charging starts.' }) + @IsInt() + @Min(0) + freeDays!: number; + + @ApiProperty() + @IsNumber() + @Min(0) + ratePerDay!: number; + + @ApiPropertyOptional({ default: 'USD' }) + @IsOptional() + @IsString() + currency?: string; +} + +export class UpdateFeeRuleDto extends PartialType(CreateFeeRuleDto) {} diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/filter-inventory.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/filter-inventory.dto.ts new file mode 100644 index 000000000..c867eec3c --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/dto/filter-inventory.dto.ts @@ -0,0 +1,54 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsEnum, IsOptional, IsString, IsUUID } from 'class-validator'; + +import { + WAREHOUSE_INVENTORY_STATUSES, + WarehouseInventoryStatus, +} from '../entities/warehouse-inventory.entity'; + +export class FilterWarehouseInventoryDto { + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + warehouseId?: string; + + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + yardId?: string; + + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + zoneId?: string; + + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + bookingId?: string; + + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + cargoId?: string; + + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + containerId?: string; + + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + goodsId?: string; + + @ApiPropertyOptional({ enum: WAREHOUSE_INVENTORY_STATUSES }) + @IsOptional() + @IsEnum(WAREHOUSE_INVENTORY_STATUSES) + status?: WarehouseInventoryStatus; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + search?: string; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/filter-warehouse.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/filter-warehouse.dto.ts new file mode 100644 index 000000000..f07088ccc --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/dto/filter-warehouse.dto.ts @@ -0,0 +1,26 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsEnum, IsOptional, IsString, IsUUID } from 'class-validator'; + +import { WAREHOUSE_STATUSES, WAREHOUSE_TYPES, WarehouseStatus, WarehouseType } from '../entities/warehouse.entity'; + +export class FilterWarehouseDto { + @ApiPropertyOptional() + @IsOptional() + @IsString() + search?: string; + + @ApiPropertyOptional({ enum: WAREHOUSE_TYPES }) + @IsOptional() + @IsEnum(WAREHOUSE_TYPES) + type?: WarehouseType; + + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + stationId?: string; + + @ApiPropertyOptional({ enum: WAREHOUSE_STATUSES }) + @IsOptional() + @IsEnum(WAREHOUSE_STATUSES) + status?: WarehouseStatus; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/inquiry-inventory.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/inquiry-inventory.dto.ts new file mode 100644 index 000000000..cba259d00 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/dto/inquiry-inventory.dto.ts @@ -0,0 +1,49 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsEnum, IsOptional, IsString, IsUUID } from 'class-validator'; + +import { + WAREHOUSE_INVENTORY_STATUSES, + WarehouseInventoryStatus, +} from '../entities/warehouse-inventory.entity'; + +export class InquiryWarehouseInventoryDto { + @ApiPropertyOptional() + @IsOptional() + @IsString() + bookingNumber?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + containerNumber?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + cargoType?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + goodsName?: string; + + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + warehouseId?: string; + + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + yardId?: string; + + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + zoneId?: string; + + @ApiPropertyOptional({ enum: WAREHOUSE_INVENTORY_STATUSES }) + @IsOptional() + @IsEnum(WAREHOUSE_INVENTORY_STATUSES) + status?: WarehouseInventoryStatus; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/invoice.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/invoice.dto.ts new file mode 100644 index 000000000..9d25c974a --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/dto/invoice.dto.ts @@ -0,0 +1,31 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsBoolean, IsNumber, IsOptional, IsString, Min } from 'class-validator'; + +export class GenerateInvoiceDto { + @ApiPropertyOptional({ description: 'Create even when the calculated amount is zero.' }) + @IsOptional() + @IsBoolean() + confirmZero?: boolean; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + performedBy?: string; +} + +export class PayInvoiceBodyDto { + @ApiPropertyOptional() + @IsNumber() + @Min(0.01) + amount!: number; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + method?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + reference?: string; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/load-inventory.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/load-inventory.dto.ts new file mode 100644 index 000000000..063bb7d1d --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/dto/load-inventory.dto.ts @@ -0,0 +1,25 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsNumber, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator'; + +export class LoadInventoryDto { + @ApiProperty({ format: 'uuid', description: 'Physical wagon the item is loaded onto' }) + @IsUUID() + wagonId!: string; + + @ApiPropertyOptional({ description: 'Weight loaded onto the wagon (kg)' }) + @IsOptional() + @IsNumber() + @Min(0) + loadedWeight?: number; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + @MaxLength(120) + loadedBy?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + notes?: string; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/move-inventory.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/move-inventory.dto.ts new file mode 100644 index 000000000..1aae7896f --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/dto/move-inventory.dto.ts @@ -0,0 +1,26 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsOptional, IsString, IsUUID } from 'class-validator'; + +export class MoveInventoryDto { + @ApiProperty({ format: 'uuid' }) + @IsUUID() + warehouseId!: string; + + @ApiProperty({ format: 'uuid' }) + @IsUUID() + yardId!: string; + + @ApiProperty({ format: 'uuid' }) + @IsUUID() + zoneId!: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + remarks?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + movedBy?: string; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/receive-inventory.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/receive-inventory.dto.ts new file mode 100644 index 000000000..46fecb044 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/dto/receive-inventory.dto.ts @@ -0,0 +1,62 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsNumber, IsOptional, IsString, IsUUID, Min } from 'class-validator'; + +export class ReceiveWarehouseInventoryDto { + @ApiProperty({ format: 'uuid' }) + @IsUUID() + warehouseId!: string; + + @ApiProperty({ format: 'uuid' }) + @IsUUID() + yardId!: string; + + @ApiProperty({ format: 'uuid' }) + @IsUUID() + zoneId!: string; + + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + bookingId?: string; + + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + cargoId?: string; + + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + containerId?: string; + + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + goodsId?: string; + + @ApiProperty() + @IsNumber() + @Min(0) + quantity!: number; + + @ApiProperty() + @IsNumber() + @Min(0) + weight!: number; + + @ApiPropertyOptional() + @IsOptional() + @IsNumber() + @Min(0) + volume?: number; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + notes?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + performedBy?: string; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/reserve-inventory.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/reserve-inventory.dto.ts new file mode 100644 index 000000000..f1cb05d9e --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/dto/reserve-inventory.dto.ts @@ -0,0 +1,17 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsOptional, IsString, IsUUID } from 'class-validator'; + +export class ReserveInventoryDto { + @ApiProperty({ format: 'uuid' }) + @IsUUID() + bookingId!: string; + + @ApiProperty({ format: 'uuid' }) + @IsUUID() + inventoryId!: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + performedBy?: string; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/unload-booking.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/unload-booking.dto.ts new file mode 100644 index 000000000..3138285e2 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/dto/unload-booking.dto.ts @@ -0,0 +1,34 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsDateString, IsOptional, IsString, IsUUID } from 'class-validator'; + +export class UnloadBookingDto { + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + facilityId?: string; + + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + warehouseId?: string; + + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + yardId?: string; + + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + zoneId?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsDateString() + unloadedAt?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + notes?: string; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/update-inspection-report.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/update-inspection-report.dto.ts new file mode 100644 index 000000000..372008d3b --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/dto/update-inspection-report.dto.ts @@ -0,0 +1,5 @@ +import { PartialType } from '@nestjs/swagger'; + +import { CreateInspectionReportDto } from './create-inspection-report.dto'; + +export class UpdateInspectionReportDto extends PartialType(CreateInspectionReportDto) {} diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/update-warehouse-yard.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/update-warehouse-yard.dto.ts new file mode 100644 index 000000000..717923534 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/dto/update-warehouse-yard.dto.ts @@ -0,0 +1,12 @@ +import { ApiPropertyOptional, PartialType } from '@nestjs/swagger'; +import { IsEnum, IsOptional } from 'class-validator'; + +import { WAREHOUSE_YARD_STATUSES, WarehouseYardStatus } from '../entities/warehouse-yard.entity'; +import { CreateWarehouseYardDto } from './create-warehouse-yard.dto'; + +export class UpdateWarehouseYardDto extends PartialType(CreateWarehouseYardDto) { + @ApiPropertyOptional({ enum: WAREHOUSE_YARD_STATUSES }) + @IsOptional() + @IsEnum(WAREHOUSE_YARD_STATUSES) + status?: WarehouseYardStatus; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/update-warehouse-zone.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/update-warehouse-zone.dto.ts new file mode 100644 index 000000000..01cd8301d --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/dto/update-warehouse-zone.dto.ts @@ -0,0 +1,12 @@ +import { ApiPropertyOptional, PartialType } from '@nestjs/swagger'; +import { IsEnum, IsOptional } from 'class-validator'; + +import { WAREHOUSE_ZONE_STATUSES, WarehouseZoneStatus } from '../entities/warehouse-zone.entity'; +import { CreateWarehouseZoneDto } from './create-warehouse-zone.dto'; + +export class UpdateWarehouseZoneDto extends PartialType(CreateWarehouseZoneDto) { + @ApiPropertyOptional({ enum: WAREHOUSE_ZONE_STATUSES }) + @IsOptional() + @IsEnum(WAREHOUSE_ZONE_STATUSES) + status?: WarehouseZoneStatus; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/update-warehouse.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/update-warehouse.dto.ts new file mode 100644 index 000000000..e6038fca2 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/dto/update-warehouse.dto.ts @@ -0,0 +1,12 @@ +import { ApiPropertyOptional, PartialType } from '@nestjs/swagger'; +import { IsEnum, IsOptional } from 'class-validator'; + +import { WAREHOUSE_STATUSES, WarehouseStatus } from '../entities/warehouse.entity'; +import { CreateWarehouseDto } from './create-warehouse.dto'; + +export class UpdateWarehouseDto extends PartialType(CreateWarehouseDto) { + @ApiPropertyOptional({ enum: WAREHOUSE_STATUSES }) + @IsOptional() + @IsEnum(WAREHOUSE_STATUSES) + status?: WarehouseStatus; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-activity-log.entity.ts b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-activity-log.entity.ts new file mode 100644 index 000000000..4521bb808 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-activity-log.entity.ts @@ -0,0 +1,34 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index } from 'typeorm'; + +export const WAREHOUSE_ACTIVITY_TYPES = [ + 'INVENTORY_RECEIVED', + 'INVENTORY_STORED', + 'INVENTORY_MOVED', + 'INVENTORY_RESERVED', + 'READY_FOR_LOADING', + 'INVENTORY_LOADED', + 'INVENTORY_DISPATCHED', +] as const; +export type WarehouseActivityType = (typeof WAREHOUSE_ACTIVITY_TYPES)[number]; + +@Entity({ schema: 'freight', name: 'warehouse_activity_log' }) +@Index(['inventoryId']) +@Index(['warehouseId']) +@Index(['activityType']) +export class WarehouseActivityLog extends BaseEntity { + @Column({ name: 'inventory_id', type: 'uuid', nullable: true }) + inventoryId?: string | null; + + @Column({ name: 'warehouse_id', type: 'uuid', nullable: true }) + warehouseId?: string | null; + + @Column({ name: 'activity_type', type: 'varchar', length: 40 }) + activityType!: WarehouseActivityType; + + @Column({ name: 'description', type: 'text', nullable: true }) + description?: string | null; + + @Column({ name: 'performed_by', type: 'varchar', length: 120, nullable: true }) + performedBy?: string | null; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-allocation-rule.entity.ts b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-allocation-rule.entity.ts new file mode 100644 index 000000000..a597ecf0e --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-allocation-rule.entity.ts @@ -0,0 +1,54 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index } from 'typeorm'; + +/** + * Batch 5 — deterministic warehouse/yard allocation. + * A booking's (freightType, tradeDirection, cargoType, containerStatus, inspection) + * is matched against active rules in ascending `priority`; the first match wins and + * resolves the target Yard (and optional Warehouse/Zone) by code. + */ +@Entity({ schema: 'freight', name: 'warehouse_allocation_rules' }) +@Index(['priority']) +@Index(['isActive']) +export class WarehouseAllocationRule extends BaseEntity { + @Column({ name: 'name', type: 'varchar', length: 160 }) + name!: string; + + @Column({ name: 'priority', type: 'int', default: 100 }) + priority!: number; + + // ── Match criteria (null = wildcard) ────────────────────────────────────── + @Column({ name: 'freight_type', type: 'varchar', length: 16, nullable: true }) + freightType?: string | null; // CONTAINER | BULK + + @Column({ name: 'trade_direction', type: 'varchar', length: 16, nullable: true }) + tradeDirection?: string | null; // IMPORT | EXPORT | DOMESTIC | BOTH + + @Column({ name: 'cargo_type_code', type: 'varchar', length: 50, nullable: true }) + cargoTypeCode?: string | null; + + @Column({ name: 'container_status', type: 'varchar', length: 24, nullable: true }) + containerStatus?: string | null; // e.g. EMPTY | MAINTENANCE + + @Column({ name: 'requires_inspection', type: 'boolean', nullable: true }) + requiresInspection?: boolean | null; + + // ── Resolved target (by code) ───────────────────────────────────────────── + @Column({ name: 'target_facility_code', type: 'varchar', length: 40, nullable: true }) + targetFacilityCode?: string | null; + + @Column({ name: 'target_yard_code', type: 'varchar', length: 40 }) + targetYardCode!: string; + + @Column({ name: 'target_warehouse_code', type: 'varchar', length: 40, nullable: true }) + targetWarehouseCode?: string | null; + + @Column({ name: 'target_zone_code', type: 'varchar', length: 40, nullable: true }) + targetZoneCode?: string | null; + + @Column({ name: 'storage_type', type: 'varchar', length: 80, nullable: true }) + storageType?: string | null; // descriptive: "Container terminal import / stack area" + + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive!: boolean; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-fee-invoice-item.entity.ts b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-fee-invoice-item.entity.ts new file mode 100644 index 000000000..8b14dcea3 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-fee-invoice-item.entity.ts @@ -0,0 +1,50 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; + +import { WarehouseFeeInvoice } from './warehouse-fee-invoice.entity'; + +export const WAREHOUSE_FEE_TYPES = [ + 'CONTAINER_DEMURRAGE', + 'BULK_DEMURRAGE', + 'STORAGE_FEE', + 'HANDLING_FEE', +] as const; +export type WarehouseFeeType = (typeof WAREHOUSE_FEE_TYPES)[number]; + +@Entity({ schema: 'freight', name: 'warehouse_fee_invoice_items' }) +@Index(['invoiceId']) +export class WarehouseFeeInvoiceItem extends BaseEntity { + @Column({ name: 'invoice_id', type: 'uuid' }) + invoiceId!: string; + + @ManyToOne(() => WarehouseFeeInvoice, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'invoice_id' }) + invoice?: WarehouseFeeInvoice; + + @Column({ name: 'fee_rule_id', type: 'uuid', nullable: true }) + feeRuleId?: string | null; + + @Column({ name: 'fee_type', type: 'varchar', length: 32 }) + feeType!: WarehouseFeeType; + + @Column({ name: 'description', type: 'varchar', length: 255 }) + description!: string; + + @Column({ name: 'quantity', type: 'numeric', precision: 12, scale: 2, default: 1 }) + quantity!: number; + + @Column({ name: 'unit_rate', type: 'numeric', precision: 14, scale: 2, default: 0 }) + unitRate!: number; + + @Column({ name: 'amount', type: 'numeric', precision: 14, scale: 2, default: 0 }) + amount!: number; + + @Column({ name: 'currency', type: 'varchar', length: 8, default: 'USD' }) + currency!: string; + + @Column({ name: 'chargeable_days', type: 'int', nullable: true }) + chargeableDays?: number | null; + + @Column({ name: 'free_days', type: 'int', nullable: true }) + freeDays?: number | null; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-fee-invoice.entity.ts b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-fee-invoice.entity.ts new file mode 100644 index 000000000..e57d626d5 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-fee-invoice.entity.ts @@ -0,0 +1,107 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index } from 'typeorm'; + +export const WAREHOUSE_INVOICE_TYPES = ['DEMURRAGE', 'STORAGE_FEE', 'MIXED_WAREHOUSE_FEES'] as const; +export type WarehouseInvoiceType = (typeof WAREHOUSE_INVOICE_TYPES)[number]; + +export const WAREHOUSE_INVOICE_STATUSES = [ + 'DRAFT', + 'ISSUED', + 'PARTIALLY_PAID', + 'PAID', + 'CANCELLED', +] as const; +export type WarehouseInvoiceStatus = (typeof WAREHOUSE_INVOICE_STATUSES)[number]; + +/** A single recorded payment against a warehouse fee invoice (history). */ +export interface WarehouseInvoicePayment { + amount: number; + method?: string | null; + reference?: string | null; + paidAt: string; +} + +/** + * Batch 6 — invoice generated from Batch 5 demurrage/storage fee calculation. + * Owns warehouse fees; links to booking/customer/inventory/location so it can + * connect to the existing payment module without duplicating it. + */ +@Entity({ schema: 'freight', name: 'warehouse_fee_invoices' }) +@Index(['invoiceNumber'], { unique: true }) +@Index(['bookingId']) +@Index(['inventoryId']) +@Index(['status']) +export class WarehouseFeeInvoice extends BaseEntity { + @Column({ name: 'invoice_number', type: 'varchar', length: 40, unique: true }) + invoiceNumber!: string; + + @Column({ name: 'booking_id', type: 'uuid', nullable: true }) + bookingId?: string | null; + + @Column({ name: 'customer_id', type: 'uuid', nullable: true }) + customerId?: string | null; + + @Column({ name: 'inventory_id', type: 'uuid' }) + inventoryId!: string; + + @Column({ name: 'facility_id', type: 'uuid', nullable: true }) + facilityId?: string | null; + + @Column({ name: 'warehouse_id', type: 'uuid', nullable: true }) + warehouseId?: string | null; + + @Column({ name: 'yard_id', type: 'uuid', nullable: true }) + yardId?: string | null; + + @Column({ name: 'zone_id', type: 'uuid', nullable: true }) + zoneId?: string | null; + + @Column({ name: 'invoice_type', type: 'varchar', length: 32, default: 'MIXED_WAREHOUSE_FEES' }) + invoiceType!: WarehouseInvoiceType; + + @Column({ name: 'status', type: 'varchar', length: 20, default: 'DRAFT' }) + status!: WarehouseInvoiceStatus; + + @Column({ name: 'subtotal_amount', type: 'numeric', precision: 14, scale: 2, default: 0 }) + subtotalAmount!: number; + + @Column({ name: 'tax_amount', type: 'numeric', precision: 14, scale: 2, default: 0 }) + taxAmount!: number; + + @Column({ name: 'total_amount', type: 'numeric', precision: 14, scale: 2, default: 0 }) + totalAmount!: number; + + @Column({ name: 'paid_amount', type: 'numeric', precision: 14, scale: 2, default: 0 }) + paidAmount!: number; + + @Column({ name: 'balance_amount', type: 'numeric', precision: 14, scale: 2, default: 0 }) + balanceAmount!: number; + + @Column({ name: 'currency', type: 'varchar', length: 8, default: 'USD' }) + currency!: string; + + /** Charge window covered by this invoice — used to allow a later invoice for a new period. */ + @Column({ name: 'period_start', type: 'timestamptz', nullable: true }) + periodStart?: Date | null; + + @Column({ name: 'period_end', type: 'timestamptz', nullable: true }) + periodEnd?: Date | null; + + @Column({ name: 'issued_at', type: 'timestamptz', nullable: true }) + issuedAt?: Date | null; + + @Column({ name: 'due_date', type: 'timestamptz', nullable: true }) + dueDate?: Date | null; + + @Column({ name: 'paid_at', type: 'timestamptz', nullable: true }) + paidAt?: Date | null; + + @Column({ name: 'cancelled_at', type: 'timestamptz', nullable: true }) + cancelledAt?: Date | null; + + @Column({ name: 'payments', type: 'jsonb', default: () => "'[]'" }) + payments!: WarehouseInvoicePayment[]; + + @Column({ name: 'notes', type: 'text', nullable: true }) + notes?: string | null; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-fee-rule.entity.ts b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-fee-rule.entity.ts new file mode 100644 index 000000000..f346be282 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-fee-rule.entity.ts @@ -0,0 +1,62 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index } from 'typeorm'; + +export const FEE_RULE_TYPES = ['STORAGE_FEE', 'DEMURRAGE_FEE'] as const; +export type FeeRuleType = (typeof FEE_RULE_TYPES)[number]; + +/** + * Batch 5 — configurable storage / demurrage fee rules (no invoice/payment here — that is Batch 6). + * The most specific active rule (highest `specificity` then lowest `priority`) applies to an item. + * `freeDays` is the grace period; charging starts the day after it expires. + */ +@Entity({ schema: 'freight', name: 'warehouse_fee_rules' }) +@Index(['ruleType']) +@Index(['isActive']) +export class WarehouseFeeRule extends BaseEntity { + @Column({ name: 'name', type: 'varchar', length: 160 }) + name!: string; + + @Column({ name: 'rule_type', type: 'varchar', length: 20 }) + ruleType!: FeeRuleType; + + @Column({ name: 'priority', type: 'int', default: 100 }) + priority!: number; + + // ── Scope (null = applies to all) ───────────────────────────────────────── + @Column({ name: 'freight_type', type: 'varchar', length: 16, nullable: true }) + freightType?: string | null; // CONTAINER | BULK + + @Column({ name: 'trade_direction', type: 'varchar', length: 16, nullable: true }) + tradeDirection?: string | null; // IMPORT | EXPORT | DOMESTIC | BOTH + + @Column({ name: 'cargo_type_code', type: 'varchar', length: 50, nullable: true }) + cargoTypeCode?: string | null; + + @Column({ name: 'container_type', type: 'varchar', length: 40, nullable: true }) + containerType?: string | null; + + @Column({ name: 'facility_id', type: 'uuid', nullable: true }) + facilityId?: string | null; + + @Column({ name: 'warehouse_id', type: 'uuid', nullable: true }) + warehouseId?: string | null; + + @Column({ name: 'yard_id', type: 'uuid', nullable: true }) + yardId?: string | null; + + @Column({ name: 'zone_id', type: 'uuid', nullable: true }) + zoneId?: string | null; + + // ── Fee definition ──────────────────────────────────────────────────────── + @Column({ name: 'free_days', type: 'int', default: 0 }) + freeDays!: number; + + @Column({ name: 'rate_per_day', type: 'numeric', precision: 14, scale: 2, default: 0 }) + ratePerDay!: number; + + @Column({ name: 'currency', type: 'varchar', length: 8, default: 'USD' }) + currency!: string; + + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive!: boolean; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-inspection-report.entity.ts b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-inspection-report.entity.ts new file mode 100644 index 000000000..598b8d168 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-inspection-report.entity.ts @@ -0,0 +1,77 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; + +import { WarehouseInventory } from './warehouse-inventory.entity'; + +export const INSPECTION_REPORT_TYPES = [ + 'INSPECTION', + 'DAMAGE', + 'WEIGHT_LOSS', + 'MISSING_ITEM', + 'GENERAL', +] as const; +export type InspectionReportType = (typeof INSPECTION_REPORT_TYPES)[number]; + +export const INSPECTION_STATUSES = ['PASSED', 'FAILED', 'NEEDS_REVIEW'] as const; +export type InspectionStatus = (typeof INSPECTION_STATUSES)[number]; + +@Entity({ schema: 'freight', name: 'warehouse_inspection_reports' }) +@Index(['inventoryId']) +@Index(['bookingId']) +@Index(['inspectionStatus']) +export class WarehouseInspectionReport extends BaseEntity { + @Column({ name: 'inventory_id', type: 'uuid' }) + inventoryId!: string; + + @ManyToOne(() => WarehouseInventory) + @JoinColumn({ name: 'inventory_id' }) + inventory?: WarehouseInventory; + + @Column({ name: 'booking_id', type: 'uuid', nullable: true }) + bookingId?: string | null; + + @Column({ name: 'customer_id', type: 'uuid', nullable: true }) + customerId?: string | null; + + @Column({ name: 'report_type', type: 'varchar', length: 32, default: 'INSPECTION' }) + reportType!: InspectionReportType; + + @Column({ name: 'inspection_status', type: 'varchar', length: 20, default: 'NEEDS_REVIEW' }) + inspectionStatus!: InspectionStatus; + + @Column({ name: 'has_damage', type: 'boolean', default: false }) + hasDamage!: boolean; + + @Column({ name: 'damage_description', type: 'text', nullable: true }) + damageDescription?: string | null; + + @Column({ name: 'has_weight_loss', type: 'boolean', default: false }) + hasWeightLoss!: boolean; + + @Column({ name: 'expected_weight', type: 'numeric', precision: 14, scale: 3, nullable: true }) + expectedWeight?: number | null; + + @Column({ name: 'actual_weight', type: 'numeric', precision: 14, scale: 3, nullable: true }) + actualWeight?: number | null; + + @Column({ name: 'weight_loss', type: 'numeric', precision: 14, scale: 3, nullable: true }) + weightLoss?: number | null; + + @Column({ name: 'weight_loss_unit', type: 'varchar', length: 12, nullable: true }) + weightLossUnit?: string | null; + + @Column({ name: 'has_missing_items', type: 'boolean', default: false }) + hasMissingItems!: boolean; + + @Column({ name: 'missing_items_description', type: 'text', nullable: true }) + missingItemsDescription?: string | null; + + @Column({ name: 'remarks', type: 'text', nullable: true }) + remarks?: string | null; + + @Column({ name: 'inspected_by_id', type: 'uuid', nullable: true }) + inspectedById?: string | null; + + @Column({ name: 'inspected_at', type: 'timestamptz', nullable: true }) + inspectedAt?: Date | null; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-inventory-movement.entity.ts b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-inventory-movement.entity.ts new file mode 100644 index 000000000..4ecfdc0dd --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-inventory-movement.entity.ts @@ -0,0 +1,42 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; + +import { WarehouseInventory } from './warehouse-inventory.entity'; + +@Entity({ schema: 'freight', name: 'warehouse_inventory_movement' }) +@Index(['inventoryId']) +export class WarehouseInventoryMovement extends BaseEntity { + @Column({ name: 'inventory_id', type: 'uuid' }) + inventoryId!: string; + + @ManyToOne(() => WarehouseInventory, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'inventory_id' }) + inventory?: WarehouseInventory; + + @Column({ name: 'from_warehouse_id', type: 'uuid' }) + fromWarehouseId!: string; + + @Column({ name: 'from_yard_id', type: 'uuid' }) + fromYardId!: string; + + @Column({ name: 'from_zone_id', type: 'uuid' }) + fromZoneId!: string; + + @Column({ name: 'to_warehouse_id', type: 'uuid' }) + toWarehouseId!: string; + + @Column({ name: 'to_yard_id', type: 'uuid' }) + toYardId!: string; + + @Column({ name: 'to_zone_id', type: 'uuid' }) + toZoneId!: string; + + @Column({ name: 'remarks', type: 'text', nullable: true }) + remarks?: string | null; + + @Column({ name: 'moved_by', type: 'varchar', length: 120, nullable: true }) + movedBy?: string | null; + + @Column({ name: 'moved_at', type: 'timestamptz' }) + movedAt!: Date; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-inventory.entity.ts b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-inventory.entity.ts new file mode 100644 index 000000000..6c9270987 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-inventory.entity.ts @@ -0,0 +1,143 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; + +import { Booking } from '../../bookings/entities/booking.entity'; +import { Cargo } from '../../cargoes/entities/cargoes.entity'; +import { Container } from '../../container-management/entities/container.entity'; +import { Warehouse } from './warehouse.entity'; +import { WarehouseYard } from './warehouse-yard.entity'; +import { WarehouseZone } from './warehouse-zone.entity'; + +// Batch 2 lifecycle. Supersedes the Batch 1 set +// (ARRIVED_AT_WAREHOUSE / UNDER_INSPECTION / READY_FOR_LOADING) — migrated in place. +export const WAREHOUSE_INVENTORY_STATUSES = [ + 'RECEIVED', + 'STORED', + 'RESERVED', + 'READY_FOR_LOADING', + 'LOADED', + 'DISPATCHED', +] as const; +export type WarehouseInventoryStatus = (typeof WAREHOUSE_INVENTORY_STATUSES)[number]; + +/** Allowed forward transitions for the inventory lifecycle. */ +export const WAREHOUSE_INVENTORY_TRANSITIONS: Record = { + RECEIVED: ['STORED'], + STORED: ['RESERVED'], + RESERVED: ['READY_FOR_LOADING'], + READY_FOR_LOADING: ['LOADED'], + LOADED: ['DISPATCHED'], + DISPATCHED: [], +}; + +@Entity({ schema: 'freight', name: 'warehouse_inventory' }) +@Index(['warehouseId']) +@Index(['yardId']) +@Index(['zoneId']) +@Index(['bookingId']) +@Index(['cargoId']) +@Index(['containerId']) +@Index(['goodsId']) +@Index(['status']) +export class WarehouseInventory extends BaseEntity { + @Column({ name: 'warehouse_id', type: 'uuid' }) + warehouseId!: string; + + @ManyToOne(() => Warehouse) + @JoinColumn({ name: 'warehouse_id' }) + warehouse?: Warehouse; + + @Column({ name: 'yard_id', type: 'uuid' }) + yardId!: string; + + @ManyToOne(() => WarehouseYard) + @JoinColumn({ name: 'yard_id' }) + yard?: WarehouseYard; + + @Column({ name: 'zone_id', type: 'uuid' }) + zoneId!: string; + + @ManyToOne(() => WarehouseZone) + @JoinColumn({ name: 'zone_id' }) + zone?: WarehouseZone; + + @Column({ name: 'booking_id', type: 'uuid', nullable: true }) + bookingId?: string | null; + + @ManyToOne(() => Booking, { nullable: true }) + @JoinColumn({ name: 'booking_id' }) + booking?: Booking | null; + + @Column({ name: 'cargo_id', type: 'uuid', nullable: true }) + cargoId?: string | null; + + @ManyToOne(() => Cargo, { nullable: true }) + @JoinColumn({ name: 'cargo_id' }) + cargo?: Cargo | null; + + @Column({ name: 'container_id', type: 'uuid', nullable: true }) + containerId?: string | null; + + @ManyToOne(() => Container, { nullable: true }) + @JoinColumn({ name: 'container_id' }) + container?: Container | null; + + @Column({ name: 'goods_id', type: 'uuid', nullable: true }) + goodsId?: string | null; + + @Column({ name: 'quantity', type: 'numeric', precision: 12, scale: 3, default: 0 }) + quantity!: number; + + @Column({ name: 'weight', type: 'numeric', precision: 14, scale: 3, default: 0 }) + weight!: number; + + @Column({ name: 'volume', type: 'numeric', precision: 12, scale: 3, nullable: true }) + volume?: number | null; + + @Column({ name: 'status', type: 'varchar', length: 32, default: 'RECEIVED' }) + status!: WarehouseInventoryStatus; + + // Batch 4.5: latest inspection outcome (PASSED | FAILED | NEEDS_REVIEW). Null = not yet inspected. + @Column({ name: 'inspection_status', type: 'varchar', length: 20, nullable: true }) + inspectionStatus?: string | null; + + @Column({ name: 'arrived_at', type: 'timestamptz', nullable: true }) + arrivedAt?: Date | null; + + @Column({ name: 'stored_at', type: 'timestamptz', nullable: true }) + storedAt?: Date | null; + + @Column({ name: 'reserved_at', type: 'timestamptz', nullable: true }) + reservedAt?: Date | null; + + @Column({ name: 'inspected_at', type: 'timestamptz', nullable: true }) + inspectedAt?: Date | null; + + @Column({ name: 'ready_for_loading_at', type: 'timestamptz', nullable: true }) + readyForLoadingAt?: Date | null; + + @Column({ name: 'loaded_at', type: 'timestamptz', nullable: true }) + loadedAt?: Date | null; + + @Column({ name: 'dispatched_at', type: 'timestamptz', nullable: true }) + dispatchedAt?: Date | null; + + // Batch 5 — demurrage / storage lifecycle timestamps. + @Column({ name: 'inspection_started_at', type: 'timestamptz', nullable: true }) + inspectionStartedAt?: Date | null; + + @Column({ name: 'inspection_completed_at', type: 'timestamptz', nullable: true }) + inspectionCompletedAt?: Date | null; + + @Column({ name: 'ready_for_pickup_at', type: 'timestamptz', nullable: true }) + readyForPickupAt?: Date | null; + + @Column({ name: 'release_date', type: 'timestamptz', nullable: true }) + releaseDate?: Date | null; + + @Column({ name: 'gate_cleared_at', type: 'timestamptz', nullable: true }) + gateClearedAt?: Date | null; + + @Column({ name: 'notes', type: 'text', nullable: true }) + notes?: string | null; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-loading.entity.ts b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-loading.entity.ts new file mode 100644 index 000000000..5f6952aec --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-loading.entity.ts @@ -0,0 +1,46 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; + +import { Booking } from '../../bookings/entities/booking.entity'; +import { WarehouseInventory } from './warehouse-inventory.entity'; + +/** + * Batch 3 — a record that a warehouse inventory item was physically loaded onto a wagon. + * The warehouse OWNS this record. It only READS wagon/schedule data from the scheduling + * domain (via SchedulingReadFacade); it never writes to wagons or train schedules. + */ +@Entity({ schema: 'freight', name: 'warehouse_loadings' }) +@Index(['warehouseInventoryId']) +@Index(['bookingId']) +@Index(['wagonId']) +export class WarehouseLoading extends BaseEntity { + @Column({ name: 'warehouse_inventory_id', type: 'uuid' }) + warehouseInventoryId!: string; + + @ManyToOne(() => WarehouseInventory) + @JoinColumn({ name: 'warehouse_inventory_id' }) + inventory?: WarehouseInventory; + + @Column({ name: 'booking_id', type: 'uuid', nullable: true }) + bookingId?: string | null; + + @ManyToOne(() => Booking, { nullable: true }) + @JoinColumn({ name: 'booking_id' }) + booking?: Booking | null; + + /** Physical wagon the item was loaded onto. References freight.wagons (read-only link). */ + @Column({ name: 'wagon_id', type: 'uuid' }) + wagonId!: string; + + @Column({ name: 'loaded_at', type: 'timestamptz' }) + loadedAt!: Date; + + @Column({ name: 'loaded_by', type: 'varchar', length: 120, nullable: true }) + loadedBy?: string | null; + + @Column({ name: 'loaded_weight', type: 'numeric', precision: 14, scale: 3, nullable: true }) + loadedWeight?: number | null; + + @Column({ name: 'notes', type: 'text', nullable: true }) + notes?: string | null; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-yard.entity.ts b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-yard.entity.ts new file mode 100644 index 000000000..6e3c93292 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-yard.entity.ts @@ -0,0 +1,69 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm'; + +import { Warehouse } from './warehouse.entity'; +import { WarehouseZone } from './warehouse-zone.entity'; + +export const WAREHOUSE_YARD_TYPES = [ + 'CONTAINER_YARD', + 'BULK_YARD', + 'GENERAL_CARGO_YARD', + 'HAZARDOUS_YARD', + 'COLD_STORAGE_YARD', +] as const; +export type WarehouseYardType = (typeof WAREHOUSE_YARD_TYPES)[number]; + +export const WAREHOUSE_YARD_STATUSES = ['ACTIVE', 'INACTIVE'] as const; +export type WarehouseYardStatus = (typeof WAREHOUSE_YARD_STATUSES)[number]; + +@Entity({ schema: 'freight', name: 'warehouse_yards' }) +@Index(['warehouseId']) +@Index(['type']) +@Index(['status']) +export class WarehouseYard extends BaseEntity { + @Column({ name: 'warehouse_id', type: 'uuid' }) + warehouseId!: string; + + @ManyToOne(() => Warehouse, (warehouse) => warehouse.yards, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'warehouse_id' }) + warehouse?: Warehouse; + + @Column({ name: 'name', type: 'varchar', length: 160 }) + name!: string; + + @Column({ name: 'code', type: 'varchar', length: 40 }) + code!: string; + + @Column({ name: 'type', type: 'varchar', length: 32 }) + type!: WarehouseYardType; + + @Column({ name: 'capacity_weight', type: 'numeric', precision: 14, scale: 3, nullable: true }) + capacityWeight?: number | null; + + @Column({ name: 'capacity_containers', type: 'int', nullable: true }) + capacityContainers?: number | null; + + @Column({ name: 'current_weight', type: 'numeric', precision: 14, scale: 3, default: 0 }) + currentWeight!: number; + + @Column({ name: 'current_containers', type: 'int', default: 0 }) + currentContainers!: number; + + @Column({ name: 'max_weight', type: 'numeric', precision: 14, scale: 3, nullable: true }) + maxWeight?: number | null; + + @Column({ name: 'max_volume', type: 'numeric', precision: 14, scale: 3, nullable: true }) + maxVolume?: number | null; + + @Column({ name: 'current_volume', type: 'numeric', precision: 14, scale: 3, default: 0 }) + currentVolume!: number; + + @Column({ name: 'status', type: 'varchar', length: 16, default: 'ACTIVE' }) + status!: WarehouseYardStatus; + + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive!: boolean; + + @OneToMany(() => WarehouseZone, (zone) => zone.yard) + zones?: WarehouseZone[]; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-zone.entity.ts b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-zone.entity.ts new file mode 100644 index 000000000..9cfaad6de --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-zone.entity.ts @@ -0,0 +1,65 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; + +import { WarehouseYard } from './warehouse-yard.entity'; + +export const WAREHOUSE_ZONE_TYPES = [ + 'CONTAINER_ZONE', + 'BULK_ZONE', + 'GENERAL_CARGO_ZONE', + 'HAZARDOUS_ZONE', + 'COLD_STORAGE_ZONE', +] as const; +export type WarehouseZoneType = (typeof WAREHOUSE_ZONE_TYPES)[number]; + +export const WAREHOUSE_ZONE_STATUSES = ['ACTIVE', 'INACTIVE'] as const; +export type WarehouseZoneStatus = (typeof WAREHOUSE_ZONE_STATUSES)[number]; + +@Entity({ schema: 'freight', name: 'warehouse_zones' }) +@Index(['yardId']) +@Index(['type']) +@Index(['status']) +export class WarehouseZone extends BaseEntity { + @Column({ name: 'yard_id', type: 'uuid' }) + yardId!: string; + + @ManyToOne(() => WarehouseYard, (yard) => yard.zones, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'yard_id' }) + yard?: WarehouseYard; + + @Column({ name: 'name', type: 'varchar', length: 160 }) + name!: string; + + @Column({ name: 'code', type: 'varchar', length: 40 }) + code!: string; + + @Column({ name: 'type', type: 'varchar', length: 32 }) + type!: WarehouseZoneType; + + @Column({ name: 'capacity_weight', type: 'numeric', precision: 14, scale: 3, nullable: true }) + capacityWeight?: number | null; + + @Column({ name: 'capacity_containers', type: 'int', nullable: true }) + capacityContainers?: number | null; + + @Column({ name: 'current_weight', type: 'numeric', precision: 14, scale: 3, default: 0 }) + currentWeight!: number; + + @Column({ name: 'current_containers', type: 'int', default: 0 }) + currentContainers!: number; + + @Column({ name: 'max_weight', type: 'numeric', precision: 14, scale: 3, nullable: true }) + maxWeight?: number | null; + + @Column({ name: 'max_volume', type: 'numeric', precision: 14, scale: 3, nullable: true }) + maxVolume?: number | null; + + @Column({ name: 'current_volume', type: 'numeric', precision: 14, scale: 3, default: 0 }) + currentVolume!: number; + + @Column({ name: 'status', type: 'varchar', length: 16, default: 'ACTIVE' }) + status!: WarehouseZoneStatus; + + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive!: boolean; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse.entity.ts b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse.entity.ts new file mode 100644 index 000000000..9d27ed64a --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse.entity.ts @@ -0,0 +1,70 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, ManyToOne, OneToMany } from 'typeorm'; + +import { Facility } from '../../facilities/entities/facility.entity'; +import { WarehouseYard } from './warehouse-yard.entity'; + +export const WAREHOUSE_TYPES = ['OPEN_WAREHOUSE', 'CLOSED_WAREHOUSE'] as const; +export type WarehouseType = (typeof WAREHOUSE_TYPES)[number]; + +export const WAREHOUSE_STATUSES = ['ACTIVE', 'INACTIVE'] as const; +export type WarehouseStatus = (typeof WAREHOUSE_STATUSES)[number]; + +@Entity({ schema: 'freight', name: 'warehouses' }) +@Index(['code'], { unique: true }) +@Index(['type']) +@Index(['status']) +@Index(['stationId']) +export class Warehouse extends BaseEntity { + @Column({ name: 'name', type: 'varchar', length: 160 }) + name!: string; + + @Column({ name: 'code', type: 'varchar', length: 40, unique: true }) + code!: string; + + @Column({ name: 'type', type: 'varchar', length: 32 }) + type!: WarehouseType; + + @Column({ name: 'station_id', type: 'uuid', nullable: true }) + stationId?: string | null; + + @Column({ name: 'location_name', type: 'varchar', length: 200, nullable: true }) + locationName?: string | null; + + @Column({ name: 'capacity_weight', type: 'numeric', precision: 14, scale: 3, nullable: true }) + capacityWeight?: number | null; + + @Column({ name: 'capacity_containers', type: 'int', nullable: true }) + capacityContainers?: number | null; + + @Column({ name: 'current_weight', type: 'numeric', precision: 14, scale: 3, default: 0 }) + currentWeight!: number; + + @Column({ name: 'current_containers', type: 'int', default: 0 }) + currentContainers!: number; + + // Batch 2 capacity (weight + volume). maxWeight backfilled from capacityWeight. + @Column({ name: 'max_weight', type: 'numeric', precision: 14, scale: 3, nullable: true }) + maxWeight?: number | null; + + @Column({ name: 'max_volume', type: 'numeric', precision: 14, scale: 3, nullable: true }) + maxVolume?: number | null; + + @Column({ name: 'current_volume', type: 'numeric', precision: 14, scale: 3, default: 0 }) + currentVolume!: number; + + @Column({ name: 'status', type: 'varchar', length: 16, default: 'ACTIVE' }) + status!: WarehouseStatus; + + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive!: boolean; + + @Column({ name: 'facility_id', type: 'uuid', nullable: true }) + facilityId?: string | null; + + @ManyToOne(() => Facility, (facility) => facility.warehouses, { nullable: true }) + facility?: Facility | null; + + @OneToMany(() => WarehouseYard, (yard) => yard.warehouse) + yards?: WarehouseYard[]; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/scheduling-read.facade.ts b/apps/edr-freight-api/src/modules/warehouses/scheduling-read.facade.ts new file mode 100644 index 000000000..de5a791c1 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/scheduling-read.facade.ts @@ -0,0 +1,118 @@ +import { Injectable } from '@nestjs/common'; +import { DataSource } from 'typeorm'; + +/** + * READ-ONLY view into the train-scheduling / wagons domain for the warehouse module. + * + * IMPORTANT: this facade only ever runs SELECTs. The warehouse must never modify + * wagon assignment, rescheduling, import_ready/export_ready, or locomotive flow. + * It is intentionally decoupled (raw SQL) so it does not import the scheduling + * services/entities and cannot accidentally write to them. + */ +export interface WagonView { + id: string; + wagonNumber: string; + status: string; + trainId: string | null; +} + +export interface BookingScheduleView { + schedule: { + id: string; + status: string; + scheduledDepartureDate: string | null; + scheduledArrivalDate: string | null; + originStationId: string | null; + destinationStationId: string | null; + } | null; + wagon: { + wagonId: string | null; + wagonNumber: string | null; + sequenceNo: number | null; + allocatedWeightTons: number | null; + } | null; + /** Mirror of schedule.status — the headline "where is the train" indicator. */ + departureStatus: string | null; +} + +@Injectable() +export class SchedulingReadFacade { + constructor(private readonly dataSource: DataSource) {} + + /** Look up a single physical wagon. Returns null if it does not exist. */ + async findWagon(wagonId: string): Promise { + const rows = await this.dataSource.query( + `SELECT id, wagon_number AS "wagonNumber", status, train_id AS "trainId" + FROM freight.wagons + WHERE id = $1 AND deleted_at IS NULL + LIMIT 1`, + [wagonId], + ); + return rows?.[0] ?? null; + } + + /** True when the wagon is already part of a train set (selected by an existing schedule). */ + async isWagonScheduled(wagonId: string): Promise { + const rows = await this.dataSource.query( + `SELECT 1 FROM freight.train_set_wagons + WHERE physical_wagon_id = $1 AND deleted_at IS NULL + LIMIT 1`, + [wagonId], + ); + return (rows?.length ?? 0) > 0; + } + + /** List wagons usable for loading (available, or already assigned to a schedule). */ + listLoadableWagons(): Promise { + return this.dataSource.query( + `SELECT id, wagon_number AS "wagonNumber", status, train_id AS "trainId" + FROM freight.wagons + WHERE deleted_at IS NULL + AND status NOT IN ('RETIRED', 'MAINTENANCE') + ORDER BY wagon_number ASC`, + ); + } + + /** + * Given a booking, return its related schedule, wagon assignment and departure status. + * All fields are read straight from the scheduling tables — nothing is written. + */ + async getBookingSchedule(bookingId: string): Promise { + const scheduleRows = await this.dataSource.query( + `SELECT ts.id, + ts.status, + ts.scheduled_departure_date AS "scheduledDepartureDate", + ts.scheduled_arrival_date AS "scheduledArrivalDate", + ts.origin_station_id AS "originStationId", + ts.destination_station_id AS "destinationStationId" + FROM freight.train_schedule_bookings tsb + INNER JOIN freight.train_schedules ts ON ts.id = tsb.train_schedule_id + WHERE tsb.booking_id = $1 AND ts.deleted_at IS NULL + ORDER BY ts.scheduled_departure_date DESC NULLS LAST + LIMIT 1`, + [bookingId], + ); + const schedule = scheduleRows?.[0] ?? null; + + const wagonRows = await this.dataSource.query( + `SELECT w.id AS "wagonId", + w.wagon_number AS "wagonNumber", + tsw.sequence_no AS "sequenceNo", + wba.allocated_weight_tons AS "allocatedWeightTons" + FROM freight.wagon_booking_allocations wba + INNER JOIN freight.train_set_wagons tsw ON tsw.id = wba.train_set_wagon_id + LEFT JOIN freight.wagons w ON w.id = tsw.physical_wagon_id + WHERE wba.booking_id = $1 + ORDER BY tsw.sequence_no ASC NULLS LAST + LIMIT 1`, + [bookingId], + ); + const wagon = wagonRows?.[0] ?? null; + + return { + schedule, + wagon, + departureStatus: schedule?.status ?? null, + }; + } +} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-activity-log.repository.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-activity-log.repository.ts new file mode 100644 index 000000000..2e4eb7fae --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-activity-log.repository.ts @@ -0,0 +1,13 @@ +import { BaseRepository } from '@edr/api-common'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { WarehouseActivityLog } from './entities/warehouse-activity-log.entity'; + +@Injectable() +export class WarehouseActivityLogRepository extends BaseRepository { + constructor(@InjectRepository(WarehouseActivityLog) repository: Repository) { + super(repository); + } +} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-activity-log.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-activity-log.service.ts new file mode 100644 index 000000000..74c823a03 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-activity-log.service.ts @@ -0,0 +1,48 @@ +import { Injectable } from '@nestjs/common'; +import { EntityManager } from 'typeorm'; + +import { + WarehouseActivityLog, + WarehouseActivityType, +} from './entities/warehouse-activity-log.entity'; +import { WarehouseActivityLogRepository } from './warehouse-activity-log.repository'; + +interface LogInput { + activityType: WarehouseActivityType; + description?: string; + inventoryId?: string | null; + warehouseId?: string | null; + performedBy?: string | null; +} + +@Injectable() +export class WarehouseActivityLogService { + constructor(private readonly logRepository: WarehouseActivityLogRepository) {} + + /** Persist an activity record. Pass a transaction manager to enrol in the caller's transaction. */ + async record(input: LogInput, manager?: EntityManager): Promise { + const data = { + activityType: input.activityType, + description: input.description ?? null, + inventoryId: input.inventoryId ?? null, + warehouseId: input.warehouseId ?? null, + performedBy: input.performedBy ?? 'system', + }; + + if (manager) { + await manager.getRepository(WarehouseActivityLog).save( + manager.getRepository(WarehouseActivityLog).create(data), + ); + return; + } + + await this.logRepository.create(data); + } + + findByInventory(inventoryId: string): Promise { + return this.logRepository.findAll({ + where: { inventoryId }, + order: { createdAt: 'DESC' }, + }); + } +} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-allocation-rule.repository.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-allocation-rule.repository.ts new file mode 100644 index 000000000..a066c93f1 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-allocation-rule.repository.ts @@ -0,0 +1,15 @@ +import { BaseRepository } from '@edr/api-common'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { WarehouseAllocationRule } from './entities/warehouse-allocation-rule.entity'; + +@Injectable() +export class WarehouseAllocationRuleRepository extends BaseRepository { + constructor( + @InjectRepository(WarehouseAllocationRule) repository: Repository, + ) { + super(repository); + } +} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-allocation.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-allocation.service.ts new file mode 100644 index 000000000..f110dfcf7 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-allocation.service.ts @@ -0,0 +1,120 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { DataSource } from 'typeorm'; + +import { CreateAllocationRuleDto, UpdateAllocationRuleDto } from './dto/allocation-rule.dto'; +import { WarehouseAllocationRule } from './entities/warehouse-allocation-rule.entity'; +import { WarehouseAllocationRuleRepository } from './warehouse-allocation-rule.repository'; + +export interface AllocationCriteria { + freightType?: string | null; // CONTAINER | BULK + tradeDirection?: string | null; // IMPORT | EXPORT | DOMESTIC | BOTH + cargoTypeCode?: string | null; + containerStatus?: string | null; // EMPTY | MAINTENANCE | ... + requiresInspection?: boolean | null; +} + +export interface AllocationResult { + warehouseId: string; + yardId: string; + zoneId: string; + facilityId: string | null; + rule: { id: string; name: string; storageType: string | null } | null; + /** Human-readable path: Facility → Warehouse → Yard → Zone. */ + path: string; +} + +/** + * Batch 5 — deterministic warehouse/yard allocation driven by configurable rules. + * Never assigns randomly: matches criteria against active rules by priority and + * resolves the target Yard/Warehouse/Zone by code. + */ +@Injectable() +export class WarehouseAllocationService { + constructor( + private readonly dataSource: DataSource, + private readonly ruleRepository: WarehouseAllocationRuleRepository, + ) {} + + // ── Rule CRUD ────────────────────────────────────────────────────────────── + listRules(): Promise { + return this.ruleRepository.findAll({ order: { priority: 'ASC' } }); + } + + createRule(dto: CreateAllocationRuleDto): Promise { + return this.ruleRepository.create({ isActive: true, priority: 100, ...dto }); + } + + async updateRule(id: string, dto: UpdateAllocationRuleDto): Promise { + const updated = await this.ruleRepository.update(id, dto); + if (!updated) throw new NotFoundException(`Allocation rule ${id} not found`); + return updated; + } + + deleteRule(id: string): Promise { + return this.ruleRepository.softDelete(id); + } + + private matches(rule: WarehouseAllocationRule, c: AllocationCriteria): boolean { + const eq = (ruleVal?: string | null, inVal?: string | null) => + ruleVal == null || (inVal != null && ruleVal.toUpperCase() === inVal.toUpperCase()); + return ( + eq(rule.freightType, c.freightType) && + eq(rule.tradeDirection, c.tradeDirection) && + eq(rule.cargoTypeCode, c.cargoTypeCode) && + eq(rule.containerStatus, c.containerStatus) && + (rule.requiresInspection == null || rule.requiresInspection === Boolean(c.requiresInspection)) + ); + } + + /** First active rule (by priority) whose criteria match. */ + async findMatchingRule(criteria: AllocationCriteria): Promise { + const rules = await this.ruleRepository.findAll({ + where: { isActive: true }, + order: { priority: 'ASC' }, + }); + return rules.find((r) => this.matches(r, criteria)) ?? null; + } + + /** Resolve a concrete warehouse/yard/zone for the given criteria, or null if none configured. */ + async resolveLocation(criteria: AllocationCriteria): Promise { + const rule = await this.findMatchingRule(criteria); + const yardCode = rule?.targetYardCode; + + // Resolve yard (by rule code, else first available yard with a zone). + const [yard] = await this.dataSource.query( + yardCode + ? `SELECT y.id, y.warehouse_id AS "warehouseId", y.name FROM freight.warehouse_yards y + WHERE y.code = $1 AND y.deleted_at IS NULL LIMIT 1` + : `SELECT y.id, y.warehouse_id AS "warehouseId", y.name FROM freight.warehouse_yards y + JOIN freight.warehouse_zones z ON z.yard_id = y.id AND z.deleted_at IS NULL + WHERE y.deleted_at IS NULL ORDER BY y.created_at ASC LIMIT 1`, + yardCode ? [yardCode] : [], + ); + if (!yard) return null; + + // Zone: rule code if given, else first zone in the yard. + const [zone] = await this.dataSource.query( + rule?.targetZoneCode + ? `SELECT z.id, z.name FROM freight.warehouse_zones z WHERE z.code = $1 AND z.deleted_at IS NULL LIMIT 1` + : `SELECT z.id, z.name FROM freight.warehouse_zones z WHERE z.yard_id = $1 AND z.deleted_at IS NULL ORDER BY z.created_at ASC LIMIT 1`, + rule?.targetZoneCode ? [rule.targetZoneCode] : [yard.id], + ); + if (!zone) return null; + + const [wh] = await this.dataSource.query( + `SELECT w.id, w.name, w.facility_id AS "facilityId", + (SELECT name FROM freight.facilities f WHERE f.id = w.facility_id) AS "facilityName" + FROM freight.warehouses w WHERE w.id = $1 AND w.deleted_at IS NULL LIMIT 1`, + [yard.warehouseId], + ); + + return { + warehouseId: yard.warehouseId, + yardId: yard.id, + zoneId: zone.id, + facilityId: wh?.facilityId ?? null, + rule: rule ? { id: rule.id, name: rule.name, storageType: rule.storageType ?? null } : null, + path: [wh?.facilityName, wh?.name, yard.name, zone.name].filter(Boolean).join(' → '), + }; + } +} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-dashboard.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-dashboard.service.ts new file mode 100644 index 000000000..1bb5b1289 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-dashboard.service.ts @@ -0,0 +1,55 @@ +import { Injectable } from '@nestjs/common'; +import { DataSource } from 'typeorm'; + +import { Warehouse } from './entities/warehouse.entity'; +import { WarehouseInventory } from './entities/warehouse-inventory.entity'; + +export interface WarehouseDashboard { + totalWarehouses: number; + totalInventory: number; + receivedToday: number; + stored: number; + reserved: number; + readyForLoading: number; + loaded: number; + dispatched: number; +} + +@Injectable() +export class WarehouseDashboardService { + constructor(private readonly dataSource: DataSource) {} + + async getDashboard(): Promise { + const warehouseRepo = this.dataSource.getRepository(Warehouse); + const inventoryRepo = this.dataSource.getRepository(WarehouseInventory); + + const startOfToday = new Date(); + startOfToday.setHours(0, 0, 0, 0); + + const [totalWarehouses, totalInventory, stored, reserved, readyForLoading, loaded, dispatched, receivedToday] = + await Promise.all([ + warehouseRepo.count(), + inventoryRepo.count(), + inventoryRepo.count({ where: { status: 'STORED' } }), + inventoryRepo.count({ where: { status: 'RESERVED' } }), + inventoryRepo.count({ where: { status: 'READY_FOR_LOADING' } }), + inventoryRepo.count({ where: { status: 'LOADED' } }), + inventoryRepo.count({ where: { status: 'DISPATCHED' } }), + inventoryRepo + .createQueryBuilder('inv') + .where('inv.arrived_at >= :start', { start: startOfToday }) + .getCount(), + ]); + + return { + totalWarehouses, + totalInventory, + receivedToday, + stored, + reserved, + readyForLoading, + loaded, + dispatched, + }; + } +} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-fee-invoice-item.repository.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-fee-invoice-item.repository.ts new file mode 100644 index 000000000..5b5df396e --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-fee-invoice-item.repository.ts @@ -0,0 +1,13 @@ +import { BaseRepository } from '@edr/api-common'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { WarehouseFeeInvoiceItem } from './entities/warehouse-fee-invoice-item.entity'; + +@Injectable() +export class WarehouseFeeInvoiceItemRepository extends BaseRepository { + constructor(@InjectRepository(WarehouseFeeInvoiceItem) repository: Repository) { + super(repository); + } +} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-fee-invoice.repository.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-fee-invoice.repository.ts new file mode 100644 index 000000000..97328f46d --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-fee-invoice.repository.ts @@ -0,0 +1,13 @@ +import { BaseRepository } from '@edr/api-common'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { WarehouseFeeInvoice } from './entities/warehouse-fee-invoice.entity'; + +@Injectable() +export class WarehouseFeeInvoiceRepository extends BaseRepository { + constructor(@InjectRepository(WarehouseFeeInvoice) repository: Repository) { + super(repository); + } +} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-fee-rule.repository.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-fee-rule.repository.ts new file mode 100644 index 000000000..5b5d3b2ce --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-fee-rule.repository.ts @@ -0,0 +1,13 @@ +import { BaseRepository } from '@edr/api-common'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { WarehouseFeeRule } from './entities/warehouse-fee-rule.entity'; + +@Injectable() +export class WarehouseFeeRuleRepository extends BaseRepository { + constructor(@InjectRepository(WarehouseFeeRule) repository: Repository) { + super(repository); + } +} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts new file mode 100644 index 000000000..ccf66deb4 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts @@ -0,0 +1,168 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { DataSource } from 'typeorm'; + +import { CreateFeeRuleDto, UpdateFeeRuleDto } from './dto/fee-rule.dto'; +import { FeeRuleType, WarehouseFeeRule } from './entities/warehouse-fee-rule.entity'; +import { WarehouseFeeRuleRepository } from './warehouse-fee-rule.repository'; + +interface ItemAttributes { + arrivedAt: Date | null; + gateClearedAt: Date | null; + releaseDate: Date | null; + freightType: string | null; + tradeDirection: string | null; + cargoTypeCode: string | null; + containerTypeCode: string | null; + facilityId: string | null; + warehouseId: string | null; + yardId: string | null; + zoneId: string | null; +} + +export interface FeePreview { + ruleType: FeeRuleType; + ruleId: string | null; + ruleName: string | null; + freeDays: number; + ratePerDay: number; + currency: string; + startDate: string | null; + endDate: string; + endIsOpen: boolean; // true when still accruing (no release/gate-clear yet) + elapsedDays: number; + chargeableDays: number; + amount: number; +} + +const MS_PER_DAY = 24 * 60 * 60 * 1000; + +@Injectable() +export class WarehouseFeeService { + constructor( + private readonly dataSource: DataSource, + private readonly feeRuleRepository: WarehouseFeeRuleRepository, + ) {} + + // ── Rule CRUD ────────────────────────────────────────────────────────────── + listRules(): Promise { + return this.feeRuleRepository.findAll({ order: { ruleType: 'ASC', priority: 'ASC' } }); + } + + createRule(dto: CreateFeeRuleDto): Promise { + return this.feeRuleRepository.create({ isActive: true, priority: 100, currency: 'USD', ...dto }); + } + + async updateRule(id: string, dto: UpdateFeeRuleDto): Promise { + const updated = await this.feeRuleRepository.update(id, dto); + if (!updated) throw new NotFoundException(`Fee rule ${id} not found`); + return updated; + } + + deleteRule(id: string): Promise { + return this.feeRuleRepository.softDelete(id); + } + + private async loadItem(inventoryId: string): Promise { + const [row] = await this.dataSource.query( + `SELECT inv.arrived_at AS "arrivedAt", + inv.gate_cleared_at AS "gateClearedAt", + inv.release_date AS "releaseDate", + inv.warehouse_id AS "warehouseId", + inv.yard_id AS "yardId", + inv.zone_id AS "zoneId", + w.facility_id AS "facilityId", + b.freight_type AS "freightType", + b.trade_direction AS "tradeDirection", + cgt.code AS "cargoTypeCode", + ctt.code AS "containerTypeCode" + FROM freight.warehouse_inventory inv + LEFT JOIN freight.warehouses w ON w.id = inv.warehouse_id + LEFT JOIN freight.bookings b ON b.id = inv.booking_id + LEFT JOIN freight.cargoes cg ON cg.id = inv.cargo_id + LEFT JOIN freight.cargo_types cgt ON cgt.id = cg.cargo_type_id + LEFT JOIN freight.containers ct ON ct.id = inv.container_id + LEFT JOIN freight.container_types ctt ON ctt.id = ct.container_type_id + WHERE inv.id = $1 AND inv.deleted_at IS NULL`, + [inventoryId], + ); + if (!row) throw new NotFoundException(`Inventory item ${inventoryId} not found`); + return row; + } + + private matchScore(rule: WarehouseFeeRule, item: ItemAttributes): number | null { + // Returns specificity score (#matched non-null scope fields), or null if any constraint fails. + let score = 0; + const check = (ruleVal: string | null | undefined, itemVal: string | null) => { + if (ruleVal == null) return true; + if (itemVal != null && ruleVal.toUpperCase() === itemVal.toUpperCase()) { + score += 1; + return true; + } + return false; + }; + if (!check(rule.freightType, item.freightType)) return null; + if (!check(rule.tradeDirection, item.tradeDirection)) return null; + if (!check(rule.cargoTypeCode, item.cargoTypeCode)) return null; + if (!check(rule.containerType, item.containerTypeCode)) return null; + if (!check(rule.facilityId, item.facilityId)) return null; + if (!check(rule.warehouseId, item.warehouseId)) return null; + if (!check(rule.yardId, item.yardId)) return null; + if (!check(rule.zoneId, item.zoneId)) return null; + return score; + } + + private bestRule(rules: WarehouseFeeRule[], item: ItemAttributes): WarehouseFeeRule | null { + let best: WarehouseFeeRule | null = null; + let bestScore = -1; + for (const rule of rules) { + const score = this.matchScore(rule, item); + if (score == null) continue; + if (score > bestScore || (score === bestScore && best && rule.priority < best.priority)) { + best = rule; + bestScore = score; + } + } + return best; + } + + private compute(ruleType: FeeRuleType, rule: WarehouseFeeRule | null, item: ItemAttributes, now: Date): FeePreview { + const start = item.arrivedAt ? new Date(item.arrivedAt) : null; + const endDate = item.gateClearedAt ?? item.releaseDate ?? now; + const endIsOpen = !item.gateClearedAt && !item.releaseDate; + const freeDays = rule?.freeDays ?? 0; + const ratePerDay = Number(rule?.ratePerDay ?? 0); + + const elapsedDays = start + ? Math.max(0, Math.ceil((new Date(endDate).getTime() - start.getTime()) / MS_PER_DAY)) + : 0; + const chargeableDays = Math.max(0, elapsedDays - freeDays); + const amount = Math.round(chargeableDays * ratePerDay * 100) / 100; + + return { + ruleType, + ruleId: rule?.id ?? null, + ruleName: rule?.name ?? null, + freeDays, + ratePerDay, + currency: rule?.currency ?? 'USD', + startDate: start ? start.toISOString() : null, + endDate: new Date(endDate).toISOString(), + endIsOpen, + elapsedDays, + chargeableDays, + amount, + }; + } + + /** Preview demurrage + storage fees for an inventory item using the most specific active rules. */ + async previewForInventory(inventoryId: string): Promise { + const item = await this.loadItem(inventoryId); + const rules = await this.feeRuleRepository.findAll({ where: { isActive: true } }); + const now = new Date(); + + const byType: FeeRuleType[] = ['DEMURRAGE_FEE', 'STORAGE_FEE']; + return byType.map((type) => + this.compute(type, this.bestRule(rules.filter((r) => r.ruleType === type), item), item, now), + ); + } +} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.controller.ts new file mode 100644 index 000000000..533b0c6ae --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.controller.ts @@ -0,0 +1,64 @@ +import { + Body, + Controller, + Get, + Param, + ParseUUIDPipe, + Patch, + Post, + UploadedFiles, + UseInterceptors, +} from '@nestjs/common'; +import { AnyFilesInterceptor } from '@nestjs/platform-express'; +import { ApiBearerAuth, ApiConsumes, ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { CreateInspectionReportDto } from './dto/create-inspection-report.dto'; +import { UpdateInspectionReportDto } from './dto/update-inspection-report.dto'; +import { WarehouseInspectionService } from './warehouse-inspection.service'; + +@ApiTags('warehouse-inspection') +@ApiBearerAuth() +@Controller() +export class WarehouseInspectionController { + constructor(private readonly inspectionService: WarehouseInspectionService) {} + + @Post('warehouse-inventory/:inventoryId/inspection-reports') + @ApiOperation({ summary: 'Create an inspection / damage report for an inventory item' }) + create( + @Param('inventoryId', ParseUUIDPipe) inventoryId: string, + @Body() dto: CreateInspectionReportDto, + ) { + return this.inspectionService.create(inventoryId, dto); + } + + @Get('warehouse-inventory/:inventoryId/inspection-reports') + @ApiOperation({ summary: 'List inspection reports for an inventory item' }) + listByInventory(@Param('inventoryId', ParseUUIDPipe) inventoryId: string) { + return this.inspectionService.findByInventory(inventoryId); + } + + @Get('warehouse-inspection-reports/:id') + @ApiOperation({ summary: 'Get an inspection report (with attachments)' }) + async findOne(@Param('id', ParseUUIDPipe) id: string) { + const report = await this.inspectionService.findById(id); + const attachments = await this.inspectionService.listAttachments(id); + return { ...report, attachments }; + } + + @Patch('warehouse-inspection-reports/:id') + @ApiOperation({ summary: 'Update an inspection report' }) + update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateInspectionReportDto) { + return this.inspectionService.update(id, dto); + } + + @Post('warehouse-inspection-reports/:id/attachments') + @UseInterceptors(AnyFilesInterceptor()) + @ApiConsumes('multipart/form-data') + @ApiOperation({ summary: 'Upload inspection images / documents' }) + addAttachments( + @Param('id', ParseUUIDPipe) id: string, + @UploadedFiles() files: Express.Multer.File[], + ) { + return this.inspectionService.addAttachments(id, files); + } +} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.repository.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.repository.ts new file mode 100644 index 000000000..4cd92e846 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.repository.ts @@ -0,0 +1,15 @@ +import { BaseRepository } from '@edr/api-common'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { WarehouseInspectionReport } from './entities/warehouse-inspection-report.entity'; + +@Injectable() +export class WarehouseInspectionRepository extends BaseRepository { + constructor( + @InjectRepository(WarehouseInspectionReport) repository: Repository, + ) { + super(repository); + } +} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.service.ts new file mode 100644 index 000000000..9d1d0f148 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.service.ts @@ -0,0 +1,116 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { DataSource } from 'typeorm'; + +import { FilesService } from '../files/files.service'; +import { CreateInspectionReportDto } from './dto/create-inspection-report.dto'; +import { UpdateInspectionReportDto } from './dto/update-inspection-report.dto'; +import { WarehouseInspectionReport } from './entities/warehouse-inspection-report.entity'; +import { WarehouseInventory } from './entities/warehouse-inventory.entity'; +import { WarehouseInspectionRepository } from './warehouse-inspection.repository'; + +const INSPECTION_RESOURCE = 'warehouse-inspection-report'; + +@Injectable() +export class WarehouseInspectionService { + constructor( + private readonly dataSource: DataSource, + private readonly inspectionRepository: WarehouseInspectionRepository, + private readonly filesService: FilesService, + ) {} + + /** Create an inspection report for an inventory item and sync its inspectionStatus. */ + async create(inventoryId: string, dto: CreateInspectionReportDto): Promise { + const inventoryRepo = this.dataSource.getRepository(WarehouseInventory); + const inventory = await inventoryRepo.findOne({ where: { id: inventoryId } }); + if (!inventory) { + throw new NotFoundException(`Inventory item ${inventoryId} not found`); + } + + const expected = dto.expectedWeight ?? null; + const actual = dto.actualWeight ?? null; + const weightLoss = expected !== null && actual !== null ? Math.max(0, expected - actual) : null; + + const report = await this.inspectionRepository.create({ + inventoryId, + bookingId: inventory.bookingId ?? null, + reportType: dto.reportType, + inspectionStatus: dto.inspectionStatus, + hasDamage: dto.hasDamage ?? false, + damageDescription: dto.damageDescription ?? null, + hasWeightLoss: dto.hasWeightLoss ?? false, + expectedWeight: expected, + actualWeight: actual, + weightLoss, + weightLossUnit: weightLoss !== null ? 'kg' : null, + hasMissingItems: dto.hasMissingItems ?? false, + missingItemsDescription: dto.missingItemsDescription ?? null, + remarks: dto.remarks ?? null, + inspectedById: dto.inspectedById ?? null, + inspectedAt: new Date(), + }); + + // Mirror the latest outcome onto the inventory item so loading rules can read it. + await inventoryRepo.update(inventoryId, { + inspectionStatus: dto.inspectionStatus, + inspectedAt: new Date(), + }); + + return report; + } + + async findByInventory(inventoryId: string): Promise { + return this.inspectionRepository.findAll({ + where: { inventoryId }, + order: { createdAt: 'DESC' }, + }); + } + + async findById(id: string): Promise { + const report = await this.inspectionRepository.findById(id); + if (!report) { + throw new NotFoundException(`Inspection report ${id} not found`); + } + return report; + } + + async update(id: string, dto: UpdateInspectionReportDto): Promise { + const report = await this.findById(id); + + const expected = dto.expectedWeight ?? report.expectedWeight ?? null; + const actual = dto.actualWeight ?? report.actualWeight ?? null; + const weightLoss = expected !== null && actual !== null ? Math.max(0, expected - actual) : report.weightLoss ?? null; + + await this.inspectionRepository.update(id, { + ...(dto.reportType ? { reportType: dto.reportType } : {}), + ...(dto.inspectionStatus ? { inspectionStatus: dto.inspectionStatus } : {}), + ...(dto.hasDamage !== undefined ? { hasDamage: dto.hasDamage } : {}), + ...(dto.damageDescription !== undefined ? { damageDescription: dto.damageDescription } : {}), + ...(dto.hasWeightLoss !== undefined ? { hasWeightLoss: dto.hasWeightLoss } : {}), + expectedWeight: expected, + actualWeight: actual, + weightLoss, + ...(dto.hasMissingItems !== undefined ? { hasMissingItems: dto.hasMissingItems } : {}), + ...(dto.missingItemsDescription !== undefined ? { missingItemsDescription: dto.missingItemsDescription } : {}), + ...(dto.remarks !== undefined ? { remarks: dto.remarks } : {}), + }); + + if (dto.inspectionStatus) { + await this.dataSource + .getRepository(WarehouseInventory) + .update(report.inventoryId, { inspectionStatus: dto.inspectionStatus }); + } + + return this.findById(id); + } + + /** Attach uploaded images/documents to a report, reusing the shared Files (MinIO) module. */ + async addAttachments(reportId: string, files: Express.Multer.File[]) { + await this.findById(reportId); + if (!files?.length) return []; + return this.filesService.uploadMany(reportId, INSPECTION_RESOURCE, files); + } + + listAttachments(reportId: string) { + return this.filesService.findByResource(reportId, INSPECTION_RESOURCE); + } +} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory-movement.repository.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory-movement.repository.ts new file mode 100644 index 000000000..ccc0bcd41 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory-movement.repository.ts @@ -0,0 +1,15 @@ +import { BaseRepository } from '@edr/api-common'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { WarehouseInventoryMovement } from './entities/warehouse-inventory-movement.entity'; + +@Injectable() +export class WarehouseInventoryMovementRepository extends BaseRepository { + constructor( + @InjectRepository(WarehouseInventoryMovement) repository: Repository, + ) { + super(repository); + } +} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts new file mode 100644 index 000000000..ce8a2c188 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts @@ -0,0 +1,145 @@ +import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { FilterWarehouseInventoryDto } from './dto/filter-inventory.dto'; +import { InquiryWarehouseInventoryDto } from './dto/inquiry-inventory.dto'; +import { LoadInventoryDto } from './dto/load-inventory.dto'; +import { MoveInventoryDto } from './dto/move-inventory.dto'; +import { ReceiveWarehouseInventoryDto } from './dto/receive-inventory.dto'; +import { ReserveInventoryDto } from './dto/reserve-inventory.dto'; +import { UnloadBookingDto } from './dto/unload-booking.dto'; +import { SchedulingReadFacade } from './scheduling-read.facade'; +import { WarehouseInventoryService } from './warehouse-inventory.service'; + +@ApiTags('warehouse-inventory') +@ApiBearerAuth() +@Controller('warehouse-inventory') +export class WarehouseInventoryController { + constructor( + private readonly inventoryService: WarehouseInventoryService, + private readonly scheduling: SchedulingReadFacade, + ) {} + + @Get() + @ApiOperation({ summary: 'List warehouse inventory' }) + findAll(@Query() filter: FilterWarehouseInventoryDto) { + return this.inventoryService.findAll(filter); + } + + @Get('ready-for-loading') + @ApiOperation({ summary: 'List inventory ready for loading' }) + findReadyForLoading(@Query() filter: FilterWarehouseInventoryDto) { + return this.inventoryService.findReadyForLoading(filter); + } + + @Get('inquiry') + @ApiOperation({ summary: 'Locate any item inside the warehouse' }) + inquiry(@Query() filter: InquiryWarehouseInventoryDto) { + return this.inventoryService.inquiry(filter); + } + + @Get('arrival-queue') + @ApiOperation({ summary: 'Arrived bookings awaiting unload / inspection' }) + arrivalQueue() { + return this.inventoryService.arrivalQueue(); + } + + @Post('auto-unload-arrived') + @ApiOperation({ summary: 'Bulk auto-unload all arrived bookings into the warehouse' }) + autoUnloadArrived() { + return this.inventoryService.autoUnloadArrived(); + } + + @Post('auto-load-ready') + @ApiOperation({ summary: 'Auto-load READY_FOR_LOADING inventory with PAID bookings' }) + autoLoadReady() { + return this.inventoryService.autoLoadReady(); + } + + @Post('bookings/:bookingId/unload') + @ApiOperation({ summary: 'Unload a single arrived booking into a location' }) + unloadBooking( + @Param('bookingId', ParseUUIDPipe) bookingId: string, + @Body() dto: UnloadBookingDto, + ) { + return this.inventoryService.unloadBooking(bookingId, dto); + } + + @Post(':id/gate-clearance') + @ApiOperation({ summary: 'Final terminal release / gate clearance (blocked while fees unpaid)' }) + gateClearance(@Param('id', ParseUUIDPipe) id: string, @Body('performedBy') performedBy?: string) { + return this.inventoryService.gateClearance(id, performedBy); + } + + @Get('loadable-wagons') + @ApiOperation({ summary: 'List wagons usable for loading (read-only from scheduling)' }) + loadableWagons() { + return this.scheduling.listLoadableWagons(); + } + + @Get('booking/:bookingId/schedule') + @ApiOperation({ summary: 'Read-only schedule + wagon + departure status for a booking' }) + bookingSchedule(@Param('bookingId', ParseUUIDPipe) bookingId: string) { + return this.scheduling.getBookingSchedule(bookingId); + } + + @Post('receive') + @ApiOperation({ summary: 'Receive inventory at a warehouse location' }) + receive(@Body() dto: ReceiveWarehouseInventoryDto) { + return this.inventoryService.receive(dto); + } + + @Post('reserve') + @ApiOperation({ summary: 'Reserve stored inventory for a PAID booking' }) + reserve(@Body() dto: ReserveInventoryDto) { + return this.inventoryService.reserve(dto); + } + + @Get(':id/movements') + @ApiOperation({ summary: 'Inventory movement history' }) + movements(@Param('id', ParseUUIDPipe) id: string) { + return this.inventoryService.findMovements(id); + } + + @Get(':id/activity') + @ApiOperation({ summary: 'Inventory activity log' }) + activity(@Param('id', ParseUUIDPipe) id: string) { + return this.inventoryService.findActivity(id); + } + + @Get(':id/loadings') + @ApiOperation({ summary: 'Loading records for an inventory item' }) + loadings(@Param('id', ParseUUIDPipe) id: string) { + return this.inventoryService.findLoadingsByInventory(id); + } + + @Post(':id/move') + @ApiOperation({ summary: 'Move inventory to another warehouse/yard/zone' }) + move(@Param('id', ParseUUIDPipe) id: string, @Body() dto: MoveInventoryDto) { + return this.inventoryService.move(id, dto); + } + + @Post(':id/store') + @ApiOperation({ summary: 'Mark received inventory as STORED' }) + store(@Param('id', ParseUUIDPipe) id: string, @Body('performedBy') performedBy?: string) { + return this.inventoryService.store(id, performedBy); + } + + @Post(':id/ready-for-loading') + @ApiOperation({ summary: 'Mark reserved inventory READY_FOR_LOADING' }) + readyForLoading(@Param('id', ParseUUIDPipe) id: string, @Body('performedBy') performedBy?: string) { + return this.inventoryService.readyForLoading(id, performedBy); + } + + @Post(':id/load') + @ApiOperation({ summary: 'Load READY_FOR_LOADING inventory onto a wagon' }) + load(@Param('id', ParseUUIDPipe) id: string, @Body() dto: LoadInventoryDto) { + return this.inventoryService.load(id, dto); + } + + @Patch(':id/dispatch') + @ApiOperation({ summary: 'Mark loaded inventory DISPATCHED (left the terminal)' }) + dispatch(@Param('id', ParseUUIDPipe) id: string, @Body('performedBy') performedBy?: string) { + return this.inventoryService.dispatch(id, performedBy); + } +} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.repository.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.repository.ts new file mode 100644 index 000000000..4f249cf53 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.repository.ts @@ -0,0 +1,13 @@ +import { BaseRepository } from '@edr/api-common'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { WarehouseInventory } from './entities/warehouse-inventory.entity'; + +@Injectable() +export class WarehouseInventoryRepository extends BaseRepository { + constructor(@InjectRepository(WarehouseInventory) repository: Repository) { + super(repository); + } +} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts new file mode 100644 index 000000000..652be600c --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts @@ -0,0 +1,940 @@ +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { DataSource, EntityManager, FindManyOptions, ILike } from 'typeorm'; + +import { FilterWarehouseInventoryDto } from './dto/filter-inventory.dto'; +import { InquiryWarehouseInventoryDto } from './dto/inquiry-inventory.dto'; +import { LoadInventoryDto } from './dto/load-inventory.dto'; +import { MoveInventoryDto } from './dto/move-inventory.dto'; +import { ReceiveWarehouseInventoryDto } from './dto/receive-inventory.dto'; +import { ReserveInventoryDto } from './dto/reserve-inventory.dto'; +import { UnloadBookingDto } from './dto/unload-booking.dto'; +import { WarehouseAllocationService } from './warehouse-allocation.service'; +import { WarehouseInvoiceService } from './warehouse-invoice.service'; +import { WarehouseActivityLog } from './entities/warehouse-activity-log.entity'; +import { WarehouseInventoryMovement } from './entities/warehouse-inventory-movement.entity'; +import { + WAREHOUSE_INVENTORY_TRANSITIONS, + WarehouseInventory, + WarehouseInventoryStatus, +} from './entities/warehouse-inventory.entity'; +import { WarehouseLoading } from './entities/warehouse-loading.entity'; +import { WarehouseYard } from './entities/warehouse-yard.entity'; +import { WarehouseZone } from './entities/warehouse-zone.entity'; +import { Warehouse } from './entities/warehouse.entity'; +import { SchedulingReadFacade } from './scheduling-read.facade'; +import { WarehouseActivityLogService } from './warehouse-activity-log.service'; +import { WarehouseInventoryRepository } from './warehouse-inventory.repository'; +import { WarehouseLoadingRepository } from './warehouse-loading.repository'; + +/** Wagon states that may receive a load (besides being part of an existing schedule). */ +const LOADABLE_WAGON_STATUSES = ['AVAILABLE', 'IMPORT_READY', 'EXPORT_READY', 'ASSIGNED']; + +export interface InventoryInquiryResult { + id: string; + bookingId: string | null; + bookingNumber: string | null; + customerName: string | null; + containerNumber: string | null; + cargoType: string | null; + cargoDescription: string | null; + goodsId: string | null; + warehouse: { id: string; name: string; code: string } | null; + yard: { id: string; name: string; code: string } | null; + zone: { id: string; name: string; code: string } | null; + status: string; + quantity: number; + weight: number; + arrivedAt: Date | null; + readyForLoadingAt: Date | null; +} + +interface LocationNode { + maxWeight?: number | null; + capacityWeight?: number | null; + maxVolume?: number | null; + capacityContainers?: number | null; + currentWeight: number; + currentVolume: number; + currentContainers: number; +} + +// ── Batch 4.5 result/queue shapes ──────────────────────────────────────────── +interface ArrivalQueueRow { + bookingId: string; + bookingReference: string; + customer: string | null; + cargo: string | null; + container: string | null; + arrivalDate: Date | null; + bookingStatus: string; + inventoryId: string | null; + currentStatus: string | null; + inspectionStatus: string | null; + facility: string | null; + warehouse: string | null; + yard: string | null; + zone: string | null; +} + +export interface ArrivalQueueItem { + bookingId: string; + bookingReference: string; + customer: string | null; + cargo: string | null; + container: string | null; + facility: string | null; + warehouse: string | null; + yard: string | null; + zone: string | null; + inventoryId: string | null; + currentStatus: string | null; + arrivalDate: Date | null; + inspectionStatus: string | null; + unloaded: boolean; +} + +interface DefaultLocation { + warehouseId: string; + yardId: string; + zoneId: string; + facilityId: string | null; +} + +export interface AutoUnloadResult { + processedCount: number; + skippedCount: number; + failedCount: number; + results: { bookingId: string; inventoryId?: string; status: string; reason?: string }[]; +} + +export interface AutoLoadResult { + loadedCount: number; + skippedCount: number; + results: { inventoryId: string; status: string; reason?: string }[]; +} + +@Injectable() +export class WarehouseInventoryService { + constructor( + private readonly dataSource: DataSource, + private readonly inventoryRepository: WarehouseInventoryRepository, + private readonly loadingRepository: WarehouseLoadingRepository, + private readonly activityLog: WarehouseActivityLogService, + private readonly scheduling: SchedulingReadFacade, + private readonly allocation: WarehouseAllocationService, + private readonly invoices: WarehouseInvoiceService, + ) {} + + /** + * Batch 6 — final terminal release / gate clearance. + * Blocked while an unpaid demurrage/storage invoice exists. Does NOT touch + * inspection / storage / loading steps — only the final release. + */ + async gateClearance(id: string, performedBy?: string): Promise { + const item = await this.findById(id); + const blocking = await this.invoices.findBlockingInvoice(id); + if (blocking) { + throw new BadRequestException( + 'Warehouse demurrage/storage fee must be paid before terminal release.', + ); + } + const now = new Date(); + await this.inventoryRepository.update(id, { + gateClearedAt: now, + releaseDate: item.releaseDate ?? now, + }); + await this.activityLog.record({ + activityType: 'INVENTORY_DISPATCHED', + inventoryId: id, + warehouseId: item.warehouseId, + description: 'Gate clearance / terminal release', + performedBy, + }); + return this.findById(id); + } + + // ── Listing ──────────────────────────────────────────────────────────── + + findAll(filter: FilterWarehouseInventoryDto): Promise { + const base = { + ...(filter.warehouseId ? { warehouseId: filter.warehouseId } : {}), + ...(filter.yardId ? { yardId: filter.yardId } : {}), + ...(filter.zoneId ? { zoneId: filter.zoneId } : {}), + ...(filter.bookingId ? { bookingId: filter.bookingId } : {}), + ...(filter.cargoId ? { cargoId: filter.cargoId } : {}), + ...(filter.containerId ? { containerId: filter.containerId } : {}), + ...(filter.goodsId ? { goodsId: filter.goodsId } : {}), + ...(filter.status ? { status: filter.status } : {}), + }; + + const search = filter.search?.trim(); + const where: FindManyOptions['where'] = search + ? { ...base, notes: ILike(`%${search}%`) } + : base; + + return this.inventoryRepository.findAll({ + where, + relations: { warehouse: { facility: true }, yard: true, zone: true, booking: true }, + order: { createdAt: 'DESC' }, + }); + } + + findReadyForLoading(filter: FilterWarehouseInventoryDto): Promise { + return this.findAll({ ...filter, status: 'READY_FOR_LOADING' }); + } + + async findById(id: string): Promise { + const item = await this.inventoryRepository.findById(id, { + relations: { warehouse: true, yard: true, zone: true }, + }); + + if (!item) { + throw new NotFoundException(`Inventory item ${id} not found`); + } + + return item; + } + + // ── Batch 4.5: Arrival / Unload / Load automation ────────────────────────── + + /** Bookings whose goods have arrived and may be unloaded into the warehouse. */ + private readonly ARRIVED_BOOKING_STATUSES = ['IN_TRANSIT']; + + /** Arrived bookings + their current inventory/inspection state (queue view). */ + async arrivalQueue(): Promise { + const rows: ArrivalQueueRow[] = await this.dataSource.query( + `SELECT b.id AS "bookingId", + b.reference AS "bookingReference", + company.name AS "customer", + b.cargo_free_text AS "cargo", + ct.container_number AS "container", + b.scheduled_date AS "arrivalDate", + b.status AS "bookingStatus", + inv.id AS "inventoryId", + inv.status AS "currentStatus", + inv.inspection_status AS "inspectionStatus", + fac.name AS "facility", + wh.name AS "warehouse", + yard.name AS "yard", + zone.name AS "zone" + FROM freight.bookings b + LEFT JOIN freight.companies company ON company.id = b.company_id + LEFT JOIN freight.warehouse_inventory inv ON inv.booking_id = b.id AND inv.deleted_at IS NULL + LEFT JOIN freight.warehouses wh ON wh.id = inv.warehouse_id + LEFT JOIN freight.warehouse_yards yard ON yard.id = inv.yard_id + LEFT JOIN freight.warehouse_zones zone ON zone.id = inv.zone_id + LEFT JOIN freight.facilities fac ON fac.id = wh.facility_id + LEFT JOIN freight.containers ct ON ct.id = inv.container_id + WHERE b.status = ANY($1) AND b.deleted_at IS NULL + ORDER BY b.scheduled_date DESC NULLS LAST`, + [this.ARRIVED_BOOKING_STATUSES], + ); + + return rows.map((r) => ({ + bookingId: r.bookingId, + bookingReference: r.bookingReference, + customer: r.customer ?? null, + cargo: r.cargo ?? null, + container: r.container ?? null, + facility: r.facility ?? null, + warehouse: r.warehouse ?? null, + yard: r.yard ?? null, + zone: r.zone ?? null, + inventoryId: r.inventoryId ?? null, + currentStatus: r.currentStatus ?? null, + arrivalDate: r.arrivalDate ?? null, + inspectionStatus: r.inspectionStatus ?? null, + unloaded: Boolean(r.inventoryId), + })); + } + + /** First warehouse that has at least one yard + zone (fallback location for auto-unload). */ + private async pickDefaultLocation(): Promise { + const [row]: DefaultLocation[] = await this.dataSource.query( + `SELECT wh.id AS "warehouseId", wh.facility_id AS "facilityId", + yard.id AS "yardId", zone.id AS "zoneId" + FROM freight.warehouses wh + JOIN freight.warehouse_yards yard ON yard.warehouse_id = wh.id AND yard.deleted_at IS NULL + JOIN freight.warehouse_zones zone ON zone.yard_id = yard.id AND zone.deleted_at IS NULL + WHERE wh.deleted_at IS NULL + ORDER BY wh.created_at ASC + LIMIT 1`, + ); + return row ?? null; + } + + /** Bulk-create inventory (RECEIVED) for arrived bookings that are not yet unloaded. */ + async autoUnloadArrived(): Promise { + const arrived: { + id: string; + weight: string | null; + freightType: string | null; + tradeDirection: string | null; + cargoTypeCode: string | null; + }[] = await this.dataSource.query( + `SELECT b.id, b.cargo_total_weight_vgm AS weight, + b.freight_type AS "freightType", b.trade_direction AS "tradeDirection", + cgt.code AS "cargoTypeCode" + FROM freight.bookings b + LEFT JOIN freight.warehouse_inventory inv ON inv.booking_id = b.id AND inv.deleted_at IS NULL + LEFT JOIN freight.cargo_types cgt ON cgt.id = b.cargo_type_id + WHERE b.status = ANY($1) AND b.deleted_at IS NULL AND inv.id IS NULL`, + [this.ARRIVED_BOOKING_STATUSES], + ); + + const result: AutoUnloadResult = { processedCount: 0, skippedCount: 0, failedCount: 0, results: [] }; + + if (arrived.length === 0) return result; + + const fallback = await this.pickDefaultLocation(); + + for (const booking of arrived) { + try { + // Deterministic allocation by rules; fall back to default location if no rule resolves. + const allocated = await this.allocation.resolveLocation({ + freightType: booking.freightType, + tradeDirection: booking.tradeDirection, + cargoTypeCode: booking.cargoTypeCode, + }); + const location = allocated ?? fallback; + if (!location) { + result.failedCount += 1; + result.results.push({ bookingId: booking.id, status: 'FAILED', reason: 'No warehouse/yard/zone configured' }); + continue; + } + const saved = await this.inventoryRepository.create({ + warehouseId: location.warehouseId, + yardId: location.yardId, + zoneId: location.zoneId, + bookingId: booking.id, + quantity: 1, + weight: Number(booking.weight) || 0, + status: 'RECEIVED', + arrivedAt: new Date(), + notes: allocated?.rule ? `Auto-unloaded → ${allocated.path}` : 'Auto-unloaded from arrival queue', + }); + result.processedCount += 1; + result.results.push({ bookingId: booking.id, inventoryId: saved.id, status: 'PROCESSED' }); + } catch (error) { + result.failedCount += 1; + result.results.push({ + bookingId: booking.id, + status: 'FAILED', + reason: error instanceof Error ? error.message : String(error), + }); + } + } + + return result; + } + + /** Unload a single arrived booking into a chosen (or default) location. */ + async unloadBooking(bookingId: string, dto: UnloadBookingDto): Promise { + const existing = await this.inventoryRepository.findAll({ where: { bookingId } }); + + let location: DefaultLocation | null = + dto.warehouseId && dto.yardId && dto.zoneId + ? { warehouseId: dto.warehouseId, yardId: dto.yardId, zoneId: dto.zoneId, facilityId: dto.facilityId ?? null } + : null; + if (!location) location = await this.pickDefaultLocation(); + if (!location) { + throw new BadRequestException('No warehouse/yard/zone provided or configured for unloading'); + } + + const arrivedAt = dto.unloadedAt ? new Date(dto.unloadedAt) : new Date(); + + if (existing[0]) { + await this.inventoryRepository.update(existing[0].id, { + warehouseId: location.warehouseId, + yardId: location.yardId, + zoneId: location.zoneId, + status: 'RECEIVED', + arrivedAt, + notes: dto.notes ?? existing[0].notes ?? 'Unloaded', + }); + return this.findById(existing[0].id); + } + + const saved = await this.inventoryRepository.create({ + warehouseId: location.warehouseId, + yardId: location.yardId, + zoneId: location.zoneId, + bookingId, + quantity: 1, + weight: 0, + status: 'RECEIVED', + arrivedAt, + notes: dto.notes ?? 'Unloaded', + }); + return this.findById(saved.id); + } + + /** Auto-load all READY_FOR_LOADING inventory whose booking is PAID. Unpaid stay pending. */ + async autoLoadReady(): Promise { + const ready = await this.inventoryRepository.findAll({ where: { status: 'READY_FOR_LOADING' } }); + const result: AutoLoadResult = { loadedCount: 0, skippedCount: 0, results: [] }; + + for (const item of ready) { + const bookingStatus = item.bookingId ? await this.getBookingStatus(item.bookingId) : null; + if (bookingStatus !== 'PAID') { + result.skippedCount += 1; + result.results.push({ inventoryId: item.id, status: 'SKIPPED', reason: 'Booking not PAID' }); + continue; + } + await this.dataSource.transaction(async (manager) => { + await manager.getRepository(WarehouseInventory).update(item.id, { + status: 'LOADED', + loadedAt: new Date(), + }); + await this.activityLog.record( + { + activityType: 'INVENTORY_LOADED', + inventoryId: item.id, + warehouseId: item.warehouseId, + description: 'Auto-loaded (PAID booking)', + }, + manager, + ); + }); + result.loadedCount += 1; + result.results.push({ inventoryId: item.id, status: 'LOADED' }); + } + + return result; + } + + // ── Receive ────────────────────────────────────────────────────────────── + + async receive(dto: ReceiveWarehouseInventoryDto): Promise { + const weight = Number(dto.weight) || 0; + const volume = Number(dto.volume) || 0; + const containerCount = dto.containerId ? Math.round(Number(dto.quantity) || 0) : 0; + + const id = await this.dataSource.transaction(async (manager) => { + const { warehouse, yard, zone } = await this.validateLocation(manager, dto); + + if (dto.bookingId) { + await this.assertBookingExists(manager, dto.bookingId); + } + + this.assertCapacity('Warehouse', warehouse, weight, volume, containerCount); + this.assertCapacity('Yard', yard, weight, volume, containerCount); + this.assertCapacity('Zone', zone, weight, volume, containerCount); + + const now = new Date(); + const saved = await manager.getRepository(WarehouseInventory).save( + manager.getRepository(WarehouseInventory).create({ + warehouseId: dto.warehouseId, + yardId: dto.yardId, + zoneId: dto.zoneId, + bookingId: dto.bookingId ?? null, + cargoId: dto.cargoId ?? null, + containerId: dto.containerId ?? null, + goodsId: dto.goodsId ?? null, + quantity: Number(dto.quantity) || 0, + weight, + volume: dto.volume ?? null, + status: 'RECEIVED', + arrivedAt: now, + notes: dto.notes?.trim() ?? null, + }), + ); + + await this.applyCapacityDelta(manager, dto.warehouseId, dto.yardId, dto.zoneId, weight, volume, containerCount, +1); + + await this.activityLog.record( + { + activityType: 'INVENTORY_RECEIVED', + inventoryId: saved.id, + warehouseId: dto.warehouseId, + description: `Received ${weight}kg at warehouse location`, + performedBy: dto.performedBy, + }, + manager, + ); + + return saved.id; + }); + + return this.findById(id); + } + + // ── Lifecycle transitions ──────────────────────────────────────────────── + + store(id: string, performedBy?: string): Promise { + return this.transition(id, 'STORED', { + timestampField: 'storedAt', + activityType: 'INVENTORY_STORED', + description: 'Inventory stored', + performedBy, + }); + } + + async reserve(dto: ReserveInventoryDto): Promise { + const item = await this.findById(dto.inventoryId); + + if (item.status !== 'STORED') { + throw new BadRequestException(`Inventory must be STORED to reserve (current: ${item.status})`); + } + + const status = await this.getBookingStatus(dto.bookingId); + if (!status) { + throw new NotFoundException(`Booking ${dto.bookingId} not found`); + } + if (status !== 'PAID') { + throw new BadRequestException(`Booking must be PAID to reserve inventory (current: ${status})`); + } + + await this.dataSource.transaction(async (manager) => { + await manager.getRepository(WarehouseInventory).update(dto.inventoryId, { + status: 'RESERVED', + bookingId: dto.bookingId, + reservedAt: new Date(), + }); + await this.activityLog.record( + { + activityType: 'INVENTORY_RESERVED', + inventoryId: dto.inventoryId, + warehouseId: item.warehouseId, + description: `Reserved for booking ${dto.bookingId}`, + performedBy: dto.performedBy, + }, + manager, + ); + }); + + return this.findById(dto.inventoryId); + } + + async readyForLoading(id: string, performedBy?: string): Promise { + const item = await this.findById(id); + if (!item.bookingId || !item.warehouseId || !item.yardId || !item.zoneId) { + throw new BadRequestException('Inventory must have booking, warehouse, yard and zone before loading prep'); + } + return this.transition(id, 'READY_FOR_LOADING', { + timestampField: 'readyForLoadingAt', + activityType: 'READY_FOR_LOADING', + description: 'Inventory ready for loading', + performedBy, + preloaded: item, + }); + } + + /** + * Load READY_FOR_LOADING inventory onto a wagon. Creates a WarehouseLoading record. + * Reads wagon/schedule data read-only — never modifies scheduling. + */ + async load(id: string, dto: LoadInventoryDto): Promise { + const item = await this.findById(id); + + // 1. inventory status must be READY_FOR_LOADING (and not already LOADED). + this.assertTransition(item.status, 'LOADED'); + + // 2. inventory is at a valid warehouse/yard/zone location. + if (!item.warehouseId || !item.yardId || !item.zoneId) { + throw new BadRequestException('Inventory must be at a warehouse/yard/zone before loading'); + } + + // 3. wagon must exist. + const wagon = await this.scheduling.findWagon(dto.wagonId); + if (!wagon) { + throw new NotFoundException(`Wagon ${dto.wagonId} not found`); + } + + // 4. wagon must be available, or already selected by an existing train schedule. + const scheduled = await this.scheduling.isWagonScheduled(dto.wagonId); + if (!LOADABLE_WAGON_STATUSES.includes(wagon.status) && !scheduled) { + throw new BadRequestException( + `Wagon ${wagon.wagonNumber} is not available for loading (status: ${wagon.status})`, + ); + } + + // 5. inventory must not already have a loading record. + const existing = await this.loadingRepository.findAll({ where: { warehouseInventoryId: id } }); + if (existing.length > 0) { + throw new BadRequestException('Inventory has already been loaded'); + } + + const loadedWeight = dto.loadedWeight ?? (Number(item.weight) || 0); + + await this.dataSource.transaction(async (manager) => { + const now = new Date(); + await manager.getRepository(WarehouseInventory).update(id, { + status: 'LOADED', + loadedAt: now, + }); + + await manager.getRepository(WarehouseLoading).save( + manager.getRepository(WarehouseLoading).create({ + warehouseInventoryId: id, + bookingId: item.bookingId ?? null, + wagonId: dto.wagonId, + loadedAt: now, + loadedBy: dto.loadedBy ?? null, + loadedWeight, + notes: dto.notes?.trim() ?? null, + }), + ); + + await this.activityLog.record( + { + activityType: 'INVENTORY_LOADED', + inventoryId: id, + warehouseId: item.warehouseId, + description: `Loaded onto wagon ${wagon.wagonNumber}`, + performedBy: dto.loadedBy, + }, + manager, + ); + }); + + return this.findById(id); + } + + // ── Loading records (Batch 3) ───────────────────────────────────────────── + + async findLoadings( + filter: { bookingId?: string; wagonId?: string }, + ): Promise> { + const where = { + ...(filter.bookingId ? { bookingId: filter.bookingId } : {}), + ...(filter.wagonId ? { wagonId: filter.wagonId } : {}), + }; + const loadings = await this.loadingRepository.findAll({ + where, + relations: { inventory: { warehouse: true, yard: true, zone: true } }, + order: { loadedAt: 'DESC' }, + }); + + // Enrich with wagon numbers (read-only lookup into the scheduling domain). + const wagonIds = [...new Set(loadings.map((l) => l.wagonId))]; + const wagonNumbers = new Map(); + if (wagonIds.length > 0) { + const rows: Array<{ id: string; wagon_number: string }> = await this.dataSource.query( + 'SELECT id, wagon_number FROM freight.wagons WHERE id = ANY($1)', + [wagonIds], + ); + rows.forEach((r) => wagonNumbers.set(r.id, r.wagon_number)); + } + + return loadings.map((loading) => + Object.assign(loading, { wagonNumber: wagonNumbers.get(loading.wagonId) ?? null }), + ); + } + + findLoadingsByInventory(inventoryId: string): Promise { + return this.loadingRepository.findAll({ + where: { warehouseInventoryId: inventoryId }, + order: { loadedAt: 'DESC' }, + }); + } + + async dispatch(id: string, performedBy?: string): Promise { + const item = await this.findById(id); + this.assertTransition(item.status, 'DISPATCHED'); + + const weight = Number(item.weight) || 0; + const volume = Number(item.volume) || 0; + const containerCount = item.containerId ? Math.round(Number(item.quantity) || 0) : 0; + + await this.dataSource.transaction(async (manager) => { + await manager.getRepository(WarehouseInventory).update(id, { + status: 'DISPATCHED', + dispatchedAt: new Date(), + }); + // Item physically leaves the warehouse — free up capacity. + await this.applyCapacityDelta(manager, item.warehouseId, item.yardId, item.zoneId, weight, volume, containerCount, -1); + await this.activityLog.record( + { + activityType: 'INVENTORY_DISPATCHED', + inventoryId: id, + warehouseId: item.warehouseId, + description: 'Inventory dispatched', + performedBy, + }, + manager, + ); + }); + + return this.findById(id); + } + + // ── Movement ────────────────────────────────────────────────────────────── + + async move(id: string, dto: MoveInventoryDto): Promise { + const item = await this.findById(id); + if (item.status === 'DISPATCHED') { + throw new BadRequestException('Dispatched inventory cannot be moved'); + } + + const weight = Number(item.weight) || 0; + const volume = Number(item.volume) || 0; + const containerCount = item.containerId ? Math.round(Number(item.quantity) || 0) : 0; + + const from = { warehouseId: item.warehouseId, yardId: item.yardId, zoneId: item.zoneId }; + + await this.dataSource.transaction(async (manager) => { + const { warehouse } = await this.validateLocation(manager, dto); + + // Capacity check at the destination (item is added there). + const dest = await this.loadLocation(manager, dto); + this.assertCapacity('Warehouse', dest.warehouse, weight, volume, containerCount); + this.assertCapacity('Yard', dest.yard, weight, volume, containerCount); + this.assertCapacity('Zone', dest.zone, weight, volume, containerCount); + + // Free the old location, occupy the new one. + await this.applyCapacityDelta(manager, from.warehouseId, from.yardId, from.zoneId, weight, volume, containerCount, -1); + await this.applyCapacityDelta(manager, dto.warehouseId, dto.yardId, dto.zoneId, weight, volume, containerCount, +1); + + await manager.getRepository(WarehouseInventory).update(id, { + warehouseId: dto.warehouseId, + yardId: dto.yardId, + zoneId: dto.zoneId, + }); + + await manager.getRepository(WarehouseInventoryMovement).save( + manager.getRepository(WarehouseInventoryMovement).create({ + inventoryId: id, + fromWarehouseId: from.warehouseId, + fromYardId: from.yardId, + fromZoneId: from.zoneId, + toWarehouseId: dto.warehouseId, + toYardId: dto.yardId, + toZoneId: dto.zoneId, + remarks: dto.remarks?.trim() ?? null, + movedBy: dto.movedBy ?? 'system', + movedAt: new Date(), + }), + ); + + await this.activityLog.record( + { + activityType: 'INVENTORY_MOVED', + inventoryId: id, + warehouseId: warehouse.id, + description: dto.remarks?.trim() || 'Inventory moved', + performedBy: dto.movedBy, + }, + manager, + ); + }); + + return this.findById(id); + } + + findMovements(id: string): Promise { + return this.dataSource.getRepository(WarehouseInventoryMovement).find({ + where: { inventoryId: id }, + order: { movedAt: 'DESC' }, + }); + } + + findActivity(id: string): Promise { + return this.activityLog.findByInventory(id); + } + + // ── Inquiry (Batch 1) ────────────────────────────────────────────────── + + async inquiry(filter: InquiryWarehouseInventoryDto): Promise { + const qb = this.dataSource + .getRepository(WarehouseInventory) + .createQueryBuilder('inv') + .leftJoinAndSelect('inv.warehouse', 'warehouse') + .leftJoinAndSelect('inv.yard', 'yard') + .leftJoinAndSelect('inv.zone', 'zone') + .leftJoin('freight.bookings', 'booking', 'booking.id = inv.booking_id') + .leftJoin('freight.companies', 'company', 'company.id = booking.company_id') + .leftJoin('freight.containers', 'container', 'container.id = inv.container_id') + .leftJoin('freight.cargoes', 'cargo', 'cargo.id = inv.cargo_id') + .leftJoin('freight.cargo_types', 'cargo_type', 'cargo_type.id = cargo.cargo_type_id') + .addSelect('booking.reference', 'b_reference') + .addSelect('company.name', 'c_name') + .addSelect('container.container_number', 'ct_number') + .addSelect('cargo.description', 'cg_description') + .addSelect('cargo_type.cargo_type_name', 'cgt_name') + .orderBy('inv.created_at', 'DESC'); + + if (filter.bookingNumber?.trim()) { + qb.andWhere('booking.reference ILIKE :bn', { bn: `%${filter.bookingNumber.trim()}%` }); + } + if (filter.containerNumber?.trim()) { + qb.andWhere('container.container_number ILIKE :cn', { cn: `%${filter.containerNumber.trim()}%` }); + } + if (filter.cargoType?.trim()) { + qb.andWhere('cargo_type.cargo_type_name ILIKE :ctype', { ctype: `%${filter.cargoType.trim()}%` }); + } + if (filter.goodsName?.trim()) { + qb.andWhere('(inv.notes ILIKE :gn OR cargo.description ILIKE :gn)', { gn: `%${filter.goodsName.trim()}%` }); + } + if (filter.warehouseId) qb.andWhere('inv.warehouse_id = :wid', { wid: filter.warehouseId }); + if (filter.yardId) qb.andWhere('inv.yard_id = :yid', { yid: filter.yardId }); + if (filter.zoneId) qb.andWhere('inv.zone_id = :zid', { zid: filter.zoneId }); + if (filter.status) qb.andWhere('inv.status = :status', { status: filter.status }); + + const { entities, raw } = await qb.getRawAndEntities(); + + return entities.map((inv, index) => { + const row = raw[index] ?? {}; + return { + id: inv.id, + bookingId: inv.bookingId ?? null, + bookingNumber: row.b_reference ?? null, + customerName: row.c_name ?? null, + containerNumber: row.ct_number ?? null, + cargoType: row.cgt_name ?? null, + cargoDescription: row.cg_description ?? null, + goodsId: inv.goodsId ?? null, + warehouse: inv.warehouse + ? { id: inv.warehouse.id, name: inv.warehouse.name, code: inv.warehouse.code } + : null, + yard: inv.yard ? { id: inv.yard.id, name: inv.yard.name, code: inv.yard.code } : null, + zone: inv.zone ? { id: inv.zone.id, name: inv.zone.name, code: inv.zone.code } : null, + status: inv.status, + quantity: Number(inv.quantity), + weight: Number(inv.weight), + arrivedAt: inv.arrivedAt ?? null, + readyForLoadingAt: inv.readyForLoadingAt ?? null, + }; + }); + } + + // ── Helpers ────────────────────────────────────────────────────────────── + + private async transition( + id: string, + to: WarehouseInventoryStatus, + opts: { + timestampField: keyof WarehouseInventory; + activityType: Parameters[0]['activityType']; + description: string; + performedBy?: string; + preloaded?: WarehouseInventory; + }, + ): Promise { + const item = opts.preloaded ?? (await this.findById(id)); + this.assertTransition(item.status, to); + + await this.dataSource.transaction(async (manager) => { + await manager.getRepository(WarehouseInventory).update(id, { + status: to, + [opts.timestampField]: new Date(), + }); + await this.activityLog.record( + { + activityType: opts.activityType, + inventoryId: id, + warehouseId: item.warehouseId, + description: opts.description, + performedBy: opts.performedBy, + }, + manager, + ); + }); + + return this.findById(id); + } + + private assertTransition(from: WarehouseInventoryStatus, to: WarehouseInventoryStatus): void { + if (!WAREHOUSE_INVENTORY_TRANSITIONS[from]?.includes(to)) { + throw new BadRequestException(`Invalid transition ${from} → ${to}`); + } + } + + private async validateLocation( + manager: EntityManager, + dto: { warehouseId: string; yardId: string; zoneId: string }, + ): Promise<{ warehouse: Warehouse; yard: WarehouseYard; zone: WarehouseZone }> { + const { warehouse, yard, zone } = await this.loadLocation(manager, dto); + + if (warehouse.status !== 'ACTIVE') throw new BadRequestException('Warehouse is not ACTIVE'); + if (yard.warehouseId !== warehouse.id) throw new BadRequestException('Yard does not belong to the selected warehouse'); + if (yard.status !== 'ACTIVE') throw new BadRequestException('Yard is not ACTIVE'); + if (zone.yardId !== yard.id) throw new BadRequestException('Zone does not belong to the selected yard'); + if (zone.status !== 'ACTIVE') throw new BadRequestException('Zone is not ACTIVE'); + + return { warehouse, yard, zone }; + } + + private async loadLocation( + manager: EntityManager, + dto: { warehouseId: string; yardId: string; zoneId: string }, + ): Promise<{ warehouse: Warehouse; yard: WarehouseYard; zone: WarehouseZone }> { + const warehouse = await manager.getRepository(Warehouse).findOne({ where: { id: dto.warehouseId } }); + if (!warehouse) throw new NotFoundException(`Warehouse ${dto.warehouseId} not found`); + const yard = await manager.getRepository(WarehouseYard).findOne({ where: { id: dto.yardId } }); + if (!yard) throw new NotFoundException(`Yard ${dto.yardId} not found`); + const zone = await manager.getRepository(WarehouseZone).findOne({ where: { id: dto.zoneId } }); + if (!zone) throw new NotFoundException(`Zone ${dto.zoneId} not found`); + return { warehouse, yard, zone }; + } + + private async assertBookingExists(manager: EntityManager, bookingId: string): Promise { + const rows = await manager.query( + 'SELECT id FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL LIMIT 1', + [bookingId], + ); + if (!rows || rows.length === 0) { + throw new NotFoundException(`Booking ${bookingId} not found`); + } + } + + private async getBookingStatus(bookingId: string): Promise { + const rows = await this.dataSource.query( + 'SELECT status FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL LIMIT 1', + [bookingId], + ); + return rows?.[0]?.status ?? null; + } + + private assertCapacity( + label: string, + node: LocationNode, + weightAdd: number, + volumeAdd: number, + containerAdd: number, + ): void { + const maxWeight = node.maxWeight ?? node.capacityWeight; + if (maxWeight != null) { + const projected = Number(node.currentWeight) + weightAdd; + if (projected > Number(maxWeight)) { + throw new BadRequestException(`${label} weight capacity exceeded (${projected} / ${maxWeight})`); + } + } + if (node.maxVolume != null && volumeAdd > 0) { + const projected = Number(node.currentVolume) + volumeAdd; + if (projected > Number(node.maxVolume)) { + throw new BadRequestException(`${label} volume capacity exceeded (${projected} / ${node.maxVolume})`); + } + } + if (node.capacityContainers != null && containerAdd > 0) { + const projected = Number(node.currentContainers) + containerAdd; + if (projected > Number(node.capacityContainers)) { + throw new BadRequestException(`${label} container capacity exceeded (${projected} / ${node.capacityContainers})`); + } + } + } + + private async applyCapacityDelta( + manager: EntityManager, + warehouseId: string, + yardId: string, + zoneId: string, + weight: number, + volume: number, + containers: number, + sign: 1 | -1, + ): Promise { + const apply = sign === 1 ? manager.increment.bind(manager) : manager.decrement.bind(manager); + const targets: Array<[typeof Warehouse | typeof WarehouseYard | typeof WarehouseZone, string]> = [ + [Warehouse, warehouseId], + [WarehouseYard, yardId], + [WarehouseZone, zoneId], + ]; + + for (const [entity, id] of targets) { + if (weight) await apply(entity, { id }, 'currentWeight', weight); + if (volume) await apply(entity, { id }, 'currentVolume', volume); + if (containers) await apply(entity, { id }, 'currentContainers', containers); + } + } +} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.controller.ts new file mode 100644 index 000000000..da818b5a3 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.controller.ts @@ -0,0 +1,68 @@ +import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { GenerateInvoiceDto, PayInvoiceBodyDto } from './dto/invoice.dto'; +import { WarehouseInvoiceService } from './warehouse-invoice.service'; + +@ApiTags('warehouse-fee-invoices') +@ApiBearerAuth() +@Controller() +export class WarehouseInvoiceController { + constructor(private readonly invoiceService: WarehouseInvoiceService) {} + + @Post('warehouse-inventory/:id/generate-fee-invoice') + @ApiOperation({ summary: 'Generate a warehouse fee invoice from Batch 5 fee calculation' }) + generate(@Param('id', ParseUUIDPipe) id: string, @Body() dto: GenerateInvoiceDto) { + return this.invoiceService.generateForInventory(id, dto); + } + + @Get('warehouse-inventory/:id/fee-invoices') + @ApiOperation({ summary: 'List fee invoices for an inventory item' }) + listForInventory(@Param('id', ParseUUIDPipe) id: string) { + return this.invoiceService.listForInventory(id); + } + + @Get('bookings/:id/warehouse-fee-invoices') + @ApiOperation({ summary: 'List warehouse fee invoices for a booking' }) + listForBooking(@Param('id', ParseUUIDPipe) id: string) { + return this.invoiceService.listForBooking(id); + } + + @Get('warehouse-fee-invoices') + @ApiOperation({ summary: 'List / filter warehouse fee invoices' }) + findAll( + @Query('status') status?: string, + @Query('invoiceType') invoiceType?: string, + @Query('warehouseId') warehouseId?: string, + @Query('facilityId') facilityId?: string, + @Query('customerId') customerId?: string, + @Query('bookingId') bookingId?: string, + ) { + return this.invoiceService.findAll({ + status: status as never, + invoiceType: invoiceType as never, + warehouseId, + facilityId, + customerId, + bookingId, + }); + } + + @Get('warehouse-fee-invoices/:id') + @ApiOperation({ summary: 'Get a warehouse fee invoice with items + payment history' }) + findOne(@Param('id', ParseUUIDPipe) id: string) { + return this.invoiceService.findById(id); + } + + @Patch('warehouse-fee-invoices/:id/cancel') + @ApiOperation({ summary: 'Cancel a warehouse fee invoice' }) + cancel(@Param('id', ParseUUIDPipe) id: string) { + return this.invoiceService.cancel(id); + } + + @Post('warehouse-fee-invoices/:id/pay') + @ApiOperation({ summary: 'Record a payment against a warehouse fee invoice' }) + pay(@Param('id', ParseUUIDPipe) id: string, @Body() dto: PayInvoiceBodyDto) { + return this.invoiceService.pay(id, dto); + } +} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts new file mode 100644 index 000000000..d58a74b7d --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts @@ -0,0 +1,214 @@ +import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common'; +import { DataSource } from 'typeorm'; + +import { + WarehouseFeeInvoice, + WarehouseInvoiceStatus, + WarehouseInvoiceType, +} from './entities/warehouse-fee-invoice.entity'; +import { WarehouseFeeType } from './entities/warehouse-fee-invoice-item.entity'; +import { WarehouseFeeInvoiceItemRepository } from './warehouse-fee-invoice-item.repository'; +import { WarehouseFeeInvoiceRepository } from './warehouse-fee-invoice.repository'; +import { WarehouseFeeService } from './warehouse-fee.service'; + +interface GenerateOptions { + confirmZero?: boolean; + performedBy?: string; +} + +export interface PayInvoiceDto { + amount: number; + method?: string; + reference?: string; +} + +/** Invoices that still owe money and therefore block terminal release. */ +const BLOCKING_STATUSES: WarehouseInvoiceStatus[] = ['ISSUED', 'PARTIALLY_PAID']; +const ACTIVE_STATUSES: WarehouseInvoiceStatus[] = ['ISSUED', 'PARTIALLY_PAID', 'PAID']; + +@Injectable() +export class WarehouseInvoiceService { + constructor( + private readonly dataSource: DataSource, + private readonly invoiceRepository: WarehouseFeeInvoiceRepository, + private readonly itemRepository: WarehouseFeeInvoiceItemRepository, + private readonly feeService: WarehouseFeeService, + ) {} + + // ── Generation ─────────────────────────────────────────────────────────── + async generateForInventory(inventoryId: string, opts: GenerateOptions = {}): Promise { + const [item] = await this.dataSource.query( + `SELECT inv.id, inv.booking_id AS "bookingId", inv.warehouse_id AS "warehouseId", + inv.yard_id AS "yardId", inv.zone_id AS "zoneId", inv.arrived_at AS "arrivedAt", + w.facility_id AS "facilityId", + b.company_id AS "customerId", b.freight_type AS "freightType" + FROM freight.warehouse_inventory inv + LEFT JOIN freight.warehouses w ON w.id = inv.warehouse_id + LEFT JOIN freight.bookings b ON b.id = inv.booking_id + WHERE inv.id = $1 AND inv.deleted_at IS NULL`, + [inventoryId], + ); + if (!item) throw new NotFoundException(`Inventory item ${inventoryId} not found`); + + // Dedup: only one active (non-cancelled) invoice per inventory item. + const active = await this.invoiceRepository.findAll({ where: { inventoryId } }); + if (active.some((inv) => ACTIVE_STATUSES.includes(inv.status))) { + throw new ConflictException( + 'An active warehouse fee invoice already exists for this item. Cancel it before generating a new one.', + ); + } + + const previews = await this.feeService.previewForInventory(inventoryId); + const isContainer = (item.freightType ?? '').toUpperCase() === 'CONTAINER'; + + const items = previews + .filter((p) => p.amount > 0) + .map((p) => { + const feeType: WarehouseFeeType = + p.ruleType === 'STORAGE_FEE' + ? 'STORAGE_FEE' + : isContainer + ? 'CONTAINER_DEMURRAGE' + : 'BULK_DEMURRAGE'; + return { + feeRuleId: p.ruleId, + feeType, + description: + p.ruleType === 'STORAGE_FEE' + ? `Storage fee — ${p.chargeableDays} chargeable day(s) after ${p.freeDays} free` + : `${isContainer ? 'Container' : 'Bulk'} demurrage — ${p.chargeableDays} chargeable day(s) after ${p.freeDays} free`, + quantity: p.chargeableDays, + unitRate: p.ratePerDay, + amount: p.amount, + currency: p.currency, + chargeableDays: p.chargeableDays, + freeDays: p.freeDays, + }; + }); + + const subtotal = items.reduce((s, i) => s + i.amount, 0); + const total = subtotal; // tax model can be layered on later + + if (total <= 0 && !opts.confirmZero) { + throw new BadRequestException('No payable warehouse fee found for this item.'); + } + + const hasDemurrage = items.some((i) => i.feeType !== 'STORAGE_FEE'); + const hasStorage = items.some((i) => i.feeType === 'STORAGE_FEE'); + const invoiceType: WarehouseInvoiceType = + hasDemurrage && hasStorage ? 'MIXED_WAREHOUSE_FEES' : hasStorage ? 'STORAGE_FEE' : 'DEMURRAGE'; + + const currency = items[0]?.currency ?? 'USD'; + const now = new Date(); + const periodEnd = previews[0] ? new Date(previews[0].endDate) : now; + + const invoice = await this.invoiceRepository.create({ + invoiceNumber: await this.nextInvoiceNumber(), + bookingId: item.bookingId ?? null, + customerId: item.customerId ?? null, + inventoryId, + facilityId: item.facilityId ?? null, + warehouseId: item.warehouseId ?? null, + yardId: item.yardId ?? null, + zoneId: item.zoneId ?? null, + invoiceType, + status: 'ISSUED', + subtotalAmount: subtotal, + taxAmount: 0, + totalAmount: total, + paidAmount: 0, + balanceAmount: total, + currency, + periodStart: item.arrivedAt ?? null, + periodEnd, + issuedAt: now, + payments: [], + notes: opts.performedBy ? `Generated by ${opts.performedBy}` : null, + }); + + for (const it of items) { + await this.itemRepository.create({ invoiceId: invoice.id, ...it }); + } + + return this.findById(invoice.id); + } + + /** WHF-YYYYMMDD-00001 — sequential per day. */ + private async nextInvoiceNumber(): Promise { + const now = new Date(); + const ymd = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}`; + const prefix = `WHF-${ymd}-`; + const [row] = await this.dataSource.query( + `SELECT COALESCE(MAX(CAST(split_part(invoice_number, '-', 3) AS int)), 0) AS seq + FROM freight.warehouse_fee_invoices WHERE invoice_number LIKE $1`, + [`${prefix}%`], + ); + const next = Number(row?.seq ?? 0) + 1; + return `${prefix}${String(next).padStart(5, '0')}`; + } + + // ── Reads ──────────────────────────────────────────────────────────────── + async findById(id: string): Promise { + const invoice = await this.invoiceRepository.findById(id); + if (!invoice) throw new NotFoundException(`Invoice ${id} not found`); + const items = await this.itemRepository.findAll({ where: { invoiceId: id } }); + return { ...invoice, items } as WarehouseFeeInvoice & { items: unknown[] }; + } + + listForInventory(inventoryId: string): Promise { + return this.invoiceRepository.findAll({ where: { inventoryId }, order: { createdAt: 'DESC' } }); + } + + listForBooking(bookingId: string): Promise { + return this.invoiceRepository.findAll({ where: { bookingId }, order: { createdAt: 'DESC' } }); + } + + findAll(filter: Partial>): Promise { + const where = Object.fromEntries(Object.entries(filter).filter(([, v]) => v != null)); + return this.invoiceRepository.findAll({ where, order: { createdAt: 'DESC' } }); + } + + // ── State changes ──────────────────────────────────────────────────────── + async cancel(id: string): Promise { + const invoice = await this.invoiceRepository.findById(id); + if (!invoice) throw new NotFoundException(`Invoice ${id} not found`); + if (invoice.status === 'PAID') throw new BadRequestException('A paid invoice cannot be cancelled.'); + const updated = await this.invoiceRepository.update(id, { status: 'CANCELLED', cancelledAt: new Date() }); + return updated as WarehouseFeeInvoice; + } + + /** Record a payment against the invoice and sync status (links to existing payment flow). */ + async pay(id: string, dto: PayInvoiceDto): Promise { + const invoice = await this.invoiceRepository.findById(id); + if (!invoice) throw new NotFoundException(`Invoice ${id} not found`); + if (invoice.status === 'CANCELLED') throw new BadRequestException('Cannot pay a cancelled invoice.'); + if (invoice.status === 'PAID') throw new BadRequestException('Invoice is already fully paid.'); + if (!(dto.amount > 0)) throw new BadRequestException('Payment amount must be greater than zero.'); + + const paidAmount = Number(invoice.paidAmount) + dto.amount; + const total = Number(invoice.totalAmount); + const balance = Math.max(0, Math.round((total - paidAmount) * 100) / 100); + const fullyPaid = paidAmount >= total; + + const payments = [ + ...(invoice.payments ?? []), + { amount: dto.amount, method: dto.method ?? null, reference: dto.reference ?? null, paidAt: new Date().toISOString() }, + ]; + + const updated = await this.invoiceRepository.update(id, { + paidAmount: Math.round(paidAmount * 100) / 100, + balanceAmount: balance, + status: fullyPaid ? 'PAID' : 'PARTIALLY_PAID', + paidAt: fullyPaid ? new Date() : invoice.paidAt ?? null, + payments, + }); + return updated as WarehouseFeeInvoice; + } + + // ── Release blocking ────────────────────────────────────────────────────── + /** Returns the first unpaid invoice that blocks terminal release, or null. */ + async findBlockingInvoice(inventoryId: string): Promise { + const invoices = await this.invoiceRepository.findAll({ where: { inventoryId } }); + return invoices.find((inv) => BLOCKING_STATUSES.includes(inv.status)) ?? null; + } +} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-loading.repository.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-loading.repository.ts new file mode 100644 index 000000000..45773c0f6 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-loading.repository.ts @@ -0,0 +1,13 @@ +import { BaseRepository } from '@edr/api-common'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { WarehouseLoading } from './entities/warehouse-loading.entity'; + +@Injectable() +export class WarehouseLoadingRepository extends BaseRepository { + constructor(@InjectRepository(WarehouseLoading) repository: Repository) { + super(repository); + } +} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-loadings.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-loadings.controller.ts new file mode 100644 index 000000000..aab8e18b2 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-loadings.controller.ts @@ -0,0 +1,17 @@ +import { Controller, Get, Query } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { WarehouseInventoryService } from './warehouse-inventory.service'; + +@ApiTags('warehouse-loadings') +@ApiBearerAuth() +@Controller('warehouse-loadings') +export class WarehouseLoadingsController { + constructor(private readonly inventoryService: WarehouseInventoryService) {} + + @Get() + @ApiOperation({ summary: 'List wagon loading records' }) + findAll(@Query('bookingId') bookingId?: string, @Query('wagonId') wagonId?: string) { + return this.inventoryService.findLoadings({ bookingId, wagonId }); + } +} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-rules.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-rules.controller.ts new file mode 100644 index 000000000..333597618 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-rules.controller.ts @@ -0,0 +1,85 @@ +import { Body, Controller, Delete, Get, HttpCode, Param, ParseUUIDPipe, Patch, Post } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { + AllocationPreviewDto, + CreateAllocationRuleDto, + UpdateAllocationRuleDto, +} from './dto/allocation-rule.dto'; +import { CreateFeeRuleDto, UpdateFeeRuleDto } from './dto/fee-rule.dto'; +import { WarehouseAllocationService } from './warehouse-allocation.service'; +import { WarehouseFeeService } from './warehouse-fee.service'; + +@ApiTags('warehouse-rules') +@ApiBearerAuth() +@Controller() +export class WarehouseRulesController { + constructor( + private readonly allocationService: WarehouseAllocationService, + private readonly feeService: WarehouseFeeService, + ) {} + + // ── Allocation rules ─────────────────────────────────────────────────────── + @Get('warehouse-allocation-rules') + @ApiOperation({ summary: 'List warehouse allocation rules' }) + listAllocationRules() { + return this.allocationService.listRules(); + } + + @Post('warehouse-allocation-rules') + @ApiOperation({ summary: 'Create a warehouse allocation rule' }) + createAllocationRule(@Body() dto: CreateAllocationRuleDto) { + return this.allocationService.createRule(dto); + } + + @Patch('warehouse-allocation-rules/:id') + @ApiOperation({ summary: 'Update a warehouse allocation rule' }) + updateAllocationRule(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateAllocationRuleDto) { + return this.allocationService.updateRule(id, dto); + } + + @Delete('warehouse-allocation-rules/:id') + @HttpCode(204) + @ApiOperation({ summary: 'Delete a warehouse allocation rule' }) + deleteAllocationRule(@Param('id', ParseUUIDPipe) id: string) { + return this.allocationService.deleteRule(id); + } + + @Post('warehouse-allocation/preview') + @ApiOperation({ summary: 'Preview the yard/warehouse/zone a booking would be allocated to' }) + previewAllocation(@Body() dto: AllocationPreviewDto) { + return this.allocationService.resolveLocation(dto); + } + + // ── Fee rules ──────────────────────────────────────────────────────────────── + @Get('warehouse-fee-rules') + @ApiOperation({ summary: 'List storage / demurrage fee rules' }) + listFeeRules() { + return this.feeService.listRules(); + } + + @Post('warehouse-fee-rules') + @ApiOperation({ summary: 'Create a storage / demurrage fee rule' }) + createFeeRule(@Body() dto: CreateFeeRuleDto) { + return this.feeService.createRule(dto); + } + + @Patch('warehouse-fee-rules/:id') + @ApiOperation({ summary: 'Update a fee rule' }) + updateFeeRule(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateFeeRuleDto) { + return this.feeService.updateRule(id, dto); + } + + @Delete('warehouse-fee-rules/:id') + @HttpCode(204) + @ApiOperation({ summary: 'Delete a fee rule' }) + deleteFeeRule(@Param('id', ParseUUIDPipe) id: string) { + return this.feeService.deleteRule(id); + } + + @Get('warehouse-inventory/:id/fee-preview') + @ApiOperation({ summary: 'Preview demurrage + storage fees for an inventory item' }) + feePreview(@Param('id', ParseUUIDPipe) id: string) { + return this.feeService.previewForInventory(id); + } +} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-scheduling-adapter.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-scheduling-adapter.service.ts new file mode 100644 index 000000000..dcf685c9f --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-scheduling-adapter.service.ts @@ -0,0 +1,63 @@ +import { Injectable } from '@nestjs/common'; +import { DataSource } from 'typeorm'; + +import { WarehouseInventory } from './entities/warehouse-inventory.entity'; + +/** + * READ-ONLY bridge that exposes warehouse inventory to the Train Scheduling + * domain. It only reads warehouse data — it never assigns wagons, creates or + * mutates schedules, and is intentionally NOT imported by the scheduling module. + */ +@Injectable() +export class WarehouseSchedulingAdapterService { + constructor(private readonly dataSource: DataSource) {} + + private get repo() { + return this.dataSource.getRepository(WarehouseInventory); + } + + getReadyForLoadingInventory(): Promise { + return this.repo.find({ + where: { status: 'READY_FOR_LOADING' }, + relations: { warehouse: true, yard: true, zone: true }, + order: { readyForLoadingAt: 'ASC' }, + }); + } + + getReservedInventory(): Promise { + return this.repo.find({ + where: { status: 'RESERVED' }, + relations: { warehouse: true, yard: true, zone: true }, + order: { reservedAt: 'ASC' }, + }); + } + + getInventoryByBooking(bookingId: string): Promise { + return this.repo.find({ + where: { bookingId }, + relations: { warehouse: true, yard: true, zone: true }, + order: { createdAt: 'DESC' }, + }); + } + + /** + * Inventory whose origin booking runs on the given route. Best-effort, read-only: + * matches the route's origin/destination yards against the booking's yards. + */ + async getInventoryByRoute(routeId: string): Promise { + return this.repo + .createQueryBuilder('inv') + .leftJoinAndSelect('inv.warehouse', 'warehouse') + .leftJoinAndSelect('inv.yard', 'yard') + .leftJoinAndSelect('inv.zone', 'zone') + .innerJoin('freight.bookings', 'booking', 'booking.id = inv.booking_id') + .innerJoin( + 'freight.routes', + 'route', + 'route.id = :routeId AND (route.origin_yard_id = booking.origin_yard_id OR route.destination_yard_id = booking.destination_yard_id)', + { routeId }, + ) + .orderBy('inv.created_at', 'DESC') + .getMany(); + } +} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.controller.ts new file mode 100644 index 000000000..3ee0dde82 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.controller.ts @@ -0,0 +1,44 @@ +import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { CreateWarehouseZoneDto } from './dto/create-warehouse-zone.dto'; +import { UpdateWarehouseYardDto } from './dto/update-warehouse-yard.dto'; +import { WarehouseYardsService } from './warehouse-yards.service'; +import { WarehouseZonesService } from './warehouse-zones.service'; + +@ApiTags('warehouse-yards') +@ApiBearerAuth() +@Controller('warehouse-yards') +export class WarehouseYardsController { + constructor( + private readonly yardsService: WarehouseYardsService, + private readonly zonesService: WarehouseZonesService, + ) {} + + @Get(':id') + @ApiOperation({ summary: 'Get warehouse yard by ID' }) + findOne(@Param('id', ParseUUIDPipe) id: string) { + return this.yardsService.findById(id); + } + + @Patch(':id') + @ApiOperation({ summary: 'Update warehouse yard' }) + update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateWarehouseYardDto) { + return this.yardsService.update(id, dto); + } + + @Get(':yardId/zones') + @ApiOperation({ summary: 'List zones within a yard' }) + listZones(@Param('yardId', ParseUUIDPipe) yardId: string) { + return this.zonesService.findByYard(yardId); + } + + @Post(':yardId/zones') + @ApiOperation({ summary: 'Create a zone within a yard' }) + createZone( + @Param('yardId', ParseUUIDPipe) yardId: string, + @Body() dto: CreateWarehouseZoneDto, + ) { + return this.zonesService.create(yardId, dto); + } +} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.repository.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.repository.ts new file mode 100644 index 000000000..99bbdd21f --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.repository.ts @@ -0,0 +1,13 @@ +import { BaseRepository } from '@edr/api-common'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { WarehouseYard } from './entities/warehouse-yard.entity'; + +@Injectable() +export class WarehouseYardsRepository extends BaseRepository { + constructor(@InjectRepository(WarehouseYard) repository: Repository) { + super(repository); + } +} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.service.ts new file mode 100644 index 000000000..f65e4593e --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.service.ts @@ -0,0 +1,93 @@ +import { ConflictException, Injectable, NotFoundException } from '@nestjs/common'; + +import { CreateWarehouseYardDto } from './dto/create-warehouse-yard.dto'; +import { UpdateWarehouseYardDto } from './dto/update-warehouse-yard.dto'; +import { WarehouseYard } from './entities/warehouse-yard.entity'; +import { WarehouseYardsRepository } from './warehouse-yards.repository'; +import { WarehousesService } from './warehouses.service'; + +@Injectable() +export class WarehouseYardsService { + constructor( + private readonly yardsRepository: WarehouseYardsRepository, + private readonly warehousesService: WarehousesService, + ) {} + + findByWarehouse(warehouseId: string): Promise { + return this.yardsRepository.findAll({ + where: { warehouseId }, + relations: { zones: true }, + order: { code: 'ASC' }, + }); + } + + async findById(id: string): Promise { + const yard = await this.yardsRepository.findById(id, { + relations: { warehouse: true, zones: true }, + }); + + if (!yard) { + throw new NotFoundException(`Warehouse yard ${id} not found`); + } + + return yard; + } + + async create(warehouseId: string, dto: CreateWarehouseYardDto): Promise { + // Ensure the parent warehouse exists. + await this.warehousesService.findById(warehouseId); + await this.assertCodeUnique(warehouseId, dto.code.trim()); + + return this.yardsRepository.create({ + warehouseId, + name: dto.name.trim(), + code: dto.code.trim(), + type: dto.type, + capacityWeight: dto.capacityWeight ?? null, + capacityContainers: dto.capacityContainers ?? null, + maxWeight: dto.maxWeight ?? dto.capacityWeight ?? null, + maxVolume: dto.maxVolume ?? null, + currentWeight: 0, + currentContainers: 0, + currentVolume: 0, + status: 'ACTIVE', + isActive: true, + }); + } + + async update(id: string, dto: UpdateWarehouseYardDto): Promise { + const existing = await this.findById(id); + + if (dto.code && dto.code.trim() !== existing.code) { + await this.assertCodeUnique(existing.warehouseId, dto.code.trim(), id); + } + + const status = dto.status ?? existing.status; + + const updated = await this.yardsRepository.update(id, { + name: dto.name?.trim() ?? existing.name, + code: dto.code?.trim() ?? existing.code, + type: dto.type ?? existing.type, + capacityWeight: dto.capacityWeight ?? existing.capacityWeight, + capacityContainers: dto.capacityContainers ?? existing.capacityContainers, + maxWeight: dto.maxWeight ?? existing.maxWeight, + maxVolume: dto.maxVolume ?? existing.maxVolume, + status, + isActive: status === 'ACTIVE', + }); + + if (!updated) { + throw new NotFoundException(`Warehouse yard ${id} not found`); + } + + return this.findById(id); + } + + private async assertCodeUnique(warehouseId: string, code: string, ignoreId?: string): Promise { + const [existing] = await this.yardsRepository.findAll({ where: { warehouseId, code } }); + + if (existing && existing.id !== ignoreId) { + throw new ConflictException(`Yard code ${code} already exists in this warehouse`); + } + } +} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.controller.ts new file mode 100644 index 000000000..30c4407f6 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.controller.ts @@ -0,0 +1,24 @@ +import { Body, Controller, Get, Param, ParseUUIDPipe, Patch } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { UpdateWarehouseZoneDto } from './dto/update-warehouse-zone.dto'; +import { WarehouseZonesService } from './warehouse-zones.service'; + +@ApiTags('warehouse-zones') +@ApiBearerAuth() +@Controller('warehouse-zones') +export class WarehouseZonesController { + constructor(private readonly zonesService: WarehouseZonesService) {} + + @Get(':id') + @ApiOperation({ summary: 'Get warehouse zone by ID' }) + findOne(@Param('id', ParseUUIDPipe) id: string) { + return this.zonesService.findById(id); + } + + @Patch(':id') + @ApiOperation({ summary: 'Update warehouse zone' }) + update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateWarehouseZoneDto) { + return this.zonesService.update(id, dto); + } +} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.repository.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.repository.ts new file mode 100644 index 000000000..94580f116 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.repository.ts @@ -0,0 +1,13 @@ +import { BaseRepository } from '@edr/api-common'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { WarehouseZone } from './entities/warehouse-zone.entity'; + +@Injectable() +export class WarehouseZonesRepository extends BaseRepository { + constructor(@InjectRepository(WarehouseZone) repository: Repository) { + super(repository); + } +} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.service.ts new file mode 100644 index 000000000..a2f3800cd --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.service.ts @@ -0,0 +1,92 @@ +import { ConflictException, Injectable, NotFoundException } from '@nestjs/common'; + +import { CreateWarehouseZoneDto } from './dto/create-warehouse-zone.dto'; +import { UpdateWarehouseZoneDto } from './dto/update-warehouse-zone.dto'; +import { WarehouseZone } from './entities/warehouse-zone.entity'; +import { WarehouseYardsService } from './warehouse-yards.service'; +import { WarehouseZonesRepository } from './warehouse-zones.repository'; + +@Injectable() +export class WarehouseZonesService { + constructor( + private readonly zonesRepository: WarehouseZonesRepository, + private readonly yardsService: WarehouseYardsService, + ) {} + + findByYard(yardId: string): Promise { + return this.zonesRepository.findAll({ + where: { yardId }, + order: { code: 'ASC' }, + }); + } + + async findById(id: string): Promise { + const zone = await this.zonesRepository.findById(id, { + relations: { yard: { warehouse: true } }, + }); + + if (!zone) { + throw new NotFoundException(`Warehouse zone ${id} not found`); + } + + return zone; + } + + async create(yardId: string, dto: CreateWarehouseZoneDto): Promise { + // Ensure the parent yard exists. + await this.yardsService.findById(yardId); + await this.assertCodeUnique(yardId, dto.code.trim()); + + return this.zonesRepository.create({ + yardId, + name: dto.name.trim(), + code: dto.code.trim(), + type: dto.type, + capacityWeight: dto.capacityWeight ?? null, + capacityContainers: dto.capacityContainers ?? null, + maxWeight: dto.maxWeight ?? dto.capacityWeight ?? null, + maxVolume: dto.maxVolume ?? null, + currentWeight: 0, + currentContainers: 0, + currentVolume: 0, + status: 'ACTIVE', + isActive: true, + }); + } + + async update(id: string, dto: UpdateWarehouseZoneDto): Promise { + const existing = await this.findById(id); + + if (dto.code && dto.code.trim() !== existing.code) { + await this.assertCodeUnique(existing.yardId, dto.code.trim(), id); + } + + const status = dto.status ?? existing.status; + + const updated = await this.zonesRepository.update(id, { + name: dto.name?.trim() ?? existing.name, + code: dto.code?.trim() ?? existing.code, + type: dto.type ?? existing.type, + capacityWeight: dto.capacityWeight ?? existing.capacityWeight, + capacityContainers: dto.capacityContainers ?? existing.capacityContainers, + maxWeight: dto.maxWeight ?? existing.maxWeight, + maxVolume: dto.maxVolume ?? existing.maxVolume, + status, + isActive: status === 'ACTIVE', + }); + + if (!updated) { + throw new NotFoundException(`Warehouse zone ${id} not found`); + } + + return this.findById(id); + } + + private async assertCodeUnique(yardId: string, code: string, ignoreId?: string): Promise { + const [existing] = await this.zonesRepository.findAll({ where: { yardId, code } }); + + if (existing && existing.id !== ignoreId) { + throw new ConflictException(`Zone code ${code} already exists in this yard`); + } + } +} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouses.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouses.controller.ts new file mode 100644 index 000000000..bb7702603 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/warehouses.controller.ts @@ -0,0 +1,66 @@ +import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { CreateWarehouseDto } from './dto/create-warehouse.dto'; +import { CreateWarehouseYardDto } from './dto/create-warehouse-yard.dto'; +import { FilterWarehouseDto } from './dto/filter-warehouse.dto'; +import { UpdateWarehouseDto } from './dto/update-warehouse.dto'; +import { WarehouseDashboardService } from './warehouse-dashboard.service'; +import { WarehouseYardsService } from './warehouse-yards.service'; +import { WarehousesService } from './warehouses.service'; + +@ApiTags('warehouses') +@ApiBearerAuth() +@Controller('warehouses') +export class WarehousesController { + constructor( + private readonly warehousesService: WarehousesService, + private readonly yardsService: WarehouseYardsService, + private readonly dashboardService: WarehouseDashboardService, + ) {} + + @Get() + @ApiOperation({ summary: 'List warehouses' }) + findAll(@Query() filter: FilterWarehouseDto) { + return this.warehousesService.findAll(filter); + } + + @Get('dashboard') + @ApiOperation({ summary: 'Warehouse dashboard metrics' }) + dashboard() { + return this.dashboardService.getDashboard(); + } + + @Post() + @ApiOperation({ summary: 'Create warehouse' }) + create(@Body() dto: CreateWarehouseDto) { + return this.warehousesService.create(dto); + } + + @Get(':id') + @ApiOperation({ summary: 'Get warehouse by ID' }) + findOne(@Param('id', ParseUUIDPipe) id: string) { + return this.warehousesService.findById(id); + } + + @Patch(':id') + @ApiOperation({ summary: 'Update warehouse' }) + update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateWarehouseDto) { + return this.warehousesService.update(id, dto); + } + + @Get(':warehouseId/yards') + @ApiOperation({ summary: 'List yards within a warehouse' }) + listYards(@Param('warehouseId', ParseUUIDPipe) warehouseId: string) { + return this.yardsService.findByWarehouse(warehouseId); + } + + @Post(':warehouseId/yards') + @ApiOperation({ summary: 'Create a yard within a warehouse' }) + createYard( + @Param('warehouseId', ParseUUIDPipe) warehouseId: string, + @Body() dto: CreateWarehouseYardDto, + ) { + return this.yardsService.create(warehouseId, dto); + } +} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts b/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts new file mode 100644 index 000000000..4a08d7f28 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts @@ -0,0 +1,114 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { FilesModule } from '../files/files.module'; +import { WarehouseActivityLog } from './entities/warehouse-activity-log.entity'; +import { WarehouseAllocationRule } from './entities/warehouse-allocation-rule.entity'; +import { WarehouseFeeInvoice } from './entities/warehouse-fee-invoice.entity'; +import { WarehouseFeeInvoiceItem } from './entities/warehouse-fee-invoice-item.entity'; +import { WarehouseFeeRule } from './entities/warehouse-fee-rule.entity'; +import { WarehouseInspectionReport } from './entities/warehouse-inspection-report.entity'; +import { WarehouseInventory } from './entities/warehouse-inventory.entity'; +import { WarehouseInventoryMovement } from './entities/warehouse-inventory-movement.entity'; +import { WarehouseLoading } from './entities/warehouse-loading.entity'; +import { WarehouseYard } from './entities/warehouse-yard.entity'; +import { WarehouseZone } from './entities/warehouse-zone.entity'; +import { Warehouse } from './entities/warehouse.entity'; +import { SchedulingReadFacade } from './scheduling-read.facade'; +import { WarehouseActivityLogRepository } from './warehouse-activity-log.repository'; +import { WarehouseActivityLogService } from './warehouse-activity-log.service'; +import { WarehouseDashboardService } from './warehouse-dashboard.service'; +import { WarehouseInspectionController } from './warehouse-inspection.controller'; +import { WarehouseInspectionRepository } from './warehouse-inspection.repository'; +import { WarehouseInspectionService } from './warehouse-inspection.service'; +import { WarehouseInventoryController } from './warehouse-inventory.controller'; +import { WarehouseInventoryMovementRepository } from './warehouse-inventory-movement.repository'; +import { WarehouseInventoryRepository } from './warehouse-inventory.repository'; +import { WarehouseInventoryService } from './warehouse-inventory.service'; +import { WarehouseLoadingRepository } from './warehouse-loading.repository'; +import { WarehouseLoadingsController } from './warehouse-loadings.controller'; +import { WarehouseAllocationRuleRepository } from './warehouse-allocation-rule.repository'; +import { WarehouseAllocationService } from './warehouse-allocation.service'; +import { WarehouseFeeRuleRepository } from './warehouse-fee-rule.repository'; +import { WarehouseFeeService } from './warehouse-fee.service'; +import { WarehouseFeeInvoiceItemRepository } from './warehouse-fee-invoice-item.repository'; +import { WarehouseFeeInvoiceRepository } from './warehouse-fee-invoice.repository'; +import { WarehouseInvoiceController } from './warehouse-invoice.controller'; +import { WarehouseInvoiceService } from './warehouse-invoice.service'; +import { WarehouseRulesController } from './warehouse-rules.controller'; +import { WarehouseSchedulingAdapterService } from './warehouse-scheduling-adapter.service'; +import { WarehouseYardsController } from './warehouse-yards.controller'; +import { WarehouseYardsRepository } from './warehouse-yards.repository'; +import { WarehouseYardsService } from './warehouse-yards.service'; +import { WarehouseZonesController } from './warehouse-zones.controller'; +import { WarehouseZonesRepository } from './warehouse-zones.repository'; +import { WarehouseZonesService } from './warehouse-zones.service'; +import { WarehousesController } from './warehouses.controller'; +import { WarehousesRepository } from './warehouses.repository'; +import { WarehousesService } from './warehouses.service'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([ + Warehouse, + WarehouseYard, + WarehouseZone, + WarehouseInventory, + WarehouseInventoryMovement, + WarehouseActivityLog, + WarehouseLoading, + WarehouseInspectionReport, + WarehouseAllocationRule, + WarehouseFeeRule, + WarehouseFeeInvoice, + WarehouseFeeInvoiceItem, + ]), + FilesModule, + ], + controllers: [ + WarehousesController, + WarehouseYardsController, + WarehouseZonesController, + WarehouseInventoryController, + WarehouseLoadingsController, + WarehouseInspectionController, + WarehouseRulesController, + WarehouseInvoiceController, + ], + providers: [ + WarehousesRepository, + WarehouseYardsRepository, + WarehouseZonesRepository, + WarehouseInventoryRepository, + WarehouseInventoryMovementRepository, + WarehouseActivityLogRepository, + WarehouseLoadingRepository, + WarehouseInspectionRepository, + WarehouseAllocationRuleRepository, + WarehouseFeeRuleRepository, + WarehouseFeeInvoiceRepository, + WarehouseFeeInvoiceItemRepository, + WarehousesService, + WarehouseYardsService, + WarehouseZonesService, + WarehouseInventoryService, + WarehouseActivityLogService, + WarehouseDashboardService, + WarehouseInspectionService, + WarehouseAllocationService, + WarehouseFeeService, + WarehouseInvoiceService, + WarehouseSchedulingAdapterService, + SchedulingReadFacade, + ], + exports: [ + WarehousesService, + WarehouseYardsService, + WarehouseZonesService, + WarehouseInventoryService, + WarehouseAllocationService, + WarehouseFeeService, + WarehouseSchedulingAdapterService, + ], +}) +export class WarehousesModule {} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouses.repository.ts b/apps/edr-freight-api/src/modules/warehouses/warehouses.repository.ts new file mode 100644 index 000000000..b88ddab50 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/warehouses.repository.ts @@ -0,0 +1,13 @@ +import { BaseRepository } from '@edr/api-common'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { Warehouse } from './entities/warehouse.entity'; + +@Injectable() +export class WarehousesRepository extends BaseRepository { + constructor(@InjectRepository(Warehouse) repository: Repository) { + super(repository); + } +} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouses.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouses.service.ts new file mode 100644 index 000000000..3cbcc6833 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/warehouses.service.ts @@ -0,0 +1,109 @@ +import { ConflictException, Injectable, NotFoundException } from '@nestjs/common'; +import { FindManyOptions, ILike } from 'typeorm'; + +import { CreateWarehouseDto } from './dto/create-warehouse.dto'; +import { FilterWarehouseDto } from './dto/filter-warehouse.dto'; +import { UpdateWarehouseDto } from './dto/update-warehouse.dto'; +import { Warehouse } from './entities/warehouse.entity'; +import { WarehousesRepository } from './warehouses.repository'; + +@Injectable() +export class WarehousesService { + constructor(private readonly warehousesRepository: WarehousesRepository) {} + + async findAll(filter: FilterWarehouseDto): Promise { + const where: FindManyOptions['where'] = { + ...(filter.type ? { type: filter.type } : {}), + ...(filter.stationId ? { stationId: filter.stationId } : {}), + ...(filter.status ? { status: filter.status } : {}), + }; + + const search = filter.search?.trim(); + const whereClauses = search + ? [ + { ...where, name: ILike(`%${search}%`) }, + { ...where, code: ILike(`%${search}%`) }, + { ...where, locationName: ILike(`%${search}%`) }, + ] + : where; + + return this.warehousesRepository.findAll({ + where: whereClauses, + relations: { facility: true }, + order: { code: 'ASC' }, + }); + } + + async findById(id: string): Promise { + const warehouse = await this.warehousesRepository.findById(id, { + relations: { facility: true, yards: { zones: true } }, + }); + + if (!warehouse) { + throw new NotFoundException(`Warehouse ${id} not found`); + } + + return warehouse; + } + + async create(dto: CreateWarehouseDto): Promise { + await this.assertCodeUnique(dto.code.trim()); + + return this.warehousesRepository.create({ + name: dto.name.trim(), + code: dto.code.trim(), + type: dto.type, + stationId: dto.stationId ?? null, + facilityId: dto.facilityId ?? null, + locationName: dto.locationName?.trim() ?? null, + capacityWeight: dto.capacityWeight ?? null, + capacityContainers: dto.capacityContainers ?? null, + maxWeight: dto.maxWeight ?? dto.capacityWeight ?? null, + maxVolume: dto.maxVolume ?? null, + currentWeight: 0, + currentContainers: 0, + currentVolume: 0, + status: 'ACTIVE', + isActive: true, + }); + } + + async update(id: string, dto: UpdateWarehouseDto): Promise { + const existing = await this.findById(id); + + if (dto.code && dto.code.trim() !== existing.code) { + await this.assertCodeUnique(dto.code.trim(), id); + } + + const status = dto.status ?? existing.status; + + const updated = await this.warehousesRepository.update(id, { + name: dto.name?.trim() ?? existing.name, + code: dto.code?.trim() ?? existing.code, + type: dto.type ?? existing.type, + stationId: dto.stationId ?? existing.stationId, + facilityId: dto.facilityId ?? existing.facilityId, + locationName: dto.locationName?.trim() ?? existing.locationName, + capacityWeight: dto.capacityWeight ?? existing.capacityWeight, + capacityContainers: dto.capacityContainers ?? existing.capacityContainers, + maxWeight: dto.maxWeight ?? existing.maxWeight, + maxVolume: dto.maxVolume ?? existing.maxVolume, + status, + isActive: status === 'ACTIVE', + }); + + if (!updated) { + throw new NotFoundException(`Warehouse ${id} not found`); + } + + return this.findById(id); + } + + private async assertCodeUnique(code: string, ignoreId?: string): Promise { + const [existing] = await this.warehousesRepository.findAll({ where: { code } }); + + if (existing && existing.id !== ignoreId) { + throw new ConflictException(`Warehouse code ${code} already exists`); + } + } +} diff --git a/apps/edr-freight-api/src/scripts/create-freight-schema.js b/apps/edr-freight-api/src/scripts/create-freight-schema.js new file mode 100644 index 000000000..60d98616c --- /dev/null +++ b/apps/edr-freight-api/src/scripts/create-freight-schema.js @@ -0,0 +1,25 @@ +const { Client } = require('pg'); + +(async function createSchema(){ + const client = new Client({ + host: 'localhost', + port: 5432, + user: 'postgres', + password: '', + database: 'edr_freight', + }); + + try { + console.log('Connecting to Postgres...'); + await client.connect(); + console.log('Creating schema freight if not exists...'); + await client.query('CREATE SCHEMA IF NOT EXISTS freight'); + console.log('Schema ensured.'); + await client.end(); + process.exit(0); + } catch (err) { + console.error('Failed to create schema:', err); + try { await client.end(); } catch {} + process.exit(1); + } +})(); diff --git a/apps/edr-freight-api/src/scripts/create-freight-schema.ts b/apps/edr-freight-api/src/scripts/create-freight-schema.ts new file mode 100644 index 000000000..5f66d2e76 --- /dev/null +++ b/apps/edr-freight-api/src/scripts/create-freight-schema.ts @@ -0,0 +1,27 @@ +import { Client } from 'pg'; + +async function createSchema() { + const client = new Client({ + host: 'localhost', + port: 5432, + user: 'postgres', + password: '', + database: 'edr_freight', + }); + + try { + console.log('Connecting to Postgres...'); + await client.connect(); + console.log('Creating schema freight if not exists...'); + await client.query('CREATE SCHEMA IF NOT EXISTS freight'); + console.log('Schema ensured.'); + await client.end(); + process.exit(0); + } catch (err) { + console.error('Failed to create schema:', err); + try { await client.end(); } catch {} + process.exit(1); + } +} + +createSchema(); diff --git a/apps/edr-freight-api/src/scripts/run-migrations.ts b/apps/edr-freight-api/src/scripts/run-migrations.ts new file mode 100644 index 000000000..b5cb23078 --- /dev/null +++ b/apps/edr-freight-api/src/scripts/run-migrations.ts @@ -0,0 +1,21 @@ +import { AppDataSource } from '../data-source'; + +async function runMigrations() { + try { + console.log('Initializing datasource...'); + await AppDataSource.initialize(); + console.log('Datasource initialized. Running migrations...'); + const migrations = await AppDataSource.runMigrations(); + console.log(`Applied ${migrations.length} migrations.`); + await AppDataSource.destroy(); + process.exit(0); + } catch (err) { + console.error('Migration run failed:', err); + try { + await AppDataSource.destroy(); + } catch {} + process.exit(1); + } +} + +runMigrations(); diff --git a/apps/edr-freight-api/src/scripts/seed-demo-scheduling.ts b/apps/edr-freight-api/src/scripts/seed-demo-scheduling.ts new file mode 100644 index 000000000..d9ae15f2d --- /dev/null +++ b/apps/edr-freight-api/src/scripts/seed-demo-scheduling.ts @@ -0,0 +1,29 @@ +import 'reflect-metadata'; +import { config } from 'dotenv'; +import { resolve } from 'path'; + +config({ path: resolve(__dirname, '../../.env') }); +process.env.SEED_DEMO_BOOKINGS = 'true'; + +import { NestFactory } from '@nestjs/core'; +import { AppModule } from '../app.module'; +import { DemoBookingsSeeder } from '../seed/demo-bookings.seeder'; + +async function main() { + const app = await NestFactory.createApplicationContext(AppModule, { + logger: ['error', 'warn', 'log'], + }); + + try { + const seeder = app.get(DemoBookingsSeeder); + await seeder.run(); + console.log('Demo train scheduling data seeded successfully.'); + } finally { + await app.close(); + } +} + +main().catch((err) => { + console.error('Demo scheduling seed failed:', err); + process.exit(1); +}); diff --git a/apps/edr-freight-api/src/scripts/seed-edr-wagons.ts b/apps/edr-freight-api/src/scripts/seed-edr-wagons.ts new file mode 100644 index 000000000..b6a6484f8 --- /dev/null +++ b/apps/edr-freight-api/src/scripts/seed-edr-wagons.ts @@ -0,0 +1,47 @@ +import { AppDataSource } from '../data-source'; +import { SeedEdRWagonFleet1750400000000 } from '../migrations/1750400000000-SeedEdRWagonFleet'; + +async function seedEdRWagons() { + await AppDataSource.initialize(); + + const queryRunner = AppDataSource.createQueryRunner(); + + try { + await queryRunner.connect(); + await queryRunner.startTransaction(); + + await new SeedEdRWagonFleet1750400000000().up(queryRunner); + + const [summary] = await queryRunner.query(` + SELECT + COUNT(*)::int AS total, + COUNT(*) FILTER (WHERE wt.code = 'PW2')::int AS pw2, + COUNT(*) FILTER (WHERE wt.code = 'CW4')::int AS cw4, + COUNT(*) FILTER (WHERE wt.code = 'CW3')::int AS cw3, + COUNT(*) FILTER (WHERE wt.code = 'KW2')::int AS kw2, + COUNT(*) FILTER (WHERE wt.code = 'KW3')::int AS kw3, + COUNT(*) FILTER (WHERE wt.code = 'NW5')::int AS nw5, + COUNT(*) FILTER (WHERE wt.supported_load_types @> ARRAY['CONTAINER'])::int AS container_ready, + COUNT(*) FILTER (WHERE wt.supported_load_types @> ARRAY['BULK'])::int AS bulk_ready, + COUNT(*) FILTER (WHERE w.status = 'IMPORT_READY')::int AS import_ready + FROM freight.wagons w + JOIN freight.wagon_types wt ON wt.id = w.wagon_type_id + WHERE w.wagon_number BETWEEN 'ER0001' AND 'ER0940'; + `); + + await queryRunner.commitTransaction(); + + console.log('Seeded EDR wagon fleet:', summary); + } catch (error) { + await queryRunner.rollbackTransaction(); + throw error; + } finally { + await queryRunner.release(); + await AppDataSource.destroy(); + } +} + +seedEdRWagons().catch((error) => { + console.error('Failed to seed EDR wagon fleet:', error); + process.exit(1); +}); diff --git a/apps/edr-freight-api/src/scripts/seed-freight-demo.ts b/apps/edr-freight-api/src/scripts/seed-freight-demo.ts new file mode 100644 index 000000000..8f4e8d0b2 --- /dev/null +++ b/apps/edr-freight-api/src/scripts/seed-freight-demo.ts @@ -0,0 +1,28 @@ +import 'reflect-metadata'; +import { config } from 'dotenv'; +import { resolve } from 'path'; + +config({ path: resolve(__dirname, '../../.env') }); + +import { NestFactory } from '@nestjs/core'; +import { AppModule } from '../app.module'; +import { DemoFreightDataSeeder } from '../seed/demo-freight-data.seeder'; + +async function main() { + const app = await NestFactory.createApplicationContext(AppModule, { + logger: ['error', 'warn', 'log'], + }); + + try { + const seeder = app.get(DemoFreightDataSeeder); + await seeder.run(); + console.log('Freight demo data seeded (wagons, approval rules, staff users).'); + } finally { + await app.close(); + } +} + +main().catch((err) => { + console.error('Freight demo seed failed:', err); + process.exit(1); +}); diff --git a/apps/edr-freight-api/src/seed/batch1-4-test-data.seeder.ts b/apps/edr-freight-api/src/seed/batch1-4-test-data.seeder.ts new file mode 100644 index 000000000..cb4038097 --- /dev/null +++ b/apps/edr-freight-api/src/seed/batch1-4-test-data.seeder.ts @@ -0,0 +1,170 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { DataSource } from 'typeorm'; + +import { Facility } from '../modules/facilities/entities/facility.entity'; +import { Warehouse } from '../modules/warehouses/entities/warehouse.entity'; +import { WarehouseInventory } from '../modules/warehouses/entities/warehouse-inventory.entity'; +import { WarehouseYard } from '../modules/warehouses/entities/warehouse-yard.entity'; +import { WarehouseZone } from '../modules/warehouses/entities/warehouse-zone.entity'; + +/** + * Test data seeder for Batch 1-4 warehouse system. + * Creates Indode facility with warehouses, yards, zones, and sample inventory + * in all status states (RECEIVED, STORED, RESERVED, READY_FOR_LOADING, LOADED, DISPATCHED). + */ +@Injectable() +export class Batch14TestDataSeeder { + private readonly logger = new Logger(Batch14TestDataSeeder.name); + + constructor(private readonly dataSource: DataSource) {} + + async run(): Promise { + try { + const facilityRepo = this.dataSource.getRepository(Facility); + const warehouseRepo = this.dataSource.getRepository(Warehouse); + const yardRepo = this.dataSource.getRepository(WarehouseYard); + const zoneRepo = this.dataSource.getRepository(WarehouseZone); + const inventoryRepo = this.dataSource.getRepository(WarehouseInventory); + + // Check if facility already exists + const existingFacility = await facilityRepo.findOne({ + where: { code: 'INDODE_TEST' }, + }); + + if (existingFacility) { + this.logger.log('Batch 1-4 test data already seeded, skipping'); + return; + } + + // Create Facility + const facility = await facilityRepo.save( + facilityRepo.create({ + code: 'INDODE_TEST', + name: 'Indode Test Facility', + facilityType: 'DRY_PORT', + facilityStatus: 'ACTIVE', + locationName: 'Indode', + country: 'Djibouti', + city: 'Djibouti', + isActive: true, + capacity: 100000, + }), + ); + this.logger.log(`Created facility: ${facility.code}`); + + // Create Warehouse + const warehouse = await warehouseRepo.save( + warehouseRepo.create({ + code: 'TEST_WH_001', + name: 'Test Warehouse 1', + type: 'OPEN_WAREHOUSE', + locationName: 'Test Location', + status: 'ACTIVE', + isActive: true, + facilityId: facility.id, + capacityWeight: 50000, + capacityContainers: 500, + maxWeight: 50000, + maxVolume: 10000, + }), + ); + this.logger.log(`Created warehouse: ${warehouse.code}`); + + // Create Yards + const yard1 = await yardRepo.save( + yardRepo.create({ + warehouseId: warehouse.id, + code: 'YARD_001', + name: 'Container Yard 1', + type: 'CONTAINER_YARD', + status: 'ACTIVE', + isActive: true, + capacityWeight: 25000, + capacityContainers: 250, + maxWeight: 25000, + maxVolume: 5000, + }), + ); + + const yard2 = await yardRepo.save( + yardRepo.create({ + warehouseId: warehouse.id, + code: 'YARD_002', + name: 'Bulk Yard 1', + type: 'BULK_YARD', + status: 'ACTIVE', + isActive: true, + capacityWeight: 25000, + capacityContainers: 100, + maxWeight: 25000, + maxVolume: 5000, + }), + ); + this.logger.log(`Created yards: ${yard1.code}, ${yard2.code}`); + + // Create Zones + const zone1 = await zoneRepo.save( + zoneRepo.create({ + yardId: yard1.id, + code: 'ZONE_001', + name: 'Container Zone A', + type: 'CONTAINER_ZONE', + status: 'ACTIVE', + isActive: true, + capacityWeight: 12500, + capacityContainers: 125, + maxWeight: 12500, + maxVolume: 2500, + }), + ); + + const zone2 = await zoneRepo.save( + zoneRepo.create({ + yardId: yard2.id, + code: 'ZONE_002', + name: 'Bulk Zone A', + type: 'BULK_ZONE', + status: 'ACTIVE', + isActive: true, + capacityWeight: 12500, + capacityContainers: 50, + maxWeight: 12500, + maxVolume: 2500, + }), + ); + this.logger.log(`Created zones: ${zone1.code}, ${zone2.code}`); + + // Create inventory in all statuses for testing + const statuses = ['RECEIVED', 'STORED', 'RESERVED', 'READY_FOR_LOADING', 'LOADED', 'DISPATCHED'] as const; + const now = new Date(); + + for (let i = 0; i < statuses.length; i++) { + const status = statuses[i]; + const zone = i < 3 ? zone1 : zone2; + + await inventoryRepo.save( + inventoryRepo.create({ + warehouseId: warehouse.id, + yardId: zone.yardId, + zoneId: zone.id, + status: status as any, + quantity: 100 + i * 10, + weight: 500 + i * 50, + volume: 100 + i * 10, + arrivedAt: new Date(now.getTime() - i * 3600000), + storedAt: status !== 'RECEIVED' ? new Date(now.getTime() - (i - 1) * 3600000) : null, + reservedAt: ['RESERVED', 'READY_FOR_LOADING', 'LOADED', 'DISPATCHED'].includes(status) ? new Date() : null, + readyForLoadingAt: ['READY_FOR_LOADING', 'LOADED', 'DISPATCHED'].includes(status) ? new Date() : null, + loadedAt: ['LOADED', 'DISPATCHED'].includes(status) ? new Date() : null, + dispatchedAt: status === 'DISPATCHED' ? new Date() : null, + }), + ); + } + this.logger.log('Created 6 test inventory items in all statuses'); + + this.logger.log('✅ Batch 1-4 test data seeded successfully'); + } catch (error) { + this.logger.error(`Batch 1-4 seeder failed: ${error instanceof Error ? error.message : String(error)}`); + } + } +} diff --git a/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts b/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts new file mode 100644 index 000000000..736c1fbf9 --- /dev/null +++ b/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts @@ -0,0 +1,561 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { randomUUID } from "crypto"; +import { DataSource } from "typeorm"; + +import { BookingContainer } from "../modules/bookings/entities/booking-container.entity"; +import { Booking } from "../modules/bookings/entities/booking.entity"; +import { + Company, + CompanyStatus, + CompanyType, +} from "../modules/companies/entities/company.entity"; +import { Locomotive } from "../modules/locomotives/entities/locomotive.entity"; +import { ServiceType } from "../modules/rule-engine/entities/service-type.entity"; +import { Yard } from "../modules/rule-engine/entities/yard.entity"; +import { WagonType } from "../modules/wagon-types/entities/wagon-type.entity"; +import { CargoType } from "../modules/rule-engine/entities/cargo-type.entity"; +import { ContainerType } from "../modules/rule-engine/entities/container-type.entity"; +import { Container } from "../modules/container-management/entities/container.entity"; +import { Route } from "../modules/routes/entities/route.entity"; +import { RouteMilestone } from "../modules/routes/entities/route-milestone.entity"; +import { Wagon } from "../modules/wagons/entities/wagon.entity"; +import { WagonStatus } from "@edr/types"; + +const SEED_FLAG = "SEED_DEMO_BOOKINGS"; + +const SERVICE_TYPE_CODE = "RAIL_CONTAINER"; +const COMPANY_EMAIL = "train-scheduling-demo@edr.local"; +const COMPANY_TIN = "1234567890"; + +const YARDS = [ + { code: "DJIBOUTI", label: "Djibouti", country: "Djibouti", displayOrder: 1 }, + { + code: "ADDIS_ABABA", + label: "Addis Ababa", + country: "Ethiopia", + displayOrder: 2, + }, + { + code: "DIRE_DAWA", + label: "Dire Dawa", + country: "Ethiopia", + displayOrder: 3, + }, +]; + +const CONTAINER_TYPES = [ + { code: "20FT", label: "20FT", sizeFt: 20 }, + { code: "40FT", label: "40FT", sizeFt: 40 }, +]; + +const DEMO_BOOKINGS = [ + { + reference: "BKG_CONT_001", + containerCode: "40FT", + quantity: 20, + totalWeightTons: 500, + originCode: "DJIBOUTI", + destinationCode: "ADDIS_ABABA", + scheduledDate: "2026-06-20T08:00:00.000Z", + status: "PAID", + paymentStatus: "PAID", + }, + { + reference: "BKG_ONT_02", + containerCode: "20FT", + quantity: 10, + totalWeightTons: 300, + originCode: "DJIBOUTI", + destinationCode: "ADDIS_ABABA", + scheduledDate: "2026-06-20T08:00:00.000Z", + status: "PAID", + paymentStatus: "PAID", + }, + { + reference: "BKG_ONT_03", + containerCode: "40FT", + quantity: 15, + totalWeightTons: 450, + originCode: "DJIBOUTI", + destinationCode: "ADDIS_ABABA", + scheduledDate: "2026-06-20T08:00:00.000Z", + status: "PAID", + paymentStatus: "PAID", + }, + { + reference: "BKG_ONT_07", + containerCode: "20FT", + quantity: 6, + totalWeightTons: 180, + originCode: "DJIBOUTI", + destinationCode: "ADDIS_ABABA", + scheduledDate: "2026-06-20T08:00:00.000Z", + status: "PAID", + paymentStatus: "PAID", + }, + { + reference: "BKG_ONT_08", + containerCode: "40FT", + quantity: 4, + totalWeightTons: 120, + originCode: "DJIBOUTI", + destinationCode: "ADDIS_ABABA", + scheduledDate: "2026-06-20T08:00:00.000Z", + status: "PAID", + paymentStatus: "PAID", + }, + { + reference: "BKG_ONT_09", + containerCode: "20FT", + quantity: 5, + totalWeightTons: 110, + originCode: "DJIBOUTI", + destinationCode: "ADDIS_ABABA", + scheduledDate: "2026-06-20T08:00:00.000Z", + status: "PAID", + paymentStatus: "PAID", + }, + { + reference: "BKG_ONT_04", + containerCode: "40FT", + quantity: 12, + totalWeightTons: 360, + originCode: "ADDIS_ABABA", + destinationCode: "DIRE_DAWA", + scheduledDate: "2026-06-20T08:00:00.000Z", + status: "PAID", + paymentStatus: "PAID" + }, + { + reference: "BKG_ONT_05", + containerCode: "20FT", + quantity: 8, + totalWeightTons: 160, + originCode: "DJIBOUTI", + destinationCode: "ADDIS_ABABA", + scheduledDate: "2026-06-21T08:00:00.000Z", + status: "PAID", + paymentStatus: "PAID", + }, + { + reference: "BKG-CONT-006", + containerCode: "40FT", + quantity: 80, + totalWeightTons: 3600, + originCode: "DJIBOUTI", + destinationCode: "ADDIS_ABABA", + scheduledDate: "2026-06-20T08:00:00.000Z", + status: "PAID", + paymentStatus: "PAID", + }, +]; + +const DEMO_BULK_BOOKINGS = [ + { + reference: "BKG-BULK-001", + cargoCode: "COFFEE", + totalWeightTons: 1200, + originCode: "DJIBOUTI", + destinationCode: "ADDIS_ABABA", + scheduledDate: "2026-06-20T08:00:00.000Z", + status: "PAID", + paymentStatus: "PAID", + }, + { + reference: "BKG-BULK-002", + cargoCode: "FERTILIZER", + totalWeightTons: 800, + originCode: "DJIBOUTI", + destinationCode: "ADDIS_ABABA", + scheduledDate: "2026-06-20T08:00:00.000Z", + status: "PAID", + paymentStatus: "PAID", + }, + { + reference: "BKG-BULK-003", + cargoCode: "STEEL", + totalWeightTons: 450, + originCode: "ADDIS_ABABA", + destinationCode: "DIRE_DAWA", + scheduledDate: "2026-06-20T08:00:00.000Z", + status: "PAID", + paymentStatus: "PAID", + }, +]; + +@Injectable() +export class DemoBookingsSeeder { + private readonly logger = new Logger(DemoBookingsSeeder.name); + + constructor(private readonly dataSource: DataSource) { } + + async run() { + const shouldSeed = process.env[SEED_FLAG]?.trim().toLowerCase() === "true"; + if (!shouldSeed) { + this.logger.log( + `Skipping demo booking seed because ${SEED_FLAG} is not enabled`, + ); + return; + } + + await this.dataSource.transaction(async (manager) => { + await manager.getRepository(WagonType).upsert( + [ + { + code: "NW5", + name: "Flat Wagon", + capacityTons: 70, + lengthMeters: 14, + maxWagonsPerTrain: 53, + supportedLoadTypes: ["CONTAINER"], + isActive: true, + equatedLengthM: 14, + tareWeightTons: 20, + supportsContainer: true, + maxContainerGrossT: 70, + }, + { + code: "KW2", + name: "Covered Hopper", + capacityTons: 60, + lengthMeters: 12, + maxWagonsPerTrain: 55, + supportedLoadTypes: ["BULK"], + isActive: true, + equatedLengthM: 12, + tareWeightTons: 18, + supportsContainer: false, + }, + { + code: "PW2", + name: "Powder Wagon", + capacityTons: 55, + lengthMeters: 12, + maxWagonsPerTrain: 55, + supportedLoadTypes: ["BULK"], + isActive: true, + equatedLengthM: 12, + tareWeightTons: 17, + supportsContainer: false, + }, + { + code: "CW3", + name: "Open Wagon", + capacityTons: 65, + lengthMeters: 13, + maxWagonsPerTrain: 53, + supportedLoadTypes: ["BULK"], + isActive: true, + equatedLengthM: 13, + tareWeightTons: 19, + supportsContainer: false, + }, + ], + { conflictPaths: { code: true } }, + ); + + await manager.getRepository(Locomotive).upsert( + [ + { + code: "LOC-001", + name: "Demo Locomotive 1", + locomotiveType: 'ELECTRIC', + maxPullWeightTons: 3500, + maxTrainLengthMeters: 760, + status: "AVAILABLE", + }, + { + code: "LOC-002", + name: "Demo Locomotive 2", + locomotiveType: 'DIESEL', + maxPullWeightTons: 2500, + maxTrainLengthMeters: 760, + status: "AVAILABLE", + }, + ], + { conflictPaths: { code: true } }, + ); + + await manager.getRepository(Yard).upsert( + YARDS.map((yard) => ({ ...yard, isActive: true })), + { conflictPaths: { code: true } }, + ); + + await manager.getRepository(ServiceType).upsert( + { + code: SERVICE_TYPE_CODE, + serviceName: "Rail Container Service", + description: "Temporary service type for train scheduling demos", + canBeBookedAlone: true, + includesFirstMile: false, + includesLastMile: false, + includesCustoms: false, + priorityBonusPoints: 0, + isActive: true, + displayOrder: 1, + }, + { conflictPaths: { code: true } }, + ); + + await manager.getRepository(ContainerType).upsert( + CONTAINER_TYPES.map((containerType, index) => ({ + ...containerType, + wagonsPerUnit: 1, + isReefer: false, + isOpenTop: false, + isActive: true, + displayOrder: index + 1, + })), + { conflictPaths: { code: true } }, + ); + + await manager.getRepository(Company).upsert( + { + name: "Train Scheduling Demo Customer", + type: CompanyType.Customer, + status: CompanyStatus.Active, + tin: COMPANY_TIN, + vatNumber: "1234567890", + fanNumber: "1234567890123456", + country: "Ethiopia", + address: "Demo Address", + phone: "251900000001", + email: COMPANY_EMAIL, + website: null, + contactPersonName: "Train Scheduling", + contactPersonPhone: "251900000001", + generalManagerName: "Demo Manager", + generalManagerEmail: COMPANY_EMAIL, + generalManagerPhone: "251900000001", + }, + { conflictPaths: { tin: true } }, + ); + + const [serviceType, company, yards, containerTypes] = await Promise.all([ + manager + .getRepository(ServiceType) + .findOneByOrFail({ code: SERVICE_TYPE_CODE }), + manager + .getRepository(Company) + .findOneByOrFail({ tin: COMPANY_TIN }), + manager.getRepository(Yard).find(), + manager.getRepository(ContainerType).find(), + ]); + + const yardByCode = new Map(yards.map((yard) => [yard.code, yard])); + const containerTypeByCode = new Map( + containerTypes.map((containerType) => [ + containerType.code, + containerType, + ]), + ); + + for (const demoBooking of DEMO_BOOKINGS) { + const origin = yardByCode.get(demoBooking.originCode); + const destination = yardByCode.get(demoBooking.destinationCode); + const containerType = containerTypeByCode.get( + demoBooking.containerCode, + ); + + if (!origin || !destination || !containerType) { + throw new Error( + `demo_booking_seed_dependency_missing:${demoBooking.reference}`, + ); + } + + const vgmPerUnitTons = + demoBooking.totalWeightTons / demoBooking.quantity; + + await manager.getRepository(Booking).upsert( + { + reference: demoBooking.reference, + companyId: company.id, + status: demoBooking.status, + scheduledDate: new Date(demoBooking.scheduledDate), + totalAmount: 2, + paymentStatus: demoBooking.paymentStatus, + contractType: "NEW", + serviceTypeId: serviceType.id, + equipmentReturn: "WITHOUT_RETURN", + originYardId: origin.id, + destinationYardId: destination.id, + tradeDirection: "IMPORT", + freightType: "CONTAINER", + cargoTypeId: null, + cargoFreeText: null, + shippingLineId: null, + cargoTotalWeightVgm: demoBooking.totalWeightTons, + isHazardous: false, + paymentCurrency: "ETB", + allowConsolidation: false, + priorityScore: 0, + versionNumber: 1, + }, + { conflictPaths: { reference: true } }, + ); + + const booking = await manager.getRepository(Booking).findOneByOrFail({ + reference: demoBooking.reference, + }); + + await manager + .getRepository(BookingContainer) + .delete({ bookingId: booking.id }); + const wagonsRequired = + Number(demoBooking.quantity) * Number(containerType.wagonsPerUnit ?? 1); + + await manager.getRepository(BookingContainer).insert({ + id: randomUUID(), + bookingId: booking.id, + containerTypeId: containerType.id, + quantity: demoBooking.quantity, + vgmPerUnitTons, + totalVgmTons: demoBooking.totalWeightTons, + wagonsRequired, + weightLimitRuleId: null, + isOverweight: vgmPerUnitTons > 35, + overweightExcessTons: vgmPerUnitTons > 35 ? vgmPerUnitTons - 35 : null, + }); + } + + await manager.getRepository(CargoType).upsert( + [ + { code: "COFFEE", cargoTypeName: "Coffee", isActive: true, displayOrder: 1 }, + { code: "FERTILIZER", cargoTypeName: "Fertilizer", isActive: true, displayOrder: 2 }, + { code: "STEEL", cargoTypeName: "Steel", isActive: true, displayOrder: 3 }, + ], + { conflictPaths: { code: true } }, + ); + + const cargoTypes = await manager.getRepository(CargoType).find(); + const cargoByCode = new Map(cargoTypes.map((c) => [c.code, c])); + + for (const demoBulk of DEMO_BULK_BOOKINGS) { + const origin = yardByCode.get(demoBulk.originCode); + const destination = yardByCode.get(demoBulk.destinationCode); + const cargoType = cargoByCode.get(demoBulk.cargoCode); + + if (!origin || !destination || !cargoType) { + throw new Error(`demo_bulk_seed_dependency_missing:${demoBulk.reference}`); + } + + await manager.getRepository(Booking).upsert( + { + reference: demoBulk.reference, + companyId: company.id, + status: demoBulk.status, + scheduledDate: new Date(demoBulk.scheduledDate), + totalAmount: 0, + paymentStatus: demoBulk.paymentStatus, + contractType: "NEW", + serviceTypeId: serviceType.id, + equipmentReturn: "WITHOUT_RETURN", + originYardId: origin.id, + destinationYardId: destination.id, + tradeDirection: "IMPORT", + freightType: "BULK", + cargoTypeId: cargoType.id, + cargoFreeText: demoBulk.cargoCode, + shippingLineId: null, + cargoTotalWeightVgm: demoBulk.totalWeightTons, + isHazardous: false, + paymentCurrency: "USD", + allowConsolidation: false, + priorityScore: 10, + schedulingStatus: "HOLDING", + versionNumber: 1, + }, + { conflictPaths: { reference: true } }, + ); + } + + const djibouti = yardByCode.get("DJIBOUTI"); + const addis = yardByCode.get("ADDIS_ABABA"); + if (djibouti && addis) { + const routeName = "Djibouti → Addis Ababa"; + let route = await manager.getRepository(Route).findOneBy({ name: routeName }); + if (!route) { + route = await manager.getRepository(Route).save( + manager.getRepository(Route).create({ + name: routeName, + originYardId: djibouti.id, + destinationYardId: addis.id, + isActive: true, + }), + ); + await manager.getRepository(RouteMilestone).save([ + manager.getRepository(RouteMilestone).create({ + routeId: route.id, + yardId: djibouti.id, + sequenceNo: 1, + }), + manager.getRepository(RouteMilestone).create({ + routeId: route.id, + yardId: addis.id, + sequenceNo: 2, + }), + ]); + } + } + + const nw5 = await manager.getRepository(WagonType).findOneBy({ code: "NW5" }); + if (nw5 && djibouti && addis) { + await manager.getRepository(Wagon).upsert( + Array.from({ length: 20 }, (_, index) => ({ + wagonNumber: `WGN-DEMO-${String(index + 1).padStart(3, "0")}`, + wagonTypeId: nw5.id, + trainId: null, + sequenceNumber: null, + tareWeight: 20, + maxPayloadWeight: 70, + status: WagonStatus.Available, + currentYardId: index % 2 === 0 ? djibouti.id : addis.id, + notes: "Demo wagon for train scheduling", + trainSetWagonId: null, + currentTrainScheduleId: null, + })), + { conflictPaths: { wagonNumber: true } }, + ); + } + + if (djibouti) { + await manager.getRepository(Locomotive).update( + { code: "LOC-001" }, + { currentYardId: djibouti.id }, + ); + } + if (addis) { + await manager.getRepository(Locomotive).update( + { code: "LOC-002" }, + { currentYardId: addis.id }, + ); + } + + const ft20 = containerTypeByCode.get("20FT"); + const ft40 = containerTypeByCode.get("40FT"); + if (ft20 && ft40) { + await manager.getRepository(Container).upsert( + Array.from({ length: 30 }, (_, index) => { + const is40Ft = index % 2 === 0; + return { + containerNumber: `CONT-DEMO-${String(index + 1).padStart(3, "0")}`, + containerTypeId: is40Ft ? ft40.id : ft20.id, + wagonId: null, + position: null, + tareWeight: is40Ft ? 4.0 : 2.5, + maxGrossWeight: is40Ft ? 32.5 : 24.5, + sealNumber: null, + status: "AVAILABLE", + bookingId: null, + wagonBookingAllocationId: null, + bookingContainerId: null, + }; + }), + { conflictPaths: { containerNumber: true } }, + ); + } + }); + + this.logger.log("Seeded demo train scheduling data"); + } +} diff --git a/apps/edr-freight-api/src/seed/demo-freight-data.seeder.ts b/apps/edr-freight-api/src/seed/demo-freight-data.seeder.ts new file mode 100644 index 000000000..b391d3f9e --- /dev/null +++ b/apps/edr-freight-api/src/seed/demo-freight-data.seeder.ts @@ -0,0 +1,185 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { WagonStatus } from '@edr/types'; +import { hashPassword } from '@tria-plc/api-common/utils/argon'; +import { EUserStatus } from '@tria-plc/api-common/utils/enums/user.enum'; +import { + Employee, + Organization, + Role, + User, + UserCredential, + UserRole, +} from '@tria-plc/iamapi-common'; +import { DataSource, EntityManager } from 'typeorm'; + +import { Wagon } from '../modules/wagons/entities/wagon.entity'; +import { WagonType } from '../modules/wagon-types/entities/wagon-type.entity'; +import { ApprovalRule } from '../modules/rule-engine/entities/approval-rule.entity'; +import { DEFAULT_APPROVAL_RULE_ROWS } from '../modules/rule-engine/approval-rules.defaults'; + +const EDR_ORG_KEY = 'edr_freight'; +const MIN_WAGONS_PER_TYPE = 100; + +/** The four demo staff users, each mapped to a seeded freight role. */ +const DEMO_STAFF_USERS = [ + { email: 'marketing@edr.local', username: 'marketing', roleKey: 'edr_marketing' }, + { email: 'operations@edr.local', username: 'operations', roleKey: 'edr_operations_officer' }, + { email: 'director@edr.local', username: 'director', roleKey: 'edr_director' }, + { email: 'ceo@edr.local', username: 'ceo', roleKey: 'edr_ceo' }, +] as const; + +/** + * One-shot demo data: at least 100 wagons per wagon type, the default approval + * chains, and four staff users with distinct permissions. Every block guards on + * an "is it already populated?" check, so this is safe to run on every boot and + * does nothing once the data exists. + */ +@Injectable() +export class DemoFreightDataSeeder { + private readonly logger = new Logger(DemoFreightDataSeeder.name); + + constructor(private readonly dataSource: DataSource) {} + + async run() { + await this.dataSource.transaction(async (manager) => { + // Demo freight data (wagons + approval rules) disabled — keep only the + // 4 staff users. The seeders are retained for easy re-enabling; flip + // SEED_DEMO_FREIGHT_DATA=true to run them. + if (process.env.SEED_DEMO_FREIGHT_DATA === 'true') { + await this.seedWagons(manager); + await this.seedApprovalRules(manager); + } + await this.seedStaffUsers(manager); + }); + } + + /** Ensure every wagon type has at least MIN_WAGONS_PER_TYPE wagons. */ + private async seedWagons(manager: EntityManager) { + const wagonTypeRepo = manager.getRepository(WagonType); + const wagonRepo = manager.getRepository(Wagon); + + const wagonTypes = await wagonTypeRepo.find(); + if (wagonTypes.length === 0) { + this.logger.warn('No wagon types found; skipping wagon seed'); + return; + } + + for (const type of wagonTypes) { + const existing = await wagonRepo.count({ where: { wagonTypeId: type.id } }); + if (existing >= MIN_WAGONS_PER_TYPE) { + this.logger.log( + `Wagon type ${type.code} already has ${existing} wagons; skipping`, + ); + continue; + } + + const toCreate = MIN_WAGONS_PER_TYPE - existing; + const tare = Number(type.tareWeightTons ?? 20); + const maxPayload = Number(type.capacityTons ?? 60); + const rows = Array.from({ length: toCreate }, (_, i) => { + const seq = existing + i + 1; + return wagonRepo.create({ + wagonNumber: `${type.code}-${String(seq).padStart(4, '0')}`, + wagonTypeId: type.id, + tareWeight: tare, + maxPayloadWeight: maxPayload, + status: WagonStatus.Available, + }); + }); + await wagonRepo.save(rows); + this.logger.log(`Seeded ${toCreate} wagons for type ${type.code}`); + } + } + + /** Seed the default approval chains when the table is empty. */ + private async seedApprovalRules(manager: EntityManager) { + const repo = manager.getRepository(ApprovalRule); + const count = await repo.count(); + if (count > 0) { + this.logger.log(`Approval rules already populated (${count}); skipping`); + return; + } + await repo.save(DEFAULT_APPROVAL_RULE_ROWS.map((row) => repo.create(row))); + this.logger.log(`Seeded ${DEFAULT_APPROVAL_RULE_ROWS.length} approval rules`); + } + + /** Create the four demo staff users with their roles (idempotent per email). */ + private async seedStaffUsers(manager: EntityManager) { + const organization = await manager.getRepository(Organization).findOne({ + where: { key: EDR_ORG_KEY }, + select: { id: true, key: true }, + }); + if (!organization) { + this.logger.warn(`Missing organization ${EDR_ORG_KEY}; skipping staff users`); + return; + } + + const roleRepo = manager.getRepository(Role); + const userRepo = manager.getRepository(User); + const credentialRepo = manager.getRepository(UserCredential); + const userRoleRepo = manager.getRepository(UserRole); + const employeeRepo = manager.getRepository(Employee); + + const password = process.env.DEFAULT_PASSWORD?.trim() || '12345678'; + const hashedPassword = await hashPassword(password); + + for (const staff of DEMO_STAFF_USERS) { + const role = await roleRepo.findOne({ + where: { key: staff.roleKey }, + select: { id: true, key: true }, + }); + if (!role) { + this.logger.warn(`Missing role ${staff.roleKey}; skipping ${staff.email}`); + continue; + } + + let user = await userRepo.findOne({ + where: { email: staff.email }, + select: { id: true, email: true }, + }); + if (!user) { + user = await userRepo.save( + userRepo.create({ + email: staff.email, + username: staff.username, + name: { en: staff.username }, + isActive: true, + hasSetPassword: true, + status: EUserStatus.ACCEPTED, + }), + ); + this.logger.log(`Seeded staff user ${staff.email}`); + } + + const hasCredential = await credentialRepo.exists({ + where: { userId: user.id, isActive: true }, + }); + if (!hasCredential) { + await credentialRepo.insert({ + userId: user.id, + password: hashedPassword, + isActive: true, + }); + } + + await userRoleRepo.upsert( + { userId: user.id, roleId: role.id, organizationId: organization.id }, + { conflictPaths: { userId: true, roleId: true } }, + ); + + const hasEmployee = await employeeRepo.exists({ + where: { userId: user.id, organizationId: organization.id, isCurrent: true }, + }); + if (!hasEmployee) { + await employeeRepo.insert({ + userId: user.id, + organizationId: organization.id, + isCurrent: true, + name: { en: staff.username }, + }); + } + } + + this.logger.log('Ensured demo staff users (marketing@, operations@, director@, ceo@)'); + } +} diff --git a/apps/edr-freight-api/src/seed/demo-users.seeder.ts b/apps/edr-freight-api/src/seed/demo-users.seeder.ts new file mode 100644 index 000000000..8d886e3aa --- /dev/null +++ b/apps/edr-freight-api/src/seed/demo-users.seeder.ts @@ -0,0 +1,213 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { hashPassword } from "@tria-plc/api-common/utils/argon"; +import { EUserStatus } from "@tria-plc/api-common/utils/enums/user.enum"; +import { ERoleKey } from "@tria-plc/api-common/utils/enums/seed.enum"; +import { + Employee, + Organization, + Permission, + Role, + RolePermission, + User, + UserCredential, + UserRole, +} from "@tria-plc/iamapi-common"; +import { DataSource } from "typeorm"; + +const SEED_FLAG = "SEED_DEMO_USERS"; + +const DEMO_ORG_KEY = "demo_iam"; +const DEMO_ORG_NAME = { en: "Demo IAM" }; + +const DEMO_PERMISSIONS = [ + { key: "can:demo:user1", name: { en: "Can access demo user1" } }, + { key: "can:demo:user2", name: { en: "Can access demo user2" } }, +]; + +const DEMO_ROLES = [ + { key: "demo_user1", name: { en: "Demo User1" } }, + { key: "demo_user2", name: { en: "Demo User2" } }, +]; + +const DEMO_USERS = [ + { + email: "user@gmail.com", + username: "user", + name: { en: "Demo User 1" }, + roleKey: "demo_user1", + }, + { + email: "user2@gmail.com", + username: "user2", + name: { en: "Demo User 2" }, + roleKey: "demo_user2", + }, +]; + +@Injectable() +export class DemoUsersSeeder { + private readonly logger = new Logger(DemoUsersSeeder.name); + + constructor(private readonly dataSource: DataSource) {} + + async run() { + const shouldSeed = process.env[SEED_FLAG]?.trim().toLowerCase() === "true"; + if (!shouldSeed) { + this.logger.log(`Skipping demo user seed because ${SEED_FLAG} is not enabled`); + return; + } + + await this.dataSource.transaction(async (manager) => { + const organizationRepository = manager.getRepository(Organization); + const employeeRepository = manager.getRepository(Employee); + const permissionRepository = manager.getRepository(Permission); + const roleRepository = manager.getRepository(Role); + const rolePermissionRepository = manager.getRepository(RolePermission); + const userRepository = manager.getRepository(User); + const userCredentialRepository = manager.getRepository(UserCredential); + const userRoleRepository = manager.getRepository(UserRole); + + await organizationRepository.upsert( + { + key: DEMO_ORG_KEY, + name: DEMO_ORG_NAME, + // status defaults to ACTIVE in IAM entity + isGovernmentOrganization: true, + }, + { conflictPaths: { key: true } }, + ); + + const organization = await organizationRepository.findOne({ + where: { key: DEMO_ORG_KEY }, + select: { id: true, key: true }, + }); + + if (!organization) { + throw new Error("demo_org_seed_failed"); + } + + await permissionRepository.upsert(DEMO_PERMISSIONS, { + conflictPaths: { key: true }, + }); + + await roleRepository.upsert(DEMO_ROLES, { + conflictPaths: { key: true }, + }); + + const roles = await roleRepository.find({ where: DEMO_ROLES.map((r) => ({ key: r.key })) }); + const permissions = await permissionRepository.find({ + where: DEMO_PERMISSIONS.map((p) => ({ key: p.key })), + }); + + const roleByKey = new Map(roles.map((r) => [r.key, r])); + const permissionByKey = new Map(permissions.map((p) => [p.key, p])); + + const superAdminRole = await roleRepository.findOne({ + where: { key: ERoleKey.SUPER_ADMIN }, + select: { id: true, key: true }, + }); + + const rolePermissionsToUpsert = [ + { + roleId: roleByKey.get("demo_user1")!.id, + permissionId: permissionByKey.get("can:demo:user1")!.id, + }, + { + roleId: roleByKey.get("demo_user2")!.id, + permissionId: permissionByKey.get("can:demo:user2")!.id, + }, + ...(superAdminRole + ? ([ + { + roleId: superAdminRole.id, + permissionId: permissionByKey.get("can:demo:user1")!.id, + }, + { + roleId: superAdminRole.id, + permissionId: permissionByKey.get("can:demo:user2")!.id, + }, + ] as Array<{ roleId: string; permissionId: string }>) + : []), + ]; + + await rolePermissionRepository.upsert(rolePermissionsToUpsert, { + conflictPaths: { roleId: true, permissionId: true }, + }); + + const hashedPassword = await hashPassword("12345678"); + + for (const demoUser of DEMO_USERS) { + const existingUser = await userRepository.findOne({ + where: { email: demoUser.email }, + select: { id: true, email: true }, + }); + + let user = existingUser; + if (!user) { + user = await userRepository.save( + userRepository.create({ + email: demoUser.email, + username: demoUser.username, + name: demoUser.name, + isActive: true, + hasSetPassword: true, + status: EUserStatus.ACCEPTED, + }), + ); + } + + // Ensure an active credential exists for login. + const activeCredentialExists = await userCredentialRepository.exists({ + where: { + userId: user.id, + isActive: true, + }, + }); + + if (!activeCredentialExists) { + await userCredentialRepository.insert({ + userId: user.id, + password: hashedPassword, + isActive: true, + }); + } + + // Login query requires a current employee in an ACTIVE organization. + const employeeExists = await employeeRepository.exists({ + where: { + userId: user.id, + organizationId: organization.id, + isCurrent: true, + }, + }); + + if (!employeeExists) { + await employeeRepository.insert({ + userId: user.id, + organizationId: organization.id, + isCurrent: true, + name: demoUser.name, + }); + } + + const role = roleByKey.get(demoUser.roleKey); + if (!role) { + throw new Error(`missing_role:${demoUser.roleKey}`); + } + + await userRoleRepository.upsert( + { + userId: user.id, + roleId: role.id, + organizationId: organization.id, + }, + { conflictPaths: { userId: true, roleId: true } }, + ); + } + }); + + this.logger.log( + "Seeded demo users + permissions (user@gmail.com, user2@gmail.com; permissions can:demo:user1/can:demo:user2)", + ); + } +} diff --git a/apps/edr-freight-api/src/seed/edr-freight.seed.ts b/apps/edr-freight-api/src/seed/edr-freight.seed.ts new file mode 100644 index 000000000..a88ee8f89 --- /dev/null +++ b/apps/edr-freight-api/src/seed/edr-freight.seed.ts @@ -0,0 +1,274 @@ +import { + BOOKING_RULE_ENGINE_PERMISSIONS, + BOOKING_RULE_ENGINE_PERMISSION_KEYS, + ROLE_PERMISSION_PRESETS, +} from './freight-permissions.registry'; + +export type FreightSeedRole = { + key: string; + name: { en: string }; + permissionKeys: string[]; +}; + +const IAM_PERMISSION_KEYS = { + activateEmployee: "can:activateEmployee", + activateUser: "can:activateUser", + createEmployee: "can:createEmployee", + createPositionPermission: "can:create:position_permission", + createUnit: "can:create:unit", + createUserRole: "can:create:user_role", + deactivateEmployee: "can:deactivateEmployee", + deletePositionPermission: "can:delete:position_permission", + deleteUnit: "can:delete:unit", + deleteUserRole: "can:delete:user_role", + findAllOrganization: "can:find_all:organization", + manageOrganizationAdmin: "manage:organizationAdmin", + manageUnitAdmin: "manage:unitAdmin", + updateUnit: "can:update:unit", + viewPositionPermission: "can:view:position_permission", + viewUserRole: "can:view:user_role", +} as const; + +export const EDR_FREIGHT_APPLICATION = { + id: "7f5a2175-c270-495b-bec9-d59ddbdab5d1", + key: "edr_freight_app", + name: { + am: "EDR Freight App", + en: "EDR Freight App", + }, +} as const; + +const EMPLOYEE_REGISTRATION_PERMISSIONS = [ + { + id: "62b5aa2d-4ef6-474d-913a-994568dce1c8", + key: "edr_freight_app:employee_registration:view", + name: { am: "View employee registration", en: "View employee registration" }, + applicationKey: EDR_FREIGHT_APPLICATION.key, + }, + { + id: "8072204d-26de-4e62-88aa-74afd916a0cb", + key: "edr_freight_app:employee_registration:create", + name: { am: "Create employee registration", en: "Create employee registration" }, + applicationKey: EDR_FREIGHT_APPLICATION.key, + }, + { + id: "b7dc55a6-ae7c-4558-8c4e-7d8ce5c7fa08", + key: "edr_freight_app:employee_registration:update", + name: { am: "Update employee registration", en: "Update employee registration" }, + applicationKey: EDR_FREIGHT_APPLICATION.key, + }, + { + id: "7ef06121-bd31-4c0d-b36d-5401b4bfd05c", + key: "edr_freight_app:employee_registration:activate", + name: { am: "Activate employee registration", en: "Activate employee registration" }, + applicationKey: EDR_FREIGHT_APPLICATION.key, + }, + { + id: "2688e144-7f0c-4704-8d59-e92b0c08117a", + key: "edr_freight_app:employee_registration:deactivate", + name: { am: "Deactivate employee registration", en: "Deactivate employee registration" }, + applicationKey: EDR_FREIGHT_APPLICATION.key, + }, +] as const; + +const ROLE_ASSIGNMENT_PERMISSIONS = [ + { + id: "4de87873-e00d-4330-9b4f-f4fb065f49e0", + key: "edr_freight_app:role_assignment:view", + name: { am: "View role assignment", en: "View role assignment" }, + applicationKey: EDR_FREIGHT_APPLICATION.key, + }, + { + id: "36f022b4-4b94-4220-a46c-df7bd1a1b184", + key: "edr_freight_app:role_assignment:assign", + name: { am: "Assign role", en: "Assign role" }, + applicationKey: EDR_FREIGHT_APPLICATION.key, + }, + { + id: "c1f34177-a0ae-4a46-a24a-3281b9137bab", + key: "edr_freight_app:role_assignment:replace", + name: { am: "Replace role assignment", en: "Replace role assignment" }, + applicationKey: EDR_FREIGHT_APPLICATION.key, + }, +] as const; + +const HIERARCHY_UNIT_PERMISSIONS = [ + { + id: "2bfa2428-ec40-4588-9b01-dfacce6a2b82", + key: "edr_freight_app:hierarchy_units:view", + name: { am: "View hierarchy units", en: "View hierarchy units" }, + applicationKey: EDR_FREIGHT_APPLICATION.key, + }, + { + id: "1e92daff-9cc7-4a67-9994-879f34bfda16", + key: "edr_freight_app:hierarchy_units:create", + name: { am: "Create hierarchy unit", en: "Create hierarchy unit" }, + applicationKey: EDR_FREIGHT_APPLICATION.key, + }, + { + id: "4ef2d8ad-c627-4448-b4b6-dd6b8b602dc1", + key: "edr_freight_app:hierarchy_units:update", + name: { am: "Update hierarchy unit", en: "Update hierarchy unit" }, + applicationKey: EDR_FREIGHT_APPLICATION.key, + }, + { + id: "15353ac5-246b-42e6-9ac3-eb61c4f1cd22", + key: "edr_freight_app:hierarchy_units:delete", + name: { am: "Delete hierarchy unit", en: "Delete hierarchy unit" }, + applicationKey: EDR_FREIGHT_APPLICATION.key, + }, +] as const; + +const HIERARCHY_POSITION_PERMISSIONS = [ + { + id: "37ff6f5b-9fb0-4139-af99-22fe54703029", + key: "edr_freight_app:hierarchy_positions:view", + name: { am: "View hierarchy positions", en: "View hierarchy positions" }, + applicationKey: EDR_FREIGHT_APPLICATION.key, + }, + { + id: "af6c091a-6448-4459-a635-c2181efd1de0", + key: "edr_freight_app:hierarchy_positions:create", + name: { am: "Create hierarchy position", en: "Create hierarchy position" }, + applicationKey: EDR_FREIGHT_APPLICATION.key, + }, + { + id: "e78f624d-b570-4cd6-8f16-12090a4a9d31", + key: "edr_freight_app:hierarchy_positions:update", + name: { am: "Update hierarchy position", en: "Update hierarchy position" }, + applicationKey: EDR_FREIGHT_APPLICATION.key, + }, + { + id: "7fba7887-a365-4281-96ea-fb14582b047e", + key: "edr_freight_app:hierarchy_positions:delete", + name: { am: "Delete hierarchy position", en: "Delete hierarchy position" }, + applicationKey: EDR_FREIGHT_APPLICATION.key, + }, + { + id: "a33905ff-f2b8-40b9-a8cf-e2968f6f46fb", + key: "edr_freight_app:hierarchy_positions:change_parent", + name: { am: "Change hierarchy position parent", en: "Change hierarchy position parent" }, + applicationKey: EDR_FREIGHT_APPLICATION.key, + }, +] as const; + +const HIERARCHY_EMPLOYEE_ASSIGNMENT_PERMISSIONS = [ + { + id: "b6ca90ff-3e95-4af2-bac8-fb298ca62080", + key: "edr_freight_app:hierarchy_employee_assignment:view", + name: { am: "View hierarchy employee assignment", en: "View hierarchy employee assignment" }, + applicationKey: EDR_FREIGHT_APPLICATION.key, + }, + { + id: "0637472f-d6b7-4332-85bb-eaa6a02205c1", + key: "edr_freight_app:hierarchy_employee_assignment:invite", + name: { am: "Invite hierarchy employee assignment", en: "Invite hierarchy employee assignment" }, + applicationKey: EDR_FREIGHT_APPLICATION.key, + }, + { + id: "de366c81-b6d1-4cf9-a5f1-a5c8a6fb5e7b", + key: "edr_freight_app:hierarchy_employee_assignment:assign", + name: { am: "Assign hierarchy employee assignment", en: "Assign hierarchy employee assignment" }, + applicationKey: EDR_FREIGHT_APPLICATION.key, + }, +] as const; + +const POSITION_TYPE_PERMISSIONS = [ + { + id: "f258fb51-2890-4c93-b024-271b09d705d0", + key: "edr_freight_app:position_types:view", + name: { am: "View position types", en: "View position types" }, + applicationKey: EDR_FREIGHT_APPLICATION.key, + }, +] as const; + +export const EDR_FREIGHT_PERMISSIONS = [ + ...EMPLOYEE_REGISTRATION_PERMISSIONS, + ...ROLE_ASSIGNMENT_PERMISSIONS, + ...HIERARCHY_UNIT_PERMISSIONS, + ...HIERARCHY_POSITION_PERMISSIONS, + ...HIERARCHY_EMPLOYEE_ASSIGNMENT_PERMISSIONS, + ...POSITION_TYPE_PERMISSIONS, + ...BOOKING_RULE_ENGINE_PERMISSIONS, +]; + +export { BOOKING_RULE_ENGINE_PERMISSION_KEYS } from './freight-permissions.registry'; + +export const EDR_FREIGHT_ROLES: FreightSeedRole[] = [ + { + key: "edr_employee", + name: { en: "EDR Employee" }, + permissionKeys: [ + "edr_freight_app:employee_registration:view", + "edr_freight_app:role_assignment:view", + "edr_freight_app:hierarchy_units:view", + "edr_freight_app:hierarchy_positions:view", + "edr_freight_app:hierarchy_employee_assignment:view", + "edr_freight_app:position_types:view", + ], + }, + { + key: "edr_line_staff", + name: { en: "EDR Line Staff" }, + permissionKeys: [...ROLE_PERMISSION_PRESETS.lineStaff], + }, + { + key: "edr_operations_officer", + name: { en: "EDR Operations Officer" }, + permissionKeys: [...ROLE_PERMISSION_PRESETS.operationsOfficer], + }, + { + key: "edr_director", + name: { en: "EDR Director" }, + permissionKeys: [...ROLE_PERMISSION_PRESETS.director], + }, + { + key: "edr_ceo", + name: { en: "EDR CEO" }, + permissionKeys: [...ROLE_PERMISSION_PRESETS.ceo], + }, + { + key: "edr_finance", + name: { en: "EDR Finance" }, + permissionKeys: [...ROLE_PERMISSION_PRESETS.finance], + }, + { + key: "edr_marketing", + name: { en: "EDR Marketing" }, + permissionKeys: [...ROLE_PERMISSION_PRESETS.marketing], + }, + { + key: "edr_org_manager", + name: { en: "EDR Org Manager" }, + permissionKeys: [ + ...BOOKING_RULE_ENGINE_PERMISSION_KEYS, + ...EMPLOYEE_REGISTRATION_PERMISSIONS.map((p) => p.key), + ...ROLE_ASSIGNMENT_PERMISSIONS.map((p) => p.key), + ...HIERARCHY_UNIT_PERMISSIONS.map((p) => p.key), + ...HIERARCHY_POSITION_PERMISSIONS.map((p) => p.key), + ...HIERARCHY_EMPLOYEE_ASSIGNMENT_PERMISSIONS.map((p) => p.key), + ...POSITION_TYPE_PERMISSIONS.map((p) => p.key), + IAM_PERMISSION_KEYS.createEmployee, + IAM_PERMISSION_KEYS.deactivateEmployee, + IAM_PERMISSION_KEYS.activateEmployee, + IAM_PERMISSION_KEYS.activateUser, + IAM_PERMISSION_KEYS.createUserRole, + IAM_PERMISSION_KEYS.deleteUserRole, + IAM_PERMISSION_KEYS.viewUserRole, + IAM_PERMISSION_KEYS.manageOrganizationAdmin, + IAM_PERMISSION_KEYS.manageUnitAdmin, + IAM_PERMISSION_KEYS.createUnit, + IAM_PERMISSION_KEYS.updateUnit, + IAM_PERMISSION_KEYS.deleteUnit, + IAM_PERMISSION_KEYS.createPositionPermission, + IAM_PERMISSION_KEYS.deletePositionPermission, + IAM_PERMISSION_KEYS.viewPositionPermission, + IAM_PERMISSION_KEYS.findAllOrganization, + ], + }, + { + key: "edr_customer", + name: { en: "EDR Customer" }, + permissionKeys: [], + }, +]; diff --git a/apps/edr-freight-api/src/seed/edr-org.seeder.ts b/apps/edr-freight-api/src/seed/edr-org.seeder.ts new file mode 100644 index 000000000..8243ef267 --- /dev/null +++ b/apps/edr-freight-api/src/seed/edr-org.seeder.ts @@ -0,0 +1,208 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { + Organization, + OrganizationConfiguration, + Permission, + Role, + RolePermission, +} from "@tria-plc/iamapi-common"; +import { DataSource, EntityManager, In } from "typeorm"; + +import { ERoleKey } from "@tria-plc/api-common/utils/enums/seed.enum"; +import { BOOKING_RULE_ENGINE_PERMISSION_KEYS } from "./freight-permissions.registry"; +import { EDR_FREIGHT_ROLES, type FreightSeedRole } from "./edr-freight.seed"; + +const EDR_ORG_KEY = "edr_freight"; +const EDR_ORG_NAME = { en: "EDR Freight" }; +const SEED_FLAG = "SEED_EDR_ORG"; + +type SeedOrganization = { + id: string; + key: string; +}; + +@Injectable() +export class EdrOrgSeeder { + private readonly logger = new Logger(EdrOrgSeeder.name); + + constructor(private readonly dataSource: DataSource) {} + + async run() { + if (!this.shouldSeed()) { + this.logger.log(`Skipping EDR org seed because ${SEED_FLAG} is not enabled`); + return; + } + + await this.dataSource.transaction(async (manager) => { + const organization = await this.ensureOrganization(manager); + + await this.ensureOrganizationConfiguration(manager, organization.id); + await this.ensureRoles(manager, EDR_FREIGHT_ROLES); + await this.ensureRolePermissions(manager, EDR_FREIGHT_ROLES); + await this.ensureSuperAdminPermissions(manager); + }); + + this.logger.log(`Ensured EDR organization seed for '${EDR_ORG_KEY}'`); + } + + private shouldSeed() { + return process.env[SEED_FLAG]?.trim().toLowerCase() === "true"; + } + + private async ensureOrganization( + manager: EntityManager, + ): Promise { + const organizationRepository = manager.getRepository(Organization); + let organization = await organizationRepository.findOne({ + where: { key: EDR_ORG_KEY }, + select: { id: true, key: true }, + }); + + if (!organization) { + const insertResult = await organizationRepository.insert({ + key: EDR_ORG_KEY, + name: EDR_ORG_NAME, + isGovernmentOrganization: true, + }); + + this.logger.log(`Seeded EDR organization '${EDR_ORG_KEY}'`); + + return { + id: insertResult.identifiers[0]?.id as string, + key: EDR_ORG_KEY, + }; + } + + this.logger.log(`Ensured EDR organization '${EDR_ORG_KEY}'`); + + return { + id: organization.id as string, + key: EDR_ORG_KEY, + }; + } + + private async ensureOrganizationConfiguration( + manager: EntityManager, + organizationId: string, + ) { + const organizationConfigurationRepository = + manager.getRepository(OrganizationConfiguration); + + await organizationConfigurationRepository.upsert({ + organizationId, + canCreateBranchByItself: true, + canStartReceivingRecord: true, + }, { + conflictPaths: { organizationId: true }, + }); + + this.logger.log( + `Ensured organization configuration for '${EDR_ORG_KEY}'`, + ); + } + + private async ensureRoles(manager: EntityManager, seedRoles: FreightSeedRole[]) { + await manager.getRepository(Role).upsert( + seedRoles.map(({ key, name }) => ({ key, name })), + { + conflictPaths: { key: true }, + }, + ); + + this.logger.log( + `Ensured EDR roles '${seedRoles.map((role) => role.key).join("', '")}'`, + ); + } + + private async ensureRolePermissions( + manager: EntityManager, + seedRoles: FreightSeedRole[], + ) { + const permissionKeys = [...new Set(seedRoles.flatMap((role) => role.permissionKeys))]; + + if (!permissionKeys.length) { + this.logger.log("No EDR role permissions configured; skipping role-permission links"); + return; + } + + const roleRepository = manager.getRepository(Role); + const rolePermissionRepository = manager.getRepository(RolePermission); + + const roles = await roleRepository.find({ + where: { key: In(seedRoles.map((role) => role.key)) }, + select: { id: true, key: true }, + }); + const seededPermissions = await manager.getRepository(Permission).find({ + where: { key: In(permissionKeys) }, + select: { id: true, key: true }, + }); + + const roleByKey = new Map(roles.map((role) => [role.key, role])); + const permissionByKey = new Map( + seededPermissions.map((permission) => [permission.key, permission]), + ); + + const rolePermissions = seedRoles.flatMap((role) => { + const seededRole = roleByKey.get(role.key); + + if (!seededRole) { + throw new Error(`missing_role:${role.key}`); + } + + return role.permissionKeys.map((permissionKey) => { + const seededPermission = permissionByKey.get(permissionKey); + + if (!seededPermission) { + throw new Error(`missing_permission:${permissionKey}`); + } + + return { + roleId: seededRole.id, + permissionId: seededPermission.id, + }; + }); + }); + + await rolePermissionRepository.upsert(rolePermissions, { + conflictPaths: { roleId: true, permissionId: true }, + }); + + this.logger.log(`Ensured ${rolePermissions.length} EDR role-permission links`); + } + + private async ensureSuperAdminPermissions(manager: EntityManager) { + const role = await manager.getRepository(Role).findOne({ + where: { key: ERoleKey.SUPER_ADMIN }, + select: { id: true, key: true }, + }); + + if (!role) { + this.logger.warn( + `Role ${ERoleKey.SUPER_ADMIN} not found; skipping booking/rule-engine super_admin links`, + ); + return; + } + + const permissions = await manager.getRepository(Permission).find({ + where: { key: In(BOOKING_RULE_ENGINE_PERMISSION_KEYS) }, + select: { id: true, key: true }, + }); + + if (!permissions.length) { + this.logger.warn('No booking/rule-engine permissions found for super_admin'); + return; + } + + await manager.getRepository(RolePermission).upsert( + permissions.map((permission) => ({ + roleId: role.id, + permissionId: permission.id, + })), + { conflictPaths: { roleId: true, permissionId: true } }, + ); + + this.logger.log( + `Ensured ${permissions.length} booking+rule-engine permissions on super_admin`, + ); + } +} diff --git a/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts b/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts new file mode 100644 index 000000000..2ef1f79f0 --- /dev/null +++ b/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts @@ -0,0 +1,125 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { DataSource } from "typeorm"; + +import { FileUploadField } from "../modules/file-upload-settings/entities/file-upload-field.entity"; +import { FileUploadSetting } from "../modules/file-upload-settings/entities/file-upload-setting.entity"; + +const COMPANY_ONBOARDING_DOCUMENTS = [ + { + code: "company_onboarding_documents_customer", + label: "Customer onboarding documents", + entity: "customer", + }, + { + code: "company_onboarding_documents_forwarder", + label: "Forwarder onboarding documents", + entity: "other", + }, + { + code: "company_onboarding_documents_transporter", + label: "Transporter onboarding documents", + entity: "other", + }, + { + code: "company_onboarding_documents_forwarder_dj", + label: "Djibouti forwarder onboarding documents", + entity: "other", + }, +] as const; + +const COMPANY_ONBOARDING_DESCRIPTION = + "Required documents for external company onboarding. The same set applies to customers, forwarders, transporters, and brokers."; + +const COMPANY_ONBOARDING_FIELDS = [ + { + fileKey: "business_license", + fileLabel: "Business License / Trade License", + helpText: "Verified against the government trade system during registration.", + isRequired: true, + isMultiple: false, + maxFiles: 1, + allowedExtensions: ["pdf", "jpg", "jpeg", "png"], + maxSizeMb: 10, + displayOrder: 1, + }, + { + fileKey: "tin_certificate", + fileLabel: "TIN Certificate", + helpText: "Verified against the TIN registry during registration.", + isRequired: true, + isMultiple: false, + maxFiles: 1, + allowedExtensions: ["pdf", "jpg", "jpeg", "png"], + maxSizeMb: 10, + displayOrder: 2, + }, + { + fileKey: "national_id_passport", + fileLabel: "National ID / Passport", + helpText: "Verified against the National ID API during registration.", + isRequired: true, + isMultiple: false, + maxFiles: 1, + allowedExtensions: ["pdf", "jpg", "jpeg", "png"], + maxSizeMb: 10, + displayOrder: 3, + }, +] as const; + +@Injectable() +export class FileUploadSettingsSeeder { + private readonly logger = new Logger(FileUploadSettingsSeeder.name); + + constructor(private readonly dataSource: DataSource) {} + + async run() { + await this.dataSource.transaction(async (manager) => { + const settingRepository = manager.getRepository(FileUploadSetting); + const fieldRepository = manager.getRepository(FileUploadField); + + for (const documentSetting of COMPANY_ONBOARDING_DOCUMENTS) { + await settingRepository.upsert( + { + code: documentSetting.code, + label: documentSetting.label, + description: COMPANY_ONBOARDING_DESCRIPTION, + entity: documentSetting.entity, + }, + { + conflictPaths: { code: true }, + }, + ); + + const setting = await settingRepository.findOne({ + where: { code: documentSetting.code }, + select: { id: true, code: true }, + }); + + if (!setting) { + throw new Error(`file_upload_setting_seed_failed:${documentSetting.code}`); + } + + await fieldRepository.delete({ settingId: setting.id }); + + await fieldRepository.insert( + COMPANY_ONBOARDING_FIELDS.map((field, index) => ({ + settingId: setting.id, + fileKey: field.fileKey, + fileLabel: field.fileLabel, + helpText: field.helpText, + isRequired: field.isRequired, + isMultiple: field.isMultiple, + maxFiles: field.maxFiles, + allowedExtensions: [...field.allowedExtensions], + maxSizeMb: field.maxSizeMb, + displayOrder: field.displayOrder ?? index + 1, + })), + ); + } + }); + + this.logger.log( + "Ensured company onboarding file upload settings for external companies", + ); + } +} diff --git a/apps/edr-freight-api/src/seed/freight-permission-key-migration.seeder.ts b/apps/edr-freight-api/src/seed/freight-permission-key-migration.seeder.ts new file mode 100644 index 000000000..0a0f86a64 --- /dev/null +++ b/apps/edr-freight-api/src/seed/freight-permission-key-migration.seeder.ts @@ -0,0 +1,48 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { Permission } from '@tria-plc/iamapi-common'; +import { DataSource } from 'typeorm'; + +/** Renamed rule-engine resources: old key -> new key (same permission id). */ +const PERMISSION_KEY_RENAMES: ReadonlyArray<{ from: string; to: string }> = [ + { + from: 'edr_freight_app:rule_engine:priority_rules:view', + to: 'edr_freight_app:rule_engine:priority_configs:view', + }, + { + from: 'edr_freight_app:rule_engine:priority_rules:manage', + to: 'edr_freight_app:rule_engine:priority_configs:manage', + }, +]; + +@Injectable() +export class FreightPermissionKeyMigrationSeeder { + private readonly logger = new Logger(FreightPermissionKeyMigrationSeeder.name); + + constructor(private readonly dataSource: DataSource) {} + + async run() { + const permissionRepository = this.dataSource.getRepository(Permission); + + for (const { from, to } of PERMISSION_KEY_RENAMES) { + const existing = await permissionRepository.findOne({ + where: { key: from }, + select: { id: true, key: true }, + }); + + if (!existing) { + continue; + } + + const targetExists = await permissionRepository.existsBy({ key: to }); + if (targetExists) { + this.logger.warn( + `Skipping permission key rename ${from} -> ${to}: target key already exists`, + ); + continue; + } + + await permissionRepository.update({ id: existing.id }, { key: to }); + this.logger.log(`Renamed permission key ${from} -> ${to}`); + } + } +} diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts new file mode 100644 index 000000000..ed0a494ab --- /dev/null +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -0,0 +1,189 @@ +const EDR_FREIGHT_APP_KEY = 'edr_freight_app'; + +export type FreightPermissionSeed = { + id: string; + key: string; + name: { am: string; en: string }; + applicationKey: string; +}; + +export const RULE_ENGINE_RESOURCE_SLUGS = [ + 'cargo-types', + 'container-types', + 'wagon-types', + 'service-types', + 'yards', + 'shipping-lines', + 'weight-limit-rules', + 'surcharge-types', + 'priority-configs', + 'rates', + 'approval-rules', +] as const; + +export type RuleEngineResourceSlug = (typeof RULE_ENGINE_RESOURCE_SLUGS)[number]; + +const slugToResourceKey = (slug: RuleEngineResourceSlug): string => + slug.replace(/-/g, '_'); + +const perm = ( + id: string, + key: string, + en: string, +): FreightPermissionSeed => ({ + id, + key, + name: { am: en, en }, + applicationKey: EDR_FREIGHT_APP_KEY, +}); + +export const BOOKING_PERMISSIONS: FreightPermissionSeed[] = [ + perm('a1000001-0001-4000-8000-000000000001', 'edr_freight_app:bookings:view', 'View bookings'), + perm('a1000001-0001-4000-8000-000000000002', 'edr_freight_app:bookings:staff_accept', 'Accept booking intake'), + perm('a1000001-0001-4000-8000-000000000003', 'edr_freight_app:bookings:request_changes', 'Request booking changes'), + perm('a1000001-0001-4000-8000-000000000004', 'edr_freight_app:bookings:reject', 'Reject booking submission'), + perm('a1000001-0001-4000-8000-000000000005', 'edr_freight_app:bookings:approve_line_staff', 'Approve as line staff'), + perm('a1000001-0001-4000-8000-000000000006', 'edr_freight_app:bookings:approve_director', 'Approve as director'), + perm('a1000001-0001-4000-8000-000000000007', 'edr_freight_app:bookings:approve_ceo', 'Approve as CEO'), + perm('a1000001-0001-4000-8000-000000000008', 'edr_freight_app:bookings:reject_approval', 'Reject at approval step'), + perm('a1000001-0001-4000-8000-000000000009', 'edr_freight_app:bookings:generate_contract', 'Generate contract'), + perm('a1000001-0001-4000-8000-00000000000a', 'edr_freight_app:bookings:sign_staff', 'Staff contract signature'), + perm('a1000001-0001-4000-8000-00000000000b', 'edr_freight_app:bookings:payment_pnr', 'Generate PNR'), + perm('a1000001-0001-4000-8000-00000000000c', 'edr_freight_app:bookings:payment_verify', 'Verify payment'), + perm('a1000001-0001-4000-8000-00000000000d', 'edr_freight_app:bookings:operations', 'Booking operations'), + perm('a1000001-0001-4000-8000-00000000000e', 'edr_freight_app:bookings:cancel', 'Cancel booking'), + perm('a1000001-0001-4000-8000-00000000000f', 'edr_freight_app:train_scheduling:view', 'View train scheduling'), + perm('a1000001-0001-4000-8000-000000000010', 'edr_freight_app:train_scheduling:manage', 'Manage train scheduling'), + perm('a1000001-0001-4000-8000-000000000011', 'edr_freight_app:fleet:view', 'View fleet'), + perm('a1000001-0001-4000-8000-000000000012', 'edr_freight_app:fleet:manage', 'Manage fleet'), + perm('a1000001-0001-4000-8000-000000000013', 'edr_freight_app:admin', 'Freight administration'), +]; + +const RULE_ENGINE_PERMISSION_IDS: Record = { + 'cargo-types': { view: 'b2000001-0001-4000-8000-000000000001', manage: 'b2000001-0001-4000-8000-000000000002' }, + 'container-types': { view: 'b2000001-0001-4000-8000-000000000003', manage: 'b2000001-0001-4000-8000-000000000004' }, + 'wagon-types': { view: 'b2000001-0001-4000-8000-000000000015', manage: 'b2000001-0001-4000-8000-000000000016' }, + 'service-types': { view: 'b2000001-0001-4000-8000-000000000005', manage: 'b2000001-0001-4000-8000-000000000006' }, + yards: { view: 'b2000001-0001-4000-8000-000000000007', manage: 'b2000001-0001-4000-8000-000000000008' }, + 'shipping-lines': { view: 'b2000001-0001-4000-8000-000000000009', manage: 'b2000001-0001-4000-8000-00000000000a' }, + 'weight-limit-rules': { view: 'b2000001-0001-4000-8000-00000000000b', manage: 'b2000001-0001-4000-8000-00000000000c' }, + 'surcharge-types': { view: 'b2000001-0001-4000-8000-00000000000d', manage: 'b2000001-0001-4000-8000-00000000000e' }, + 'priority-configs': { view: 'b2000001-0001-4000-8000-00000000000f', manage: 'b2000001-0001-4000-8000-000000000010' }, + rates: { view: 'b2000001-0001-4000-8000-000000000011', manage: 'b2000001-0001-4000-8000-000000000012' }, + 'approval-rules': { view: 'b2000001-0001-4000-8000-000000000013', manage: 'b2000001-0001-4000-8000-000000000014' }, +}; + +export const RULE_ENGINE_PERMISSIONS: FreightPermissionSeed[] = RULE_ENGINE_RESOURCE_SLUGS.flatMap( + (slug) => { + const resource = slugToResourceKey(slug); + const ids = RULE_ENGINE_PERMISSION_IDS[slug]; + return [ + perm(ids.view, `edr_freight_app:rule_engine:${resource}:view`, `View ${slug}`), + perm(ids.manage, `edr_freight_app:rule_engine:${resource}:manage`, `Manage ${slug}`), + ]; + }, +); + +export const BOOKING_RULE_ENGINE_PERMISSIONS = [ + ...BOOKING_PERMISSIONS, + ...RULE_ENGINE_PERMISSIONS, +]; + +export const BOOKING_RULE_ENGINE_PERMISSION_KEYS = BOOKING_RULE_ENGINE_PERMISSIONS.map( + (p) => p.key, +); + +export const FREIGHT_PERMS = { + bookings: { + view: 'edr_freight_app:bookings:view', + staffAccept: 'edr_freight_app:bookings:staff_accept', + requestChanges: 'edr_freight_app:bookings:request_changes', + reject: 'edr_freight_app:bookings:reject', + approveLineStaff: 'edr_freight_app:bookings:approve_line_staff', + approveDirector: 'edr_freight_app:bookings:approve_director', + approveCeo: 'edr_freight_app:bookings:approve_ceo', + rejectApproval: 'edr_freight_app:bookings:reject_approval', + generateContract: 'edr_freight_app:bookings:generate_contract', + signStaff: 'edr_freight_app:bookings:sign_staff', + operations: 'edr_freight_app:bookings:operations', + cancel: 'edr_freight_app:bookings:cancel', + }, + trainScheduling: { + view: 'edr_freight_app:train_scheduling:view', + manage: 'edr_freight_app:train_scheduling:manage', + }, + fleet: { + view: 'edr_freight_app:fleet:view', + manage: 'edr_freight_app:fleet:manage', + }, + admin: 'edr_freight_app:admin', + ruleEngine: { + view: (slug: RuleEngineResourceSlug) => + `edr_freight_app:rule_engine:${slugToResourceKey(slug)}:view`, + manage: (slug: RuleEngineResourceSlug) => + `edr_freight_app:rule_engine:${slugToResourceKey(slug)}:manage`, + }, +} as const; + +const allRuleEngineViewKeys = () => + RULE_ENGINE_RESOURCE_SLUGS.map((s) => FREIGHT_PERMS.ruleEngine.view(s)); + +export const ROLE_PERMISSION_PRESETS = { + // Marketing / line staff: drives a booking from intake through line-staff + // approval and contract generation/signing — i.e. until the contract is ready + // and signed. No director/CEO approval, no scheduling, no operations. + lineStaff: [ + FREIGHT_PERMS.bookings.view, + FREIGHT_PERMS.bookings.staffAccept, + FREIGHT_PERMS.bookings.requestChanges, + FREIGHT_PERMS.bookings.reject, + FREIGHT_PERMS.bookings.approveLineStaff, + FREIGHT_PERMS.bookings.rejectApproval, + FREIGHT_PERMS.bookings.cancel, + ...allRuleEngineViewKeys(), + ], + // Operations Officer: train scheduling + wagon allocation + transit/complete + // + fleet management (wagons, trains, locomotives, routes, containers, cargo). + operationsOfficer: [ + FREIGHT_PERMS.bookings.view, + FREIGHT_PERMS.bookings.operations, + FREIGHT_PERMS.trainScheduling.view, + FREIGHT_PERMS.trainScheduling.manage, + FREIGHT_PERMS.fleet.view, + FREIGHT_PERMS.fleet.manage, + ...allRuleEngineViewKeys(), + ], + director: [ + FREIGHT_PERMS.bookings.view, + FREIGHT_PERMS.bookings.approveDirector, + FREIGHT_PERMS.bookings.rejectApproval, + FREIGHT_PERMS.bookings.generateContract, + ...allRuleEngineViewKeys(), + ], + ceo: [ + FREIGHT_PERMS.bookings.view, + FREIGHT_PERMS.bookings.approveCeo, + FREIGHT_PERMS.bookings.rejectApproval, + ...allRuleEngineViewKeys(), + ], + finance: [FREIGHT_PERMS.bookings.view], + // Marketing handles intake through contract (same as line staff here). + marketing: [ + FREIGHT_PERMS.bookings.view, + FREIGHT_PERMS.bookings.staffAccept, + FREIGHT_PERMS.bookings.requestChanges, + FREIGHT_PERMS.bookings.reject, + FREIGHT_PERMS.bookings.approveLineStaff, + FREIGHT_PERMS.bookings.rejectApproval, + FREIGHT_PERMS.bookings.cancel, + FREIGHT_PERMS.bookings.generateContract, + FREIGHT_PERMS.bookings.signStaff, + ], + orgManager: [...BOOKING_RULE_ENGINE_PERMISSION_KEYS], +} as const; + +export const PERMISSIONS_CATALOG = BOOKING_RULE_ENGINE_PERMISSIONS.map((p) => ({ + key: p.key, + label: p.name.en, + module: p.key.includes(':bookings:') ? 'bookings' : 'rule_engine', +})); diff --git a/apps/edr-freight-api/src/seed/freight-staff-users.seeder.ts b/apps/edr-freight-api/src/seed/freight-staff-users.seeder.ts new file mode 100644 index 000000000..06b9afa94 --- /dev/null +++ b/apps/edr-freight-api/src/seed/freight-staff-users.seeder.ts @@ -0,0 +1,127 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { hashPassword } from '@tria-plc/api-common/utils/argon'; +import { EUserStatus } from '@tria-plc/api-common/utils/enums/user.enum'; +import { + Employee, + Organization, + Role, + User, + UserCredential, + UserRole, +} from '@tria-plc/iamapi-common'; +import { DataSource } from 'typeorm'; + +const SEED_FLAG = 'SEED_FREIGHT_STAFF'; +const EDR_ORG_KEY = 'edr_freight'; + +const STAFF_USERS = [ + { email: 'linestaff@edr.local', username: 'linestaff', roleKey: 'edr_line_staff' }, + { email: 'director@edr.local', username: 'director', roleKey: 'edr_director' }, + { email: 'ceo@edr.local', username: 'ceo', roleKey: 'edr_ceo' }, +] as const; + +@Injectable() +export class FreightStaffUsersSeeder { + private readonly logger = new Logger(FreightStaffUsersSeeder.name); + + constructor(private readonly dataSource: DataSource) {} + + async run() { + if (process.env[SEED_FLAG]?.trim().toLowerCase() !== 'true') { + this.logger.log(`Skipping freight staff seed because ${SEED_FLAG} is not enabled`); + return; + } + + const password = + process.env.DEFAULT_PASSWORD?.trim() || '12345678'; + + await this.dataSource.transaction(async (manager) => { + const organization = await manager.getRepository(Organization).findOne({ + where: { key: EDR_ORG_KEY }, + select: { id: true, key: true }, + }); + + if (!organization) { + throw new Error(`missing_organization:${EDR_ORG_KEY}`); + } + + const roleRepository = manager.getRepository(Role); + const userRepository = manager.getRepository(User); + const userCredentialRepository = manager.getRepository(UserCredential); + const userRoleRepository = manager.getRepository(UserRole); + const employeeRepository = manager.getRepository(Employee); + + const hashedPassword = await hashPassword(password); + + for (const staff of STAFF_USERS) { + const role = await roleRepository.findOne({ + where: { key: staff.roleKey }, + select: { id: true, key: true }, + }); + + if (!role) { + throw new Error(`missing_role:${staff.roleKey}`); + } + + let user = await userRepository.findOne({ + where: { email: staff.email }, + select: { id: true, email: true }, + }); + + if (!user) { + user = await userRepository.save( + userRepository.create({ + email: staff.email, + username: staff.username, + name: { en: staff.username }, + isActive: true, + hasSetPassword: true, + status: EUserStatus.ACCEPTED, + }), + ); + this.logger.log(`Seeded freight staff user ${staff.email}`); + } + + const activeCredentialExists = await userCredentialRepository.exists({ + where: { userId: user.id, isActive: true }, + }); + + if (!activeCredentialExists) { + await userCredentialRepository.insert({ + userId: user.id, + password: hashedPassword, + isActive: true, + }); + } + + await userRoleRepository.upsert( + { + userId: user.id, + roleId: role.id, + organizationId: organization.id, + }, + { conflictPaths: { userId: true, roleId: true } }, + ); + + const employeeExists = await employeeRepository.exists({ + where: { + userId: user.id, + organizationId: organization.id, + isCurrent: true, + }, + }); + + if (!employeeExists) { + await employeeRepository.insert({ + userId: user.id, + organizationId: organization.id, + isCurrent: true, + name: { en: staff.username }, + }); + } + } + }); + + this.logger.log('Ensured freight staff users (linestaff@, director@, ceo@)'); + } +} diff --git a/apps/edr-freight-api/src/seed/indode-facility.seeder.ts b/apps/edr-freight-api/src/seed/indode-facility.seeder.ts new file mode 100644 index 000000000..4b4ae23f0 --- /dev/null +++ b/apps/edr-freight-api/src/seed/indode-facility.seeder.ts @@ -0,0 +1,186 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { DataSource } from 'typeorm'; + +import { Facility } from '../modules/facilities/entities/facility.entity'; +import { Warehouse } from '../modules/warehouses/entities/warehouse.entity'; +import { WarehouseYard, type WarehouseYardType } from '../modules/warehouses/entities/warehouse-yard.entity'; +import { WarehouseZone, type WarehouseZoneType } from '../modules/warehouses/entities/warehouse-zone.entity'; + +const INDODE_FACILITY = { + code: 'INDODE_DRY_PORT', + name: 'Indode Multipurpose Dry Port', + description: 'Main facility for container and cargo handling', + facilityType: 'DRY_PORT' as const, + facilityStatus: 'ACTIVE' as const, + locationName: 'Indode', + country: 'Djibouti', + city: 'Djibouti', + address: 'Indode, Djibouti', + latitude: 11.5447, + longitude: 43.145, + capacity: 50000, + isActive: true, + notes: 'Primary dry port for container consolidation and distribution', +}; + +const WAREHOUSES = [ + { + name: 'Open Warehouse - Indode', + code: 'INDODE_OPEN', + type: 'OPEN_WAREHOUSE' as const, + locationName: 'Indode Open', + capacityWeight: 25000, + capacityContainers: 500, + maxWeight: 25000, + maxVolume: 5000, + status: 'ACTIVE' as const, + isActive: true, + }, + { + name: 'Closed Warehouse - Indode', + code: 'INDODE_CLOSED', + type: 'CLOSED_WAREHOUSE' as const, + locationName: 'Indode Closed', + capacityWeight: 20000, + capacityContainers: 400, + maxWeight: 20000, + maxVolume: 4000, + status: 'ACTIVE' as const, + isActive: true, + }, +]; + +const YARD_TYPES = [ + 'CONTAINER_YARD', + 'BULK_YARD', + 'GENERAL_CARGO_YARD', + 'HAZARDOUS_YARD', + 'COLD_STORAGE_YARD', +] as const; + +@Injectable() +export class IndodeFacilitySeeder { + private readonly logger = new Logger(IndodeFacilitySeeder.name); + + constructor(private readonly dataSource: DataSource) {} + + async run(): Promise { + try { + await this.dataSource.transaction(async (manager) => { + const facilityRepo = manager.getRepository(Facility); + const warehouseRepo = manager.getRepository(Warehouse); + const yardRepo = manager.getRepository(WarehouseYard); + const zoneRepo = manager.getRepository(WarehouseZone); + + // Ensure facility exists + const facility = await facilityRepo.findOne({ + where: { code: INDODE_FACILITY.code }, + }); + + if (facility) { + this.logger.log('Indode facility already exists, skipping seed'); + return; + } + + const newFacility = facilityRepo.create(INDODE_FACILITY); + const savedFacility = await facilityRepo.save(newFacility); + this.logger.log(`Created facility: ${savedFacility.code}`); + + // Create warehouses for the facility + for (const warehouseData of WAREHOUSES) { + try { + const warehouse = await warehouseRepo.findOne({ + where: { code: warehouseData.code }, + }); + + if (warehouse) { + this.logger.log(`Warehouse ${warehouseData.code} already exists, skipping`); + continue; + } + + const newWarehouse = warehouseRepo.create({ + ...warehouseData, + facilityId: savedFacility.id, + }); + const savedWarehouse = await warehouseRepo.save(newWarehouse); + this.logger.log(`Created warehouse: ${savedWarehouse.code} under facility ${savedFacility.code}`); + + // Create 11 yards per warehouse + await this.createYardsForWarehouse(yardRepo, zoneRepo, savedWarehouse); + } catch (warehouseError) { + this.logger.warn( + `Failed to create warehouse ${warehouseData.code}: ${warehouseError instanceof Error ? warehouseError.message : String(warehouseError)}`, + ); + } + } + + this.logger.log( + 'Indode Multipurpose Dry Port facility seeded successfully with 2 warehouses and 11 yards each', + ); + }); + } catch (error) { + this.logger.error( + `IndodeFacilitySeeder error: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + + private async createYardsForWarehouse( + yardRepo: any, + zoneRepo: any, + warehouse: Warehouse, + ): Promise { + const baseCapacityWeight = 5000; + const baseCapacityContainers = 100; + const yardCount = 11; + + for (let i = 0; i < yardCount; i++) { + const yardType: WarehouseYardType = i < YARD_TYPES.length ? YARD_TYPES[i] : 'GENERAL_CARGO_YARD'; + const yardCode = `${warehouse.code}_YARD_${String(i + 1).padStart(2, '0')}`; + + const existingYard = await yardRepo.findOne({ where: { code: yardCode } }); + if (existingYard) { + this.logger.log(`Yard ${yardCode} already exists, skipping`); + continue; + } + + const yardData = { + warehouseId: warehouse.id, + name: `${warehouse.code} ${yardType.replace(/_/g, ' ')} ${String(i + 1).padStart(2, '0')}`, + code: yardCode, + type: yardType, + capacityWeight: baseCapacityWeight, + capacityContainers: baseCapacityContainers, + maxWeight: baseCapacityWeight, + maxVolume: baseCapacityWeight / 2, + status: 'ACTIVE' as const, + isActive: true, + }; + + const newYard = yardRepo.create(yardData); + const savedYard = (await yardRepo.save(newYard)) as WarehouseYard; + this.logger.log(`Created yard: ${savedYard.code}`); + + // Create default zone for the yard + const zoneType: WarehouseZoneType = yardType.replace('_YARD', '_ZONE') as WarehouseZoneType; + const zoneCode = `${savedYard.code}_ZONE_A`; + + const zoneData = { + yardId: savedYard.id, + name: `${savedYard.name} Zone A`, + code: zoneCode, + type: zoneType, + capacityWeight: (baseCapacityWeight ?? 1000) / 2, + capacityContainers: (baseCapacityContainers ?? 100) / 2, + maxWeight: (baseCapacityWeight ?? 1000) / 2, + maxVolume: (baseCapacityWeight ?? 500) / 2, + status: 'ACTIVE' as const, + isActive: true, + }; + + const newZone = zoneRepo.create(zoneData); + await zoneRepo.save(newZone); + this.logger.log(`Created zone: ${zoneCode}`); + } + } +} diff --git a/apps/edr-freight-api/src/seed/pricing-data.seeder.ts b/apps/edr-freight-api/src/seed/pricing-data.seeder.ts new file mode 100644 index 000000000..75d17be98 --- /dev/null +++ b/apps/edr-freight-api/src/seed/pricing-data.seeder.ts @@ -0,0 +1,777 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { DataSource } from "typeorm"; + +import { BookingCargoModifier } from "../modules/bookings/entities/booking-cargo-modifier.entity"; +import { CargoType } from "../modules/rule-engine/entities/cargo-type.entity"; +import { ContainerType } from "../modules/rule-engine/entities/container-type.entity"; +import { PriorityConfig } from "../modules/rule-engine/entities/priority-config.entity"; +import { Rate } from "../modules/rule-engine/entities/rate.entity"; +import { ServiceType } from "../modules/rule-engine/entities/service-type.entity"; +import { ShippingLine } from "../modules/rule-engine/entities/shipping-line.entity"; +import { SurchargeType } from "../modules/rule-engine/entities/surcharge-type.entity"; +import { WeightLimitRule } from "../modules/rule-engine/entities/weight-limit-rule.entity"; +import { Route } from "../modules/routes/entities/route.entity"; +import { RouteMilestone } from "../modules/routes/entities/route-milestone.entity"; +import { Yard } from "../modules/rule-engine/entities/yard.entity"; + +const STAFF_USER_ID = "00000000-0000-0000-0000-000000000001"; +const CEO_USER_ID = "00000000-0000-0000-0000-000000000002"; + +@Injectable() +export class PricingDataSeeder { + private readonly logger = new Logger(PricingDataSeeder.name); + + constructor(private readonly dataSource: DataSource) { } + + async run(): Promise { + await this.dataSource.transaction(async (manager) => { + const ctRepo = manager.getRepository(ContainerType); + const stRepo = manager.getRepository(ServiceType); + const yRepo = manager.getRepository(Yard); + const slRepo = manager.getRepository(ShippingLine); + const wlRepo = manager.getRepository(WeightLimitRule); + const prRepo = manager.getRepository(PriorityConfig); + const rRepo = manager.getRepository(Rate); + + await this.upsertReferenceData(manager, ctRepo, stRepo, yRepo, slRepo); + await this.seedDomesticRoute(manager, yRepo); + await this.seedWeightLimits(wlRepo, ctRepo); + await this.seedPriorityConfigs(prRepo); + const containerTypes = await ctRepo.find(); + const ctByCode = new Map(containerTypes.map((ct) => [ct.code, ct])); + + const rates = await this.seedRates(rRepo, ctByCode); + const ratesByType = new Map(); + for (const r of rates) { + const key = `${r.rateType}|${r.currency}|${r.containerTypeId ?? ""}`; + if (!ratesByType.has(key)) ratesByType.set(key, []); + ratesByType.get(key)!.push(r); + } + + await this.seedSurchargeTypes(manager, ratesByType); + + const yards = await yRepo.find(); + const yardByCode = new Map(yards.map((y) => [y.code, y])); + const serviceTypes = await stRepo.find(); + const stByCode = new Map(serviceTypes.map((st) => [st.code, st])); + const shippingLines = await slRepo.find(); + const slByCode = new Map(shippingLines.map((sl) => [sl.code, sl])); + const cargoTypes = await manager.getRepository(CargoType).find(); + const cargoByCode = new Map(cargoTypes.map((c) => [c.code, c])); + + await this.seedDraftBookings( + ctByCode, + yardByCode, + stByCode, + slByCode, + cargoByCode, + ); + }); + + this.logger.log("Seeded pricing data"); + } + + private async upsertReferenceData( + manager: any, + ctRepo: any, + stRepo: any, + yRepo: any, + slRepo: any, + ): Promise { + await yRepo.upsert( + [ + { + code: "DJIBOUTI", + label: "Djibouti", + country: "Djibouti", + displayOrder: 1, + isActive: true, + }, + { + code: "ADDIS_ABABA", + label: "Addis Ababa", + country: "Ethiopia", + displayOrder: 2, + isActive: true, + }, + { + code: "DIRE_DAWA", + label: "Dire Dawa", + country: "Ethiopia", + displayOrder: 3, + isActive: true, + }, + { + code: "MODJO", + label: "Modjo", + country: "Ethiopia", + displayOrder: 4, + isActive: true, + }, + ], + { conflictPaths: { code: true } }, + ); + + await ctRepo.upsert( + [ + { + code: "20FT", + label: "20FT Standard", + sizeFt: 20, + wagonsPerUnit: 0.5, + isReefer: false, + isOpenTop: false, + isActive: true, + displayOrder: 1, + }, + { + code: "40FT", + label: "40FT Standard", + sizeFt: 40, + wagonsPerUnit: 1, + isReefer: false, + isOpenTop: false, + isActive: true, + displayOrder: 2, + }, + { + code: "20FT_REEFER", + label: "20FT Reefer", + sizeFt: 20, + wagonsPerUnit: 0.5, + isReefer: true, + isOpenTop: false, + isActive: true, + displayOrder: 3, + }, + { + code: "40FT_REEFER", + label: "40FT Reefer", + sizeFt: 40, + wagonsPerUnit: 1, + isReefer: true, + isOpenTop: false, + isActive: true, + displayOrder: 4, + }, + ], + { conflictPaths: { code: true } }, + ); + + await stRepo.upsert( + [ + { + code: "RAIL_CONTAINER", + serviceName: "Rail Container Service", + description: "Standard rail container transport", + canBeBookedAlone: true, + includesFirstMile: false, + includesLastMile: false, + includesCustoms: false, + priorityBonusPoints: 0, + isActive: true, + displayOrder: 1, + }, + { + code: "RAIL_FORWARDING", + serviceName: "Rail Forwarding Service", + description: "Rail transport with first/last mile and customs", + canBeBookedAlone: true, + includesFirstMile: true, + includesLastMile: true, + includesCustoms: true, + priorityBonusPoints: 100, + isActive: true, + displayOrder: 2, + }, + { + code: "RAIL_BULK", + serviceName: "Rail Bulk Transport", + description: "Bulk commodity rail transport", + canBeBookedAlone: true, + includesFirstMile: false, + includesLastMile: false, + includesCustoms: false, + priorityBonusPoints: 50, + isActive: true, + displayOrder: 3, + }, + ], + { conflictPaths: { code: true } }, + ); + + await slRepo.upsert( + [ + { + code: "MAERSK", + label: "Maersk Line", + mappedToCode: "MAERSK", + showExtraFeeNotice: true, + isActive: true, + }, + { + code: "MSC", + label: "MSC", + mappedToCode: "MSC", + showExtraFeeNotice: true, + isActive: true, + }, + { + code: "CMA_CGM", + label: "CMA CGM", + mappedToCode: "CMA_CGM", + showExtraFeeNotice: true, + isActive: true, + }, + { + code: "COSCO", + label: "COSCO Shipping", + mappedToCode: "COSCO", + showExtraFeeNotice: true, + isActive: true, + }, + { + code: "OTHER", + label: "Other Line", + mappedToCode: null, + showExtraFeeNotice: false, + isActive: true, + }, + ], + { conflictPaths: { code: true } }, + ); + + await manager.getRepository(CargoType).upsert( + [ + { + code: "GRAIN", + cargoTypeName: "Grain / Cereals", + requiresDirectorApproval: false, + isActive: true, + displayOrder: 1, + }, + { + code: "FERTILIZER", + cargoTypeName: "Fertilizer", + requiresDirectorApproval: false, + isActive: true, + displayOrder: 2, + }, + { + code: "CEMENT", + cargoTypeName: "Cement / Clinker", + requiresDirectorApproval: false, + isActive: true, + displayOrder: 3, + }, + { + code: "STEEL", + cargoTypeName: "Steel / Rebar", + requiresDirectorApproval: true, + isActive: true, + displayOrder: 4, + }, + { + code: "MACHINERY", + cargoTypeName: "Heavy Machinery", + requiresDirectorApproval: true, + isActive: true, + displayOrder: 5, + }, + { + code: "OTHER_BULK", + cargoTypeName: "Other Bulk Cargo", + requiresDirectorApproval: false, + isActive: true, + displayOrder: 6, + }, + ], + { conflictPaths: { code: true } }, + ); + } + + private async seedDomesticRoute(manager: any, yRepo: any): Promise { + const addis = await yRepo.findOneBy({ code: "ADDIS_ABABA" }); + const direDawa = await yRepo.findOneBy({ code: "DIRE_DAWA" }); + if (!addis || !direDawa) return; + + const routeRepo = manager.getRepository(Route); + const milestoneRepo = manager.getRepository(RouteMilestone); + const routeName = "Addis Ababa → Dire Dawa"; + let route = await routeRepo.findOneBy({ name: routeName }); + if (!route) { + route = await routeRepo.save( + routeRepo.create({ + name: routeName, + originYardId: addis.id, + destinationYardId: direDawa.id, + isActive: true, + }), + ); + await milestoneRepo.save([ + milestoneRepo.create({ + routeId: route.id, + yardId: addis.id, + sequenceNo: 1, + }), + milestoneRepo.create({ + routeId: route.id, + yardId: direDawa.id, + sequenceNo: 2, + }), + ]); + this.logger.log("Seeded domestic route Addis Ababa → Dire Dawa"); + } + } + +private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise { + const twenty = await ctRepo.findOneByOrFail({ code: "20FT" }); + const forty = await ctRepo.findOneByOrFail({ code: "40FT" }); + + const base = new Date("2026-01-01"); + + const rules = [ + { + containerTypeId: twenty.id, + tradeDirection: "IMPORT", + maxVgmTons: 26, + effectiveFrom: base, + isActive: true, + }, + { + containerTypeId: twenty.id, + tradeDirection: "EXPORT", + maxVgmTons: 26, + effectiveFrom: base, + isActive: true, + }, + { + containerTypeId: forty.id, + tradeDirection: "IMPORT", + maxVgmTons: 28, + effectiveFrom: base, + isActive: true, + }, + { + containerTypeId: forty.id, + tradeDirection: "EXPORT", + maxVgmTons: 28, + effectiveFrom: base, + isActive: true, + }, + ]; + + for (const rule of rules) { + const existing = await wlRepo.findOne({ + where: { + containerTypeId: rule.containerTypeId, + tradeDirection: rule.tradeDirection, + }, + }); + + if (existing) { + await wlRepo.update(existing.id, { + maxVgmTons: rule.maxVgmTons, + effectiveFrom: rule.effectiveFrom, + }); + } else { + await wlRepo.insert(rule); + } + } + + this.logger.log("Seeded weight limit rules"); +} + private async seedPriorityConfigs(prRepo: any): Promise { + // Wagon Count Block — independent, applies regardless of currency. + // Currency Block — applies only to the matching payment currency, within the wagon range. + // Both blocks are additive (see RuleEngineService.evaluate). + const rows = [ + // ── Wagon Count Block ─────────────────────────────────────────────── + { type: "WAGON", label: "Wagons 1–20", currency: null, minWagonCount: 1, maxWagonCount: 20, scorePoints: 0, displayOrder: 1 }, + { type: "WAGON", label: "Wagons 21–30", currency: null, minWagonCount: 21, maxWagonCount: 30, scorePoints: 15, displayOrder: 2 }, + { type: "WAGON", label: "Wagons 31–40", currency: null, minWagonCount: 31, maxWagonCount: 40, scorePoints: 30, displayOrder: 3 }, + { type: "WAGON", label: "Wagons 41–50", currency: null, minWagonCount: 41, maxWagonCount: 50, scorePoints: 50, displayOrder: 4 }, + // ── Payment Currency Block ────────────────────────────────────────── + { type: "CURRENCY", label: "USD · Wagons 1–25", currency: "USD", minWagonCount: 1, maxWagonCount: 25, scorePoints: 17, displayOrder: 5 }, + { type: "CURRENCY", label: "USD · Wagons 26–50", currency: "USD", minWagonCount: 26, maxWagonCount: 50, scorePoints: 35, displayOrder: 6 }, + { type: "CURRENCY", label: "ETB · Wagons 1–50", currency: "ETB", minWagonCount: 1, maxWagonCount: 50, scorePoints: 0, displayOrder: 7 }, + ]; + + for (const row of rows) { + const existing = await prRepo.findOne({ + where: { type: row.type, label: row.label }, + withDeleted: true, + }); + if (existing) { + await prRepo.save({ ...existing, ...row, isActive: true, deletedAt: null }); + } else { + await prRepo.save(prRepo.create({ ...row, isActive: true })); + } + } + this.logger.log("Seeded priority configs"); + } + + private async seedRates( + rRepo: any, + ctByCode: Map, + ): Promise { + const effectiveFrom = new Date("2026-01-01"); + const now = new Date(); + // await rRepo.createQueryBuilder().delete().execute(); + + const rateData = [ + { + rateType: "CONTAINER_IMPORT", + containerTypeId: ctByCode.get("20FT")!.id, + currency: "USD", + rateValue: 800, + rateUnit: "PER_CONTAINER", + }, + { + rateType: "CONTAINER_IMPORT", + containerTypeId: ctByCode.get("40FT")!.id, + currency: "USD", + rateValue: 1200, + rateUnit: "PER_CONTAINER", + }, + { + rateType: "CONTAINER_EXPORT", + containerTypeId: ctByCode.get("20FT")!.id, + currency: "USD", + rateValue: 600, + rateUnit: "PER_CONTAINER", + }, + { + rateType: "CONTAINER_EXPORT", + containerTypeId: ctByCode.get("40FT")!.id, + currency: "USD", + rateValue: 900, + rateUnit: "PER_CONTAINER", + }, + { + rateType: "CONTAINER_IMPORT", + containerTypeId: null, + currency: "USD", + rateValue: 1000, + rateUnit: "PER_CONTAINER", + }, + { + rateType: "CONTAINER_EXPORT", + containerTypeId: null, + currency: "USD", + rateValue: 750, + rateUnit: "PER_CONTAINER", + }, + { + rateType: "INTERCITY_CONTAINER", + containerTypeId: ctByCode.get("20FT")!.id, + currency: "USD", + rateValue: 350, + rateUnit: "PER_CONTAINER", + }, + { + rateType: "INTERCITY_CONTAINER", + containerTypeId: ctByCode.get("40FT")!.id, + currency: "USD", + rateValue: 550, + rateUnit: "PER_CONTAINER", + }, + { + rateType: "INTERCITY_CONTAINER", + containerTypeId: null, + currency: "USD", + rateValue: 400, + rateUnit: "PER_CONTAINER", + }, + { + rateType: "INTERCITY_BULK", + containerTypeId: null, + currency: "USD", + rateValue: 35, + rateUnit: "PER_TON", + }, + { + rateType: "BULK_IMPORT", + containerTypeId: null, + currency: "USD", + rateValue: 50, + rateUnit: "PER_TON", + }, + { + rateType: "BULK_EXPORT", + containerTypeId: null, + currency: "USD", + rateValue: 40, + rateUnit: "PER_TON", + }, + { + rateType: "OVERWEIGHT_PER_TON", + containerTypeId: null, + currency: "USD", + rateValue: 25, + rateUnit: "PER_TON", + }, + { + rateType: "HAZARD_SURCHARGE", + containerTypeId: null, + currency: "USD", + rateValue: 150, + rateUnit: "FLAT", + }, + { + rateType: "REEFER_SURCHARGE", + containerTypeId: null, + currency: "USD", + rateValue: 200, + rateUnit: "FLAT", + }, + { + rateType: "DOUBLE_HANDLING", + containerTypeId: null, + currency: "USD", + rateValue: 100, + rateUnit: "PER_CONTAINER", + }, + { + rateType: "LASHING", + containerTypeId: null, + currency: "USD", + rateValue: 50, + rateUnit: "PER_CONTAINER", + }, + ]; + + const entities = rateData.map((d) => + rRepo.create({ + ...d, + status: "LIVE", + proposedByStaffId: STAFF_USER_ID, + approvedByCeoId: CEO_USER_ID, + approvedAt: now, + effectiveFrom, + }), + ); + return rRepo.save(entities); + } + + private async seedSurchargeTypes( + manager: any, + ratesByType: Map, + ): Promise { + const surRepo = manager.getRepository(SurchargeType); + const bcmRepo = manager.getRepository(BookingCargoModifier); + await bcmRepo.createQueryBuilder().delete().execute(); + const findRate = (rateType: string, currency: string) => { + const key = `${rateType}|${currency}|`; + const rates = ratesByType.get(key); + return rates?.[0]; + }; + + const hazardRateUsd = findRate("HAZARD_SURCHARGE", "USD"); + const reeferRateUsd = findRate("REEFER_SURCHARGE", "USD"); + const overweightRateUsd = findRate("OVERWEIGHT_PER_TON", "USD"); + const shipLineRateUsd = findRate("DOUBLE_HANDLING", "USD"); + const consolidRateUsd = findRate("LASHING", "USD"); + + await surRepo.createQueryBuilder().delete().execute(); + await surRepo.save([ + surRepo.create({ + code: "HAZARDOUS_CARGO", + label: "Hazardous Cargo", + triggerCondition: "CARGO_FLAG_HAZARDOUS", + rateId: hazardRateUsd?.id, + isActive: true, + }), + surRepo.create({ + code: "REEFER_CARGO", + label: "Reefer Cargo", + triggerCondition: "CARGO_FLAG_REEFER", + rateId: reeferRateUsd?.id, + isActive: true, + }), + surRepo.create({ + code: "OVERWEIGHT_CARGO", + label: "Overweight Cargo", + triggerCondition: "VGM_EXCEEDS_LIMIT", + rateId: overweightRateUsd?.id, + isActive: true, + }), + surRepo.create({ + code: "SHIPPING_LINE_FEE", + label: "Shipping Line Fee", + triggerCondition: "SHIPPING_LINE_MAPPED", + rateId: shipLineRateUsd?.id, + isActive: true, + }), + surRepo.create({ + code: "CONSOLIDATION_FEE", + label: "Consolidation Fee", + triggerCondition: "CONSOLIDATION_ENABLED", + rateId: consolidRateUsd?.id, + isActive: true, + }), + ]); + this.logger.log("Seeded surcharge types"); + } + + private async seedDraftBookings( + ctByCode: Map, + yardByCode: Map, + stByCode: Map, + slByCode: Map, + cargoByCode: Map, + ): Promise { + const djibouti = yardByCode.get("DJIBOUTI")!; + const addis = yardByCode.get("ADDIS_ABABA")!; + const railContainer = stByCode.get("RAIL_CONTAINER")!; + const railBulk = stByCode.get("RAIL_BULK")!; + const maersk = slByCode.get("MAERSK")!; + const grain = cargoByCode.get("GRAIN")!; + const twenty = ctByCode.get("20FT")!; + const forty = ctByCode.get("40FT")!; + const twentyReefer = ctByCode.get("20FT_REEFER")!; + + const drafts = [ + { + reference: "BKG-PRICE-001", + description: "Standard 20FT container import — base rail only", + freightType: "CONTAINER" as const, + tradeDirection: "IMPORT", + paymentCurrency: "USD", + serviceTypeId: railContainer.id, + originYardId: djibouti.id, + destinationYardId: addis.id, + isHazardous: false, + allowConsolidation: false, + shippingLineId: null, + cargoTypeId: null, + cargoTotalWeightVgm: 250, + containers: [ + { containerTypeId: twenty.id, quantity: 10, vgmPerUnitTons: 25 }, + ], + expectedBaseRate: 800, + expectedSurcharges: [], + }, + { + reference: "BKG-PRICE-002", + description: "40FT container import + hazardous surcharge", + freightType: "CONTAINER" as const, + tradeDirection: "IMPORT", + paymentCurrency: "USD", + serviceTypeId: railContainer.id, + originYardId: djibouti.id, + destinationYardId: addis.id, + isHazardous: true, + allowConsolidation: false, + shippingLineId: null, + cargoTypeId: null, + cargoTotalWeightVgm: 135, + containers: [ + { containerTypeId: forty.id, quantity: 5, vgmPerUnitTons: 27 }, + ], + expectedBaseRate: 1200, + expectedSurcharges: ["HAZARDOUS_CARGO"], + }, + { + reference: "BKG-PRICE-003", + description: "20FT container import + shipping line (USD)", + freightType: "CONTAINER" as const, + tradeDirection: "IMPORT", + paymentCurrency: "USD", + serviceTypeId: railContainer.id, + originYardId: djibouti.id, + destinationYardId: addis.id, + isHazardous: false, + allowConsolidation: false, + shippingLineId: maersk.id, + cargoTypeId: null, + cargoTotalWeightVgm: 480, + containers: [ + { containerTypeId: twenty.id, quantity: 20, vgmPerUnitTons: 24 }, + ], + expectedBaseRate: 800, + expectedSurcharges: ["SHIPPING_LINE_FEE"], + }, + { + reference: "BKG-PRICE-004", + description: "40FT container import + consolidation (USD)", + freightType: "CONTAINER" as const, + tradeDirection: "IMPORT", + paymentCurrency: "USD", + serviceTypeId: railContainer.id, + originYardId: djibouti.id, + destinationYardId: addis.id, + isHazardous: false, + allowConsolidation: true, + shippingLineId: null, + cargoTypeId: null, + cargoTotalWeightVgm: 224, + containers: [ + { containerTypeId: forty.id, quantity: 8, vgmPerUnitTons: 28 }, + ], + expectedBaseRate: 1200, + expectedSurcharges: ["CONSOLIDATION_FEE"], + }, + { + reference: "BKG-PRICE-005", + description: "Bulk import — grain", + freightType: "BULK" as const, + tradeDirection: "IMPORT", + paymentCurrency: "USD", + serviceTypeId: railBulk.id, + originYardId: djibouti.id, + destinationYardId: addis.id, + isHazardous: false, + allowConsolidation: false, + shippingLineId: null, + cargoTypeId: grain.id, + cargoTotalWeightVgm: 500, + containers: [], + expectedBaseRate: 50, + expectedSurcharges: [], + }, + { + reference: "BKG-PRICE-006", + description: "20FT reefer container import + reefer surcharge", + freightType: "CONTAINER" as const, + tradeDirection: "IMPORT", + paymentCurrency: "USD", + serviceTypeId: railContainer.id, + originYardId: djibouti.id, + destinationYardId: addis.id, + isHazardous: false, + allowConsolidation: false, + shippingLineId: null, + cargoTypeId: null, + cargoTotalWeightVgm: 75, + containers: [ + { containerTypeId: twentyReefer.id, quantity: 3, vgmPerUnitTons: 25 }, + ], + expectedBaseRate: 800, + expectedSurcharges: ["REEFER_CARGO"], + }, + { + reference: "BKG-PRICE-007", + description: "20FT container import + overweight (30t > 26t limit)", + freightType: "CONTAINER" as const, + tradeDirection: "IMPORT", + paymentCurrency: "USD", + serviceTypeId: railContainer.id, + originYardId: djibouti.id, + destinationYardId: addis.id, + isHazardous: false, + allowConsolidation: false, + shippingLineId: null, + cargoTypeId: null, + cargoTotalWeightVgm: 300, + containers: [ + { containerTypeId: twenty.id, quantity: 10, vgmPerUnitTons: 30 }, + ], + expectedBaseRate: 800, + expectedSurcharges: ["OVERWEIGHT_CARGO"], + }, + ]; + + this.logger.log(`Seeded ${drafts.length} DRAFT bookings for pricing`); + } +} diff --git a/apps/edr-freight-api/test-minio-upload.js b/apps/edr-freight-api/test-minio-upload.js new file mode 100644 index 000000000..4b793b504 --- /dev/null +++ b/apps/edr-freight-api/test-minio-upload.js @@ -0,0 +1,45 @@ +const { Client } = require('minio'); + +const config = { + endPoint: 'minio-dev.smart.aaca.gov.et', + port: 443, + useSSL: true, + accessKey: 'f2f22b0ea929cebd5567ed0c71ec351b', + secretKey: 'xxHnjRsb90suQZZdOtEcXJXls4nj0A2anMetb1kY', + bucket: 'fhc', +}; + +const filePath = '/home/marshal/Desktop/EDR/bash/download.jpeg'; +const objectName = `test-upload-${Date.now()}.jpeg`; + +console.log('Testing MinIO upload...'); +console.log('Endpoint:', `${config.useSSL ? 'https' : 'http'}://${config.endPoint}:${config.port}`); +console.log('Bucket:', config.bucket); +console.log('File:', filePath); +console.log('Object:', objectName); +console.log(''); + +const client = new Client(config); + +const fs = require('fs'); + +try { + const fileBuffer = fs.readFileSync(filePath); + console.log('File size:', fileBuffer.length, 'bytes'); + + client.putObject(config.bucket, objectName, fileBuffer, fileBuffer.length, { 'Content-Type': 'image/jpeg' }) + .then(() => { + const url = `${config.useSSL ? 'https' : 'http'}://${config.endPoint}:${config.port}/${config.bucket}/${objectName}`; + console.log('✓ Upload successful!'); + console.log('URL:', url); + }) + .catch(err => { + console.error('✗ Upload failed:', err.message); + if (err.code === 'InvalidAccessKeyId') { + console.error('The access key does not exist on the MinIO server.'); + console.error('Contact your MinIO administrator for valid credentials.'); + } + }); +} catch (err) { + console.error('Error reading file:', err.message); +} diff --git a/apps/edr-freight-api/tsconfig.json b/apps/edr-freight-api/tsconfig.json index e8cec7548..467c474ee 100644 --- a/apps/edr-freight-api/tsconfig.json +++ b/apps/edr-freight-api/tsconfig.json @@ -6,7 +6,9 @@ "rootDir": "./src", "noEmit": false, "incremental": true, - "tsBuildInfoFile": "./.tsbuildinfo" + "tsBuildInfoFile": "./.tsbuildinfo", + "module": "node16", + "moduleResolution": "node16" }, "include": ["src"] } diff --git a/apps/edr-freight-web/backoffice/.env.example b/apps/edr-freight-web/backoffice/.env.example index e0099a3fc..cbbdd289f 100644 --- a/apps/edr-freight-web/backoffice/.env.example +++ b/apps/edr-freight-web/backoffice/.env.example @@ -1 +1,2 @@ VITE_API_URL=http://localhost:3001 +VITE_BASE_API_URL=http://localhost:3001 diff --git a/apps/edr-freight-web/backoffice/.gitignore b/apps/edr-freight-web/backoffice/.gitignore new file mode 100644 index 000000000..e69de29bb diff --git a/apps/edr-freight-web/backoffice/index.css b/apps/edr-freight-web/backoffice/index.css new file mode 100644 index 000000000..5dd466b0c --- /dev/null +++ b/apps/edr-freight-web/backoffice/index.css @@ -0,0 +1,18 @@ +@import "tailwindcss"; +@import "@edr/ui-common/theme.css" layer(theme); + +:root { + --freight-brand: #1B9E7A; + --freight-brand-dark: #15805F; + --freight-brand-light: #2DBF95; + --freight-brand-muted: #E7F8F2; + --freight-brand-border: #B7EBDC; + --freight-brand-ring: rgb(27 158 122 / 0.2); +} + +html, +body, +#root { + height: 100%; + overflow: hidden; +} diff --git a/apps/edr-freight-web/backoffice/package.json b/apps/edr-freight-web/backoffice/package.json index f0ad134f1..d680625ac 100644 --- a/apps/edr-freight-web/backoffice/package.json +++ b/apps/edr-freight-web/backoffice/package.json @@ -5,34 +5,51 @@ "type": "module", "scripts": { "dev": "vite --port 5183", - "build": "tsc -b && vite build", + "build": "vite build", "preview": "vite preview --port 5183", "lint": "eslint src", "test": "vitest run", - "type-check": "tsc --noEmit" + "type-check": "tsc --noEmit", + "build:user-management": "cd user-management-config && npm run build", + "backoffice": "npm run build:user-management && nx serve @fhc-platform/backoffice", + "backoffice:no-build": "nx serve @fhc-platform/backoffice" }, "dependencies": { - "@tria-plc/iamui-common": "1.0.3", "@edr/types": "workspace:*", "@edr/ui-common": "workspace:*", - "@tanstack/react-query": "^5.59.0", + "@hello-pangea/dnd": "^18.0.1", + "@mantine/core": "^9.3.0", + "@mantine/hooks": "^9.3.0", + "@tabler/icons-react": "^3.44.0", + "@tanstack/react-query": "^5.100.11", + "@tria-plc/iamui-common": "1.1.2", "axios": "^1.7.7", + "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", - "react": "^18.3.1", - "react-dom": "^18.3.1", + "libphonenumber-js": "^1.12.24", + "lucide-react": "^1.14.0", + "radix-ui": "^1.4.3", + "react": "19.2.6", + "react-dom": "19.2.6", + "react-hot-toast": "^2.6.0", "react-router-dom": "^6.27.0", + "recharts": "^3.8.1", + "sonner": "^2.0.7", + "tailwind-merge": "^3.6.0", + "tinymce": "^8.6.0", "zustand": "^5.0.0" }, "devDependencies": { "@edr/eslint-config": "workspace:*", "@edr/tsconfig": "workspace:*", + "@tailwindcss/vite": "^4.3.0", "@types/react": "^18.3.11", "@types/react-dom": "^18.3.0", "@vitejs/plugin-react": "^4.3.2", "autoprefixer": "^10.4.20", "jsdom": "^25.0.1", "postcss": "^8.4.47", - "tailwindcss": "^3.4.13", + "tailwindcss": "^4.3.0", "typescript": "^5.5.4", "vite": "^5.4.8", "vitest": "^2.1.2" diff --git a/apps/edr-freight-web/backoffice/public/_um/assets/ActivityLogPage-CaXVQavd.js b/apps/edr-freight-web/backoffice/public/_um/assets/ActivityLogPage-CaXVQavd.js new file mode 100644 index 000000000..a19e1c041 --- /dev/null +++ b/apps/edr-freight-web/backoffice/public/_um/assets/ActivityLogPage-CaXVQavd.js @@ -0,0 +1 @@ +import{r as x,j as r,B as v,I as O}from"./index-Db-xuq0b.js";import{C as A,a as E,b as L,d as B}from"./card-BBWyxDss.js";import{T as F,a as R,b as y,c as f,d as z,e as p}from"./table-D3n3VABd.js";import{S as j,a as S,b as T,c as b,d as g}from"./select-BoQxM42A.js";import{f as M}from"./formatDistanceToNow-DU8jvZv6.js";import{d as C,f as I}from"./en-US-Cc-9gH5A.js";import{D as Y}from"./download-CX6rsOqt.js";import{S as H}from"./search-CM5F2ZRy.js";import"./endOfMonth-DmxQbzXi.js";function J(t,e){const a=W(t);let i;if(a.date){const o=V(a.date,2);i=G(o.restDateString,o.year)}if(!i||isNaN(i.getTime()))return new Date(NaN);const d=i.getTime();let l=0,c;if(a.time&&(l=Q(a.time),isNaN(l)))return new Date(NaN);if(a.timezone){if(c=_(a.timezone),isNaN(c))return new Date(NaN)}else{const o=new Date(d+l),u=new Date(0);return u.setFullYear(o.getUTCFullYear(),o.getUTCMonth(),o.getUTCDate()),u.setHours(o.getUTCHours(),o.getUTCMinutes(),o.getUTCSeconds(),o.getUTCMilliseconds()),u}return new Date(d+l+c)}const D={dateTimeDelimiter:/[T ]/,timeZoneDelimiter:/[Z ]/i,timezone:/([Z+-].*)$/},Z=/^-?(?:(\d{3})|(\d{2})(?:-?(\d{2}))?|W(\d{2})(?:-?(\d{1}))?|)$/,$=/^(\d{2}(?:[.,]\d*)?)(?::?(\d{2}(?:[.,]\d*)?))?(?::?(\d{2}(?:[.,]\d*)?))?$/,P=/^([+-])(\d{2})(?::?(\d{2}))?$/;function W(t){const e={},s=t.split(D.dateTimeDelimiter);let a;if(s.length>2)return e;if(/:/.test(s[0])?a=s[0]:(e.date=s[0],a=s[1],D.timeZoneDelimiter.test(e.date)&&(e.date=t.split(D.timeZoneDelimiter)[0],a=t.substr(e.date.length,t.length))),a){const i=D.timezone.exec(a);i?(e.time=a.replace(i[1],""),e.timezone=i[1]):e.time=a}return e}function V(t,e){const s=new RegExp("^(?:(\\d{4}|[+-]\\d{"+(4+e)+"})|(\\d{2}|[+-]\\d{"+(2+e)+"})$)"),a=t.match(s);if(!a)return{year:NaN,restDateString:""};const i=a[1]?parseInt(a[1]):null,d=a[2]?parseInt(a[2]):null;return{year:d===null?i:d*100,restDateString:t.slice((a[1]||a[2]).length)}}function G(t,e){if(e===null)return new Date(NaN);const s=t.match(Z);if(!s)return new Date(NaN);const a=!!s[4],i=h(s[1]),d=h(s[2])-1,l=h(s[3]),c=h(s[4]),o=h(s[5])-1;if(a)return te(e,c,o)?X(e,c,o):new Date(NaN);{const u=new Date(0);return!K(e,d,l)||!ee(e,i)?new Date(NaN):(u.setUTCFullYear(e,d,Math.max(i,l)),u)}}function h(t){return t?parseInt(t):1}function Q(t){const e=t.match($);if(!e)return NaN;const s=N(e[1]),a=N(e[2]),i=N(e[3]);return re(s,a,i)?s*C+a*I+i*1e3:NaN}function N(t){return t&&parseFloat(t.replace(",","."))||0}function _(t){if(t==="Z")return 0;const e=t.match(P);if(!e)return 0;const s=e[1]==="+"?-1:1,a=parseInt(e[2]),i=e[3]&&parseInt(e[3])||0;return se(a,i)?s*(a*C+i*I):NaN}function X(t,e,s){const a=new Date(0);a.setUTCFullYear(t,0,4);const i=a.getUTCDay()||7,d=(e-1)*7+s+1-i;return a.setUTCDate(a.getUTCDate()+d),a}const q=[31,null,31,30,31,30,31,31,30,31,30,31];function U(t){return t%400===0||t%4===0&&t%100!==0}function K(t,e,s){return e>=0&&e<=11&&s>=1&&s<=(q[e]||(U(t)?29:28))}function ee(t,e){return e>=1&&e<=(U(t)?366:365)}function te(t,e,s){return e>=1&&e<=53&&s>=0&&s<=6}function re(t,e,s){return t===24?e===0&&s===0:s>=0&&s<60&&e>=0&&e<60&&t>=0&&t<25}function se(t,e){return e>=0&&e<=59}const ae=t=>M(J(t),{addSuffix:!0}),w=[{id:"1",action:"CREATE",description:"Created new organization",performedBy:"John Doe",timestamp:new Date(Date.now()-36e5).toISOString(),module:"Organizations",resourceId:"org-123",status:"success"},{id:"2",action:"UPDATE",description:"Updated user permissions",performedBy:"Sarah Johnson",timestamp:new Date(Date.now()-72e5).toISOString(),module:"Users",resourceId:"user-456",status:"success"},{id:"3",action:"DELETE",description:"Deleted document template",performedBy:"Ahmed Mohamed",timestamp:new Date(Date.now()-864e5).toISOString(),module:"Templates",resourceId:"template-789",status:"success"},{id:"4",action:"LOGIN",description:"User login",performedBy:"John Doe",timestamp:new Date(Date.now()-18e5).toISOString(),module:"Authentication",status:"success"},{id:"5",action:"LOGIN",description:"Failed login attempt",performedBy:"Unknown User",timestamp:new Date(Date.now()-12e5).toISOString(),module:"Authentication",status:"failure"},{id:"6",action:"UPDATE",description:"Updated organization settings",performedBy:"Sarah Johnson",timestamp:new Date(Date.now()-72e5).toISOString(),module:"Settings",resourceId:"org-123",status:"success"},{id:"7",action:"CREATE",description:"Created new user account",performedBy:"John Doe",timestamp:new Date(Date.now()-1728e5).toISOString(),module:"Users",resourceId:"user-789",status:"success"},{id:"8",action:"EXPORT",description:"Exported organization data",performedBy:"Ahmed Mohamed",timestamp:new Date(Date.now()-2592e5).toISOString(),module:"Reports",status:"pending"}];function fe(){const[t,e]=x.useState(""),[s,a]=x.useState("all"),[i,d]=x.useState("all"),[l,c]=x.useState(w),o=Array.from(new Set(w?.map(n=>n.module))),u=Array.from(new Set(w?.map(n=>n.action)));x.useEffect(()=>{let n=[...w];t&&(n=n.filter(m=>m.description.toLowerCase().includes(t.toLowerCase())||m.performedBy.toLowerCase().includes(t.toLowerCase())||m.resourceId&&m.resourceId.toLowerCase().includes(t.toLowerCase()))),s!=="all"&&(n=n.filter(m=>m.module===s)),i!=="all"&&(n=n.filter(m=>m.action===i)),c(n)},[t,s,i]);const k=n=>{switch(n){case"success":return"bg-primary-100 text-primary-800 border-primary-300 dark:bg-primary-900/40 dark:text-primary-300 dark:border-primary-800";case"failure":return"bg-red-100 text-red-800 border-red-300 dark:bg-red-900/40 dark:text-red-300 dark:border-red-800";case"pending":return"bg-yellow-100 text-yellow-800 border-yellow-300 dark:bg-yellow-900/40 dark:text-yellow-300 dark:border-yellow-800";default:return"bg-gray-100 text-gray-800 border-gray-300 dark:bg-gray-800 dark:text-gray-200 dark:border-gray-700"}};return r.jsx("div",{className:"p-6 space-y-6",children:r.jsxs(A,{className:"shadow-lg border-gray-200 dark:border-gray-700",children:[r.jsx(E,{children:r.jsxs("div",{className:"flex justify-between items-center",children:[r.jsx(L,{className:"text-xl font-semibold",children:"Activity Log"}),r.jsxs(v,{variant:"outline",className:"flex items-center gap-2",children:[r.jsx(Y,{className:"w-4 h-4"}),"Export"]})]})}),r.jsxs(B,{children:[r.jsxs("div",{className:"flex items-center gap-4 mb-6",children:[r.jsxs("div",{className:"relative flex-1",children:[r.jsx(H,{className:"absolute left-3 top-3 h-4 w-4 text-gray-400 dark:text-gray-500"}),r.jsx(O,{placeholder:"Search logs...",value:t,onChange:n=>e(n.target.value),className:"pl-10"})]}),r.jsxs(j,{value:s,onValueChange:a,children:[r.jsx(S,{className:"w-[180px]",children:r.jsx(T,{placeholder:"Filter by module"})}),r.jsxs(b,{children:[r.jsx(g,{value:"all",children:"All modules"}),o?.map(n=>r.jsx(g,{value:n,children:n},n))]})]}),r.jsxs(j,{value:i,onValueChange:d,children:[r.jsx(S,{className:"w-[180px]",children:r.jsx(T,{placeholder:"Filter by action"})}),r.jsxs(b,{children:[r.jsx(g,{value:"all",children:"All actions"}),u?.map(n=>r.jsx(g,{value:n,children:n},n))]})]})]}),r.jsx("div",{className:"rounded-md border border-gray-200 dark:border-gray-700",children:r.jsxs(F,{children:[r.jsx(R,{children:r.jsxs(y,{children:[r.jsx(f,{className:"w-[180px]",children:"Time"}),r.jsx(f,{children:"Action"}),r.jsx(f,{className:"w-[300px]",children:"Description"}),r.jsx(f,{children:"Performed By"}),r.jsx(f,{children:"Module"}),r.jsx(f,{children:"Status"})]})}),r.jsx(z,{children:l.length>0?l?.map(n=>r.jsxs(y,{children:[r.jsx(p,{className:"font-medium",children:ae(n.timestamp)}),r.jsx(p,{children:n.action}),r.jsx(p,{children:n.description}),r.jsx(p,{children:n.performedBy}),r.jsx(p,{children:n.module}),r.jsx(p,{children:r.jsx("span",{className:`px-2 py-1 rounded-full text-xs font-semibold ${k(n.status)}`})})]},n.id)):r.jsx(y,{children:r.jsx(p,{colSpan:6,className:"h-24 text-center text-muted-foreground",children:"No activity logs found."})})})]})})]})]})})}export{fe as default}; diff --git a/apps/edr-freight-web/backoffice/public/_um/assets/AdminRegistrationPage-DvAhYHqv.js b/apps/edr-freight-web/backoffice/public/_um/assets/AdminRegistrationPage-DvAhYHqv.js new file mode 100644 index 000000000..d8a3cd18b --- /dev/null +++ b/apps/edr-freight-web/backoffice/public/_um/assets/AdminRegistrationPage-DvAhYHqv.js @@ -0,0 +1 @@ +import{f as L,az as U,d as O,r as c,j as e,v as r,I as m,B as V}from"./index-Db-xuq0b.js";import{u as T}from"./index.esm-BG4gweZJ.js";import{u as q,o as z,s as i}from"./zod-Df58YiJ6.js";import{u as w}from"./useOrganizations-DiuNBweX.js";import{C as B,a as D,b as P,c as R,d as H}from"./card-BBWyxDss.js";import{S,a as y,b,c as I,d as v}from"./select-BoQxM42A.js";import{L as n}from"./label-CsFy6wpo.js";import{u as M}from"./useOrganizationAdmins-Bl-gbkwr.js";import{u as _}from"./useUnit-C4s9nepK.js";import"./organizationsService-BEVk8qa1.js";import"./unitService-CmGVtFHQ.js";const $=z({name:z({am:i().min(1,r("organization.amharicNameRequired")),en:i().min(1,r("organization.englishNameRequired"))}),email:i().email(r("organization.invalidEmail")),username:i().min(3,r("organization.usernameMinLength")),unitId:i().min(1,r("organization.organizationRequired")),phone:i().regex(/^(\+251|0)?9\d{8}$/,r("organization.invalidPhoneNumber"))}),G=()=>{var u,j,N,f;const A=L(),x=U(),{organizationsResponse:l}=w("Org",{take:3e3}),{createUnitAdmin:C,isCreating:h}=M(),{user:J}=O(),[d,E]=c.useState(""),{data:t,isLoading:F}=_().getList(d||"",{take:300,skip:0}),[g,p]=c.useState("All");c.useEffect(()=>{var s,o;g||((o=(s=t==null?void 0:t.data)==null?void 0:s.items)!=null&&o.length?p(t.data.items[0].id):p("All"))},[t,g]);const a=T({resolver:q($),defaultValues:{name:{am:"",en:""},email:"",username:"",unitId:"",phone:""}}),k=async s=>{const o={email:s.email,name:s.name,unitId:s.unitId,username:s.username,phoneNumber:s.phone};C({payload:o,successCallback:()=>{A("/user-management/organization_admins")}})};return e.jsxs(B,{className:"max-w-2xl mx-auto shadow-md border-gray-200 dark:border-gray-700",children:[e.jsxs(D,{children:[e.jsx(P,{className:"text-xl font-semibold text-gray-800 dark:text-gray-100",children:r("organization.registerAdminTitle")}),e.jsx(R,{className:"text-gray-500 dark:text-gray-400",children:r("organization.registerAdminDescription")})]}),e.jsx(H,{children:e.jsxs("form",{onSubmit:a.handleSubmit(k),className:"space-y-6",children:[e.jsxs("div",{className:"grid grid-cols-2 gap-6",children:[e.jsxs("div",{className:"space-y-2",children:[e.jsxs(n,{htmlFor:"name.en",children:[r("organization.nameEnglish")," ",e.jsx("span",{className:"text-red-500",children:"*"})]}),e.jsx(m,{id:"name.en",...a.register("name.en"),placeholder:r("organization.enterEnglishName")}),((u=a.formState.errors.name)==null?void 0:u.en)&&e.jsx("p",{className:"text-red-500 text-xs",children:a.formState.errors.name.en.message})]}),e.jsxs("div",{className:"space-y-2",children:[e.jsxs(n,{htmlFor:"name.am",children:[r("organization.nameAmharic")," ",e.jsx("span",{className:"text-red-500",children:"*"})]}),e.jsx(m,{id:"name.am",...a.register("name.am"),placeholder:r("organization.enterAmharicName")}),((j=a.formState.errors.name)==null?void 0:j.am)&&e.jsx("p",{className:"text-red-500 text-xs",children:a.formState.errors.name.am.message})]})]}),e.jsxs("div",{className:"space-y-2",children:[e.jsxs(n,{htmlFor:"email",children:[r("organization.email")," ",e.jsx("span",{className:"text-red-500",children:"*"})]}),e.jsx(m,{id:"email",type:"email",...a.register("email"),placeholder:r("organization.emailExample")}),a.formState.errors.email&&e.jsx("p",{className:"text-red-500 text-xs",children:a.formState.errors.email.message})]}),e.jsxs("div",{className:"space-y-2",children:[e.jsxs(n,{htmlFor:"username",children:[r("organization.username")," ",e.jsx("span",{className:"text-red-500",children:"*"})]}),e.jsx(m,{id:"username",...a.register("username"),placeholder:r("organization.enterUsername")}),a.formState.errors.username&&e.jsx("p",{className:"text-red-500 text-xs",children:a.formState.errors.username.message})]}),e.jsxs("div",{className:"space-y-2",children:[e.jsxs(n,{htmlFor:"phone",children:[r("organization.phoneNumber")," ",e.jsx("span",{className:"text-red-500",children:"*"})]}),e.jsx(m,{id:"phone",type:"tel",...a.register("phone"),placeholder:r("organization.phoneNumberExample")}),a.formState.errors.phone&&e.jsx("p",{className:"text-red-500 text-xs",children:a.formState.errors.phone.message})]}),e.jsxs("div",{className:"space-y-2 ",children:[e.jsxs(n,{htmlFor:"organizationId",children:[r("organization.organization")," ",e.jsx("span",{className:"text-red-500",children:"*"})]}),e.jsxs(S,{value:d,onValueChange:s=>{E(s),a.setValue("unitId","")},children:[e.jsx(y,{children:e.jsx(b,{placeholder:r("organization.selectOrganization")})}),e.jsx(I,{children:l==null?void 0:l.items.filter(s=>s.status==="Active")?.map(s=>e.jsx(v,{value:s.id,children:x(s.name)},s.id))})]}),a.formState.errors.unitId&&e.jsx("p",{className:"text-red-500 text-xs",children:a.formState.errors.unitId.message})]}),e.jsxs(S,{value:a.watch("unitId"),onValueChange:s=>a.setValue("unitId",s),disabled:!d||F,children:[e.jsx(y,{children:e.jsx(b,{placeholder:r("organization.selectUnit")})}),e.jsx(I,{children:(f=(N=t==null?void 0:t.data)==null?void 0:N.items)==null?void 0:f?.map(s=>e.jsx(v,{value:s.id,children:x(s.name)},s.id))})]}),a.formState.errors.unitId&&e.jsx("p",{className:"text-red-500 text-xs",children:a.formState.errors.unitId.message}),e.jsx("div",{className:"pt-4",children:e.jsx(V,{type:"submit",disabled:h,className:"bg-primary hover:bg-primary/90 text-primary-foreground",children:h?r("organization.registering"):r("organization.registerAdmin")})})]})})]})};function ie(){return e.jsx("div",{className:"p-6",children:e.jsx(G,{})})}export{ie as default}; diff --git a/apps/edr-freight-web/backoffice/public/_um/assets/AdvancedTable-CC9ioMU-.js b/apps/edr-freight-web/backoffice/public/_um/assets/AdvancedTable-CC9ioMU-.js new file mode 100644 index 000000000..a6797602d --- /dev/null +++ b/apps/edr-freight-web/backoffice/public/_um/assets/AdvancedTable-CC9ioMU-.js @@ -0,0 +1,33 @@ +import{y as ve,r as A,j as p,B as z,b8 as De,aJ as Ee,aP as je,u as Ce,aA as Ae,aB as Ge,aC as He,aD as Le,b_ as ze,cD as Ne,I as ke,aS as Oe}from"./index-Db-xuq0b.js";import{T as Te,a as Be,b as B,c as qe,d as Ue,e as Q}from"./table-D3n3VABd.js";import{S as Xe,a as Ke,b as Je,c as Qe,d as We}from"./select-BoQxM42A.js";import{C as Ye}from"./chevron-left-DCpEaNzo.js";import{c as fe}from"./utils-BncSPdK1.js";import{B as W}from"./badge-D7JvaQeJ.js";import{C as Ze,a as be,b as et,c as tt,d as pe,e as me,f as nt}from"./command-Cr0tMDDu.js";import{P as ot,a as rt,b as it}from"./popover-X-j_SRnG.js";import{S as lt}from"./separator-BaOOgzZX.js";import{R as st}from"./refresh-cw-JB7N413f.js";/** + * @license lucide-react v0.513.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const ut=[["circle",{cx:"12",cy:"12",r:"10",key:"1mglay"}],["path",{d:"M8 12h8",key:"1wcyev"}],["path",{d:"M12 8v8",key:"napkw2"}]],at=ve("circle-plus",ut);/** + * @license lucide-react v0.513.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const gt=[["path",{d:"M20 7h-9",key:"3s1dr2"}],["path",{d:"M14 17H5",key:"gfn3mx"}],["circle",{cx:"17",cy:"17",r:"3",key:"18b49y"}],["circle",{cx:"7",cy:"7",r:"3",key:"dfmy0x"}]],dt=ve("settings-2",gt);/** + * table-core + * + * Copyright (c) TanStack + * + * This source code is licensed under the MIT license found in the + * LICENSE.md file in the root directory of this source tree. + * + * @license MIT + */function G(e,o){return typeof e=="function"?e(o):e}function V(e,o){return t=>{o.setState(n=>({...n,[e]:G(t,n[e])}))}}function J(e){return e instanceof Function}function ct(e){return Array.isArray(e)&&e.every(o=>typeof o=="number")}function ft(e,o){const t=[],n=r=>{r.forEach(i=>{t.push(i);const l=o(i);l!=null&&l.length&&n(l)})};return n(e),t}function h(e,o,t){let n=[],r;return i=>{let l;t.key&&t.debug&&(l=Date.now());const s=e(i);if(!(s.length!==n.length||s?.some((c,S)=>n[S]!==c)))return r;n=s;let g;if(t.key&&t.debug&&(g=Date.now()),r=o(...s),t==null||t.onChange==null||t.onChange(r),t.key&&t.debug&&t!=null&&t.debug()){const c=Math.round((Date.now()-l)*100)/100,d=Math.round((Date.now()-g)*100)/100/16,u=(f,m)=>{for(f=String(f);f.length{var r;return(r=e==null?void 0:e.debugAll)!=null?r:e[o]},key:!1,onChange:n}}function pt(e,o,t,n){const r=()=>{var l;return(l=i.getValue())!=null?l:e.options.renderFallbackValue},i={id:`${o.id}_${t.id}`,row:o,column:t,getValue:()=>o.getValue(n),renderValue:r,getContext:h(()=>[e,t,o,i],(l,s,a,g)=>({table:l,column:s,row:a,cell:g,getValue:g.getValue,renderValue:g.renderValue}),w(e.options,"debugCells"))};return e._features.forEach(l=>{l.createCell==null||l.createCell(i,t,o,e)},{}),i}function mt(e,o,t,n){var r,i;const s={...e._getDefaultColumnDef(),...o},a=s.accessorKey;let g=(r=(i=s.id)!=null?i:a?typeof String.prototype.replaceAll=="function"?a.replaceAll(".","_"):a.replace(/\./g,"_"):void 0)!=null?r:typeof s.header=="string"?s.header:void 0,c;if(s.accessorFn?c=s.accessorFn:a&&(a.includes(".")?c=d=>{let u=d;for(const m of a.split(".")){var f;u=(f=u)==null?void 0:f[m]}return u}:c=d=>d[s.accessorKey]),!g)throw new Error;let S={id:`${String(g)}`,accessorFn:c,parent:n,depth:t,columnDef:s,columns:[],getFlatColumns:h(()=>[!0],()=>{var d;return[S,...(d=S.columns)==null?void 0:d.flatMap(u=>u.getFlatColumns())]},w(e.options,"debugColumns")),getLeafColumns:h(()=>[e._getOrderColumnsFn()],d=>{var u;if((u=S.columns)!=null&&u.length){let f=S.columns.flatMap(m=>m.getLeafColumns());return d(f)}return[S]},w(e.options,"debugColumns"))};for(const d of e._features)d.createColumn==null||d.createColumn(S,e);return S}const y="debugHeaders";function Se(e,o,t){var n;let i={id:(n=t.id)!=null?n:o.id,column:o,index:t.index,isPlaceholder:!!t.isPlaceholder,placeholderId:t.placeholderId,depth:t.depth,subHeaders:[],colSpan:0,rowSpan:0,headerGroup:null,getLeafHeaders:()=>{const l=[],s=a=>{a.subHeaders&&a.subHeaders.length&&a.subHeaders?.map(s),l.push(a)};return s(i),l},getContext:()=>({table:e,header:i,column:o})};return e._features.forEach(l=>{l.createHeader==null||l.createHeader(i,e)}),i}const St={createTable:e=>{e.getHeaderGroups=h(()=>[e.getAllColumns(),e.getVisibleLeafColumns(),e.getState().columnPinning.left,e.getState().columnPinning.right],(o,t,n,r)=>{var i,l;const s=(i=n==null?void 0:n?.map(S=>t.find(d=>d.id===S)).filter(Boolean))!=null?i:[],a=(l=r==null?void 0:r?.map(S=>t.find(d=>d.id===S)).filter(Boolean))!=null?l:[],g=t.filter(S=>!(n!=null&&n.includes(S.id))&&!(r!=null&&r.includes(S.id)));return q(o,[...s,...g,...a],e)},w(e.options,y)),e.getCenterHeaderGroups=h(()=>[e.getAllColumns(),e.getVisibleLeafColumns(),e.getState().columnPinning.left,e.getState().columnPinning.right],(o,t,n,r)=>(t=t.filter(i=>!(n!=null&&n.includes(i.id))&&!(r!=null&&r.includes(i.id))),q(o,t,e,"center")),w(e.options,y)),e.getLeftHeaderGroups=h(()=>[e.getAllColumns(),e.getVisibleLeafColumns(),e.getState().columnPinning.left],(o,t,n)=>{var r;const i=(r=n==null?void 0:n?.map(l=>t.find(s=>s.id===l)).filter(Boolean))!=null?r:[];return q(o,i,e,"left")},w(e.options,y)),e.getRightHeaderGroups=h(()=>[e.getAllColumns(),e.getVisibleLeafColumns(),e.getState().columnPinning.right],(o,t,n)=>{var r;const i=(r=n==null?void 0:n?.map(l=>t.find(s=>s.id===l)).filter(Boolean))!=null?r:[];return q(o,i,e,"right")},w(e.options,y)),e.getFooterGroups=h(()=>[e.getHeaderGroups()],o=>[...o].reverse(),w(e.options,y)),e.getLeftFooterGroups=h(()=>[e.getLeftHeaderGroups()],o=>[...o].reverse(),w(e.options,y)),e.getCenterFooterGroups=h(()=>[e.getCenterHeaderGroups()],o=>[...o].reverse(),w(e.options,y)),e.getRightFooterGroups=h(()=>[e.getRightHeaderGroups()],o=>[...o].reverse(),w(e.options,y)),e.getFlatHeaders=h(()=>[e.getHeaderGroups()],o=>o?.map(t=>t.headers).flat(),w(e.options,y)),e.getLeftFlatHeaders=h(()=>[e.getLeftHeaderGroups()],o=>o?.map(t=>t.headers).flat(),w(e.options,y)),e.getCenterFlatHeaders=h(()=>[e.getCenterHeaderGroups()],o=>o?.map(t=>t.headers).flat(),w(e.options,y)),e.getRightFlatHeaders=h(()=>[e.getRightHeaderGroups()],o=>o?.map(t=>t.headers).flat(),w(e.options,y)),e.getCenterLeafHeaders=h(()=>[e.getCenterFlatHeaders()],o=>o.filter(t=>{var n;return!((n=t.subHeaders)!=null&&n.length)}),w(e.options,y)),e.getLeftLeafHeaders=h(()=>[e.getLeftFlatHeaders()],o=>o.filter(t=>{var n;return!((n=t.subHeaders)!=null&&n.length)}),w(e.options,y)),e.getRightLeafHeaders=h(()=>[e.getRightFlatHeaders()],o=>o.filter(t=>{var n;return!((n=t.subHeaders)!=null&&n.length)}),w(e.options,y)),e.getLeafHeaders=h(()=>[e.getLeftHeaderGroups(),e.getCenterHeaderGroups(),e.getRightHeaderGroups()],(o,t,n)=>{var r,i,l,s,a,g;return[...(r=(i=o[0])==null?void 0:i.headers)!=null?r:[],...(l=(s=t[0])==null?void 0:s.headers)!=null?l:[],...(a=(g=n[0])==null?void 0:g.headers)!=null?a:[]]?.map(c=>c.getLeafHeaders()).flat()},w(e.options,y))}};function q(e,o,t,n){var r,i;let l=0;const s=function(d,u){u===void 0&&(u=1),l=Math.max(l,u),d.filter(f=>f.getIsVisible()).forEach(f=>{var m;(m=f.columns)!=null&&m.length&&s(f.columns,u+1)},0)};s(e);let a=[];const g=(d,u)=>{const f={depth:u,id:[n,`${u}`].filter(Boolean).join("_"),headers:[]},m=[];d.forEach(v=>{const C=[...m].reverse()[0],R=v.column.depth===f.depth;let x,M=!1;if(R&&v.column.parent?x=v.column.parent:(x=v.column,M=!0),C&&(C==null?void 0:C.column)===x)C.subHeaders.push(v);else{const F=Se(t,x,{id:[n,u,x.id,v==null?void 0:v.id].filter(Boolean).join("_"),isPlaceholder:M,placeholderId:M?`${m.filter(D=>D.column===x).length}`:void 0,depth:u,index:m.length});F.subHeaders.push(v),m.push(F)}f.headers.push(v),v.headerGroup=f}),a.push(f),u>0&&g(m,u-1)},c=o?.map((d,u)=>Se(t,d,{depth:l,index:u}));g(c,l-1),a.reverse();const S=d=>d.filter(f=>f.column.getIsVisible())?.map(f=>{let m=0,v=0,C=[0];f.subHeaders&&f.subHeaders.length?(C=[],S(f.subHeaders).forEach(x=>{let{colSpan:M,rowSpan:F}=x;m+=M,C.push(F)})):m=1;const R=Math.min(...C);return v=v+R,f.colSpan=m,f.rowSpan=v,{colSpan:m,rowSpan:v}});return S((r=(i=a[0])==null?void 0:i.headers)!=null?r:[]),a}const ae=(e,o,t,n,r,i,l)=>{let s={id:o,index:n,original:t,depth:r,parentId:l,_valuesCache:{},_uniqueValuesCache:{},getValue:a=>{if(s._valuesCache.hasOwnProperty(a))return s._valuesCache[a];const g=e.getColumn(a);if(g!=null&&g.accessorFn)return s._valuesCache[a]=g.accessorFn(s.original,n),s._valuesCache[a]},getUniqueValues:a=>{if(s._uniqueValuesCache.hasOwnProperty(a))return s._uniqueValuesCache[a];const g=e.getColumn(a);if(g!=null&&g.accessorFn)return g.columnDef.getUniqueValues?(s._uniqueValuesCache[a]=g.columnDef.getUniqueValues(s.original,n),s._uniqueValuesCache[a]):(s._uniqueValuesCache[a]=[s.getValue(a)],s._uniqueValuesCache[a])},renderValue:a=>{var g;return(g=s.getValue(a))!=null?g:e.options.renderFallbackValue},subRows:[],getLeafRows:()=>ft(s.subRows,a=>a.subRows),getParentRow:()=>s.parentId?e.getRow(s.parentId,!0):void 0,getParentRows:()=>{let a=[],g=s;for(;;){const c=g.getParentRow();if(!c)break;a.push(c),g=c}return a.reverse()},getAllCells:h(()=>[e.getAllLeafColumns()],a=>a?.map(g=>pt(e,s,g,g.id)),w(e.options,"debugRows")),_getAllCellsByColumnId:h(()=>[s.getAllCells()],a=>a.reduce((g,c)=>(g[c.column.id]=c,g),{}),w(e.options,"debugRows"))};for(let a=0;a{e._getFacetedRowModel=o.options.getFacetedRowModel&&o.options.getFacetedRowModel(o,e.id),e.getFacetedRowModel=()=>e._getFacetedRowModel?e._getFacetedRowModel():o.getPreFilteredRowModel(),e._getFacetedUniqueValues=o.options.getFacetedUniqueValues&&o.options.getFacetedUniqueValues(o,e.id),e.getFacetedUniqueValues=()=>e._getFacetedUniqueValues?e._getFacetedUniqueValues():new Map,e._getFacetedMinMaxValues=o.options.getFacetedMinMaxValues&&o.options.getFacetedMinMaxValues(o,e.id),e.getFacetedMinMaxValues=()=>{if(e._getFacetedMinMaxValues)return e._getFacetedMinMaxValues()}}},Re=(e,o,t)=>{var n,r;const i=t==null||(n=t.toString())==null?void 0:n.toLowerCase();return!!(!((r=e.getValue(o))==null||(r=r.toString())==null||(r=r.toLowerCase())==null)&&r.includes(i))};Re.autoRemove=e=>P(e);const xe=(e,o,t)=>{var n;return!!(!((n=e.getValue(o))==null||(n=n.toString())==null)&&n.includes(t))};xe.autoRemove=e=>P(e);const _e=(e,o,t)=>{var n;return((n=e.getValue(o))==null||(n=n.toString())==null?void 0:n.toLowerCase())===(t==null?void 0:t.toLowerCase())};_e.autoRemove=e=>P(e);const Fe=(e,o,t)=>{var n;return(n=e.getValue(o))==null?void 0:n.includes(t)};Fe.autoRemove=e=>P(e);const ye=(e,o,t)=>!t?.some(n=>{var r;return!((r=e.getValue(o))!=null&&r.includes(n))});ye.autoRemove=e=>P(e)||!(e!=null&&e.length);const $e=(e,o,t)=>t?.some(n=>{var r;return(r=e.getValue(o))==null?void 0:r.includes(n)});$e.autoRemove=e=>P(e)||!(e!=null&&e.length);const Me=(e,o,t)=>e.getValue(o)===t;Me.autoRemove=e=>P(e);const Ve=(e,o,t)=>e.getValue(o)==t;Ve.autoRemove=e=>P(e);const ge=(e,o,t)=>{let[n,r]=t;const i=e.getValue(o);return i>=n&&i<=r};ge.resolveFilterValue=e=>{let[o,t]=e,n=typeof o!="number"?parseFloat(o):o,r=typeof t!="number"?parseFloat(t):t,i=o===null||Number.isNaN(n)?-1/0:n,l=t===null||Number.isNaN(r)?1/0:r;if(i>l){const s=i;i=l,l=s}return[i,l]};ge.autoRemove=e=>P(e)||P(e[0])&&P(e[1]);const j={includesString:Re,includesStringSensitive:xe,equalsString:_e,arrIncludes:Fe,arrIncludesAll:ye,arrIncludesSome:$e,equals:Me,weakEquals:Ve,inNumberRange:ge};function P(e){return e==null||e===""}const wt={getDefaultColumnDef:()=>({filterFn:"auto"}),getInitialState:e=>({columnFilters:[],...e}),getDefaultOptions:e=>({onColumnFiltersChange:V("columnFilters",e),filterFromLeafRows:!1,maxLeafRowFilterDepth:100}),createColumn:(e,o)=>{e.getAutoFilterFn=()=>{const t=o.getCoreRowModel().flatRows[0],n=t==null?void 0:t.getValue(e.id);return typeof n=="string"?j.includesString:typeof n=="number"?j.inNumberRange:typeof n=="boolean"||n!==null&&typeof n=="object"?j.equals:Array.isArray(n)?j.arrIncludes:j.weakEquals},e.getFilterFn=()=>{var t,n;return J(e.columnDef.filterFn)?e.columnDef.filterFn:e.columnDef.filterFn==="auto"?e.getAutoFilterFn():(t=(n=o.options.filterFns)==null?void 0:n[e.columnDef.filterFn])!=null?t:j[e.columnDef.filterFn]},e.getCanFilter=()=>{var t,n,r;return((t=e.columnDef.enableColumnFilter)!=null?t:!0)&&((n=o.options.enableColumnFilters)!=null?n:!0)&&((r=o.options.enableFilters)!=null?r:!0)&&!!e.accessorFn},e.getIsFiltered=()=>e.getFilterIndex()>-1,e.getFilterValue=()=>{var t;return(t=o.getState().columnFilters)==null||(t=t.find(n=>n.id===e.id))==null?void 0:t.value},e.getFilterIndex=()=>{var t,n;return(t=(n=o.getState().columnFilters)==null?void 0:n.findIndex(r=>r.id===e.id))!=null?t:-1},e.setFilterValue=t=>{o.setColumnFilters(n=>{const r=e.getFilterFn(),i=n==null?void 0:n.find(c=>c.id===e.id),l=G(t,i?i.value:void 0);if(he(r,l,e)){var s;return(s=n==null?void 0:n.filter(c=>c.id!==e.id))!=null?s:[]}const a={id:e.id,value:l};if(i){var g;return(g=n==null?void 0:n?.map(c=>c.id===e.id?a:c))!=null?g:[]}return n!=null&&n.length?[...n,a]:[a]})}},createRow:(e,o)=>{e.columnFilters={},e.columnFiltersMeta={}},createTable:e=>{e.setColumnFilters=o=>{const t=e.getAllLeafColumns(),n=r=>{var i;return(i=G(o,r))==null?void 0:i.filter(l=>{const s=t.find(a=>a.id===l.id);if(s){const a=s.getFilterFn();if(he(a,l.value,s))return!1}return!0})};e.options.onColumnFiltersChange==null||e.options.onColumnFiltersChange(n)},e.resetColumnFilters=o=>{var t,n;e.setColumnFilters(o?[]:(t=(n=e.initialState)==null?void 0:n.columnFilters)!=null?t:[])},e.getPreFilteredRowModel=()=>e.getCoreRowModel(),e.getFilteredRowModel=()=>(!e._getFilteredRowModel&&e.options.getFilteredRowModel&&(e._getFilteredRowModel=e.options.getFilteredRowModel(e)),e.options.manualFiltering||!e._getFilteredRowModel?e.getPreFilteredRowModel():e._getFilteredRowModel())}};function he(e,o,t){return(e&&e.autoRemove?e.autoRemove(o,t):!1)||typeof o>"u"||typeof o=="string"&&!o}const vt=(e,o,t)=>t.reduce((n,r)=>{const i=r.getValue(e);return n+(typeof i=="number"?i:0)},0),Ct=(e,o,t)=>{let n;return t.forEach(r=>{const i=r.getValue(e);i!=null&&(n>i||n===void 0&&i>=i)&&(n=i)}),n},Rt=(e,o,t)=>{let n;return t.forEach(r=>{const i=r.getValue(e);i!=null&&(n=i)&&(n=i)}),n},xt=(e,o,t)=>{let n,r;return t.forEach(i=>{const l=i.getValue(e);l!=null&&(n===void 0?l>=l&&(n=r=l):(n>l&&(n=l),r{let t=0,n=0;if(o.forEach(r=>{let i=r.getValue(e);i!=null&&(i=+i)>=i&&(++t,n+=i)}),t)return n/t},Ft=(e,o)=>{if(!o.length)return;const t=o?.map(i=>i.getValue(e));if(!ct(t))return;if(t.length===1)return t[0];const n=Math.floor(t.length/2),r=t.sort((i,l)=>i-l);return t.length%2!==0?r[n]:(r[n-1]+r[n])/2},yt=(e,o)=>Array.from(new Set(o?.map(t=>t.getValue(e))).values()),$t=(e,o)=>new Set(o?.map(t=>t.getValue(e))).size,Mt=(e,o)=>o.length,Y={sum:vt,min:Ct,max:Rt,extent:xt,mean:_t,median:Ft,unique:yt,uniqueCount:$t,count:Mt},Vt={getDefaultColumnDef:()=>({aggregatedCell:e=>{var o,t;return(o=(t=e.getValue())==null||t.toString==null?void 0:t.toString())!=null?o:null},aggregationFn:"auto"}),getInitialState:e=>({grouping:[],...e}),getDefaultOptions:e=>({onGroupingChange:V("grouping",e),groupedColumnMode:"reorder"}),createColumn:(e,o)=>{e.toggleGrouping=()=>{o.setGrouping(t=>t!=null&&t.includes(e.id)?t.filter(n=>n!==e.id):[...t??[],e.id])},e.getCanGroup=()=>{var t,n;return((t=e.columnDef.enableGrouping)!=null?t:!0)&&((n=o.options.enableGrouping)!=null?n:!0)&&(!!e.accessorFn||!!e.columnDef.getGroupingValue)},e.getIsGrouped=()=>{var t;return(t=o.getState().grouping)==null?void 0:t.includes(e.id)},e.getGroupedIndex=()=>{var t;return(t=o.getState().grouping)==null?void 0:t.indexOf(e.id)},e.getToggleGroupingHandler=()=>{const t=e.getCanGroup();return()=>{t&&e.toggleGrouping()}},e.getAutoAggregationFn=()=>{const t=o.getCoreRowModel().flatRows[0],n=t==null?void 0:t.getValue(e.id);if(typeof n=="number")return Y.sum;if(Object.prototype.toString.call(n)==="[object Date]")return Y.extent},e.getAggregationFn=()=>{var t,n;if(!e)throw new Error;return J(e.columnDef.aggregationFn)?e.columnDef.aggregationFn:e.columnDef.aggregationFn==="auto"?e.getAutoAggregationFn():(t=(n=o.options.aggregationFns)==null?void 0:n[e.columnDef.aggregationFn])!=null?t:Y[e.columnDef.aggregationFn]}},createTable:e=>{e.setGrouping=o=>e.options.onGroupingChange==null?void 0:e.options.onGroupingChange(o),e.resetGrouping=o=>{var t,n;e.setGrouping(o?[]:(t=(n=e.initialState)==null?void 0:n.grouping)!=null?t:[])},e.getPreGroupedRowModel=()=>e.getFilteredRowModel(),e.getGroupedRowModel=()=>(!e._getGroupedRowModel&&e.options.getGroupedRowModel&&(e._getGroupedRowModel=e.options.getGroupedRowModel(e)),e.options.manualGrouping||!e._getGroupedRowModel?e.getPreGroupedRowModel():e._getGroupedRowModel())},createRow:(e,o)=>{e.getIsGrouped=()=>!!e.groupingColumnId,e.getGroupingValue=t=>{if(e._groupingValuesCache.hasOwnProperty(t))return e._groupingValuesCache[t];const n=o.getColumn(t);return n!=null&&n.columnDef.getGroupingValue?(e._groupingValuesCache[t]=n.columnDef.getGroupingValue(e.original),e._groupingValuesCache[t]):e.getValue(t)},e._groupingValuesCache={}},createCell:(e,o,t,n)=>{e.getIsGrouped=()=>o.getIsGrouped()&&o.id===t.groupingColumnId,e.getIsPlaceholder=()=>!e.getIsGrouped()&&o.getIsGrouped(),e.getIsAggregated=()=>{var r;return!e.getIsGrouped()&&!e.getIsPlaceholder()&&!!((r=t.subRows)!=null&&r.length)}}};function It(e,o,t){if(!(o!=null&&o.length)||!t)return e;const n=e.filter(i=>!o.includes(i.id));return t==="remove"?n:[...o?.map(i=>e.find(l=>l.id===i)).filter(Boolean),...n]}const Pt={getInitialState:e=>({columnOrder:[],...e}),getDefaultOptions:e=>({onColumnOrderChange:V("columnOrder",e)}),createColumn:(e,o)=>{e.getIndex=h(t=>[T(o,t)],t=>t.findIndex(n=>n.id===e.id),w(o.options,"debugColumns")),e.getIsFirstColumn=t=>{var n;return((n=T(o,t)[0])==null?void 0:n.id)===e.id},e.getIsLastColumn=t=>{var n;const r=T(o,t);return((n=r[r.length-1])==null?void 0:n.id)===e.id}},createTable:e=>{e.setColumnOrder=o=>e.options.onColumnOrderChange==null?void 0:e.options.onColumnOrderChange(o),e.resetColumnOrder=o=>{var t;e.setColumnOrder(o?[]:(t=e.initialState.columnOrder)!=null?t:[])},e._getOrderColumnsFn=h(()=>[e.getState().columnOrder,e.getState().grouping,e.options.groupedColumnMode],(o,t,n)=>r=>{let i=[];if(!(o!=null&&o.length))i=r;else{const l=[...o],s=[...r];for(;s.length&&l.length;){const a=l.shift(),g=s.findIndex(c=>c.id===a);g>-1&&i.push(s.splice(g,1)[0])}i=[...i,...s]}return It(i,t,n)},w(e.options,"debugTable"))}},Z=()=>({left:[],right:[]}),Dt={getInitialState:e=>({columnPinning:Z(),...e}),getDefaultOptions:e=>({onColumnPinningChange:V("columnPinning",e)}),createColumn:(e,o)=>{e.pin=t=>{const n=e.getLeafColumns()?.map(r=>r.id).filter(Boolean);o.setColumnPinning(r=>{var i,l;if(t==="right"){var s,a;return{left:((s=r==null?void 0:r.left)!=null?s:[]).filter(S=>!(n!=null&&n.includes(S))),right:[...((a=r==null?void 0:r.right)!=null?a:[]).filter(S=>!(n!=null&&n.includes(S))),...n]}}if(t==="left"){var g,c;return{left:[...((g=r==null?void 0:r.left)!=null?g:[]).filter(S=>!(n!=null&&n.includes(S))),...n],right:((c=r==null?void 0:r.right)!=null?c:[]).filter(S=>!(n!=null&&n.includes(S)))}}return{left:((i=r==null?void 0:r.left)!=null?i:[]).filter(S=>!(n!=null&&n.includes(S))),right:((l=r==null?void 0:r.right)!=null?l:[]).filter(S=>!(n!=null&&n.includes(S)))}})},e.getCanPin=()=>e.getLeafColumns()?.some(n=>{var r,i,l;return((r=n.columnDef.enablePinning)!=null?r:!0)&&((i=(l=o.options.enableColumnPinning)!=null?l:o.options.enablePinning)!=null?i:!0)}),e.getIsPinned=()=>{const t=e.getLeafColumns()?.map(s=>s.id),{left:n,right:r}=o.getState().columnPinning,i=t?.some(s=>n==null?void 0:n.includes(s)),l=t?.some(s=>r==null?void 0:r.includes(s));return i?"left":l?"right":!1},e.getPinnedIndex=()=>{var t,n;const r=e.getIsPinned();return r?(t=(n=o.getState().columnPinning)==null||(n=n[r])==null?void 0:n.indexOf(e.id))!=null?t:-1:0}},createRow:(e,o)=>{e.getCenterVisibleCells=h(()=>[e._getAllVisibleCells(),o.getState().columnPinning.left,o.getState().columnPinning.right],(t,n,r)=>{const i=[...n??[],...r??[]];return t.filter(l=>!i.includes(l.column.id))},w(o.options,"debugRows")),e.getLeftVisibleCells=h(()=>[e._getAllVisibleCells(),o.getState().columnPinning.left],(t,n)=>(n??[])?.map(i=>t.find(l=>l.column.id===i)).filter(Boolean)?.map(i=>({...i,position:"left"})),w(o.options,"debugRows")),e.getRightVisibleCells=h(()=>[e._getAllVisibleCells(),o.getState().columnPinning.right],(t,n)=>(n??[])?.map(i=>t.find(l=>l.column.id===i)).filter(Boolean)?.map(i=>({...i,position:"right"})),w(o.options,"debugRows"))},createTable:e=>{e.setColumnPinning=o=>e.options.onColumnPinningChange==null?void 0:e.options.onColumnPinningChange(o),e.resetColumnPinning=o=>{var t,n;return e.setColumnPinning(o?Z():(t=(n=e.initialState)==null?void 0:n.columnPinning)!=null?t:Z())},e.getIsSomeColumnsPinned=o=>{var t;const n=e.getState().columnPinning;if(!o){var r,i;return!!((r=n.left)!=null&&r.length||(i=n.right)!=null&&i.length)}return!!((t=n[o])!=null&&t.length)},e.getLeftLeafColumns=h(()=>[e.getAllLeafColumns(),e.getState().columnPinning.left],(o,t)=>(t??[])?.map(n=>o.find(r=>r.id===n)).filter(Boolean),w(e.options,"debugColumns")),e.getRightLeafColumns=h(()=>[e.getAllLeafColumns(),e.getState().columnPinning.right],(o,t)=>(t??[])?.map(n=>o.find(r=>r.id===n)).filter(Boolean),w(e.options,"debugColumns")),e.getCenterLeafColumns=h(()=>[e.getAllLeafColumns(),e.getState().columnPinning.left,e.getState().columnPinning.right],(o,t,n)=>{const r=[...t??[],...n??[]];return o.filter(i=>!r.includes(i.id))},w(e.options,"debugColumns"))}};function Et(e){return e||(typeof document<"u"?document:null)}const U={size:150,minSize:20,maxSize:Number.MAX_SAFE_INTEGER},b=()=>({startOffset:null,startSize:null,deltaOffset:null,deltaPercentage:null,isResizingColumn:!1,columnSizingStart:[]}),jt={getDefaultColumnDef:()=>U,getInitialState:e=>({columnSizing:{},columnSizingInfo:b(),...e}),getDefaultOptions:e=>({columnResizeMode:"onEnd",columnResizeDirection:"ltr",onColumnSizingChange:V("columnSizing",e),onColumnSizingInfoChange:V("columnSizingInfo",e)}),createColumn:(e,o)=>{e.getSize=()=>{var t,n,r;const i=o.getState().columnSizing[e.id];return Math.min(Math.max((t=e.columnDef.minSize)!=null?t:U.minSize,(n=i??e.columnDef.size)!=null?n:U.size),(r=e.columnDef.maxSize)!=null?r:U.maxSize)},e.getStart=h(t=>[t,T(o,t),o.getState().columnSizing],(t,n)=>n.slice(0,e.getIndex(t)).reduce((r,i)=>r+i.getSize(),0),w(o.options,"debugColumns")),e.getAfter=h(t=>[t,T(o,t),o.getState().columnSizing],(t,n)=>n.slice(e.getIndex(t)+1).reduce((r,i)=>r+i.getSize(),0),w(o.options,"debugColumns")),e.resetSize=()=>{o.setColumnSizing(t=>{let{[e.id]:n,...r}=t;return r})},e.getCanResize=()=>{var t,n;return((t=e.columnDef.enableResizing)!=null?t:!0)&&((n=o.options.enableColumnResizing)!=null?n:!0)},e.getIsResizing=()=>o.getState().columnSizingInfo.isResizingColumn===e.id},createHeader:(e,o)=>{e.getSize=()=>{let t=0;const n=r=>{if(r.subHeaders.length)r.subHeaders.forEach(n);else{var i;t+=(i=r.column.getSize())!=null?i:0}};return n(e),t},e.getStart=()=>{if(e.index>0){const t=e.headerGroup.headers[e.index-1];return t.getStart()+t.getSize()}return 0},e.getResizeHandler=t=>{const n=o.getColumn(e.column.id),r=n==null?void 0:n.getCanResize();return i=>{if(!n||!r||(i.persist==null||i.persist(),ee(i)&&i.touches&&i.touches.length>1))return;const l=e.getSize(),s=e?e.getLeafHeaders()?.map(C=>[C.column.id,C.column.getSize()]):[[n.id,n.getSize()]],a=ee(i)?Math.round(i.touches[0].clientX):i.clientX,g={},c=(C,R)=>{typeof R=="number"&&(o.setColumnSizingInfo(x=>{var M,F;const D=o.options.columnResizeDirection==="rtl"?-1:1,L=(R-((M=x==null?void 0:x.startOffset)!=null?M:0))*D,N=Math.max(L/((F=x==null?void 0:x.startSize)!=null?F:0),-.999999);return x.columnSizingStart.forEach(k=>{let[E,_]=k;g[E]=Math.round(Math.max(_+_*N,0)*100)/100}),{...x,deltaOffset:L,deltaPercentage:N}}),(o.options.columnResizeMode==="onChange"||C==="end")&&o.setColumnSizing(x=>({...x,...g})))},S=C=>c("move",C),d=C=>{c("end",C),o.setColumnSizingInfo(R=>({...R,isResizingColumn:!1,startOffset:null,startSize:null,deltaOffset:null,deltaPercentage:null,columnSizingStart:[]}))},u=Et(t),f={moveHandler:C=>S(C.clientX),upHandler:C=>{u==null||u.removeEventListener("mousemove",f.moveHandler),u==null||u.removeEventListener("mouseup",f.upHandler),d(C.clientX)}},m={moveHandler:C=>(C.cancelable&&(C.preventDefault(),C.stopPropagation()),S(C.touches[0].clientX),!1),upHandler:C=>{var R;u==null||u.removeEventListener("touchmove",m.moveHandler),u==null||u.removeEventListener("touchend",m.upHandler),C.cancelable&&(C.preventDefault(),C.stopPropagation()),d((R=C.touches[0])==null?void 0:R.clientX)}},v=At()?{passive:!1}:!1;ee(i)?(u==null||u.addEventListener("touchmove",m.moveHandler,v),u==null||u.addEventListener("touchend",m.upHandler,v)):(u==null||u.addEventListener("mousemove",f.moveHandler,v),u==null||u.addEventListener("mouseup",f.upHandler,v)),o.setColumnSizingInfo(C=>({...C,startOffset:a,startSize:l,deltaOffset:0,deltaPercentage:0,columnSizingStart:s,isResizingColumn:n.id}))}}},createTable:e=>{e.setColumnSizing=o=>e.options.onColumnSizingChange==null?void 0:e.options.onColumnSizingChange(o),e.setColumnSizingInfo=o=>e.options.onColumnSizingInfoChange==null?void 0:e.options.onColumnSizingInfoChange(o),e.resetColumnSizing=o=>{var t;e.setColumnSizing(o?{}:(t=e.initialState.columnSizing)!=null?t:{})},e.resetHeaderSizeInfo=o=>{var t;e.setColumnSizingInfo(o?b():(t=e.initialState.columnSizingInfo)!=null?t:b())},e.getTotalSize=()=>{var o,t;return(o=(t=e.getHeaderGroups()[0])==null?void 0:t.headers.reduce((n,r)=>n+r.getSize(),0))!=null?o:0},e.getLeftTotalSize=()=>{var o,t;return(o=(t=e.getLeftHeaderGroups()[0])==null?void 0:t.headers.reduce((n,r)=>n+r.getSize(),0))!=null?o:0},e.getCenterTotalSize=()=>{var o,t;return(o=(t=e.getCenterHeaderGroups()[0])==null?void 0:t.headers.reduce((n,r)=>n+r.getSize(),0))!=null?o:0},e.getRightTotalSize=()=>{var o,t;return(o=(t=e.getRightHeaderGroups()[0])==null?void 0:t.headers.reduce((n,r)=>n+r.getSize(),0))!=null?o:0}}};let X=null;function At(){if(typeof X=="boolean")return X;let e=!1;try{const o={get passive(){return e=!0,!1}},t=()=>{};window.addEventListener("test",t,o),window.removeEventListener("test",t)}catch{e=!1}return X=e,X}function ee(e){return e.type==="touchstart"}const Gt={getInitialState:e=>({columnVisibility:{},...e}),getDefaultOptions:e=>({onColumnVisibilityChange:V("columnVisibility",e)}),createColumn:(e,o)=>{e.toggleVisibility=t=>{e.getCanHide()&&o.setColumnVisibility(n=>({...n,[e.id]:t??!e.getIsVisible()}))},e.getIsVisible=()=>{var t,n;const r=e.columns;return(t=r.length?r?.some(i=>i.getIsVisible()):(n=o.getState().columnVisibility)==null?void 0:n[e.id])!=null?t:!0},e.getCanHide=()=>{var t,n;return((t=e.columnDef.enableHiding)!=null?t:!0)&&((n=o.options.enableHiding)!=null?n:!0)},e.getToggleVisibilityHandler=()=>t=>{e.toggleVisibility==null||e.toggleVisibility(t.target.checked)}},createRow:(e,o)=>{e._getAllVisibleCells=h(()=>[e.getAllCells(),o.getState().columnVisibility],t=>t.filter(n=>n.column.getIsVisible()),w(o.options,"debugRows")),e.getVisibleCells=h(()=>[e.getLeftVisibleCells(),e.getCenterVisibleCells(),e.getRightVisibleCells()],(t,n,r)=>[...t,...n,...r],w(o.options,"debugRows"))},createTable:e=>{const o=(t,n)=>h(()=>[n(),n().filter(r=>r.getIsVisible())?.map(r=>r.id).join("_")],r=>r.filter(i=>i.getIsVisible==null?void 0:i.getIsVisible()),w(e.options,"debugColumns"));e.getVisibleFlatColumns=o("getVisibleFlatColumns",()=>e.getAllFlatColumns()),e.getVisibleLeafColumns=o("getVisibleLeafColumns",()=>e.getAllLeafColumns()),e.getLeftVisibleLeafColumns=o("getLeftVisibleLeafColumns",()=>e.getLeftLeafColumns()),e.getRightVisibleLeafColumns=o("getRightVisibleLeafColumns",()=>e.getRightLeafColumns()),e.getCenterVisibleLeafColumns=o("getCenterVisibleLeafColumns",()=>e.getCenterLeafColumns()),e.setColumnVisibility=t=>e.options.onColumnVisibilityChange==null?void 0:e.options.onColumnVisibilityChange(t),e.resetColumnVisibility=t=>{var n;e.setColumnVisibility(t?{}:(n=e.initialState.columnVisibility)!=null?n:{})},e.toggleAllColumnsVisible=t=>{var n;t=(n=t)!=null?n:!e.getIsAllColumnsVisible(),e.setColumnVisibility(e.getAllLeafColumns().reduce((r,i)=>({...r,[i.id]:t||!(i.getCanHide!=null&&i.getCanHide())}),{}))},e.getIsAllColumnsVisible=()=>!e.getAllLeafColumns()?.some(t=>!(t.getIsVisible!=null&&t.getIsVisible())),e.getIsSomeColumnsVisible=()=>e.getAllLeafColumns()?.some(t=>t.getIsVisible==null?void 0:t.getIsVisible()),e.getToggleAllColumnsVisibilityHandler=()=>t=>{var n;e.toggleAllColumnsVisible((n=t.target)==null?void 0:n.checked)}}};function T(e,o){return o?o==="center"?e.getCenterVisibleLeafColumns():o==="left"?e.getLeftVisibleLeafColumns():e.getRightVisibleLeafColumns():e.getVisibleLeafColumns()}const Ht={createTable:e=>{e._getGlobalFacetedRowModel=e.options.getFacetedRowModel&&e.options.getFacetedRowModel(e,"__global__"),e.getGlobalFacetedRowModel=()=>e.options.manualFiltering||!e._getGlobalFacetedRowModel?e.getPreFilteredRowModel():e._getGlobalFacetedRowModel(),e._getGlobalFacetedUniqueValues=e.options.getFacetedUniqueValues&&e.options.getFacetedUniqueValues(e,"__global__"),e.getGlobalFacetedUniqueValues=()=>e._getGlobalFacetedUniqueValues?e._getGlobalFacetedUniqueValues():new Map,e._getGlobalFacetedMinMaxValues=e.options.getFacetedMinMaxValues&&e.options.getFacetedMinMaxValues(e,"__global__"),e.getGlobalFacetedMinMaxValues=()=>{if(e._getGlobalFacetedMinMaxValues)return e._getGlobalFacetedMinMaxValues()}}},Lt={getInitialState:e=>({globalFilter:void 0,...e}),getDefaultOptions:e=>({onGlobalFilterChange:V("globalFilter",e),globalFilterFn:"auto",getColumnCanGlobalFilter:o=>{var t;const n=(t=e.getCoreRowModel().flatRows[0])==null||(t=t._getAllCellsByColumnId()[o.id])==null?void 0:t.getValue();return typeof n=="string"||typeof n=="number"}}),createColumn:(e,o)=>{e.getCanGlobalFilter=()=>{var t,n,r,i;return((t=e.columnDef.enableGlobalFilter)!=null?t:!0)&&((n=o.options.enableGlobalFilter)!=null?n:!0)&&((r=o.options.enableFilters)!=null?r:!0)&&((i=o.options.getColumnCanGlobalFilter==null?void 0:o.options.getColumnCanGlobalFilter(e))!=null?i:!0)&&!!e.accessorFn}},createTable:e=>{e.getGlobalAutoFilterFn=()=>j.includesString,e.getGlobalFilterFn=()=>{var o,t;const{globalFilterFn:n}=e.options;return J(n)?n:n==="auto"?e.getGlobalAutoFilterFn():(o=(t=e.options.filterFns)==null?void 0:t[n])!=null?o:j[n]},e.setGlobalFilter=o=>{e.options.onGlobalFilterChange==null||e.options.onGlobalFilterChange(o)},e.resetGlobalFilter=o=>{e.setGlobalFilter(o?void 0:e.initialState.globalFilter)}}},zt={getInitialState:e=>({expanded:{},...e}),getDefaultOptions:e=>({onExpandedChange:V("expanded",e),paginateExpandedRows:!0}),createTable:e=>{let o=!1,t=!1;e._autoResetExpanded=()=>{var n,r;if(!o){e._queue(()=>{o=!0});return}if((n=(r=e.options.autoResetAll)!=null?r:e.options.autoResetExpanded)!=null?n:!e.options.manualExpanding){if(t)return;t=!0,e._queue(()=>{e.resetExpanded(),t=!1})}},e.setExpanded=n=>e.options.onExpandedChange==null?void 0:e.options.onExpandedChange(n),e.toggleAllRowsExpanded=n=>{n??!e.getIsAllRowsExpanded()?e.setExpanded(!0):e.setExpanded({})},e.resetExpanded=n=>{var r,i;e.setExpanded(n?{}:(r=(i=e.initialState)==null?void 0:i.expanded)!=null?r:{})},e.getCanSomeRowsExpand=()=>e.getPrePaginationRowModel().flatRows?.some(n=>n.getCanExpand()),e.getToggleAllRowsExpandedHandler=()=>n=>{n.persist==null||n.persist(),e.toggleAllRowsExpanded()},e.getIsSomeRowsExpanded=()=>{const n=e.getState().expanded;return n===!0||Object.values(n)?.some(Boolean)},e.getIsAllRowsExpanded=()=>{const n=e.getState().expanded;return typeof n=="boolean"?n===!0:!(!Object.keys(n).length||e.getRowModel().flatRows?.some(r=>!r.getIsExpanded()))},e.getExpandedDepth=()=>{let n=0;return(e.getState().expanded===!0?Object.keys(e.getRowModel().rowsById):Object.keys(e.getState().expanded)).forEach(i=>{const l=i.split(".");n=Math.max(n,l.length)}),n},e.getPreExpandedRowModel=()=>e.getSortedRowModel(),e.getExpandedRowModel=()=>(!e._getExpandedRowModel&&e.options.getExpandedRowModel&&(e._getExpandedRowModel=e.options.getExpandedRowModel(e)),e.options.manualExpanding||!e._getExpandedRowModel?e.getPreExpandedRowModel():e._getExpandedRowModel())},createRow:(e,o)=>{e.toggleExpanded=t=>{o.setExpanded(n=>{var r;const i=n===!0?!0:!!(n!=null&&n[e.id]);let l={};if(n===!0?Object.keys(o.getRowModel().rowsById).forEach(s=>{l[s]=!0}):l=n,t=(r=t)!=null?r:!i,!i&&t)return{...l,[e.id]:!0};if(i&&!t){const{[e.id]:s,...a}=l;return a}return n})},e.getIsExpanded=()=>{var t;const n=o.getState().expanded;return!!((t=o.options.getIsRowExpanded==null?void 0:o.options.getIsRowExpanded(e))!=null?t:n===!0||n!=null&&n[e.id])},e.getCanExpand=()=>{var t,n,r;return(t=o.options.getRowCanExpand==null?void 0:o.options.getRowCanExpand(e))!=null?t:((n=o.options.enableExpanding)!=null?n:!0)&&!!((r=e.subRows)!=null&&r.length)},e.getIsAllParentsExpanded=()=>{let t=!0,n=e;for(;t&&n.parentId;)n=o.getRow(n.parentId,!0),t=n.getIsExpanded();return t},e.getToggleExpandedHandler=()=>{const t=e.getCanExpand();return()=>{t&&e.toggleExpanded()}}}},re=0,ie=10,te=()=>({pageIndex:re,pageSize:ie}),Nt={getInitialState:e=>({...e,pagination:{...te(),...e==null?void 0:e.pagination}}),getDefaultOptions:e=>({onPaginationChange:V("pagination",e)}),createTable:e=>{let o=!1,t=!1;e._autoResetPageIndex=()=>{var n,r;if(!o){e._queue(()=>{o=!0});return}if((n=(r=e.options.autoResetAll)!=null?r:e.options.autoResetPageIndex)!=null?n:!e.options.manualPagination){if(t)return;t=!0,e._queue(()=>{e.resetPageIndex(),t=!1})}},e.setPagination=n=>{const r=i=>G(n,i);return e.options.onPaginationChange==null?void 0:e.options.onPaginationChange(r)},e.resetPagination=n=>{var r;e.setPagination(n?te():(r=e.initialState.pagination)!=null?r:te())},e.setPageIndex=n=>{e.setPagination(r=>{let i=G(n,r.pageIndex);const l=typeof e.options.pageCount>"u"||e.options.pageCount===-1?Number.MAX_SAFE_INTEGER:e.options.pageCount-1;return i=Math.max(0,Math.min(i,l)),{...r,pageIndex:i}})},e.resetPageIndex=n=>{var r,i;e.setPageIndex(n?re:(r=(i=e.initialState)==null||(i=i.pagination)==null?void 0:i.pageIndex)!=null?r:re)},e.resetPageSize=n=>{var r,i;e.setPageSize(n?ie:(r=(i=e.initialState)==null||(i=i.pagination)==null?void 0:i.pageSize)!=null?r:ie)},e.setPageSize=n=>{e.setPagination(r=>{const i=Math.max(1,G(n,r.pageSize)),l=r.pageSize*r.pageIndex,s=Math.floor(l/i);return{...r,pageIndex:s,pageSize:i}})},e.setPageCount=n=>e.setPagination(r=>{var i;let l=G(n,(i=e.options.pageCount)!=null?i:-1);return typeof l=="number"&&(l=Math.max(-1,l)),{...r,pageCount:l}}),e.getPageOptions=h(()=>[e.getPageCount()],n=>{let r=[];return n&&n>0&&(r=[...new Array(n)].fill(null)?.map((i,l)=>l)),r},w(e.options,"debugTable")),e.getCanPreviousPage=()=>e.getState().pagination.pageIndex>0,e.getCanNextPage=()=>{const{pageIndex:n}=e.getState().pagination,r=e.getPageCount();return r===-1?!0:r===0?!1:ne.setPageIndex(n=>n-1),e.nextPage=()=>e.setPageIndex(n=>n+1),e.firstPage=()=>e.setPageIndex(0),e.lastPage=()=>e.setPageIndex(e.getPageCount()-1),e.getPrePaginationRowModel=()=>e.getExpandedRowModel(),e.getPaginationRowModel=()=>(!e._getPaginationRowModel&&e.options.getPaginationRowModel&&(e._getPaginationRowModel=e.options.getPaginationRowModel(e)),e.options.manualPagination||!e._getPaginationRowModel?e.getPrePaginationRowModel():e._getPaginationRowModel()),e.getPageCount=()=>{var n;return(n=e.options.pageCount)!=null?n:Math.ceil(e.getRowCount()/e.getState().pagination.pageSize)},e.getRowCount=()=>{var n;return(n=e.options.rowCount)!=null?n:e.getPrePaginationRowModel().rows.length}}},ne=()=>({top:[],bottom:[]}),kt={getInitialState:e=>({rowPinning:ne(),...e}),getDefaultOptions:e=>({onRowPinningChange:V("rowPinning",e)}),createRow:(e,o)=>{e.pin=(t,n,r)=>{const i=n?e.getLeafRows()?.map(a=>{let{id:g}=a;return g}):[],l=r?e.getParentRows()?.map(a=>{let{id:g}=a;return g}):[],s=new Set([...l,e.id,...i]);o.setRowPinning(a=>{var g,c;if(t==="bottom"){var S,d;return{top:((S=a==null?void 0:a.top)!=null?S:[]).filter(m=>!(s!=null&&s.has(m))),bottom:[...((d=a==null?void 0:a.bottom)!=null?d:[]).filter(m=>!(s!=null&&s.has(m))),...Array.from(s)]}}if(t==="top"){var u,f;return{top:[...((u=a==null?void 0:a.top)!=null?u:[]).filter(m=>!(s!=null&&s.has(m))),...Array.from(s)],bottom:((f=a==null?void 0:a.bottom)!=null?f:[]).filter(m=>!(s!=null&&s.has(m)))}}return{top:((g=a==null?void 0:a.top)!=null?g:[]).filter(m=>!(s!=null&&s.has(m))),bottom:((c=a==null?void 0:a.bottom)!=null?c:[]).filter(m=>!(s!=null&&s.has(m)))}})},e.getCanPin=()=>{var t;const{enableRowPinning:n,enablePinning:r}=o.options;return typeof n=="function"?n(e):(t=n??r)!=null?t:!0},e.getIsPinned=()=>{const t=[e.id],{top:n,bottom:r}=o.getState().rowPinning,i=t?.some(s=>n==null?void 0:n.includes(s)),l=t?.some(s=>r==null?void 0:r.includes(s));return i?"top":l?"bottom":!1},e.getPinnedIndex=()=>{var t,n;const r=e.getIsPinned();if(!r)return-1;const i=(t=r==="top"?o.getTopRows():o.getBottomRows())==null?void 0:t?.map(l=>{let{id:s}=l;return s});return(n=i==null?void 0:i.indexOf(e.id))!=null?n:-1}},createTable:e=>{e.setRowPinning=o=>e.options.onRowPinningChange==null?void 0:e.options.onRowPinningChange(o),e.resetRowPinning=o=>{var t,n;return e.setRowPinning(o?ne():(t=(n=e.initialState)==null?void 0:n.rowPinning)!=null?t:ne())},e.getIsSomeRowsPinned=o=>{var t;const n=e.getState().rowPinning;if(!o){var r,i;return!!((r=n.top)!=null&&r.length||(i=n.bottom)!=null&&i.length)}return!!((t=n[o])!=null&&t.length)},e._getPinnedRows=(o,t,n)=>{var r;return((r=e.options.keepPinnedRows)==null||r?(t??[])?.map(l=>{const s=e.getRow(l,!0);return s.getIsAllParentsExpanded()?s:null}):(t??[])?.map(l=>o.find(s=>s.id===l))).filter(Boolean)?.map(l=>({...l,position:n}))},e.getTopRows=h(()=>[e.getRowModel().rows,e.getState().rowPinning.top],(o,t)=>e._getPinnedRows(o,t,"top"),w(e.options,"debugRows")),e.getBottomRows=h(()=>[e.getRowModel().rows,e.getState().rowPinning.bottom],(o,t)=>e._getPinnedRows(o,t,"bottom"),w(e.options,"debugRows")),e.getCenterRows=h(()=>[e.getRowModel().rows,e.getState().rowPinning.top,e.getState().rowPinning.bottom],(o,t,n)=>{const r=new Set([...t??[],...n??[]]);return o.filter(i=>!r.has(i.id))},w(e.options,"debugRows"))}},Ot={getInitialState:e=>({rowSelection:{},...e}),getDefaultOptions:e=>({onRowSelectionChange:V("rowSelection",e),enableRowSelection:!0,enableMultiRowSelection:!0,enableSubRowSelection:!0}),createTable:e=>{e.setRowSelection=o=>e.options.onRowSelectionChange==null?void 0:e.options.onRowSelectionChange(o),e.resetRowSelection=o=>{var t;return e.setRowSelection(o?{}:(t=e.initialState.rowSelection)!=null?t:{})},e.toggleAllRowsSelected=o=>{e.setRowSelection(t=>{o=typeof o<"u"?o:!e.getIsAllRowsSelected();const n={...t},r=e.getPreGroupedRowModel().flatRows;return o?r.forEach(i=>{i.getCanSelect()&&(n[i.id]=!0)}):r.forEach(i=>{delete n[i.id]}),n})},e.toggleAllPageRowsSelected=o=>e.setRowSelection(t=>{const n=typeof o<"u"?o:!e.getIsAllPageRowsSelected(),r={...t};return e.getRowModel().rows.forEach(i=>{le(r,i.id,n,!0,e)}),r}),e.getPreSelectedRowModel=()=>e.getCoreRowModel(),e.getSelectedRowModel=h(()=>[e.getState().rowSelection,e.getCoreRowModel()],(o,t)=>Object.keys(o).length?oe(e,t):{rows:[],flatRows:[],rowsById:{}},w(e.options,"debugTable")),e.getFilteredSelectedRowModel=h(()=>[e.getState().rowSelection,e.getFilteredRowModel()],(o,t)=>Object.keys(o).length?oe(e,t):{rows:[],flatRows:[],rowsById:{}},w(e.options,"debugTable")),e.getGroupedSelectedRowModel=h(()=>[e.getState().rowSelection,e.getSortedRowModel()],(o,t)=>Object.keys(o).length?oe(e,t):{rows:[],flatRows:[],rowsById:{}},w(e.options,"debugTable")),e.getIsAllRowsSelected=()=>{const o=e.getFilteredRowModel().flatRows,{rowSelection:t}=e.getState();let n=!!(o.length&&Object.keys(t).length);return n&&o?.some(r=>r.getCanSelect()&&!t[r.id])&&(n=!1),n},e.getIsAllPageRowsSelected=()=>{const o=e.getPaginationRowModel().flatRows.filter(r=>r.getCanSelect()),{rowSelection:t}=e.getState();let n=!!o.length;return n&&o?.some(r=>!t[r.id])&&(n=!1),n},e.getIsSomeRowsSelected=()=>{var o;const t=Object.keys((o=e.getState().rowSelection)!=null?o:{}).length;return t>0&&t{const o=e.getPaginationRowModel().flatRows;return e.getIsAllPageRowsSelected()?!1:o.filter(t=>t.getCanSelect())?.some(t=>t.getIsSelected()||t.getIsSomeSelected())},e.getToggleAllRowsSelectedHandler=()=>o=>{e.toggleAllRowsSelected(o.target.checked)},e.getToggleAllPageRowsSelectedHandler=()=>o=>{e.toggleAllPageRowsSelected(o.target.checked)}},createRow:(e,o)=>{e.toggleSelected=(t,n)=>{const r=e.getIsSelected();o.setRowSelection(i=>{var l;if(t=typeof t<"u"?t:!r,e.getCanSelect()&&r===t)return i;const s={...i};return le(s,e.id,t,(l=n==null?void 0:n.selectChildren)!=null?l:!0,o),s})},e.getIsSelected=()=>{const{rowSelection:t}=o.getState();return de(e,t)},e.getIsSomeSelected=()=>{const{rowSelection:t}=o.getState();return se(e,t)==="some"},e.getIsAllSubRowsSelected=()=>{const{rowSelection:t}=o.getState();return se(e,t)==="all"},e.getCanSelect=()=>{var t;return typeof o.options.enableRowSelection=="function"?o.options.enableRowSelection(e):(t=o.options.enableRowSelection)!=null?t:!0},e.getCanSelectSubRows=()=>{var t;return typeof o.options.enableSubRowSelection=="function"?o.options.enableSubRowSelection(e):(t=o.options.enableSubRowSelection)!=null?t:!0},e.getCanMultiSelect=()=>{var t;return typeof o.options.enableMultiRowSelection=="function"?o.options.enableMultiRowSelection(e):(t=o.options.enableMultiRowSelection)!=null?t:!0},e.getToggleSelectedHandler=()=>{const t=e.getCanSelect();return n=>{var r;t&&e.toggleSelected((r=n.target)==null?void 0:r.checked)}}}},le=(e,o,t,n,r)=>{var i;const l=r.getRow(o,!0);t?(l.getCanMultiSelect()||Object.keys(e).forEach(s=>delete e[s]),l.getCanSelect()&&(e[o]=!0)):delete e[o],n&&(i=l.subRows)!=null&&i.length&&l.getCanSelectSubRows()&&l.subRows.forEach(s=>le(e,s.id,t,n,r))};function oe(e,o){const t=e.getState().rowSelection,n=[],r={},i=function(l,s){return l?.map(a=>{var g;const c=de(a,t);if(c&&(n.push(a),r[a.id]=a),(g=a.subRows)!=null&&g.length&&(a={...a,subRows:i(a.subRows)}),c)return a}).filter(Boolean)};return{rows:i(o.rows),flatRows:n,rowsById:r}}function de(e,o){var t;return(t=o[e.id])!=null?t:!1}function se(e,o,t){var n;if(!((n=e.subRows)!=null&&n.length))return!1;let r=!0,i=!1;return e.subRows.forEach(l=>{if(!(i&&!r)&&(l.getCanSelect()&&(de(l,o)?i=!0:r=!1),l.subRows&&l.subRows.length)){const s=se(l,o);s==="all"?i=!0:(s==="some"&&(i=!0),r=!1)}}),r?"all":i?"some":!1}const ue=/([0-9]+)/gm,Tt=(e,o,t)=>Ie(H(e.getValue(t)).toLowerCase(),H(o.getValue(t)).toLowerCase()),Bt=(e,o,t)=>Ie(H(e.getValue(t)),H(o.getValue(t))),qt=(e,o,t)=>ce(H(e.getValue(t)).toLowerCase(),H(o.getValue(t)).toLowerCase()),Ut=(e,o,t)=>ce(H(e.getValue(t)),H(o.getValue(t))),Xt=(e,o,t)=>{const n=e.getValue(t),r=o.getValue(t);return n>r?1:nce(e.getValue(t),o.getValue(t));function ce(e,o){return e===o?0:e>o?1:-1}function H(e){return typeof e=="number"?isNaN(e)||e===1/0||e===-1/0?"":String(e):typeof e=="string"?e:""}function Ie(e,o){const t=e.split(ue).filter(Boolean),n=o.split(ue).filter(Boolean);for(;t.length&&n.length;){const r=t.shift(),i=n.shift(),l=parseInt(r,10),s=parseInt(i,10),a=[l,s].sort();if(isNaN(a[0])){if(r>i)return 1;if(i>r)return-1;continue}if(isNaN(a[1]))return isNaN(l)?-1:1;if(l>s)return 1;if(s>l)return-1}return t.length-n.length}const O={alphanumeric:Tt,alphanumericCaseSensitive:Bt,text:qt,textCaseSensitive:Ut,datetime:Xt,basic:Kt},Jt={getInitialState:e=>({sorting:[],...e}),getDefaultColumnDef:()=>({sortingFn:"auto",sortUndefined:1}),getDefaultOptions:e=>({onSortingChange:V("sorting",e),isMultiSortEvent:o=>o.shiftKey}),createColumn:(e,o)=>{e.getAutoSortingFn=()=>{const t=o.getFilteredRowModel().flatRows.slice(10);let n=!1;for(const r of t){const i=r==null?void 0:r.getValue(e.id);if(Object.prototype.toString.call(i)==="[object Date]")return O.datetime;if(typeof i=="string"&&(n=!0,i.split(ue).length>1))return O.alphanumeric}return n?O.text:O.basic},e.getAutoSortDir=()=>{const t=o.getFilteredRowModel().flatRows[0];return typeof(t==null?void 0:t.getValue(e.id))=="string"?"asc":"desc"},e.getSortingFn=()=>{var t,n;if(!e)throw new Error;return J(e.columnDef.sortingFn)?e.columnDef.sortingFn:e.columnDef.sortingFn==="auto"?e.getAutoSortingFn():(t=(n=o.options.sortingFns)==null?void 0:n[e.columnDef.sortingFn])!=null?t:O[e.columnDef.sortingFn]},e.toggleSorting=(t,n)=>{const r=e.getNextSortingOrder(),i=typeof t<"u"&&t!==null;o.setSorting(l=>{const s=l==null?void 0:l.find(u=>u.id===e.id),a=l==null?void 0:l.findIndex(u=>u.id===e.id);let g=[],c,S=i?t:r==="desc";if(l!=null&&l.length&&e.getCanMultiSort()&&n?s?c="toggle":c="add":l!=null&&l.length&&a!==l.length-1?c="replace":s?c="toggle":c="replace",c==="toggle"&&(i||r||(c="remove")),c==="add"){var d;g=[...l,{id:e.id,desc:S}],g.splice(0,g.length-((d=o.options.maxMultiSortColCount)!=null?d:Number.MAX_SAFE_INTEGER))}else c==="toggle"?g=l?.map(u=>u.id===e.id?{...u,desc:S}:u):c==="remove"?g=l.filter(u=>u.id!==e.id):g=[{id:e.id,desc:S}];return g})},e.getFirstSortDir=()=>{var t,n;return((t=(n=e.columnDef.sortDescFirst)!=null?n:o.options.sortDescFirst)!=null?t:e.getAutoSortDir()==="desc")?"desc":"asc"},e.getNextSortingOrder=t=>{var n,r;const i=e.getFirstSortDir(),l=e.getIsSorted();return l?l!==i&&((n=o.options.enableSortingRemoval)==null||n)&&(!(t&&(r=o.options.enableMultiRemove)!=null)||r)?!1:l==="desc"?"asc":"desc":i},e.getCanSort=()=>{var t,n;return((t=e.columnDef.enableSorting)!=null?t:!0)&&((n=o.options.enableSorting)!=null?n:!0)&&!!e.accessorFn},e.getCanMultiSort=()=>{var t,n;return(t=(n=e.columnDef.enableMultiSort)!=null?n:o.options.enableMultiSort)!=null?t:!!e.accessorFn},e.getIsSorted=()=>{var t;const n=(t=o.getState().sorting)==null?void 0:t.find(r=>r.id===e.id);return n?n.desc?"desc":"asc":!1},e.getSortIndex=()=>{var t,n;return(t=(n=o.getState().sorting)==null?void 0:n.findIndex(r=>r.id===e.id))!=null?t:-1},e.clearSorting=()=>{o.setSorting(t=>t!=null&&t.length?t.filter(n=>n.id!==e.id):[])},e.getToggleSortingHandler=()=>{const t=e.getCanSort();return n=>{t&&(n.persist==null||n.persist(),e.toggleSorting==null||e.toggleSorting(void 0,e.getCanMultiSort()?o.options.isMultiSortEvent==null?void 0:o.options.isMultiSortEvent(n):!1))}}},createTable:e=>{e.setSorting=o=>e.options.onSortingChange==null?void 0:e.options.onSortingChange(o),e.resetSorting=o=>{var t,n;e.setSorting(o?[]:(t=(n=e.initialState)==null?void 0:n.sorting)!=null?t:[])},e.getPreSortedRowModel=()=>e.getGroupedRowModel(),e.getSortedRowModel=()=>(!e._getSortedRowModel&&e.options.getSortedRowModel&&(e._getSortedRowModel=e.options.getSortedRowModel(e)),e.options.manualSorting||!e._getSortedRowModel?e.getPreSortedRowModel():e._getSortedRowModel())}},Qt=[St,Gt,Pt,Dt,ht,wt,Ht,Lt,Jt,Vt,zt,Nt,kt,Ot,jt];function Wt(e){var o,t;const n=[...Qt,...(o=e._features)!=null?o:[]];let r={_features:n};const i=r._features.reduce((d,u)=>Object.assign(d,u.getDefaultOptions==null?void 0:u.getDefaultOptions(r)),{}),l=d=>r.options.mergeOptions?r.options.mergeOptions(i,d):{...i,...d};let a={...{},...(t=e.initialState)!=null?t:{}};r._features.forEach(d=>{var u;a=(u=d.getInitialState==null?void 0:d.getInitialState(a))!=null?u:a});const g=[];let c=!1;const S={_features:n,options:{...i,...e},initialState:a,_queue:d=>{g.push(d),c||(c=!0,Promise.resolve().then(()=>{for(;g.length;)g.shift()();c=!1}).catch(u=>setTimeout(()=>{throw u})))},reset:()=>{r.setState(r.initialState)},setOptions:d=>{const u=G(d,r.options);r.options=l(u)},getState:()=>r.options.state,setState:d=>{r.options.onStateChange==null||r.options.onStateChange(d)},_getRowId:(d,u,f)=>{var m;return(m=r.options.getRowId==null?void 0:r.options.getRowId(d,u,f))!=null?m:`${f?[f.id,u].join("."):u}`},getCoreRowModel:()=>(r._getCoreRowModel||(r._getCoreRowModel=r.options.getCoreRowModel(r)),r._getCoreRowModel()),getRowModel:()=>r.getPaginationRowModel(),getRow:(d,u)=>{let f=(u?r.getPrePaginationRowModel():r.getRowModel()).rowsById[d];if(!f&&(f=r.getCoreRowModel().rowsById[d],!f))throw new Error;return f},_getDefaultColumnDef:h(()=>[r.options.defaultColumn],d=>{var u;return d=(u=d)!=null?u:{},{header:f=>{const m=f.header.column.columnDef;return m.accessorKey?m.accessorKey:m.accessorFn?m.id:null},cell:f=>{var m,v;return(m=(v=f.renderValue())==null||v.toString==null?void 0:v.toString())!=null?m:null},...r._features.reduce((f,m)=>Object.assign(f,m.getDefaultColumnDef==null?void 0:m.getDefaultColumnDef()),{}),...d}},w(e,"debugColumns")),_getColumnDefs:()=>r.options.columns,getAllColumns:h(()=>[r._getColumnDefs()],d=>{const u=function(f,m,v){return v===void 0&&(v=0),f?.map(C=>{const R=mt(r,C,v,m),x=C;return R.columns=x.columns?u(x.columns,R,v+1):[],R})};return u(d)},w(e,"debugColumns")),getAllFlatColumns:h(()=>[r.getAllColumns()],d=>d.flatMap(u=>u.getFlatColumns()),w(e,"debugColumns")),_getAllFlatColumnsById:h(()=>[r.getAllFlatColumns()],d=>d.reduce((u,f)=>(u[f.id]=f,u),{}),w(e,"debugColumns")),getAllLeafColumns:h(()=>[r.getAllColumns(),r._getOrderColumnsFn()],(d,u)=>{let f=d.flatMap(m=>m.getLeafColumns());return u(f)},w(e,"debugColumns")),getColumn:d=>r._getAllFlatColumnsById()[d]};Object.assign(r,S);for(let d=0;dh(()=>[e.options.data],o=>{const t={rows:[],flatRows:[],rowsById:{}},n=function(r,i,l){i===void 0&&(i=0);const s=[];for(let g=0;ge._autoResetPageIndex()))}function Pe(e,o,t){return t.options.filterFromLeafRows?Zt(e,o,t):bt(e,o,t)}function Zt(e,o,t){var n;const r=[],i={},l=(n=t.options.maxLeafRowFilterDepth)!=null?n:100,s=function(a,g){g===void 0&&(g=0);const c=[];for(let d=0;dh(()=>[e.getPreFilteredRowModel(),e.getState().columnFilters,e.getState().globalFilter,e.getFilteredRowModel()],(t,n,r)=>{if(!t.rows.length||!(n!=null&&n.length)&&!r)return t;const i=[...n?.map(s=>s.id).filter(s=>s!==o),r?"__global__":void 0].filter(Boolean),l=s=>{for(let a=0;ah(()=>{var t;return[(t=e.getColumn(o))==null?void 0:t.getFacetedRowModel()]},t=>{if(!t)return new Map;let n=new Map;for(let i=0;ih(()=>[e.getPreFilteredRowModel(),e.getState().columnFilters,e.getState().globalFilter],(o,t,n)=>{if(!o.rows.length||!(t!=null&&t.length)&&!n){for(let d=0;d{var u;const f=e.getColumn(d.id);if(!f)return;const m=f.getFilterFn();m&&r.push({id:d.id,filterFn:m,resolvedValue:(u=m.resolveFilterValue==null?void 0:m.resolveFilterValue(d.value))!=null?u:d.value})});const l=(t??[])?.map(d=>d.id),s=e.getGlobalFilterFn(),a=e.getAllLeafColumns().filter(d=>d.getCanGlobalFilter());n&&s&&a.length&&(l.push("__global__"),a.forEach(d=>{var u;i.push({id:d.id,filterFn:s,resolvedValue:(u=s.resolveFilterValue==null?void 0:s.resolveFilterValue(n))!=null?u:n})}));let g,c;for(let d=0;d{u.columnFiltersMeta[m]=v})}if(i.length){for(let f=0;f{u.columnFiltersMeta[m]=v})){u.columnFilters.__global__=!0;break}}u.columnFilters.__global__!==!0&&(u.columnFilters.__global__=!1)}}const S=d=>{for(let u=0;ue._autoResetPageIndex()))}function on(){return e=>h(()=>[e.getState().sorting,e.getPreSortedRowModel()],(o,t)=>{if(!t.rows.length||!(o!=null&&o.length))return t;const n=e.getState().sorting,r=[],i=n.filter(a=>{var g;return(g=e.getColumn(a.id))==null?void 0:g.getCanSort()}),l={};i.forEach(a=>{const g=e.getColumn(a.id);g&&(l[a.id]={sortUndefined:g.columnDef.sortUndefined,invertSorting:g.columnDef.invertSorting,sortingFn:g.getSortingFn()})});const s=a=>{const g=a?.map(c=>({...c}));return g.sort((c,S)=>{for(let u=0;u{var S;r.push(c),(S=c.subRows)!=null&&S.length&&(c.subRows=s(c.subRows))}),g};return{rows:s(t.rows),flatRows:r,rowsById:t.rowsById}},w(e.options,"debugTable","getSortedRowModel",()=>e._autoResetPageIndex()))}/** + * react-table + * + * Copyright (c) TanStack + * + * This source code is licensed under the MIT license found in the + * LICENSE.md file in the root directory of this source tree. + * + * @license MIT + */function we(e,o){return e?rn(e)?A.createElement(e,o):e:null}function rn(e){return ln(e)||typeof e=="function"||sn(e)}function ln(e){return typeof e=="function"&&(()=>{const o=Object.getPrototypeOf(e);return o.prototype&&o.prototype.isReactComponent})()}function sn(e){return typeof e=="object"&&typeof e.$$typeof=="symbol"&&["react.memo","react.forward_ref"].includes(e.$$typeof.description)}function un(e){const o={state:{},onStateChange:()=>{},renderFallbackValue:null,...e},[t]=A.useState(()=>({current:Wt(o)})),[n,r]=A.useState(()=>t.current.initialState);return t.current.setOptions(i=>({...i,...e,state:{...n,...e.state},onStateChange:l=>{r(l),e.onStateChange==null||e.onStateChange(l)}})),t.current}const K="…";function an(e,o,t){const n=t*2+5;if(o<=n)return Array.from({length:o},(g,c)=>c+1);const r=Math.max(e-t,2),i=Math.min(e+t,o-1),l=r>2,s=i0,v=A.useMemo(()=>an(u,d,g),[u,d,g]);if(e?e.getCoreRowModel().rows.length===0&&(!n||n===0):n!==void 0&&n===0)return null;const R=!!e||!!i;if(d<=1&&!R)return null;const x=()=>{f&&(e?e.nextPage():r==null||r(S+1),l==null||l())},M=()=>{m&&(e?e.previousPage():r==null||r(S-1),s==null||s())},F=_=>{const $=Math.min(Math.max(_,1),d);$-1!==S&&(e?e.setPageIndex($-1):r==null||r($-1))},D=_=>{e?e.setPageSize(_):i==null||i(_)},L=S*c+1,N=Math.min(L+c-1,n??0);return p.jsxs("nav",{role:"navigation","aria-label":"Pagination",className:"flex flex-col gap-3 px-2 py-3 text-sm sm:flex-row sm:items-center sm:justify-between",children:[R?p.jsxs("div",{className:"flex items-center space-x-2",children:[p.jsx("p",{className:"text-xs sm:text-sm font-medium whitespace-nowrap",children:"Rows per page"}),p.jsxs(Xe,{value:`${c}`,onValueChange:_=>{D(Number(_))},children:[p.jsx(Ke,{className:"h-9 sm:h-8 w-[70px] min-w-[70px] text-sm",children:p.jsx(Je,{placeholder:c})}),p.jsx(Qe,{side:"top",children:[10,20,30,40,50]?.map(_=>p.jsx(We,{value:`${_}`,children:_},_))})]})]}):p.jsxs("p",{className:"text-xs sm:text-sm text-muted-foreground dark:text-gray-400",children:["Showing"," ",p.jsx("span",{className:"font-medium text-gray-700 dark:text-gray-200",children:L}),"–",p.jsx("span",{className:"font-medium text-gray-700 dark:text-gray-200",children:N})," ","of"," ",p.jsx("span",{className:"font-medium text-gray-700 dark:text-gray-200",children:n})]}),p.jsxs("div",{className:"flex flex-wrap items-center justify-end gap-1",children:[p.jsxs(z,{variant:"outline",size:"sm",onClick:M,disabled:!m,"aria-label":"Previous page",className:"h-8 gap-1 px-3 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700",children:[p.jsx(Ye,{className:"h-4 w-4"}),p.jsx("span",{className:"hidden sm:inline",children:"Previous"})]}),p.jsx("ul",{className:"flex items-center gap-1",children:v?.map((_,$)=>{if(_===K)return p.jsx("li",{"aria-hidden":"true",className:"flex h-8 w-8 items-center justify-center text-gray-400 dark:text-gray-500",children:K},`ellipsis-${$}`);const I=_===u;return p.jsx("li",{children:p.jsx(z,{type:"button",variant:I?"default":"ghost",size:"sm",onClick:()=>F(_),"aria-current":I?"page":void 0,"aria-label":`Go to page ${_}`,className:De("h-8 min-w-8 px-2 text-sm transition-colors",I?"bg-primary text-primary-foreground font-bold hover:bg-primary/90 dark:bg-primary dark:text-primary-foreground":"font-medium text-gray-700 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-700"),children:_})},_)})}),p.jsxs(z,{variant:"outline",size:"sm",onClick:x,disabled:!f,"aria-label":"Next page",className:"h-8 gap-1 px-3 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700",children:[p.jsx("span",{className:"hidden sm:inline",children:"Next"}),p.jsx(Ee,{className:"h-4 w-4"})]})]})]})}function dn({column:e,title:o,options:t}){const n=e==null?void 0:e.getFacetedUniqueValues(),r=new Set(e==null?void 0:e.getFilterValue());return p.jsxs(ot,{children:[p.jsx(rt,{asChild:!0,children:p.jsxs(z,{variant:"outline",size:"sm",className:"h-8 border-dashed",children:[p.jsx(at,{className:"mr-2 h-4 w-4"}),o,(r==null?void 0:r.size)>0&&p.jsxs(p.Fragment,{children:[p.jsx(lt,{orientation:"vertical",className:"mx-2 h-4"}),p.jsx(W,{variant:"secondary",className:"rounded-sm px-1 font-normal lg:hidden",children:r.size}),p.jsx("div",{className:"hidden space-x-1 lg:flex",children:r.size>2?p.jsxs(W,{variant:"secondary",className:"rounded-sm px-1 font-normal",children:[r.size," selected"]}):t.filter(i=>r.has(i.value))?.map(i=>p.jsx(W,{variant:"secondary",className:"rounded-sm px-1 font-normal",children:i.label},i.value))})]})]})}),p.jsx(it,{className:"w-[200px] p-0",align:"start",children:p.jsxs(Ze,{children:[p.jsx(be,{placeholder:o}),p.jsxs(et,{children:[p.jsx(tt,{children:"No results found."}),p.jsx(pe,{children:t?.map(i=>{const l=r.has(i.value);return p.jsxs(me,{onSelect:()=>{l?r.delete(i.value):r.add(i.value);const s=Array.from(r);e==null||e.setFilterValue(s.length?s:void 0)},children:[p.jsx("div",{className:fe("mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary",l?"bg-primary text-primary-foreground":"opacity-50 [&_svg]:invisible"),children:p.jsx(je,{className:fe("h-4 w-4")})}),i.icon&&p.jsx(i.icon,{className:"mr-2 h-4 w-4 text-muted-foreground"}),p.jsx("span",{children:i.label}),(n==null?void 0:n.get(i.value))&&p.jsx("span",{className:"ml-auto flex h-4 w-4 items-center justify-center font-mono text-xs",children:n.get(i.value)})]},i.value)})}),r.size>0&&p.jsxs(p.Fragment,{children:[p.jsx(nt,{}),p.jsx(pe,{children:p.jsx(me,{onSelect:()=>e==null?void 0:e.setFilterValue(void 0),className:"justify-center text-center",children:"Clear filters"})})]})]})]})})]})}function cn({table:e,refreshTable:o}){const[t,n]=A.useState(!1),r=async()=>{if(o)try{n(!0),await o()}finally{n(!1)}},{t:i}=Ce();return p.jsxs("div",{className:"flex flex-wrap justify-end gap-2 sm:gap-3 mt-2",children:[p.jsxs(z,{variant:"outline",size:"sm",className:"h-8",onClick:r,children:[p.jsx(st,{className:`mr-2 transition-transform duration-500 ${t?"animate-spin":""}`}),i("dashboard.refresh")]}),p.jsxs(Ae,{children:[p.jsx(Ge,{asChild:!0,children:p.jsxs(z,{variant:"outline",size:"sm",className:"h-8",children:[p.jsx(dt,{className:"mr-2"}),i("viewDetail.view")]})}),p.jsxs(He,{align:"end",className:"w-[150px]",children:[p.jsx(Le,{children:i("viewDetail.toogleColumns")}),p.jsx(ze,{}),e.getAllColumns().filter(l=>typeof l.accessorFn<"u"&&l.getCanHide())?.map(l=>p.jsx(Ne,{className:"capitalize",checked:l.getIsVisible(),onCheckedChange:s=>l.toggleVisibility(!!s),children:i(`toogle.${l.id}`)},l.id))]})]})]})}function fn({table:e,tableName:o,toolBarPosition:t="left",extraToolbar:n,filters:r=[],refreshTable:i,onGlobalFilterChange:l,hideToolbarFilter:s=!1}){const a=e.getState().columnFilters.length>0;return p.jsxs("div",{className:"flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between",children:[p.jsxs("div",{className:"flex flex-wrap items-center gap-2",children:[!s&&p.jsx(ke,{placeholder:`Filter ${o}...`,onChange:g=>{const c=g.target.value;e.setGlobalFilter(c),l==null||l(c)},className:"h-8 w-full sm:w-[150px] lg:w-[250px]"}),r?.map(({columnKey:g,title:c,options:S,isVisible:d=!0})=>d&&e.getColumn(g)&&p.jsx(dn,{column:e.getColumn(g),title:c,options:S},g)),a&&p.jsxs(z,{variant:"ghost",onClick:()=>e.resetColumnFilters(),className:"h-8 px-2 lg:px-3",children:["Reset",p.jsx(Oe,{className:"ml-2 h-4 w-4"})]})]}),p.jsxs("div",{className:`flex flex-wrap items-center gap-2 ${t==="center"?"justify-center":t==="left"?"justify-start":"justify-end"}`,children:[n,p.jsx(cn,{table:e,refreshTable:i})]})]})}function Fn({columns:e,data:o,tableName:t,initialColumnVisibility:n,extraToolbar:r,toolBarPosition:i="right",isLoading:l=!1,itemCount:s,onPageChange:a,onPageSizeChange:g,nextFunction:c,prevFunction:S,pageIndex:d,pageSize:u=10,refresh:f,onGlobalFilterChange:m,hideToolbarFilter:v=!1}){var _;const[C,R]=A.useState([]),[x,M]=A.useState([]),[F,D]=A.useState(()=>({letterName:!1,fileName:!1,...n??{}})),[L,N]=A.useState({}),{t:k}=Ce(),E=un({data:o,columns:e,pageCount:u>0?Math.ceil(s/u):1,state:{sorting:C,columnVisibility:F,rowSelection:L,columnFilters:x,pagination:{pageIndex:d,pageSize:u}},manualPagination:!0,enableRowSelection:!0,onRowSelectionChange:N,onSortingChange:R,onColumnFiltersChange:M,onColumnVisibilityChange:D,getCoreRowModel:Yt(),getFilteredRowModel:nn(),getSortedRowModel:on(),getFacetedRowModel:en(),getFacetedUniqueValues:tn()});return p.jsxs("div",{className:"space-y-4",children:[p.jsx(fn,{table:E,tableName:t,extraToolbar:r,toolBarPosition:i,refreshTable:f,onGlobalFilterChange:m,hideToolbarFilter:v}),p.jsx("div",{className:"rounded-md border dark:border-gray-700 bg-white dark:bg-gray-900 overflow-x-auto font-urbanist custom-scrollbar",children:p.jsxs(Te,{className:"w-full text-base text-gray-700 dark:text-gray-300 font-urbanist",children:[p.jsx(Be,{className:"bg-gray-50 dark:bg-gray-800",children:E.getHeaderGroups()?.map($=>p.jsx(B,{className:"dark:border-gray-700",children:$.headers?.map(I=>p.jsx(qe,{className:"text-base text-gray-500 dark:text-gray-400 dark:bg-gray-800",children:I.isPlaceholder?null:we(I.column.columnDef.header,I.getContext())},I.id))},$.id))}),p.jsx(Ue,{children:l?p.jsx(B,{children:p.jsx(Q,{colSpan:e.length,className:"text-center py-6",children:"Loading..."})}):(_=E.getRowModel().rows)!=null&&_.length?E.getRowModel().rows?.map($=>p.jsx(B,{"data-state":$.getIsSelected()&&"selected",className:` + transition-colors duration-150 + ${$.original.isUrgent===!0&&t==="Pending Records"||$.original.signStatus==="draft"&&t==="Collaborations"||$.original.action==="inprogress"&&t==="My Approval Records"?"bg-yellow-200 dark:bg-yellow-900/30 hover:bg-yellow-300 dark:hover:bg-yellow-900/50":"hover:bg-gray-100 dark:hover:bg-gray-800"} + ${t==="Pending Approval Tasks"&&"bg-primary-50 dark:bg-primary-900/20 hover:bg-primary-100 dark:hover:bg-primary-900/30 font-semibold border-l-4 border-l-primary-500 dark:border-l-primary-400"} + `,children:$.getVisibleCells()?.map(I=>p.jsx(Q,{className:"py-4",children:p.jsx("div",{className:"max-h-[110px] max-w-[460px] overflow-y-auto overflow-x-auto scrollbar-hidden hover:cursor-pointer custom-scrollbar",children:we(I.column.columnDef.cell,I.getContext())})},I.id))},$.id)):p.jsx(B,{children:p.jsx(Q,{colSpan:e.length,className:"text-center py-6",children:k("common.noResult")})})})]})}),s>u&&p.jsx(gn,{pageIndex:d,pageSize:u,itemCount:s,onPageChange:a,onPageSizeChange:g,nextFunction:c,prevFunction:S})]})}export{Fn as A,at as C}; diff --git a/apps/edr-freight-web/backoffice/public/_um/assets/ArchiveUsersPage-B2jF61Wl.js b/apps/edr-freight-web/backoffice/public/_um/assets/ArchiveUsersPage-B2jF61Wl.js new file mode 100644 index 000000000..f6ccd9ac2 --- /dev/null +++ b/apps/edr-freight-web/backoffice/public/_um/assets/ArchiveUsersPage-B2jF61Wl.js @@ -0,0 +1 @@ +import{d as N,r as c,az as S,j as t,v as u}from"./index-Db-xuq0b.js";import{C as U,a as A,b as y,d as z}from"./card-BBWyxDss.js";import{S as I,a as k,b as w,c as P,d as E}from"./select-BoQxM42A.js";import{A as T}from"./AdvancedTable-CC9ioMU-.js";import{u as F,A as L}from"./ArchivedUserColumnDefn-DQXeUrrA.js";import{u as V}from"./useUnit-C4s9nepK.js";import"./table-D3n3VABd.js";import"./chevron-left-DCpEaNzo.js";import"./utils-BncSPdK1.js";import"./badge-D7JvaQeJ.js";import"./command-Cr0tMDDu.js";import"./search-CM5F2ZRy.js";import"./popover-X-j_SRnG.js";import"./Popover-CzANYrEa.js";import"./use-uncontrolled-C3HRHW6t.js";import"./separator-BaOOgzZX.js";import"./refresh-cw-JB7N413f.js";import"./organizationsService-BEVk8qa1.js";import"./alert-dialog-B5Y0wlSz.js";import"./useEmployeePostions-CMraotEg.js";import"./employeePositionsService-CkHoE9xG.js";import"./ellipsis-vertical-Cs1B3vez.js";import"./square-pen-B91TPB19.js";import"./user-plus-CBq7Z0dQ.js";import"./unitService-CmGVtFHQ.js";const ce=()=>{var p,h,x,g,f;const{user:i}=N(),{getList:j}=V(),[o,b]=c.useState(0),s=10,v=S(),l=i!=null&&i.employee&&i.employee.length>0?i.employee[0].organizationId:void 0,{data:e}=l?j(l,{take:300,skip:0}):{data:void 0},[d,n]=c.useState(((h=(p=e==null?void 0:e.data)==null?void 0:p.items[0])==null?void 0:h.id)||"All");c.useEffect(()=>{var r;((r=e==null?void 0:e.data)==null?void 0:r.items.length)>0&&n(e==null?void 0:e.data.items[0].id)},[(x=e==null?void 0:e.data)==null?void 0:x.items]);const m=r=>{b(r)},{data:a,refetch:C}=F(d,{take:s,skip:o*s});return t.jsx("div",{className:"p-6 space-y-6",children:t.jsxs(U,{className:"col-span-2 shadow-none border-none bg-transparent px-0",children:[t.jsx(A,{className:"flex flex-row justify-between items-center px-0",children:t.jsx(y,{className:"text-xl font-semibold ",children:u("setting.archivedUsers")})}),((f=(g=e==null?void 0:e.data)==null?void 0:g.items)==null?void 0:f.length)>0&&t.jsxs("div",{className:"mb-4 w-1/2",children:[t.jsx("label",{className:"block text-sm font-medium text-gray-700",children:u("organization.selectUnit")}),t.jsxs(I,{value:d,onValueChange:r=>n(r),children:[t.jsx(k,{className:"mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm",children:t.jsx(w,{placeholder:"Select a Unit"})}),t.jsx(P,{children:e==null?void 0:e.data.items?.map(r=>t.jsx(E,{value:r.id,children:v(r.name)},r.id))})]})]}),t.jsx(z,{className:"px-0",children:t.jsx(T,{columns:L,data:(a==null?void 0:a.items)||[],tableName:"ArchivedUsers",toolBarPosition:"right",itemCount:(a==null?void 0:a.count)||0,pageIndex:o,onPageChange:m,nextFunction:a!=null&&a.count&&a.count>(o+1)*s?()=>m(o+1):()=>{},prevFunction:o>0?()=>m(Math.max(o-1,0)):()=>{},refresh:C})})]})})};export{ce as default}; diff --git a/apps/edr-freight-web/backoffice/public/_um/assets/ArchivedUnitsPositionsPage-m1hNEzal.js b/apps/edr-freight-web/backoffice/public/_um/assets/ArchivedUnitsPositionsPage-m1hNEzal.js new file mode 100644 index 000000000..fd33a93ba --- /dev/null +++ b/apps/edr-freight-web/backoffice/public/_um/assets/ArchivedUnitsPositionsPage-m1hNEzal.js @@ -0,0 +1,6 @@ +import{y as S,u as R,d as T,az as B,r as l,j as e,B as d,A as F}from"./index-Db-xuq0b.js";import{C as K,a as L,b as M,d as q}from"./card-BBWyxDss.js";import{S as E,a as V,b as _,c as H,d as G}from"./select-BoQxM42A.js";import{A as j}from"./AdvancedTable-CC9ioMU-.js";import{u as J}from"./useUnit-C4s9nepK.js";import{a as O,b as Q,u as W}from"./useArchived-D2u5xEKl.js";import{A as N}from"./archive-restore-CST5LuiK.js";import"./table-D3n3VABd.js";import"./chevron-left-DCpEaNzo.js";import"./utils-BncSPdK1.js";import"./badge-D7JvaQeJ.js";import"./command-Cr0tMDDu.js";import"./search-CM5F2ZRy.js";import"./popover-X-j_SRnG.js";import"./Popover-CzANYrEa.js";import"./use-uncontrolled-C3HRHW6t.js";import"./separator-BaOOgzZX.js";import"./refresh-cw-JB7N413f.js";import"./unitService-CmGVtFHQ.js";import"./positionService-JD0NEiGK.js";import"./organizationsService-BEVk8qa1.js";/** + * @license lucide-react v0.513.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const X=[["path",{d:"M18 21a6 6 0 0 0-12 0",key:"kaz2du"}],["circle",{cx:"12",cy:"11",r:"4",key:"1gt34v"}],["rect",{width:"18",height:"18",x:"3",y:"3",rx:"2",key:"afitv7"}]],Y=S("square-user-round",X),fe=()=>{var y;const{t}=R(),{user:i}=T(),m=B(),{getList:A}=J(),h=i!=null&&i.employee&&i.employee.length>0?i.employee[0].organizationId:void 0,[r,x]=l.useState("units"),[u,g]=l.useState(""),{data:p}=h?A(h,{take:300,skip:0}):{data:void 0},c=((y=p==null?void 0:p.data)==null?void 0:y.items)??[];!u&&c.length>0&&g(c[0].id);const{data:a,refetch:b}=O(h),{data:n,refetch:k}=Q(u||void 0),{restoreUnit:C,isRestoringUnit:U,restorePosition:z,isRestoringPosition:P}=W(),v=l.useMemo(()=>(a==null?void 0:a.items)??a??[],[a]),f=l.useMemo(()=>(n==null?void 0:n.items)??n??[],[n]),I=[{id:"name",header:t("organization.name","Name"),cell:({row:s})=>{var o;return m((o=s.original)==null?void 0:o.name)||"—"}},{id:"key",header:t("organization.key","Key"),accessorKey:"key"},{id:"actions",header:t("userIncomingretun.Actions","Actions"),cell:({row:s})=>e.jsxs(d,{size:"sm",variant:"outline",disabled:U,onClick:()=>C(s.original.id),className:"flex items-center gap-2 text-primary-700 hover:bg-primary-50",children:[e.jsx(N,{className:"h-4 w-4"}),t("archive.restore","Restore")]})}],w=[{id:"name",header:t("organization.name","Name"),cell:({row:s})=>{var o;return m((o=s.original)==null?void 0:o.name)||"—"}},{id:"key",header:t("organization.key","Key"),accessorKey:"key"},{id:"actions",header:t("userIncomingretun.Actions","Actions"),cell:({row:s})=>e.jsxs(d,{size:"sm",variant:"outline",disabled:P,onClick:()=>z(s.original.id),className:"flex items-center gap-2 text-primary-700 hover:bg-primary-50",children:[e.jsx(N,{className:"h-4 w-4"}),t("archive.restore","Restore")]})}];return e.jsx("div",{className:"p-6 space-y-6",children:e.jsxs(K,{className:"shadow-none border-none bg-transparent px-0",children:[e.jsx(L,{className:"flex flex-row justify-between items-center px-0",children:e.jsx(M,{className:"text-xl font-semibold",children:t("archive.archivedItems","Archived Items")})}),e.jsxs("div",{className:"flex gap-2 mb-4",children:[e.jsxs(d,{variant:r==="units"?"default":"outline",onClick:()=>x("units"),className:"flex items-center gap-2",children:[e.jsx(F,{className:"h-4 w-4"}),t("archive.archivedUnits","Archived Units")]}),e.jsxs(d,{variant:r==="positions"?"default":"outline",onClick:()=>x("positions"),className:"flex items-center gap-2",children:[e.jsx(Y,{className:"h-4 w-4"}),t("archive.archivedPositions","Archived Positions")]})]}),r==="positions"&&c.length>0&&e.jsxs("div",{className:"mb-4 w-full sm:w-1/2",children:[e.jsx("label",{className:"block text-sm font-medium text-gray-700 dark:text-gray-200",children:t("organization.selectUnit","Select Unit")}),e.jsxs(E,{value:u,onValueChange:s=>g(s),children:[e.jsx(V,{className:"mt-1 block w-full",children:e.jsx(_,{placeholder:t("organization.selectUnit")})}),e.jsx(H,{children:c?.map(s=>e.jsx(G,{value:s.id,children:m(s.name)},s.id))})]})]}),e.jsx(q,{className:"px-0",children:r==="units"?e.jsx(j,{columns:I,data:v,tableName:"ArchivedUnits",toolBarPosition:"right",itemCount:v.length,pageIndex:0,onPageChange:()=>{},nextFunction:()=>{},prevFunction:()=>{},refresh:b}):e.jsx(j,{columns:w,data:f,tableName:"ArchivedPositions",toolBarPosition:"right",itemCount:f.length,pageIndex:0,onPageChange:()=>{},nextFunction:()=>{},prevFunction:()=>{},refresh:k})})]})})};export{fe as default}; diff --git a/apps/edr-freight-web/backoffice/public/_um/assets/ArchivedUserColumnDefn-DQXeUrrA.js b/apps/edr-freight-web/backoffice/public/_um/assets/ArchivedUserColumnDefn-DQXeUrrA.js new file mode 100644 index 000000000..d592b891f --- /dev/null +++ b/apps/edr-freight-web/backoffice/public/_um/assets/ArchivedUserColumnDefn-DQXeUrrA.js @@ -0,0 +1 @@ +import{d as v}from"./organizationsService-BEVk8qa1.js";import{n as j,r as d,u as A,k as f,j as e,B as g,aF as w,f as D,aA as y,aB as b,aC as C,aD as N,v as o,aE as x,b_ as S,az as E}from"./index-Db-xuq0b.js";import{A as O,a as M,b as R,c as U,d as T,e as B,f as L}from"./alert-dialog-B5Y0wlSz.js";import{u as z,T as F}from"./useEmployeePostions-CMraotEg.js";import{E as I}from"./ellipsis-vertical-Cs1B3vez.js";import{S as k}from"./square-pen-B91TPB19.js";import{U as q}from"./user-plus-CBq7Z0dQ.js";import{B as K}from"./badge-D7JvaQeJ.js";const Z=(s,r)=>j({queryKey:["archived-users",s,r],queryFn:async()=>{if(!s)return{items:[],count:0};const{data:a}=await v(s,r);return a},enabled:!!s}),P=({isOpen:s,onClose:r,userId:a})=>{const{activateUser:t,isActivatingUser:i}=z(),[n,c]=d.useState(!1),{t:l}=A(),{handleError:u}=f(l),h=async()=>{try{await t({payload:a,successCallback:()=>{r()}}),c(!0)}catch(m){u(m)}};return e.jsx(O,{open:s,onOpenChange:r,children:e.jsxs(M,{children:[e.jsxs(R,{children:[e.jsx(U,{children:"Remove team member from this position?"}),e.jsx(T,{children:"Are you sure you want to activate this archived user? This action will restore the user's access and data within the organization."})]}),e.jsxs(B,{children:[e.jsx(L,{disabled:n,children:"Cancel"}),e.jsxs(g,{variant:"destructive",onClick:h,disabled:n,children:[i&&e.jsx(w,{className:"h-4 w-4 mr-2 animate-spin"}),"Confirm"]})]})]})})},H=({row:s})=>{const r=D(),[a,t]=d.useState(!1),[i,n]=d.useState(!1),[c,l]=d.useState(!1),u=()=>{r(`/user-management/archive/edit/${s==null?void 0:s.userId}`)},h=p=>{p.preventDefault(),t(!1),n(!0)},m=()=>{l(!0)};return e.jsxs(e.Fragment,{children:[e.jsxs(y,{open:a,onOpenChange:t,children:[e.jsx(b,{asChild:!0,children:e.jsxs(g,{variant:"ghost",className:"h-8 w-8 p-0",children:[e.jsx(I,{className:"h-4 w-4"}),e.jsx("span",{className:"sr-only",children:"Open actions menu"})]})}),e.jsxs(C,{align:"end",onInteractOutside:p=>{p.target.closest('[role="dialog"]')||t(!1)},children:[e.jsx(N,{children:o("userRecord.Actions")}),e.jsxs(x,{onSelect:u,className:"cursor-pointer hover:!text-primary-700 !bg-transparent !transition-colors duration-200",children:[e.jsx(k,{className:"mr-2 h-4 w-4 group-hover:text-white transition-colors duration-200"}),e.jsxs("span",{children:[" ",o("userRecord.Edit")]})]}),e.jsxs(x,{onSelect:m,className:"cursor-pointer hover:!text-primary-700 !bg-transparent !transition-colors duration-200",children:[e.jsx(q,{className:"mr-2 h-4 w-4 group-hover:text-white transition-colors duration-200"}),e.jsxs("span",{children:[" ",o("userRecord.Activate")]})]}),e.jsx(S,{}),e.jsxs(x,{onSelect:h,className:"text-red-600 cursor-pointer hover:!text-red-800 !bg-transparent !transition-colors duration-200",children:[e.jsx(F,{className:"mr-2 h-4 w-4 text-red-600 group-hover:text-white transition-colors duration-200"}),e.jsxs("span",{children:[" ",o("userRecord.Delete")]})]})]})]}),c&&e.jsx(P,{isOpen:c,onClose:()=>l(!1),userId:s.id})]})},ee=[{accessorKey:"name",header:()=>o("setting.Name"),cell:({row:s})=>{var t;const r=E(),a=(t=s.original)==null?void 0:t.name;return e.jsx("span",{children:r(a)})}},{accessorKey:"status",header:()=>o("userRecord.Status"),cell:({row:s})=>{var i;const r=(i=s.original)==null?void 0:i.status,a=n=>{switch(n.toLowerCase()){case"inactive":return"bg-red-100 text-red-600 hover:bg-red-100";case"active":return"bg-primary-100 text-primary-600 hover:bg-primary-100";default:return"bg-gray-100 text-gray-600 hover:bg-gray-100"}},t=n=>{switch(n.toLowerCase()){case"inactive":return"InActive";case"active":return"Active";default:return"Not Available"}};return e.jsx("div",{children:e.jsx(K,{className:`${a(r)} rounded-full px-6 py-1 font-medium`,children:t(r)})})}},{id:"actions",header:()=>o("userRecord.Actions"),cell:({row:s})=>e.jsx(H,{row:s.original})}];export{ee as A,Z as u}; diff --git a/apps/edr-freight-web/backoffice/public/_um/assets/BulkUploadPage-DwoaDuCZ.js b/apps/edr-freight-web/backoffice/public/_um/assets/BulkUploadPage-DwoaDuCZ.js new file mode 100644 index 000000000..a63cee3c0 --- /dev/null +++ b/apps/edr-freight-web/backoffice/public/_um/assets/BulkUploadPage-DwoaDuCZ.js @@ -0,0 +1 @@ +import{l as ye,r as p,d as je,aR as we,k as ve,v as l,j as e,B as E,aF as I,t as N,aS as ne}from"./index-Db-xuq0b.js";import{r as ke,u as ie}from"./xlsx-sUI8S2SR.js";import{u as Ee}from"./useQueries-XKsSk0XW.js";import{A as oe,b as le,a as de}from"./alert-8Xu28MAD.js";import{u as Se}from"./useUnit-C4s9nepK.js";import{S as Pe,a as Ue,b as $e,c as _e,d as Ae}from"./select-BoQxM42A.js";import{g as Ce}from"./unitService-CmGVtFHQ.js";import{C as me}from"./circle-alert-Cd_zeQMw.js";const Re=async R=>ye.post("/employees/upload-user-csv-2",R,{}),Fe=()=>{var Q,H,K;const R=p.useRef(null),[O,B]=p.useState(null),[v,D]=p.useState(""),[S,T]=p.useState(null),[k,V]=p.useState(!1),[F,W]=p.useState(!1),[q,L]=p.useState(!1),[y,j]=p.useState(null),[w,P]=p.useState(null),{user:U}=je(),ce=we.language,{getErrorMessage:ue}=ve(l),X=U!=null&&U.employee&&U.employee.length>0?U.employee[0].organizationId:void 0,{getList:he}=Se(),{data:M,isLoading:xe}=X?he(X):{data:void 0,isLoading:!1},G=((Q=M==null?void 0:M.data)==null?void 0:Q.items)??[],J=Ee({queries:G?.map(t=>({queryKey:["unitChildren",t.id],queryFn:()=>Ce(t.id),enabled:!!t.id,staleTime:0}))}),$=p.useMemo(()=>{const t=[];G.forEach((u,g)=>{var a,i;t.push({id:u.id,name:u.name,depth:0});const m=(i=(a=J[g])==null?void 0:a.data)==null?void 0:i.data;(Array.isArray(m)?m:(m==null?void 0:m.items)??[]).forEach(s=>{s!=null&&s.id&&t.push({id:s.id,name:s.name,depth:1})})});const c=new Set;return t.filter(u=>c.has(u.id)?!1:(c.add(u.id),!0))},[M,J?.map(t=>t.data).join("|")]);p.useEffect(()=>{$.length===1&&D($[0].id)},[$]);const _=()=>{T(null),L(!1)},pe=t=>{const c=a=>a.replace(/_/g," ").replace(/\b\w/g,i=>i.toUpperCase()),u=a=>{const i=a.indexOf(":");return i>=0?{reason:a.slice(0,i).trim(),value:a.slice(i+1).trim()}:{reason:a.trim(),value:""}},g=(a,i)=>{const{reason:s,value:o}=u(i),f=c(a);if(s.endsWith("_already_exists"))return o?`${f} "${o}" already exists`:`${f} already exists`;const h=c(s);return o?`${f}: ${h} (${o})`:`${f}: ${h}`},m=[],b=(a,i)=>{const s=/(\d+)/.exec(a),o=s?Number(s[1]):Number.MAX_SAFE_INTEGER,f=s?`Row ${s[1]}`:a;for(const[h,A]of Object.entries(i))typeof A!="string"||!A.trim()||m.push({row:o,text:`${f} — ${g(h,A)}`})};if(Array.isArray(t)){for(const a of t)if(!(!a||typeof a!="object"))for(const[i,s]of Object.entries(a))s&&typeof s=="object"&&b(i,s)}else if(t&&typeof t=="object"){const a=t;for(const[i,s]of Object.entries(a))s&&typeof s=="object"?b(i,s):typeof s=="string"&&s.trim()&&m.push({row:Number.MAX_SAFE_INTEGER,text:g(i,s)})}return m.sort((a,i)=>a.row-i.row)?.map(a=>a.text)},ge=async t=>{var g,m,b,a,i;const c=await ue(t),u=c.split(",")?.map(s=>s.trim()).filter(Boolean);if(typeof t=="object"&&t!==null&&"response"in t){let s=(g=t.response)==null?void 0:g.data;if(s instanceof Blob)try{s=JSON.parse(await s.text())}catch{s=null}const o=(s==null?void 0:s.message)??((b=(m=s==null?void 0:s.exception)==null?void 0:m.response)==null?void 0:b.message),f=(s==null?void 0:s.errors)??((i=(a=s==null?void 0:s.exception)==null?void 0:a.response)==null?void 0:i.errors);if(s&&typeof s=="object"&&o==="duplicate_values_found"){const h=pe(f);return{title:"Duplicate values found",message:h.length>0?`Found ${h.length} duplicate value${h.length>1?"s":""}. Fix the rows below in your Excel file and try again.`:"Some values already exist in the system. Review the conflicts and update the file before trying again.",errors:h.length>0?h:void 0}}}return u.length>1?{title:"We could not submit these users",message:"Some records could not be submitted. Review the issues below, update the file, and try again.",errors:u}:{title:"We could not submit these users",message:c}},fe=async t=>{var g;const c=(g=t.target.files)==null?void 0:g[0],u=t.target;if(c)try{W(!0),_(),j(null),P(null),B(c.name);const m=await c.arrayBuffer(),b=ke(m,{type:"array"}),a=b.Sheets.Positions,i=b.Sheets.OrgStructure;if(!a&&!i){N.error(l("contentManagement.posMissing")),j({title:"Missing template sheets",message:"The file must include the 'Positions' and 'OrgStructure' sheets before it can be imported."}),_();return}const s=(r,n)=>{if(r[n]!==void 0)return r[n];const d=Object.keys(r).find(C=>C.trim().toLowerCase()===n.toLowerCase());return d?r[d]:""},o=r=>String(r??"").trim(),f=(r,n)=>{const d=r.__rowNum__;return typeof d=="number"?d+1:n+2},h=r=>Object.values(r)?.some(n=>o(n)!==""),A=r=>[r.EnglishFirstName,r.EnglishLastName,r.Email,r.PhoneNumber,r.Username,r.Position]?.some(n=>o(n)!==""),Y=a?ie.sheet_to_json(a,{defval:""})?.map(r=>{const n=r;return{Position:o(s(n,"EnglishPosition")),AmharicPosition:o(s(n,"AmharicPosition")),ReportsTo:o(s(n,"ReportsTo")),PositionType:o(s(n,"PositionType"))}}).filter(r=>h(r)):[],z=i?ie.sheet_to_json(i,{defval:""})?.map((r,n)=>{const d=r;return{AmharicFirstName:o(s(d,"AmharicFirstName")),AmharicLastName:o(s(d,"AmharicLastName")),EnglishFirstName:o(s(d,"EnglishFirstName")),EnglishLastName:o(s(d,"EnglishLastName")),Email:o(s(d,"Email")),PhoneNumber:o(s(d,"PhoneNumber")),Position:o(s(d,"Position")),Username:o(s(d,"Username")),__rowNumber:f(d,n)}}).filter(r=>A(r)):[];if(!Y.length&&!z.length){N.error(l("contentManagement.invalidFile")),j({title:"No importable rows found",message:"We could not find any valid rows in the uploaded file. Check that the template columns are filled and try again."}),_();return}const x=[];if(z.forEach(r=>{var d,C,Z,ee,se,te,re,ae;const n=r.__rowNumber;(d=r.EnglishFirstName)!=null&&d.trim()||x.push(`Row ${n}: English First Name is required`),(C=r.EnglishLastName)!=null&&C.trim()||x.push(`Row ${n}: English Last Name is required`),(Z=r.Email)!=null&&Z.trim()||x.push(`Row ${n}: Email is required`),(ee=r.PhoneNumber)!=null&&ee.trim()||x.push(`Row ${n}: Phone Number is required`),(se=r.Username)!=null&&se.trim()||x.push(`Row ${n}: Username is required`),(te=r.Position)!=null&&te.trim()||x.push(`Row ${n}: Position is required`),(re=r.Email)!=null&&re.trim()&&!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(r.Email.trim())&&x.push(`Row ${n}: Invalid email format`),(ae=r.PhoneNumber)!=null&&ae.trim()&&!/^\+?[\d\s\-\(\)]+$/.test(r.PhoneNumber.trim())&&x.push(`Row ${n}: Invalid phone number format`)}),x.length>0){N.error(`Found ${x.length} validation errors.`),j({title:"Please fix the highlighted rows",message:"Some rows are missing required values or contain invalid formats. Update the file and upload it again.",errors:x}),_();return}const Ne={unitId:v,positions:Y,users:z?.map(({__rowNumber:r,...n})=>n)};T(Ne),j(null),P(null),N.success(l("contentManagement.parseSuccess"))}catch(m){N.error(l("contentManagement.parseError")),j({title:"We could not read this file",message:m instanceof Error?m.message:"The uploaded file could not be parsed. Please verify the Excel format and try again."}),_()}finally{W(!1),u.value=""}},be=async()=>{if(!S){N.error(l("contentManagement.noData"));return}if(!v){N.warning(l("organization.selectUnit"));return}try{V(!0),P(null),await Re({...S,unitId:v}),N.success(l("organization.uploadSuccess")),T(null),B(null),L(!1)}catch(t){P(await ge(t))}finally{V(!1)}};return e.jsxs("div",{className:"p-4 border rounded-xl bg-white shadow-sm dark:bg-gray-900 mt-10",children:[e.jsx("div",{children:$.length>0&&e.jsxs("div",{className:"mb-4 w-1/2",children:[e.jsx("label",{className:"block text-sm font-medium text-gray-700",children:l("organization.selectUnit")}),e.jsxs(Pe,{value:v,onValueChange:t=>D(t),children:[e.jsx(Ue,{className:"mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm",children:e.jsx($e,{placeholder:xe?l("organization.loading"):l("organization.selectUnit")})}),e.jsx(_e,{children:$?.map(t=>{var c,u;return e.jsx(Ae,{value:t.id,children:e.jsxs("span",{style:{paddingLeft:t.depth*12},children:[t.depth>0?"└─ ":"",ce==="en"?(c=t.name)==null?void 0:c.en:(u=t.name)==null?void 0:u.am]})},t.id)})})]})]})}),e.jsx("p",{className:"text-sm mb-2 text-gray-700 dark:text-gray-300 font-medium",children:l("contentManagement.bulkMsg")}),e.jsxs("div",{className:"flex items-center gap-3 mb-3",children:[e.jsx(E,{variant:"outline",onClick:()=>{var t;if(!v){N.warning(l("organization.selectUnit"));return}(t=R.current)==null||t.click()},disabled:k||F||!v,children:F?e.jsxs("span",{className:"inline-flex items-center gap-2",children:[e.jsx(I,{className:"h-4 w-4 animate-spin"}),"Parsing file..."]}):k?l("userIncoming.Processing..."):l("contentManagement.selectFile")}),O&&e.jsx("span",{className:"text-sm text-gray-500 dark:text-gray-400 truncate",children:O})]}),e.jsx("input",{ref:R,type:"file",accept:".xlsx, .xls",onChange:fe,className:"hidden"}),F&&e.jsxs("div",{className:"mb-4 flex items-center gap-3 rounded-lg border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-700 shadow-sm dark:border-slate-800 dark:bg-slate-900/60 dark:text-slate-200",children:[e.jsx(I,{className:"h-4 w-4 animate-spin text-slate-600 dark:text-slate-300"}),e.jsxs("div",{className:"min-w-0",children:[e.jsx("p",{className:"font-medium",children:"Reading and validating your Excel file"}),e.jsx("p",{className:"text-xs text-slate-500 dark:text-slate-400",children:"Please wait while we parse the sheets and check for errors."})]})]}),y&&e.jsxs(oe,{className:"mb-4 border-red-200 bg-red-50/80 text-red-950 shadow-sm dark:border-red-900/60 dark:bg-red-950/30 dark:text-red-100",children:[e.jsx(me,{className:"h-4 w-4"}),e.jsxs("div",{className:"w-full space-y-3",children:[e.jsxs("div",{className:"flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between",children:[e.jsxs("div",{className:"space-y-1",children:[e.jsx(le,{className:"text-sm font-semibold",children:y.title}),e.jsx(de,{className:"text-sm text-red-800 dark:text-red-200",children:y.message})]}),e.jsxs(E,{type:"button",variant:"ghost",size:"sm",className:"h-8 self-start px-2 text-red-700 hover:bg-red-100 hover:text-red-900 dark:text-red-200 dark:hover:bg-red-900/40 dark:hover:text-red-50",onClick:()=>j(null),children:[e.jsx(ne,{className:"mr-1 h-4 w-4"}),l("common.close")]})]}),(H=y.errors)!=null&&H.length?e.jsxs("div",{className:"rounded-lg border border-red-200/80 bg-white/80 p-3 dark:border-red-900/60 dark:bg-red-950/20",children:[e.jsxs("div",{className:"mb-2 flex flex-wrap items-center gap-2",children:[e.jsx("span",{className:"text-sm font-medium text-red-900 dark:text-red-100",children:"Validation details"}),e.jsxs("span",{className:"rounded-full bg-red-100 px-2 py-0.5 text-xs font-medium text-red-700 dark:bg-red-900/50 dark:text-red-100",children:[y.errors.length," issue",y.errors.length>1?"s":""]})]}),e.jsx("div",{className:"max-h-56 overflow-y-auto pr-1",children:e.jsx("ul",{className:"grid gap-2 text-sm text-red-800 dark:text-red-200 sm:grid-cols-2",children:y.errors?.map(t=>e.jsx("li",{className:"rounded-md border border-red-100 bg-red-50 px-3 py-2 leading-relaxed dark:border-red-900/50 dark:bg-red-950/30",children:t},t))})})]}):null]})]}),S&&e.jsxs("div",{className:"mt-4 space-y-4",children:[e.jsxs("div",{className:"flex flex-wrap gap-2",children:[e.jsx(E,{variant:"outline",onClick:()=>L(!q),disabled:k,children:q?l("common.Cancel"):l("contentManagement.preview")}),e.jsx(E,{onClick:be,disabled:k||F,children:k?e.jsxs("span",{className:"inline-flex items-center gap-2",children:[e.jsx(I,{className:"h-4 w-4 animate-spin"}),"Uploading users..."]}):l("contentManagement.submitUsers")})]}),k&&e.jsxs("div",{className:"flex items-center gap-3 rounded-lg border border-primary-200 bg-primary-50 px-4 py-3 text-sm text-primary-900 shadow-sm dark:border-primary-900/60 dark:bg-primary-950/30 dark:text-primary-100",children:[e.jsx(I,{className:"h-4 w-4 animate-spin text-primary-700 dark:text-primary-200"}),e.jsxs("div",{className:"min-w-0",children:[e.jsx("p",{className:"font-medium",children:"Submitting users to the server"}),e.jsx("p",{className:"text-xs text-primary-700/80 dark:text-primary-200/80",children:"Please wait while we create the uploaded users and positions."})]})]}),w&&e.jsxs(oe,{className:"border-amber-200 bg-amber-50/80 text-amber-950 shadow-sm dark:border-amber-900/60 dark:bg-amber-950/30 dark:text-amber-100",children:[e.jsx(me,{className:"h-4 w-4"}),e.jsxs("div",{className:"w-full space-y-3",children:[e.jsxs("div",{className:"flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between",children:[e.jsxs("div",{className:"space-y-1",children:[e.jsx(le,{className:"text-sm font-semibold",children:w.title}),e.jsx(de,{className:"text-sm text-amber-800 dark:text-amber-200",children:w.message})]}),e.jsxs(E,{type:"button",variant:"ghost",size:"sm",className:"h-8 self-start px-2 text-amber-700 hover:bg-amber-100 hover:text-amber-900 dark:text-amber-200 dark:hover:bg-amber-900/40 dark:hover:text-amber-50",onClick:()=>P(null),children:[e.jsx(ne,{className:"mr-1 h-4 w-4"}),l("common.close")]})]}),(K=w.errors)!=null&&K.length?e.jsxs("div",{className:"rounded-lg border border-amber-200/80 bg-white/80 p-3 dark:border-amber-900/60 dark:bg-amber-950/20",children:[e.jsxs("div",{className:"mb-2 flex flex-wrap items-center gap-2",children:[e.jsx("span",{className:"text-sm font-medium text-amber-900 dark:text-amber-100",children:"Submission details"}),e.jsxs("span",{className:"rounded-full bg-amber-100 px-2 py-0.5 text-xs font-medium text-amber-700 dark:bg-amber-900/50 dark:text-amber-100",children:[w.errors.length," issue",w.errors.length>1?"s":""]})]}),e.jsx("div",{className:"max-h-56 overflow-y-auto pr-1",children:e.jsx("ul",{className:"grid gap-2 text-sm text-amber-800 dark:text-amber-200 sm:grid-cols-2",children:w.errors?.map(t=>e.jsx("li",{className:"rounded-md border border-amber-100 bg-amber-50 px-3 py-2 leading-relaxed dark:border-amber-900/50 dark:bg-amber-950/30",children:t},t))})})]}):null]})]})]}),q&&S&&e.jsxs("div",{className:"mt-4 p-4 border rounded-lg bg-gray-50 dark:bg-gray-800 max-h-96 overflow-auto",children:[e.jsxs("div",{className:"flex justify-between items-center mb-2",children:[e.jsx("h3",{className:"text-lg font-semibold text-gray-900 dark:text-gray-100",children:l("contentManagement.preview")}),e.jsx(E,{variant:"ghost",size:"sm",onClick:()=>L(!1),children:"✕"})]}),e.jsx("pre",{className:"text-sm text-gray-700 dark:text-gray-300 whitespace-pre-wrap",children:JSON.stringify(S,null,2)})]})]})},De=()=>e.jsx(Fe,{});export{De as default}; diff --git a/apps/edr-freight-web/backoffice/public/_um/assets/ChangePassword-DnAAu07A.js b/apps/edr-freight-web/backoffice/public/_um/assets/ChangePassword-DnAAu07A.js new file mode 100644 index 000000000..385950970 --- /dev/null +++ b/apps/edr-freight-web/backoffice/public/_um/assets/ChangePassword-DnAAu07A.js @@ -0,0 +1 @@ +import{l as h,w as H,m as $,u as D,k as Y,f as O,d as Z,n as U,o as S,t as j,p as c,q as Q,r as m,j as e,v as t,I as L,B as z,x as G}from"./index-Db-xuq0b.js";import{L as I}from"./lock-BCybB-Lq.js";import{E as K}from"./eye-off-CDMvHElM.js";import{E as R}from"./eye-Bhud4znU.js";import{R as J}from"./refresh-cw-JB7N413f.js";import{A as V}from"./arrow-left-CE5-YfaQ.js";const W=async(r,s)=>h.post("/auth/login",r,{headers:{"x-organization-tenant-key":s.tenantKey,...s.unitId&&{"x-organization-unit-id":s.unitId}}}),X=async r=>h.get("/auth/me",{headers:{"x-organization-tenant-key":r.tenantKey,...r.unitId&&{"x-organization-unit-id":r.unitId}}}),ee=async()=>h.patch("/auth/logout"),se=async(r,s)=>h.put(`/account-configurations/my-config/${s}`,r,{headers:H()}),te=async r=>h.post("/account-configurations/set-my-config",r),ae=async()=>h.get("/account-configurations/my-config"),re=()=>{var x;const r=$(),{t:s}=D(),{handleError:y}=Y(s),g=O(),{setUser:b,user:p,selectedPositionId:T,setSelectedPositionId:w}=Z(),{data:o,isLoading:k,isError:n,refetch:d}=U({queryKey:["authUser"],queryFn:async()=>{const{data:a}=await X({tenantKey:c.get("tenant-key"),unitId:c.get("unit-id")||""});return a},staleTime:300*1e3,retry:!1}),{mutate:N,isError:f,isSuccess:P}=S({mutationFn:async({email:a,password:u,tenantKey:B,unitId:_})=>(await W({email:a,password:u},{tenantKey:B,unitId:_})).data,onSuccess:(a,u)=>{c.set("auth-token",a.token,{expires:1}),c.set("tenant-key",u.tenantKey,{expires:1}),c.set("unit-id",u.unitId||"",{expires:1}),j.success(s("msg.loginSuccess"),{description:s("msg.redirecting")}),r.invalidateQueries({queryKey:["authUser"]}),g("/dashboard")},onError:a=>{y(a)}}),{mutate:C,isError:E,isSuccess:q}=S({mutationFn:async()=>{await ee()},onSuccess:()=>{b(null),w(null),Object.keys(c.get()).forEach(a=>{c.remove(a)}),Q(),localStorage.removeItem("token"),r.invalidateQueries({queryKey:["authUser"]}),j.success(s("msg.logoutSuccess")||"Logged out successfully"),window.location.replace("/")},onError:a=>{b(null),w(null),Object.keys(c.get()).forEach(u=>{c.remove(u)}),Q(),localStorage.removeItem("token"),r.invalidateQueries({queryKey:["authUser"]}),y(a),window.location.replace("/")}}),{mutate:i,isPending:M}=S({mutationFn:async({isMFARequired:a})=>{await te({isMFARequired:a})},onSuccess:()=>{j.success("Two-Factor Authentication setting updated"),r.invalidateQueries({queryKey:["twoFAStatus"]})},onError:a=>{y(a)}}),{data:v,isLoading:F}=U({queryKey:["twoFAStatus",o],queryFn:ae,enabled:!!o}),{mutate:A}=S({mutationFn:async({id:a,isEnabled:u})=>{await se({isMFARequired:u},a)},onSuccess:()=>{r.invalidateQueries({queryKey:["twoFAStatus"]})}}),l=(o==null?void 0:o.selectedPositionPermissionKeys)||[];return{userDetails:o,selectedPositionPermissionKeys:l,isLoading:k,isError:n,refetch:d,login:N,logout:C,loginSuccess:P,loginError:f,logoutSuccess:q,logoutError:E,setTwoFactorAuth:i,twoFactorData:(x=v==null?void 0:v.data)==null?void 0:x.items,isLoadingStatus:F,editTwoFA:A}},me=()=>{const r=O(),[s,y]=m.useState(""),[g,b]=m.useState(""),[p,T]=m.useState(""),[w,o]=m.useState(!1),[k,n]=m.useState(""),[d,N]=m.useState(!1),[f,P]=m.useState(!1),{logout:C}=re(),E=()=>{C()},q=async i=>{if(i.preventDefault(),n(""),!g){n(t("msg.currentPasswordRequired"));return}if(!s){n(t("msg.newPasswordRequired"));return}if(s.length<8){n(t("msg.passwordMinLength"));return}if(s===g){n(t("msg.passwordMustBeDifferent"));return}if(!p){n(t("msg.confirmPasswordRequired"));return}if(s!==p){n(t("msg.passwordMismatch"));return}const M=/[A-Z]/.test(s),v=/[a-z]/.test(s),F=/\d/.test(s),A=/[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/.test(s),l=[];if(M||l.push(t("msg.uppercaseLetter")),v||l.push(t("msg.lowercaseLetter")),F||l.push(t("msg.number")),A||l.push(t("msg.specialCharacter")),l.length>0){n(t("msg.passwordComplexity")+" "+l.join(", "));return}o(!0);try{await G({oldPassword:g,newPassword:s,confirmPassword:p}),j.success(t("msg.successChange"),{description:"Your password has been changed successfully"}),setTimeout(E,1200)}catch(x){const a=(x==null?void 0:x.message)||t("msg.failedChange");n(a),j.error(t("msg.failedChange"),{description:a})}finally{o(!1)}};return e.jsx("div",{className:"flex items-center justify-center bg-gray-100 dark:bg-gray-900 p-4",style:{height:"100vh"},children:e.jsxs("div",{className:"w-full max-w-md bg-white dark:bg-gray-800 rounded-xl shadow-lg p-8",children:[e.jsxs("div",{className:"mb-8 text-center",children:[e.jsx("img",{src:"/assets/smart-office-logo.svg",alt:"Smart Office Logo",className:"h-10 mb-6 mx-auto"}),e.jsx("h1",{className:"text-2xl font-bold text-gray-900 dark:text-gray-100 mb-2",children:t("change_password")}),e.jsx("p",{className:"text-sm text-gray-500 dark:text-gray-400",children:t("msg.changePasswordMsg")})]}),e.jsxs("form",{onSubmit:q,className:"space-y-6",children:[e.jsxs("div",{className:"space-y-4",children:[e.jsxs("div",{className:"relative",children:[e.jsx("div",{className:"absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none",children:e.jsx(I,{className:"h-5 w-5 text-gray-400 dark:text-gray-500"})}),e.jsx(L,{type:d?"text":"password",placeholder:t("msg.oldPassword"),className:"h-10 rounded-md border dark:border-gray-600 px-4 text-sm ps-10 dark:bg-gray-700 dark:text-gray-100",value:g,onChange:i=>b(i.target.value),required:!0}),e.jsx("div",{className:"absolute inset-y-0 right-0 pr-3 flex items-center cursor-pointer",onClick:()=>N(!d),children:d?e.jsx(K,{className:"h-4 w-4 text-gray-400 dark:text-gray-500"}):e.jsx(R,{className:"h-4 w-4 text-gray-400 dark:text-gray-500"})})]}),e.jsxs("div",{className:"relative",children:[e.jsx("div",{className:"absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none",children:e.jsx(I,{className:"h-5 w-5 text-gray-400 dark:text-gray-500"})}),e.jsx(L,{type:d?"text":"password",placeholder:t("msg.newPassword"),className:"h-10 rounded-md border dark:border-gray-600 px-4 text-sm ps-10 dark:bg-gray-700 dark:text-gray-100",value:s,onChange:i=>y(i.target.value),required:!0}),e.jsx("div",{className:"absolute inset-y-0 right-0 pr-3 flex items-center cursor-pointer",onClick:()=>N(!d),children:d?e.jsx(K,{className:"h-4 w-4 text-gray-400 dark:text-gray-500"}):e.jsx(R,{className:"h-4 w-4 text-gray-400 dark:text-gray-500"})})]}),e.jsxs("div",{className:"relative",children:[e.jsx("div",{className:"absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none",children:e.jsx(I,{className:"h-5 w-5 text-gray-400 dark:text-gray-500"})}),e.jsx(L,{type:f?"text":"password",placeholder:t("msg.confirmNewPassword"),className:"h-10 rounded-md border dark:border-gray-600 px-4 text-sm ps-10 dark:bg-gray-700 dark:text-gray-100",value:p,onChange:i=>T(i.target.value),required:!0}),e.jsx("div",{className:"absolute inset-y-0 right-0 pr-3 flex items-center cursor-pointer",onClick:()=>P(!f),children:f?e.jsx(K,{className:"h-4 w-4 text-gray-400 dark:text-gray-500"}):e.jsx(R,{className:"h-4 w-4 text-gray-400 dark:text-gray-500"})})]}),k&&e.jsx("div",{className:"bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-md p-3 md:p-4",children:e.jsx("p",{className:"text-sm text-red-700 dark:text-red-400 font-medium",children:k})})]}),e.jsxs("div",{className:"flex flex-col gap-4",children:[e.jsx(z,{type:"submit",className:"w-full h-10 bg-primary hover:bg-primary-300 text-white text-sm",disabled:w,children:w?e.jsxs("span",{className:"flex items-center justify-center",children:[e.jsx(J,{className:"animate-spin h-5 w-5 mr-2"}),"changing..."]}):t("change_password")}),e.jsxs(z,{type:"button",variant:"outline",className:"w-full h-10 text-sm",onClick:()=>r(-1),children:[e.jsx(V,{className:"h-4 w-4 mr-2"}),t("userRecord.Back")]})]})]})]})})};export{me as ResetPassword,me as default}; diff --git a/apps/edr-freight-web/backoffice/public/_um/assets/Checkbox-IILksgz0.js b/apps/edr-freight-web/backoffice/public/_um/assets/Checkbox-IILksgz0.js new file mode 100644 index 000000000..0a9a3f319 --- /dev/null +++ b/apps/edr-freight-web/backoffice/public/_um/assets/Checkbox-IILksgz0.js @@ -0,0 +1 @@ +import{r as N,as as de,K as V,N as z,V as B,j as c,a0 as ue,W as T,Y as A,ae as Q,X as M,Q as $,am as X,aa as S,an as Y,_ as q,au as he,a8 as pe}from"./index-Db-xuq0b.js";import{g as D}from"./get-auto-contrast-value-Da6zqqWm.js";import{I as xe,a as Ce,b as be}from"./InputsGroupFieldset-COkNgcEo.js";import{u as H}from"./use-uncontrolled-C3HRHW6t.js";const J=N.createContext(null),ve=J.Provider,Z=()=>N.useContext(J),[ke,me]=de();var ee={card:"m_26775b0a"};const fe={withBorder:!0},ye=T((e,{radius:o})=>({card:{"--card-radius":A(o)}})),E=V((e,o)=>{const s=z("CheckboxCard",fe,e),{classNames:t,className:n,style:l,styles:a,unstyled:r,vars:d,checked:v,mod:i,withBorder:f,value:k,onClick:h,defaultChecked:b,onChange:p,...y}=s,g=B({name:"CheckboxCard",classes:ee,props:s,className:n,style:l,classNames:t,styles:a,unstyled:r,vars:d,varsResolver:ye,rootSelector:"card"}),u=Z(),_=typeof v=="boolean"?v:u?u.value.includes(k||""):void 0,[x,C]=H({value:_,defaultValue:b,finalValue:!1,onChange:p});return c.jsx(ke,{value:{checked:x},children:c.jsx(ue,{ref:o,mod:[{"with-border":f,checked:x},i],...g("card"),...y,role:"checkbox","aria-checked":x,onClick:m=>{h==null||h(m),u==null||u.onChange(k||""),C(!x)}})})});E.displayName="@mantine/core/CheckboxCard";E.classes=ee;const ge={},W=V((e,o)=>{const{value:s,defaultValue:t,onChange:n,size:l,wrapperProps:a,children:r,readOnly:d,...v}=z("CheckboxGroup",ge,e),[i,f]=H({value:s,defaultValue:t,finalValue:[],onChange:n}),k=h=>{const b=typeof h=="string"?h:h.currentTarget.value;!d&&f(i.includes(b)?i.filter(p=>p!==b):[...i,b])};return c.jsx(ve,{value:{value:i,onChange:k,size:l},children:c.jsx(Q.Wrapper,{size:l,ref:o,...a,...v,labelElement:"div",__staticSelector:"CheckboxGroup",children:c.jsx(xe,{role:"group",children:r})})})});W.classes=Q.Wrapper.classes;W.displayName="@mantine/core/CheckboxGroup";function _e({size:e,style:o,...s}){const t=e!==void 0?{width:M(e),height:M(e),...o}:o;return c.jsx("svg",{viewBox:"0 0 10 7",fill:"none",xmlns:"http://www.w3.org/2000/svg",style:t,"aria-hidden":!0,...s,children:c.jsx("path",{d:"M4 4.586L1.707 2.293A1 1 0 1 0 .293 3.707l3 3a.997.997 0 0 0 1.414 0l5-5A1 1 0 1 0 8.293.293L4 4.586z",fill:"currentColor",fillRule:"evenodd",clipRule:"evenodd"})})}function oe({indeterminate:e,...o}){return e?c.jsx("svg",{xmlns:"http://www.w3.org/2000/svg",fill:"none",viewBox:"0 0 32 6","aria-hidden":!0,...o,children:c.jsx("rect",{width:"32",height:"6",fill:"currentColor",rx:"3"})}):c.jsx(_e,{...o})}var se={indicator:"m_5e5256ee",icon:"m_1b1c543a","indicator--outline":"m_76e20374"};const je={icon:oe},we=T((e,{radius:o,color:s,size:t,iconColor:n,variant:l,autoContrast:a})=>{const r=X({color:s||e.primaryColor,theme:e}),d=r.isThemeColor&&r.shade===void 0?`var(--mantine-color-${r.color}-outline)`:r.color;return{indicator:{"--checkbox-size":q(t,"checkbox-size"),"--checkbox-radius":o===void 0?void 0:A(o),"--checkbox-color":l==="outline"?d:S(s,e),"--checkbox-icon-color":n?S(n,e):D(a,e)?Y({color:s,theme:e,autoContrast:a}):void 0}}}),L=V((e,o)=>{const s=z("CheckboxIndicator",je,e),{classNames:t,className:n,style:l,styles:a,unstyled:r,vars:d,icon:v,indeterminate:i,radius:f,color:k,iconColor:h,autoContrast:b,checked:p,mod:y,variant:g,disabled:u,..._}=s,x=v,C=B({name:"CheckboxIndicator",classes:se,props:s,className:n,style:l,classNames:t,styles:a,unstyled:r,vars:d,varsResolver:we,rootSelector:"indicator"}),m=me(),G=typeof p=="boolean"||typeof i=="boolean"?p||i:(m==null?void 0:m.checked)||!1;return c.jsx($,{ref:o,...C("indicator",{variant:g}),variant:g,mod:[{checked:G,disabled:u},y],..._,children:c.jsx(x,{indeterminate:i,...C("icon")})})});L.displayName="@mantine/core/CheckboxIndicator";L.classes=se;var re={root:"m_bf2d988c",inner:"m_26062bec",input:"m_26063560",icon:"m_bf295423","input--outline":"m_215c4542"};const Ie={labelPosition:"right",icon:oe},Pe=T((e,{radius:o,color:s,size:t,iconColor:n,variant:l,autoContrast:a})=>{const r=X({color:s||e.primaryColor,theme:e}),d=r.isThemeColor&&r.shade===void 0?`var(--mantine-color-${r.color}-outline)`:r.color;return{root:{"--checkbox-size":q(t,"checkbox-size"),"--checkbox-radius":o===void 0?void 0:A(o),"--checkbox-color":l==="outline"?d:S(s,e),"--checkbox-icon-color":n?S(n,e):D(a,e)?Y({color:s,theme:e,autoContrast:a}):void 0}}}),I=V((e,o)=>{const s=z("Checkbox",Ie,e),{classNames:t,className:n,style:l,styles:a,unstyled:r,vars:d,color:v,label:i,id:f,size:k,radius:h,wrapperProps:b,checked:p,labelPosition:y,description:g,error:u,disabled:_,variant:x,indeterminate:C,icon:m,rootRef:G,iconColor:Re,onChange:P,autoContrast:Ne,mod:ce,...te}=s,j=Z(),ae=k||(j==null?void 0:j.size),ne=m,R=B({name:"Checkbox",props:s,classes:re,className:n,style:l,classNames:t,styles:a,unstyled:r,vars:d,varsResolver:Pe}),{styleProps:le,rest:O}=he(te),U=pe(f),F=j?{checked:j.value.includes(O.value),onChange:K=>{j.onChange(K),P==null||P(K)}}:{},ie=N.useRef(null),w=o||ie;return N.useEffect(()=>{w&&"current"in w&&w.current&&(w.current.indeterminate=C||!1)},[C,w]),c.jsx(Ce,{...R("root"),__staticSelector:"Checkbox",__stylesApiProps:s,id:U,size:ae,labelPosition:y,label:i,description:g,error:u,disabled:_,classNames:t,styles:a,unstyled:r,"data-checked":F.checked||p||void 0,variant:x,ref:G,mod:ce,...le,...b,children:c.jsxs($,{...R("inner"),mod:{"data-label-position":y},children:[c.jsx($,{component:"input",id:U,ref:w,checked:p,disabled:_,mod:{error:!!u,indeterminate:C},...R("input",{focusable:!0,variant:x}),onChange:P,...O,...F,type:"checkbox"}),c.jsx(ne,{indeterminate:C,...R("icon")})]})})});I.classes={...re,...be};I.displayName="@mantine/core/Checkbox";I.Group=W;I.Indicator=L;I.Card=E;export{_e as C,I as a}; diff --git a/apps/edr-freight-web/backoffice/public/_um/assets/ConfigurationPage-tOdQq5-t.js b/apps/edr-freight-web/backoffice/public/_um/assets/ConfigurationPage-tOdQq5-t.js new file mode 100644 index 000000000..daba9f176 --- /dev/null +++ b/apps/edr-freight-web/backoffice/public/_um/assets/ConfigurationPage-tOdQq5-t.js @@ -0,0 +1,16 @@ +import{y as Ke,d as Ue,m as Ee,az as He,r as d,o as b,v as t,j as e,bn as Te,B as U,aK as Ae,aL as Be,aM as Pe,aN as Fe,bo as Nt,I as _a,ba as Ct,l as Le,w as De,u as Ga,k as Ja,n as Xa,t as C,aF as We,b7 as Za,bp as xt,b6 as es,A as ts}from"./index-Db-xuq0b.js";import{S as Z,a as ee,b as te,c as ae,d as f}from"./select-BoQxM42A.js";import{u as Me}from"./useUnit-C4s9nepK.js";import{u as Qe,p as N,E as le}from"./useUnitConfiguration-CKdECszp.js";import{T as ze,a as Ye,b as p,c as T,d as Ve,e as n}from"./table-D3n3VABd.js";import{u as as}from"./usePosition-Y8Dhf3qk.js";import{C as ss,d as ns}from"./card-BBWyxDss.js";import{S as g}from"./switch-BNCD27Bd.js";import{F as ht,b as E,c as H,a as Q,d as z,e as Oe}from"./form-BeLK5rTt.js";import{B as we}from"./badge-D7JvaQeJ.js";import{A as is,a as rs}from"./alert-8Xu28MAD.js";import{A as ls,h as ds,a as os,b as cs,c as us,d as ms,e as xs,f as hs,g as gs}from"./alert-dialog-B5Y0wlSz.js";import{u as fs}from"./index.esm-BG4gweZJ.js";import{u as bs,o as js,e as ps,b as Ie,s as Ns}from"./zod-Df58YiJ6.js";import{C as gt}from"./circle-alert-Cd_zeQMw.js";import{P as Cs}from"./plus-BdjS2Pc-.js";import{M as ft}from"./message-square-BPZTcbJs.js";import{M as bt}from"./mail-bwr0seHR.js";import{S as vs}from"./square-pen-B91TPB19.js";import{L as jt}from"./label-CsFy6wpo.js";import{S as ks}from"./shield-check-BeHB0C5s.js";import"./unitService-CmGVtFHQ.js";import"./positionService-JD0NEiGK.js";import"./Switch-DSHMk-sj.js";import"./InputsGroupFieldset-COkNgcEo.js";import"./use-uncontrolled-C3HRHW6t.js";/** + * @license lucide-react v0.513.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Ss=[["path",{d:"M12 20h9",key:"t2du7b"}],["path",{d:"M16.376 3.622a1 1 0 0 1 3.002 3.002L7.368 18.635a2 2 0 0 1-.855.506l-2.872.838a.5.5 0 0 1-.62-.62l.838-2.872a2 2 0 0 1 .506-.854z",key:"1ykcvy"}]],ys=Ke("pen-line",Ss);/** + * @license lucide-react v0.513.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Rs=[["path",{d:"M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z",key:"1a8usu"}]],vt=Ke("pen",Rs);/** + * @license lucide-react v0.513.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const ws=[["rect",{width:"8",height:"8",x:"3",y:"3",rx:"2",key:"by2w9f"}],["path",{d:"M7 11v4a2 2 0 0 0 2 2h4",key:"xkn7yn"}],["rect",{width:"8",height:"8",x:"13",y:"13",rx:"2",key:"1cgmvn"}]],pt=Ke("workflow",ws),Is="relative overflow-hidden rounded-2xl border border-slate-200/80 bg-gradient-to-br from-white via-slate-50 to-slate-100/60 p-4 shadow-sm sm:p-6 dark:border-slate-700/80 dark:bg-gradient-to-br dark:from-slate-950 dark:via-slate-900 dark:to-slate-900",As=()=>{var ie,y,q,X,S,W,o;const{user:x}=Ue(),c=Ee(),Y=((y=(ie=x==null?void 0:x.employee)==null?void 0:ie[0])==null?void 0:y.organizationId)||"",B=He(),{getList:L}=Me(),{data:u,isLoading:R}=L(Y,{take:1e3}),[r,I]=d.useState(""),[P,v]=d.useState(!1),[k,h]=d.useState(null),[V,_]=d.useState(0);d.useEffect(()=>{var j,O,re;((O=(j=u==null?void 0:u.data)==null?void 0:j.items)==null?void 0:O.length)>0&&!r&&I((re=u==null?void 0:u.data)==null?void 0:re.items[0].id)},[u]);const{data:F,isLoading:G,error:$}=Qe(r),m=(X=(q=F==null?void 0:F.data)==null?void 0:q.items)==null?void 0:X[0],{mutate:se,isPending:D}=b({mutationFn:({id:j,key:O,value:re})=>N.updateInternalPrefixSuffix({[O]:re,unitId:r},j),onSuccess:()=>{c.invalidateQueries({queryKey:["unitConfig",r]}),v(!1),h(null)},onError:j=>{}}),M=(j,O)=>{h(j),_(O),v(!0)},ne=()=>{!(m!=null&&m.id)||!k||se({id:m.id,key:k,value:V})},J=[{key:"escalationHour",label:t("setting.escalationHour"),value:m==null?void 0:m.escalationHour},{key:"urgentLetterEscalationHour",label:t("setting.urgentLetterEscalationHour"),value:m==null?void 0:m.urgentLetterEscalationHour},{key:"onReviewLetterEscalationHour",label:t("setting.onReviewLetterEscalationHour"),value:m==null?void 0:m.onReviewLetterEscalationHour},{key:"urgentOnReviewLetterEscalationHour",label:t("setting.urgentOnReviewLetterEscalationHour"),value:m==null?void 0:m.urgentOnReviewLetterEscalationHour}];return e.jsxs("div",{className:Is,children:[e.jsxs("div",{className:"mb-5 flex flex-col gap-4 border-b border-slate-200 pb-4 dark:border-slate-700 sm:flex-row sm:items-start sm:justify-between",children:[e.jsxs("div",{className:"space-y-1",children:[e.jsxs("h1",{className:"flex items-center gap-2 text-2xl font-bold text-slate-900 sm:text-3xl dark:text-slate-100",children:[e.jsx(Te,{className:"h-8 w-8 text-primary"}),t("setting.escalationTitle")]}),e.jsx("p",{className:"text-sm text-slate-600 dark:text-slate-400",children:t("setting.escalationDescription")})]}),e.jsx("div",{className:"w-full sm:ml-auto sm:w-auto",children:e.jsxs(Z,{value:r,onValueChange:j=>I(j),children:[e.jsx(ee,{className:"h-10 w-full bg-white/90 dark:border-slate-700 dark:bg-slate-900 sm:w-56",children:e.jsx(te,{placeholder:"Select a unit"})}),e.jsx(ae,{className:"dark:border-slate-700 dark:bg-slate-900",children:R?e.jsx(f,{value:"loading",disabled:!0,children:t("setting.loading")}):((W=(S=u==null?void 0:u.data)==null?void 0:S.items)==null?void 0:W.length)>0?(o=u==null?void 0:u.data)==null?void 0:o.items?.map(j=>e.jsx(f,{value:j.id,children:B(j.name)},j.id)):e.jsx(f,{value:"no-units",disabled:!0,children:t("setting.noUnit")})})]})})]}),e.jsxs("div",{className:"space-y-3",children:[G&&e.jsx("p",{className:"text-sm text-slate-600 dark:text-slate-300",children:t("setting.loadingConfig")}),$&&e.jsx("p",{className:"text-sm text-red-600 dark:text-red-400",children:t("setting.loadError")}),!m&&!G&&!$&&e.jsx("p",{className:"text-sm text-slate-600 dark:text-slate-300",children:t("setting.noConfigFound")}),m&&e.jsx("div",{className:"overflow-x-auto rounded-xl border border-slate-200 bg-white/90 dark:border-slate-700 dark:bg-slate-900/70",children:e.jsxs(ze,{children:[e.jsx(Ye,{children:e.jsxs(p,{className:"dark:border-slate-700",children:[e.jsx(T,{className:"text-slate-600 dark:text-slate-300",children:t("setting.Name")}),e.jsx(T,{className:"text-slate-600 dark:text-slate-300",children:t("setting.Value")}),e.jsx(T,{className:"text-right text-slate-600 dark:text-slate-300",children:t("setting.Action")})]})}),e.jsx(Ve,{children:J?.map(j=>e.jsxs(p,{className:"dark:border-slate-700",children:[e.jsx(n,{className:"text-slate-900 dark:text-slate-100",children:j.label}),e.jsx(n,{className:"text-slate-700 dark:text-slate-300",children:j.value??"-"}),e.jsx(n,{className:"text-right",children:e.jsx(U,{onClick:()=>M(j.key,j.value),variant:"outline",size:"sm",className:"dark:border-slate-700 dark:bg-slate-900 dark:hover:bg-slate-800",children:e.jsx(vt,{className:"h-4 w-4"})})})]},j.key))})]})})]}),e.jsx(Ae,{open:P,onOpenChange:v,children:e.jsxs(Be,{className:"dark:border-slate-700 dark:bg-slate-900",children:[e.jsxs(Pe,{children:[e.jsxs(Fe,{className:"dark:text-slate-100",children:[t("setting.edit")," ",k&&t(`setting.${k}`)]}),e.jsx(Nt,{className:"dark:text-slate-400",children:t("setting.updateEscalationMsg")})]}),e.jsxs("div",{className:"space-y-3",children:[e.jsx("label",{className:"text-sm font-medium text-slate-700 dark:text-slate-300",children:k&&t(`setting.${k}`)}),e.jsx(_a,{type:"number",value:V??"",onChange:j=>_(Number(j.target.value)),className:"dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"})]}),e.jsxs(Ct,{className:"flex justify-end gap-2 mt-4",children:[e.jsx(U,{variant:"secondary",onClick:()=>v(!1),className:"dark:bg-slate-800 dark:text-slate-100 dark:hover:bg-slate-700",children:t("common.Cancel")}),e.jsx(U,{onClick:ne,disabled:D,className:"bg-primary hover:bg-primary/90 text-primary-foreground",children:D?t("setting.saving"):t("setting.saveChanges")})]})]})})]})},Bs=async x=>Le.post("/position-configurations",x,{headers:De()}),Ps=async x=>Le.get(`/position-configurations/list/${x}`,{headers:De()}),Fs=async(x,c)=>Le.put(`/position-configurations/${x}`,c,{headers:De()}),Us=async x=>Le.delete(`/position-configurations/${x}`,{headers:De()}),Es=x=>{const c=Ee(),{t:Y}=Ga(),{handleError:B}=Ja(Y),{data:L,isLoading:u,isError:R,error:r,refetch:I}=Xa({queryKey:["position-configurations",x],queryFn:()=>Ps(x),select:h=>h&&h.data&&typeof h.data=="object"&&!Array.isArray(h.data)&&"items"in h.data?h.data.items:Array.isArray(h==null?void 0:h.data)?h.data:Array.isArray(h)?h:[],enabled:!!x}),P=b({mutationFn:Bs,onSuccess:()=>{c.invalidateQueries({queryKey:["position-configurations",x]}),C.success("Position configuration created successfully")},onError:h=>{B(h)}}),v=b({mutationFn:({id:h,payload:V})=>Fs(h,V),onSuccess:()=>{c.invalidateQueries({queryKey:["position-configurations",x]}),C.success("Position configuration updated successfully")},onError:h=>{B(h)}}),k=b({mutationFn:Us,onSuccess:()=>{c.invalidateQueries({queryKey:["position-configurations",x]}),C.success("Position configuration deleted successfully")},onError:h=>{B(h)}});return{configurations:L||[],isLoading:u,isError:R,error:r,refetch:I,createConfiguration:P.mutate,updateConfiguration:v.mutate,deleteConfiguration:k.mutate,isCreating:P.isPending,isUpdating:v.isPending,isDeleting:k.isPending}},kt=["all","immediate_child","parallel_with_immediate_child"],Hs=js({positionId:Ns().min(1,"Position is required"),smsNotificationWhenRecordSubmitted:Ie(),emailNotificationWhenRecordSubmitted:Ie(),inboxNotificationWhenRecordSubmitted:Ie(),skipWorkflowIfNotAssigned:Ie(),positionScopeToFetch:ps(kt)}),Ts=()=>{var ce,ue,me,xe,he,ge,fe,be,je,pe;const{user:x}=Ue(),c=((ue=(ce=x==null?void 0:x.employee)==null?void 0:ce[0])==null?void 0:ue.unitId)||"",Y=((xe=(me=x==null?void 0:x.employee)==null?void 0:me[0])==null?void 0:xe.organizationId)||"",B=He(),[L,u]=d.useState(!1),[R,r]=d.useState(null),[I,P]=d.useState(!1),[v,k]=d.useState(""),{configurations:h,isLoading:V,isError:_,error:F,refetch:G,createConfiguration:$,updateConfiguration:m,deleteConfiguration:se,isCreating:D,isUpdating:M,isDeleting:ne}=Es(c),J=Array.isArray(h)?h:[],{getList:ie}=Me(),{data:y,isLoading:q}=ie(Y,{take:1e3}),{usePositionListByUnitId:X}=as(),{data:S,isLoading:W}=X(v,{take:1e3}),o=fs({resolver:bs(Hs),defaultValues:{positionId:"",smsNotificationWhenRecordSubmitted:!0,emailNotificationWhenRecordSubmitted:!0,inboxNotificationWhenRecordSubmitted:!0,skipWorkflowIfNotAssigned:!1,positionScopeToFetch:"all"}}),j=i=>{$(i,{onSuccess:()=>{u(!1),k(""),o.reset()}})},O=i=>{R!=null&&R.id&&m({id:R==null?void 0:R.id,payload:i},{onSuccess:()=>{P(!1),r(null),k(""),o.reset()}})},re=i=>{var K;r(i);const w=(K=S==null?void 0:S.items)==null?void 0:K.find(de=>de.id===i.positionId),A=(w==null?void 0:w.unitId)||"";k(A),o.reset({positionId:i.positionId,smsNotificationWhenRecordSubmitted:i.smsNotificationWhenRecordSubmitted,emailNotificationWhenRecordSubmitted:i.emailNotificationWhenRecordSubmitted,inboxNotificationWhenRecordSubmitted:i.inboxNotificationWhenRecordSubmitted,skipWorkflowIfNotAssigned:i.skipWorkflowIfNotAssigned}),P(!0)},ve=i=>{se(i)},ke=i=>{var A;const w=(A=S==null?void 0:S.items)==null?void 0:A.find(K=>K.id===i);return B(w==null?void 0:w.name)||"Unknown Position"},Se=i=>{var de,Ne,Ce;const w=(de=S==null?void 0:S.items)==null?void 0:de.find(oe=>oe.id===i),A=w==null?void 0:w.unitId;if(!A)return"Unknown Unit";const K=(Ce=(Ne=y==null?void 0:y.data)==null?void 0:Ne.items)==null?void 0:Ce.find(oe=>oe.id===A);return B(K==null?void 0:K.name)||"Unknown Unit"};return Y?V?e.jsx("div",{className:"p-6 flex items-center justify-center h-64",children:e.jsxs("div",{className:"flex flex-col items-center gap-2",children:[e.jsx(We,{className:"h-8 w-8 animate-spin text-primary"}),e.jsx("div",{className:"text-sm text-muted-foreground",children:t("setting.msg2")})]})}):e.jsxs("div",{className:"relative overflow-hidden rounded-2xl border border-slate-200/80 bg-gradient-to-br from-white via-slate-50 to-slate-100/60 p-4 shadow-sm sm:p-6 dark:border-slate-700/80 dark:bg-gradient-to-br dark:from-slate-950 dark:via-slate-900 dark:to-slate-900",children:[e.jsxs("div",{className:"mb-5 flex flex-col gap-4 border-b border-slate-200 pb-4 dark:border-slate-700 sm:flex-row sm:items-start sm:justify-between",children:[e.jsxs("div",{className:"space-y-1",children:[e.jsxs("h1",{className:"flex items-center gap-2 text-2xl font-bold text-slate-900 sm:text-3xl dark:text-slate-100",children:[e.jsx(Te,{className:"h-8 w-8 text-primary"}),t("setting.positionConfig")]}),e.jsx("p",{className:"text-sm text-slate-600 dark:text-slate-400",children:t("setting.positionConfigMsg")})]}),e.jsxs(Ae,{open:L,onOpenChange:u,children:[e.jsx(Za,{asChild:!0,children:e.jsxs(U,{className:"bg-primary hover:bg-primary/90 text-primary-foreground",children:[e.jsx(Cs,{className:"h-4 w-4 mr-2"}),t("setting.add")]})}),e.jsxs(Be,{className:"max-w-2xl max-h-[90vh] overflow-y-auto bg-white shadow-2xl border-2 dark:bg-gray-900 dark:border-gray-700",children:[e.jsx(Pe,{className:"sticky top-0 bg-white z-10 pb-4 border-b dark:bg-gray-900 dark:border-gray-700",children:e.jsx(Fe,{className:"dark:text-gray-100",children:t("setting.create")})}),e.jsx("div",{className:"flex-1 overflow-y-auto px-1",children:e.jsx(ht,{...o,children:e.jsxs("form",{onSubmit:o.handleSubmit(j),className:"space-y-6 pb-4",children:[e.jsxs(E,{children:[e.jsx(H,{className:"dark:text-gray-200",children:"Unit"}),e.jsxs(Z,{onValueChange:i=>{k(i)},value:v,children:[e.jsx(ee,{children:e.jsx(te,{placeholder:"Select a unit"})}),e.jsx(ae,{children:q?e.jsx(f,{value:"loading",disabled:!0,children:"Loading units..."}):((ge=(he=y==null?void 0:y.data)==null?void 0:he.items)==null?void 0:ge.length)>0?(be=(fe=y==null?void 0:y.data)==null?void 0:fe.items)==null?void 0:be?.map(i=>e.jsx(f,{value:i.id,children:B(i.name)},i.id)):e.jsx(f,{value:"no-units",disabled:!0,children:"No units found"})})]})]}),e.jsx(Q,{control:o.control,name:"positionId",render:({field:i})=>{var w;return e.jsxs(E,{children:[e.jsx(H,{className:"dark:text-gray-200",children:t("setting.position")}),e.jsxs(Z,{onValueChange:i.onChange,value:i.value,disabled:!v,children:[e.jsx(z,{children:e.jsx(ee,{children:e.jsx(te,{placeholder:v?t("setting.select"):t("setting.select2")})})}),e.jsx(ae,{children:v?W?e.jsx(f,{value:"loading",disabled:!0,children:"Loading positions..."}):((w=S==null?void 0:S.items)==null?void 0:w.length)>0?S.items?.map(A=>e.jsx(f,{value:A.id,children:B(A.name)},A.id)):e.jsx(f,{value:"no-positions",disabled:!0,children:"No positions found in this unit"}):e.jsx(f,{value:"no-unit-selected",disabled:!0,children:t("setting.select2")})})]}),e.jsx(Oe,{}),!v&&e.jsx("p",{className:"text-sm text-muted-foreground",children:t("setting.plsSelect")})]})}}),e.jsx(Q,{control:o.control,name:"positionScopeToFetch",render:({field:i})=>e.jsxs(E,{children:[e.jsx(H,{className:"dark:text-gray-200",children:t("setting.positionScope")}),e.jsxs(Z,{onValueChange:i.onChange,value:i.value,children:[e.jsx(z,{children:e.jsx(ee,{children:e.jsx(te,{placeholder:t("setting.positionScope")})})}),e.jsxs(ae,{children:[e.jsx(f,{value:"all",children:t("setting.all")}),e.jsx(f,{value:"immediate_child",children:t("setting.immediateChild")}),e.jsx(f,{value:"parallel_with_immediate_child",children:t("setting.parallel")})]})]}),e.jsx(Oe,{})]})}),e.jsxs("div",{className:"space-y-4",children:[e.jsx("h3",{className:"text-lg font-semibold",children:t("setting.notification")}),e.jsx(Q,{control:o.control,name:"smsNotificationWhenRecordSubmitted",render:({field:i})=>e.jsxs(E,{className:"flex flex-row items-center justify-between rounded-lg border p-4",children:[e.jsxs("div",{className:"space-y-0.5",children:[e.jsxs(H,{className:"flex items-center gap-2",children:[e.jsx(ft,{className:"h-4 w-4"}),t("setting.sms")]}),e.jsx("div",{className:"text-sm text-muted-foreground",children:t("setting.smsMsg")})]}),e.jsx(z,{children:e.jsx(g,{checked:i.value,onCheckedChange:i.onChange})})]})}),e.jsx(Q,{control:o.control,name:"emailNotificationWhenRecordSubmitted",render:({field:i})=>e.jsxs(E,{className:"flex flex-row items-center justify-between rounded-lg border p-4",children:[e.jsxs("div",{className:"space-y-0.5",children:[e.jsxs(H,{className:"flex items-center gap-2",children:[e.jsx(bt,{className:"h-4 w-4"}),t("setting.email")]}),e.jsx("div",{className:"text-sm text-muted-foreground",children:t("setting.emailMsg")})]}),e.jsx(z,{children:e.jsx(g,{checked:i.value,onCheckedChange:i.onChange})})]})}),e.jsx(Q,{control:o.control,name:"inboxNotificationWhenRecordSubmitted",render:({field:i})=>e.jsxs(E,{className:"flex flex-row items-center justify-between rounded-lg border p-4",children:[e.jsxs("div",{className:"space-y-0.5",children:[e.jsxs(H,{className:"flex items-center gap-2",children:[e.jsx(xt,{className:"h-4 w-4"}),t("setting.inbox")]}),e.jsx("div",{className:"text-sm text-muted-foreground",children:t("setting.inboxMsg")})]}),e.jsx(z,{children:e.jsx(g,{checked:i.value,onCheckedChange:i.onChange})})]})}),e.jsx("h3",{className:"text-lg font-semibold",children:t("setting.workflow")}),e.jsx(Q,{control:o.control,name:"skipWorkflowIfNotAssigned",render:({field:i})=>e.jsxs(E,{className:"flex flex-row items-center justify-between rounded-lg border p-4",children:[e.jsxs("div",{className:"space-y-0.5",children:[e.jsxs(H,{className:"flex items-center gap-2",children:[e.jsx(pt,{className:"h-4 w-4"}),t("setting.workflowMsg")]}),e.jsx("div",{className:"text-sm text-muted-foreground",children:t("setting.workflowMsg1")})]}),e.jsx(z,{children:e.jsx(g,{checked:i.value,onCheckedChange:i.onChange})})]})})]}),e.jsxs("div",{className:"flex justify-end gap-3 pt-4",children:[e.jsx(U,{type:"button",variant:"outline",onClick:()=>{u(!1),k(""),o.reset()},disabled:D,children:t("common.Cancel")}),e.jsx(U,{type:"submit",disabled:D,className:"bg-primary hover:bg-primary/90",children:D?e.jsxs(e.Fragment,{children:[e.jsx(We,{className:"h-4 w-4 mr-2 animate-spin"}),t("setting.creating")]}):t("setting.createConfig")})]})]})})})]})]})]}),e.jsx(ss,{className:"border-slate-200 bg-white/90 dark:border-slate-700 dark:bg-slate-900/70",children:e.jsxs(ns,{className:"pt-6",children:[_&&e.jsxs(is,{variant:"destructive",className:"mb-4",children:[e.jsx(gt,{className:"h-4 w-4"}),e.jsxs(rs,{children:[t("setting.error")," ",(F==null?void 0:F.message)||"Unknown error",e.jsx(U,{variant:"outline",size:"sm",onClick:()=>G(),className:"ml-2",children:t("setting.retry")})]})]}),J.length===0?e.jsx("div",{className:"text-center py-8"}):e.jsx("div",{className:"overflow-x-auto rounded-xl border border-slate-200 dark:border-slate-700",children:e.jsxs(ze,{children:[e.jsx(Ye,{children:e.jsxs(p,{className:"dark:border-slate-700",children:[e.jsx(T,{className:"text-slate-600 dark:text-slate-300",children:t("setting.position")}),e.jsx(T,{className:"text-center text-slate-600 dark:text-slate-300",children:t("contentManagement.unit")}),e.jsx(T,{className:"text-center text-slate-600 dark:text-slate-300",children:t("setting.SMS")}),e.jsx(T,{className:"text-center text-slate-600 dark:text-slate-300",children:t("organization.email")}),e.jsx(T,{className:"text-center text-slate-600 dark:text-slate-300",children:t("setting.Inbox")}),e.jsx(T,{className:"text-center text-slate-600 dark:text-slate-300",children:t("setting.skipWF")}),e.jsx(T,{className:"text-center text-slate-600 dark:text-slate-300",children:t("setting.Action")})]})}),e.jsx(Ve,{children:J?.map(i=>e.jsxs(p,{className:"dark:border-slate-700",children:[e.jsx(n,{className:"font-medium text-slate-900 dark:text-slate-100",children:ke(i.positionId)}),e.jsx(n,{className:"text-center text-slate-700 dark:text-slate-300",children:Se(i.positionId)}),e.jsx(n,{className:"text-center",children:e.jsx(we,{variant:i.smsNotificationWhenRecordSubmitted?"default":"secondary",className:i.smsNotificationWhenRecordSubmitted?"bg-primary-100 text-primary-800":"",children:i.smsNotificationWhenRecordSubmitted?"Enabled":"Disabled"})}),e.jsx(n,{className:"text-center",children:e.jsx(we,{variant:i.emailNotificationWhenRecordSubmitted?"default":"secondary",className:i.emailNotificationWhenRecordSubmitted?"bg-primary-100 text-primary-800":"",children:i.emailNotificationWhenRecordSubmitted?"Enabled":"Disabled"})}),e.jsx(n,{className:"text-center",children:e.jsx(we,{variant:i.inboxNotificationWhenRecordSubmitted?"default":"secondary",className:i.inboxNotificationWhenRecordSubmitted?"bg-primary-100 text-primary-800":"",children:i.inboxNotificationWhenRecordSubmitted?"Enabled":"Disabled"})}),e.jsx(n,{className:"text-center",children:e.jsx(we,{variant:i.skipWorkflowIfNotAssigned?"destructive":"secondary",children:i.skipWorkflowIfNotAssigned?"Skip":"Process"})}),e.jsx(n,{className:"text-center",children:e.jsxs("div",{className:"flex justify-center gap-2",children:[e.jsx(U,{variant:"outline",size:"sm",onClick:()=>re(i),disabled:M||ne,className:"dark:border-slate-700 dark:bg-slate-900 dark:hover:bg-slate-800",children:e.jsx(vs,{className:"h-4 w-4"})}),e.jsxs(ls,{children:[e.jsx(ds,{asChild:!0,children:e.jsx(U,{variant:"outline",size:"sm",disabled:ne,className:"dark:border-slate-700 dark:bg-slate-900 dark:hover:bg-slate-800",children:e.jsx(es,{className:"h-4 w-4 text-red-500"})})}),e.jsxs(os,{className:"dark:border-slate-700 dark:bg-slate-900",children:[e.jsxs(cs,{children:[e.jsx(us,{className:"dark:text-slate-100",children:t("setting.delete")}),e.jsx(ms,{className:"dark:text-slate-400",children:t("setting.deleteMsg")})]}),e.jsxs(xs,{children:[e.jsx(hs,{className:"dark:border-slate-700 dark:bg-slate-800 dark:text-slate-100 dark:hover:bg-slate-700",children:"Cancel"}),e.jsx(gs,{onClick:()=>i.id&&ve(i.id),className:"bg-red-600 hover:bg-red-700",children:t("userRecord.Delete")})]})]})]})]})})]},i.id))})]})})]})}),e.jsx(Ae,{open:I,onOpenChange:P,children:e.jsxs(Be,{className:"max-w-2xl max-h-[90vh] overflow-y-auto bg-white shadow-2xl border-2 dark:bg-slate-900 dark:border-slate-700",children:[e.jsx(Pe,{className:"sticky top-0 bg-white z-10 pb-4 border-b dark:bg-slate-900 dark:border-slate-700",children:e.jsx(Fe,{className:"dark:text-slate-100",children:t("setting.edit")})}),e.jsx("div",{className:"flex-1 overflow-y-auto px-1",children:e.jsx(ht,{...o,children:e.jsxs("form",{onSubmit:o.handleSubmit(O),className:"space-y-6 pb-4",children:[e.jsxs(E,{children:[e.jsx(H,{children:t("contentManagement.unit")}),e.jsxs(Z,{onValueChange:i=>{k(i)},value:v,children:[e.jsx(ee,{children:e.jsx(te,{placeholder:"Select a unit"})}),e.jsx(ae,{children:q?e.jsx(f,{value:"loading",disabled:!0,children:"Loading units..."}):((pe=(je=y==null?void 0:y.data)==null?void 0:je.items)==null?void 0:pe.length)>0?y==null?void 0:y.data.items?.map(i=>e.jsx(f,{value:i.id,children:B(i.name)},i.id)):e.jsx(f,{value:"no-units",disabled:!0,children:"No units found"})})]})]}),e.jsx(Q,{control:o.control,name:"positionId",render:({field:i})=>{var w;return e.jsxs(E,{children:[e.jsx(H,{children:t("setting.position")}),e.jsxs(Z,{onValueChange:i.onChange,value:i.value,disabled:!v,children:[e.jsx(z,{children:e.jsx(ee,{children:e.jsx(te,{placeholder:v?t("setting.select"):t("setting.select2")})})}),e.jsx(ae,{children:v?W?e.jsx(f,{value:"loading",disabled:!0,children:"Loading positions..."}):((w=S==null?void 0:S.items)==null?void 0:w.length)>0?S.items?.map(A=>e.jsx(f,{value:A.id,children:B(A.name)},A.id)):e.jsx(f,{value:"no-positions",disabled:!0,children:"No positions found in this unit"}):e.jsx(f,{value:"no-unit-selected",disabled:!0,children:t("setting.select2")})})]}),e.jsx(Oe,{}),!v&&e.jsx("p",{className:"text-sm text-muted-foreground",children:t("setting.plsSelect")})]})}}),e.jsxs("div",{className:"space-y-4",children:[e.jsx("h3",{className:"text-lg font-semibold",children:t("setting.notification")}),e.jsx(Q,{control:o.control,name:"smsNotificationWhenRecordSubmitted",render:({field:i})=>e.jsxs(E,{className:"flex flex-row items-center justify-between rounded-lg border p-4",children:[e.jsxs("div",{className:"space-y-0.5",children:[e.jsxs(H,{className:"flex items-center gap-2",children:[e.jsx(ft,{className:"h-4 w-4"}),t("setting.sms")]}),e.jsx("div",{className:"text-sm text-muted-foreground",children:t("setting.smsMsg")})]}),e.jsx(z,{children:e.jsx(g,{checked:i.value,onCheckedChange:i.onChange})})]})}),e.jsx(Q,{control:o.control,name:"emailNotificationWhenRecordSubmitted",render:({field:i})=>e.jsxs(E,{className:"flex flex-row items-center justify-between rounded-lg border p-4",children:[e.jsxs("div",{className:"space-y-0.5",children:[e.jsxs(H,{className:"flex items-center gap-2",children:[e.jsx(bt,{className:"h-4 w-4"}),t("setting.email")]}),e.jsx("div",{className:"text-sm text-muted-foreground",children:t("setting.emailMsg")})]}),e.jsx(z,{children:e.jsx(g,{checked:i.value,onCheckedChange:i.onChange})})]})}),e.jsx(Q,{control:o.control,name:"inboxNotificationWhenRecordSubmitted",render:({field:i})=>e.jsxs(E,{className:"flex flex-row items-center justify-between rounded-lg border p-4",children:[e.jsxs("div",{className:"space-y-0.5",children:[e.jsxs(H,{className:"flex items-center gap-2",children:[e.jsx(xt,{className:"h-4 w-4"}),t("setting.inbox")]}),e.jsx("div",{className:"text-sm text-muted-foreground",children:t("setting.inboxMsg")})]}),e.jsx(z,{children:e.jsx(g,{checked:i.value,onCheckedChange:i.onChange})})]})}),e.jsxs("h3",{className:"text-lg font-semibold",children:[" ",t("setting.workflow")]}),e.jsx(Q,{control:o.control,name:"skipWorkflowIfNotAssigned",render:({field:i})=>e.jsxs(E,{className:"flex flex-row items-center justify-between rounded-lg border p-4",children:[e.jsxs("div",{className:"space-y-0.5",children:[e.jsxs(H,{className:"flex items-center gap-2",children:[e.jsx(pt,{className:"h-4 w-4"}),t("setting.workflowMsg")]}),e.jsx("div",{className:"text-sm text-muted-foreground",children:t("setting.workflowMsg1")})]}),e.jsx(z,{children:e.jsx(g,{checked:i.value,onCheckedChange:i.onChange})})]})})]}),e.jsxs("div",{className:"flex justify-end gap-3 pt-4",children:[e.jsx(U,{type:"button",variant:"outline",onClick:()=>{P(!1),r(null),k(""),o.reset()},disabled:M,children:t("common.Cancel")}),e.jsx(U,{type:"submit",disabled:M,className:"bg-primary hover:bg-primary/90",children:M?e.jsxs(e.Fragment,{children:[e.jsx(We,{className:"h-4 w-4 mr-2 animate-spin"}),t("setting.updating")]}):t("setting.UpdateConfig")})]})]})})})]})})]}):e.jsx("div",{className:"p-6 flex items-center justify-center h-64",children:e.jsxs("div",{className:"flex flex-col items-center gap-2",children:[e.jsx(gt,{className:"h-8 w-8 text-red-500"}),e.jsx("div",{className:"text-sm text-muted-foreground",children:t("setting.msg1")})]})})},Ls="relative overflow-hidden rounded-2xl border border-slate-200/80 bg-gradient-to-br from-white via-slate-50 to-slate-100/60 p-4 shadow-sm sm:p-6 dark:border-slate-700/80 dark:bg-gradient-to-br dark:from-slate-950 dark:via-slate-900 dark:to-slate-900",Ds=()=>{var it,rt,lt,dt,ot,ct,ut,mt;const{user:x}=Ue(),c=Ee(),Y=((rt=(it=x==null?void 0:x.employee)==null?void 0:it[0])==null?void 0:rt.organizationId)||"",B=He(),{getList:L}=Me(),{data:u,isLoading:R}=L(Y,{take:1e3}),[r,I]=d.useState(""),[P,v]=d.useState(!1),[k,h]=d.useState(""),[V,_]=d.useState(le.NAME),[F,G]=d.useState(!1),[$,m]=d.useState(!1),[se,D]=d.useState(!1),[M,ne]=d.useState(!1),[J,ie]=d.useState(!1),[y,q]=d.useState(!1),[X,S]=d.useState(!1),[W,o]=d.useState(!1),[j,O]=d.useState(!1),[re,ve]=d.useState(!1),[ke,Se]=d.useState(!1),[ce,ue]=d.useState(!1),[me,xe]=d.useState(!1),[he,ge]=d.useState(!1),[fe,be]=d.useState(!1),[je,pe]=d.useState(!1),[i,w]=d.useState(!1),[A,K]=d.useState(!1),[de,Ne]=d.useState(!1),[Ce,oe]=d.useState(!1),[_e,Ge]=d.useState(!1),[Je,Xe]=d.useState(!1),[Ze,et]=d.useState(!1),[tt,at]=d.useState(!1);d.useEffect(()=>{var a,l;if(((l=(a=u==null?void 0:u.data)==null?void 0:a.items)==null?void 0:l.length)>0){const Va=u==null?void 0:u.data.items[0];I(Va.id)}},[u]);const{data:$e,isLoading:ye,error:Re}=Qe(r,{enabled:!!r}),s=(dt=(lt=$e==null?void 0:$e.data)==null?void 0:lt.items)==null?void 0:dt[0],[$s,qs]=d.useState(!1);d.useEffect(()=>{s&&(G(s.canAssignCustomReferenceNumber||!1),_(s.recordSenderInformationScope||le.NAME),m(s.canInternalMemosHaveSenderInformation||!1),ie(s.canRecordDateLabelHasLocalization||!1),q(s.canRecordDateHaveTime||!1),S(s.forwardWithTeeterSignature||!1),o(s.waitAllCollaboratorsBeforeAction||!1),O(s.shouldCollaboratorAlwaysSign||!1),ve(s.shouldIncludeForYourReferenceInCC||!1),Se(s.canCollaborationBeCreatedForAll||!1),ue(s.canCreateDirectRecord||!1),xe(s.canPositionBasedRecordsBeCreated||!1),ge(s.canPositionBasedReferenceBeUsedForAllTypes||!1),pe(s.shouldDirectRecordHaveHeaderAndFooter||!1),be(s.canInternalMemoRequirePositionBasedReferenceNumber||!1),w(s.canRecordBeEditedByCollaborators||!1),K(s.canRecordHaveCollaboratorsName||!1),Ne(s.canRecordBeEditedOnWorkflow||!1),oe(s.canIncomingAssignmentsBeSeparated||!1),Ge(s.canROReturn||!1),Xe(s.internalMemoHasReferenceNumber||!1),et(s.isMultipleDelegationAllowed||!1),at(s.attachSignatureOnAttachment||!1),D(s.canHaveTagBasedReferenceNumber||!1),ne(s.canUserSkipLevel||!1))},[s]);const St=a=>{s!=null&&s.id&&(D(a),Rt({id:s.id,value:a}))},yt=a=>{s!=null&&s.id&&(ne(a),It({id:s.id,value:a}))},{mutate:Rt,isPending:wt}=b({mutationFn:({id:a,value:l})=>N.updateInternalPrefixSuffix({canHaveTagBasedReferenceNumber:l,unitId:r},a),onSuccess:()=>{c.invalidateQueries({queryKey:["unitConfig",r]}),C.success(`${t("setting.canHaveTagBasedReferenceNumber","Can Have Tag Based Reference Number")} ${se?"enabled":"disabled"}`)},onError:a=>{}}),{mutate:It,isPending:At}=b({mutationFn:({id:a,value:l})=>N.updateInternalPrefixSuffix({canUserSkipLevel:l,unitId:r},a),onSuccess:()=>{c.invalidateQueries({queryKey:["unitConfig",r]}),C.success(`${t("setting.canUserSkipLevel","User Can Skip Level")} ${M?"enabled":"disabled"}`)},onError:a=>{}}),{mutate:Bt,isPending:Pt}=b({mutationFn:({id:a,scope:l})=>N.updateInternalPrefixSuffix({recordSenderInformationScope:l,unitId:r},a),onSuccess:()=>{c.invalidateQueries({queryKey:["unitConfig",r]}),C.success(`${t("setting.recordSenderInformationScope","Record Sender Information Scope")} updated`)},onError:a=>{}}),{mutate:Ft,isPending:Ut}=b({mutationFn:({id:a,value:l})=>N.updateInternalPrefixSuffix({canAssignCustomReferenceNumber:l,unitId:r},a),onSuccess:()=>{c.invalidateQueries({queryKey:["unitConfig",r]}),C.success(`${t("setting.canAssignCustomReferenceNumber")} ${F?"enabled":"disabled"}`)},onError:a=>{}}),{mutate:Et,isPending:Ht}=b({mutationFn:({id:a,value:l})=>N.updateInternalPrefixSuffix({canInternalMemosHaveSenderInformation:l,unitId:r},a),onSuccess:()=>{c.invalidateQueries({queryKey:["unitConfig",r]}),C.success(`${t("setting.canUseNewInternalMemoFormat")} ${$?"enabled":"disabled"}`)},onError:a=>{}}),{mutate:Tt,isPending:Lt}=b({mutationFn:({id:a,value:l})=>N.updateInternalPrefixSuffix({canRecordDateLabelHasLocalization:l,unitId:r},a),onSuccess:()=>{c.invalidateQueries({queryKey:["unitConfig",r]}),C.success(`${t("setting.canRecordDateLabelHasLocalization")} ${J?"enabled":"disabled"}`)},onError:a=>{}}),{mutate:Dt,isPending:Mt}=b({mutationFn:({id:a,value:l})=>N.updateInternalPrefixSuffix({canRecordDateHaveTime:l,unitId:r},a),onSuccess:()=>{c.invalidateQueries({queryKey:["unitConfig",r]}),C.success(`${t("setting.canRecordDateHaveTime","Record Date Can Have Time")} ${y?"enabled":"disabled"}`)},onError:a=>{}}),{mutate:$t,isPending:st}=b({mutationFn:({id:a,scope:l})=>N.updateInternalPrefixSuffix({positionScopeToFetch:l,unitId:r},a),onSuccess:()=>{c.invalidateQueries({queryKey:["unitConfig",r]}),v(!1)},onError:a=>{}}),{mutate:qt,isPending:Wt}=b({mutationFn:({id:a,signature:l})=>N.updateInternalPrefixSuffix({forwardWithTeeterSignature:l,unitId:r},a),onSuccess:()=>{c.invalidateQueries({queryKey:["unitConfig",r]}),C.success(`Signature & Teeter ${X?"enabled":"disabled"}`)},onError:a=>{}}),{mutate:Ot,isPending:Kt}=b({mutationFn:({id:a,wait:l})=>N.updateInternalPrefixSuffix({waitAllCollaboratorsBeforeAction:l,unitId:r},a),onSuccess:()=>{c.invalidateQueries({queryKey:["unitConfig",r]}),C.success(`${t("setting.title6")} ${W?"enabled":"disabled"}`)},onError:a=>{}}),Qt=()=>{s!=null&&s.id&&(h(s.positionScopeToFetch),v(!0))},zt=()=>{s!=null&&s.id&&k&&k!==s.positionScopeToFetch&&$t({id:s.id,scope:k})},Yt=a=>{s!=null&&s.id&&(S(a),qt({id:s.id,signature:a}))},Vt=a=>{s!=null&&s.id&&(G(a),Ft({id:s.id,value:a}))},_t=a=>{s!=null&&s.id&&(m(a),Et({id:s.id,value:a}))},Gt=a=>{s!=null&&s.id&&(ie(a),Tt({id:s.id,value:a}))},Jt=a=>{s!=null&&s.id&&(q(a),Dt({id:s.id,value:a}))},Xt=a=>{s!=null&&s.id&&(o(a),Ot({id:s.id,wait:a}))},{mutate:Zt,isPending:ea}=b({mutationFn:({id:a,value:l})=>N.updateInternalPrefixSuffix({shouldCollaboratorAlwaysSign:l,unitId:r},a),onSuccess:()=>{c.invalidateQueries({queryKey:["unitConfig",r]}),C.success(`${t("setting.shouldCollaboratorAlwaysSign")} ${j?"enabled":"disabled"}`)},onError:a=>{}}),{mutate:ta,isPending:aa}=b({mutationFn:({id:a,value:l})=>N.updateInternalPrefixSuffix({shouldIncludeForYourReferenceInCC:l,unitId:r},a),onSuccess:()=>{c.invalidateQueries({queryKey:["unitConfig",r]}),C.success(`${t("setting.shouldIncludeForYourReferenceInCC")} ${re?"enabled":"disabled"}`)},onError:a=>{}}),sa=a=>{s!=null&&s.id&&(O(a),Zt({id:s.id,value:a}))},na=a=>{s!=null&&s.id&&(_(a),Bt({id:s.id,scope:a}))},ia=a=>{s!=null&&s.id&&(ve(a),ta({id:s.id,value:a}))},{mutate:ra,isPending:la}=b({mutationFn:({id:a,value:l})=>N.updateInternalPrefixSuffix({canCollaborationBeCreatedForAll:l,unitId:r},a),onSuccess:()=>{c.invalidateQueries({queryKey:["unitConfig",r]}),C.success(`${t("setting.canCollaborationBeCreatedForAll")} ${ke?"enabled":"disabled"}`)},onError:a=>{}}),da=a=>{s!=null&&s.id&&(Se(a),ra({id:s.id,value:a}))},{mutate:oa,isPending:ca}=b({mutationFn:({id:a,value:l})=>N.updateInternalPrefixSuffix({canCreateDirectRecord:l,unitId:r},a),onSuccess:()=>{c.invalidateQueries({queryKey:["unitConfig",r]}),C.success(`${t("setting.canCreateDirectRecord")} ${ce?"enabled":"disabled"}`)},onError:a=>{}}),ua=a=>{s!=null&&s.id&&(ue(a),oa({id:s.id,value:a}))},{mutate:ma,isPending:xa}=b({mutationFn:({id:a,value:l})=>N.updateInternalPrefixSuffix({canPositionBasedRecordsBeCreated:l,unitId:r},a),onSuccess:()=>{c.invalidateQueries({queryKey:["unitConfig",r]}),C.success(`${t("setting.canPositionBasedRecordsBeCreated")} ${me?"enabled":"disabled"}`)},onError:a=>{}}),ha=a=>{s!=null&&s.id&&(xe(a),ma({id:s.id,value:a}))},{mutate:ga,isPending:fa}=b({mutationFn:({id:a,value:l})=>N.updateInternalPrefixSuffix({canPositionBasedReferenceBeUsedForAllTypes:l,unitId:r},a),onSuccess:()=>{c.invalidateQueries({queryKey:["unitConfig",r]}),C.success(`${t("setting.canPositionBasedReferenceBeUsedForAllTypes")} ${he?"enabled":"disabled"}`)},onError:a=>{}}),ba=a=>{s!=null&&s.id&&(ge(a),ga({id:s.id,value:a}))},{mutate:ja,isPending:pa}=b({mutationFn:({id:a,value:l})=>N.updateInternalPrefixSuffix({shouldDirectRecordHaveHeaderAndFooter:l,unitId:r},a),onSuccess:()=>{c.invalidateQueries({queryKey:["unitConfig",r]}),C.success(`${t("setting.shouldDirectRecordHaveHeaderAndFooter")} ${je?t("setting.enabled"):t("setting.disabled")}`)},onError:a=>{}}),Na=a=>{s!=null&&s.id&&(pe(a),ja({id:s.id,value:a}))},{mutate:Ca,isPending:va}=b({mutationFn:({id:a,value:l})=>N.updateInternalPrefixSuffix({canRecordBeEditedByCollaborators:l,unitId:r},a),onSuccess:()=>{c.invalidateQueries({queryKey:["unitConfig",r]}),C.success(`${t("setting.canRecordBeEditedByCollaborators")} ${i?"enabled":"disabled"}`)},onError:a=>{}}),ka=a=>{s!=null&&s.id&&(w(a),Ca({id:s.id,value:a}))},{mutate:Sa,isPending:ya}=b({mutationFn:({id:a,value:l})=>N.updateInternalPrefixSuffix({canRecordHaveCollaboratorsName:l,unitId:r},a),onSuccess:()=>{c.invalidateQueries({queryKey:["unitConfig",r]}),C.success(`${t("setting.canRecordHaveCollaboratorsName")} ${A?"enabled":"disabled"}`)},onError:a=>{}}),Ra=a=>{s!=null&&s.id&&(K(a),Sa({id:s.id,value:a}))},{mutate:wa,isPending:Ia}=b({mutationFn:({id:a,value:l})=>N.updateInternalPrefixSuffix({canRecordBeEditedOnWorkflow:l,unitId:r},a),onSuccess:()=>{c.invalidateQueries({queryKey:["unitConfig",r]}),C.success(`${t("setting.canRecordBeEditedOnWorkflow")} ${de?"enabled":"disabled"}`)},onError:a=>{}}),{mutate:Aa,isPending:Ba}=b({mutationFn:({id:a,value:l})=>N.updateInternalPrefixSuffix({canIncomingAssignmentsBeSeparated:l,unitId:r},a),onSuccess:()=>{c.invalidateQueries({queryKey:["unitConfig",r]}),C.success(`${t("setting.canIncomingAssignmentsBeSeparated")} ${Ce?"enabled":"disabled"}`)},onError:a=>{}}),Pa=a=>{s!=null&&s.id&&(Ne(a),wa({id:s.id,value:a}))},Fa=a=>{s!=null&&s.id&&(oe(a),Aa({id:s.id,value:a}))},{mutate:Ua,isPending:Ea}=b({mutationFn:({id:a,value:l})=>N.updateInternalPrefixSuffix({canROReturn:l,unitId:r},a),onSuccess:()=>{c.invalidateQueries({queryKey:["unitConfig",r]}),C.success(`${t("setting.canROReturn")} ${_e?"enabled":"disabled"}`)},onError:a=>{}}),Ha=a=>{s!=null&&s.id&&(Ge(a),Ua({id:s.id,value:a}))},{mutate:Ta,isPending:La}=b({mutationFn:({id:a,value:l})=>N.updateInternalPrefixSuffix({internalMemoHasReferenceNumber:l,unitId:r},a),onSuccess:()=>{c.invalidateQueries({queryKey:["unitConfig",r]}),C.success(`${t("setting.internalMemoHasReferenceNumber")} ${Je?"enabled":"disabled"}`)},onError:a=>{}}),{mutate:Da,isPending:Ma}=b({mutationFn:({id:a,value:l})=>N.updateInternalPrefixSuffix({canInternalMemoRequirePositionBasedReferenceNumber:l,unitId:r},a),onSuccess:()=>{c.invalidateQueries({queryKey:["unitConfig",r]}),C.success(`${t("setting.canInternalMemoRequirePositionBasedReferenceNumber","Internal Memo Require Position Based Reference Number")} ${fe?"enabled":"disabled"}`)},onError:a=>{}}),$a=a=>{s!=null&&s.id&&(be(a),Da({id:s.id,value:a}))},qa=a=>{s!=null&&s.id&&(Xe(a),Ta({id:s.id,value:a}))},{mutate:Wa,isPending:Oa}=b({mutationFn:({id:a,value:l})=>N.updateInternalPrefixSuffix({isMultipleDelegationAllowed:l,unitId:r},a),onSuccess:()=>{c.invalidateQueries({queryKey:["unitConfig",r]}),C.success(`${t("setting.isMultipleDelegationAllowed","Multiple Delegation Allowed")} ${Ze?"enabled":"disabled"}`)},onError:a=>{}}),Ka=a=>{s!=null&&s.id&&(et(a),Wa({id:s.id,value:a}))},{mutate:Qa,isPending:za}=b({mutationFn:({id:a,value:l})=>N.updateInternalPrefixSuffix({attachSignatureOnAttachment:l,unitId:r},a),onSuccess:()=>{c.invalidateQueries({queryKey:["unitConfig",r]}),C.success(`${t("setting.attachSignatureOnAttachment","Attach Signature On Attachment")} ${tt?"enabled":"disabled"}`)},onError:a=>{}}),Ya=a=>{s!=null&&s.id&&(at(a),Qa({id:s.id,value:a}))},{mutate:nt,isPending:qe}=b({mutationFn:a=>N.createInternalPrefixSuffix({unitId:a}),onSuccess:()=>{c.invalidateQueries({queryKey:["unitConfig",r]})},onError:a=>{}});return d.useEffect(()=>{!s&&!ye&&!Re&&r&&!qe&&nt(r)},[s,ye,Re,r,qe,nt]),e.jsxs("div",{className:Ls,children:[e.jsxs("div",{className:"mb-5 flex flex-col gap-4 border-b border-slate-200 pb-4 dark:border-slate-700 sm:flex-row sm:items-start sm:justify-between",children:[e.jsxs("div",{className:"space-y-1",children:[e.jsxs("h1",{className:"flex items-center gap-2 text-2xl font-bold text-slate-900 sm:text-3xl dark:text-slate-100",children:[e.jsx(Te,{className:"h-8 w-8 text-primary"}),t("setting.title1")]}),e.jsx("p",{className:"text-sm text-slate-600 dark:text-slate-400",children:t("setting.title2")})]}),e.jsx("div",{className:"w-full sm:ml-auto sm:w-auto",children:e.jsxs(Z,{value:r,onValueChange:a=>I(a),children:[e.jsx(ee,{className:"h-10 w-full bg-white/90 dark:border-slate-700 dark:bg-slate-900 sm:w-56",children:e.jsx(te,{placeholder:"Select a unit"})}),e.jsx(ae,{className:"dark:border-slate-700 dark:bg-slate-900",children:R?e.jsx(f,{value:"loading",disabled:!0,children:t("setting.loading")}):((ct=(ot=u==null?void 0:u.data)==null?void 0:ot.items)==null?void 0:ct.length)>0?(mt=(ut=u==null?void 0:u.data)==null?void 0:ut.items)==null?void 0:mt?.map(a=>e.jsx(f,{value:a.id,children:B(a.name)},a.id)):e.jsx(f,{value:"no-units",disabled:!0,children:t("setting.noUnit")})})]})})]}),e.jsxs("div",{className:"space-y-3",children:[ye&&e.jsx("p",{className:"text-sm text-slate-600 dark:text-slate-300",children:"Loading configuration..."}),Re&&e.jsx("p",{className:"text-sm text-red-600 dark:text-red-400",children:"Failed to load configuration"}),!s&&!ye&&!Re&&e.jsx("p",{className:"text-sm text-slate-600 dark:text-slate-300",children:qe?t("setting.preparingDefaults","Preparing default configuration..."):t("setting.loading","Loading...")}),s&&e.jsx("div",{className:"max-h-128 overflow-x-auto overflow-y-auto rounded-xl border border-slate-200 bg-white/90 dark:border-slate-700 dark:bg-slate-900/70",children:e.jsxs(ze,{children:[e.jsx(Ye,{className:"sticky top-0 z-10 bg-white/95 backdrop-blur dark:bg-slate-900/95",children:e.jsxs(p,{className:"dark:border-slate-700",children:[e.jsx(T,{className:"text-slate-600 dark:text-slate-300",children:t("setting.Name")}),e.jsx(T,{className:"text-slate-600 dark:text-slate-300",children:t("setting.Value")}),e.jsx(T,{className:"text-right text-slate-600 dark:text-slate-300",children:t("setting.Action")})]})}),e.jsxs(Ve,{children:[e.jsxs(p,{className:"dark:border-slate-700",children:[e.jsx(n,{className:"font-medium text-slate-900 dark:text-slate-100",children:t("setting.title3")}),e.jsx(n,{className:"text-slate-700 dark:text-slate-300",children:s.positionScopeToFetch}),e.jsx(n,{className:"text-right",children:e.jsx(U,{onClick:Qt,variant:"outline",size:"sm",className:"dark:border-slate-700 dark:bg-slate-900 dark:hover:bg-slate-800",children:e.jsx(vt,{className:"h-4 w-4"})})})]}),e.jsxs(p,{className:"dark:border-slate-700",children:[e.jsx(n,{className:"font-medium text-slate-900 dark:text-slate-100",children:t("setting.title5")}),e.jsx(n,{className:"text-slate-700 dark:text-slate-300",children:s.forwardWithTeeterSignature?"Yes":"No"}),e.jsx(n,{className:"text-right",children:e.jsx(g,{id:"add-teeter-assign",checked:X,onCheckedChange:Yt,disabled:Wt})})]}),e.jsxs(p,{className:"dark:border-slate-700",children:[e.jsx(n,{className:"font-medium text-slate-900 dark:text-slate-100",children:t("setting.title6")}),e.jsx(n,{className:"text-slate-700 dark:text-slate-300",children:s.waitAllCollaboratorsBeforeAction?"Yes":"No"}),e.jsx(n,{className:"text-right",children:e.jsx(g,{id:"wait-for-all-collaborators-before-action",checked:W,onCheckedChange:Xt,disabled:Kt})})]}),e.jsxs(p,{children:[e.jsx(n,{className:"font-medium",children:t("setting.canAssignCustomReferenceNumber","Can Assign Custom Reference Number")}),e.jsx(n,{children:s.canAssignCustomReferenceNumber?"Yes":"No"}),e.jsx(n,{className:"text-right",children:e.jsx(g,{id:"can-assign-custom-reference-number",checked:F,onCheckedChange:Vt,disabled:Ut})})]}),e.jsxs(p,{children:[e.jsx(n,{className:"font-medium",children:t("setting.canUseNewInternalMemoFormat","New Internal Memo Format")}),e.jsx(n,{children:s.canInternalMemosHaveSenderInformation?"Yes":"No"}),e.jsx(n,{className:"text-right",children:e.jsx(g,{id:"can-use-new-internal-memo-format",checked:$,onCheckedChange:_t,disabled:Ht})})]}),$&&e.jsxs(p,{children:[e.jsx(n,{className:"font-medium",children:t("setting.recordSenderInformationScope","Record Sender Information Scope")}),e.jsx(n,{children:s.recordSenderInformationScope||"NAME"}),e.jsx(n,{className:"text-right",children:e.jsxs(Z,{value:V,onValueChange:a=>na(a),disabled:Pt,children:[e.jsx(ee,{className:"w-40 dark:border-slate-700 dark:bg-slate-900",children:e.jsx(te,{})}),e.jsxs(ae,{className:"dark:border-slate-700 dark:bg-slate-900",children:[e.jsx(f,{value:le.NAME,children:"Name"}),e.jsx(f,{value:le.SIGNATURE,children:"Signature"}),e.jsx(f,{value:le.STAMP,children:"Stamp"}),e.jsx(f,{value:le.NAME_AND_SIGNATURE,children:"Name & Signature"}),e.jsx(f,{value:le.NAME_AND_STAMP,children:"Name & Stamp"}),e.jsx(f,{value:le.ALL,children:"All"})]})]})})]}),e.jsxs(p,{children:[e.jsx(n,{className:"font-medium",children:t("setting.canRecordDateLabelHasLocalization","Does Record Date Label Have Localization?")}),e.jsx(n,{children:s.canRecordDateLabelHasLocalization?"Yes":"No"}),e.jsx(n,{className:"text-right",children:e.jsx(g,{id:"can-record-date-label-has-localization",checked:J,onCheckedChange:Gt,disabled:Lt})})]}),e.jsxs(p,{children:[e.jsx(n,{className:"font-medium",children:t("setting.canRecordDateHaveTime","Record Date Can Have Time")}),e.jsx(n,{children:s.canRecordDateHaveTime?"Yes":"No"}),e.jsx(n,{className:"text-right",children:e.jsx(g,{id:"can-record-date-have-time",checked:y,onCheckedChange:Jt,disabled:Mt})})]}),e.jsxs(p,{children:[e.jsx(n,{className:"font-medium",children:t("setting.canCreateDirectRecord")}),e.jsx(n,{children:s.canCreateDirectRecord?t("common.yes"):t("common.no")}),e.jsx(n,{className:"text-right",children:e.jsx(g,{id:"can-create-direct-record",checked:ce,onCheckedChange:ua,disabled:ca})})]}),e.jsxs(p,{children:[e.jsx(n,{className:"font-medium",children:t("setting.canPositionBasedRecordsBeCreated")}),e.jsx(n,{children:s.canPositionBasedRecordsBeCreated?t("common.yes"):t("common.no")}),e.jsx(n,{className:"text-right",children:e.jsx(g,{id:"can-position-based-records-be-created",checked:me,onCheckedChange:ha,disabled:xa})})]}),e.jsxs(p,{children:[e.jsx(n,{className:"font-medium",children:t("setting.canPositionBasedReferenceBeUsedForAllTypes")}),e.jsx(n,{children:s.canPositionBasedReferenceBeUsedForAllTypes?t("common.yes"):t("common.no")}),e.jsx(n,{className:"text-right",children:e.jsx(g,{id:"can-position-based-reference-be-used-for-all-types",checked:he,onCheckedChange:ba,disabled:fa})})]}),e.jsxs(p,{children:[e.jsx(n,{className:"font-medium",children:t("setting.canInternalMemoRequirePositionBasedReferenceNumber","Internal Memo Require Position Based Reference Number")}),e.jsx(n,{children:s.canInternalMemoRequirePositionBasedReferenceNumber?"Yes":"No"}),e.jsx(n,{className:"text-right",children:e.jsx(g,{id:"can-internal-memo-require-position-based-reference-number",checked:fe,onCheckedChange:$a,disabled:Ma})})]}),e.jsxs(p,{children:[e.jsx(n,{className:"font-medium",children:t("setting.shouldDirectRecordHaveHeaderAndFooter")}),e.jsx(n,{children:s.shouldDirectRecordHaveHeaderAndFooter?t("common.yes"):t("common.no")}),e.jsx(n,{className:"text-right",children:e.jsx(g,{id:"should-direct-record-have-header-and-footer",checked:je,onCheckedChange:Na,disabled:pa})})]}),e.jsxs(p,{children:[e.jsx(n,{className:"font-medium",children:t("setting.shouldCollaboratorAlwaysSign")}),e.jsx(n,{children:s.shouldCollaboratorAlwaysSign?"Yes":"No"}),e.jsx(n,{className:"text-right",children:e.jsx(g,{id:"should-collaborator-always-sign",checked:j,onCheckedChange:sa,disabled:ea})})]}),e.jsxs(p,{children:[e.jsx(n,{className:"font-medium",children:t("setting.shouldIncludeForYourReferenceInCC")}),e.jsx(n,{children:s.shouldIncludeForYourReferenceInCC?"Yes":"No"}),e.jsx(n,{className:"text-right",children:e.jsx(g,{id:"should-include-for-your-reference",checked:re,onCheckedChange:ia,disabled:aa})})]}),e.jsxs(p,{children:[e.jsx(n,{className:"font-medium",children:t("setting.canCollaborationBeCreatedForAll")}),e.jsx(n,{children:s.canCollaborationBeCreatedForAll?"Yes":"No"}),e.jsx(n,{className:"text-right",children:e.jsx(g,{id:"can-collaboration-be-created-for-all",checked:ke,onCheckedChange:da,disabled:la})})]}),e.jsxs(p,{children:[e.jsx(n,{className:"font-medium",children:t("setting.canRecordBeEditedByCollaborators")}),e.jsx(n,{children:s.canRecordBeEditedByCollaborators?"Yes":"No"}),e.jsx(n,{className:"text-right",children:e.jsx(g,{id:"can-record-be-edited-by-collaborators",checked:i,onCheckedChange:ka,disabled:va})})]}),e.jsxs(p,{children:[e.jsx(n,{className:"font-medium",children:t("setting.canRecordHaveCollaboratorsName")}),e.jsx(n,{children:s.canRecordHaveCollaboratorsName?"Yes":"No"}),e.jsx(n,{className:"text-right",children:e.jsx(g,{id:"can-record-have-collaborators-name",checked:A,onCheckedChange:Ra,disabled:ya})})]}),e.jsxs(p,{children:[e.jsx(n,{className:"font-medium",children:t("setting.canRecordBeEditedOnWorkflow")}),e.jsx(n,{children:s.canRecordBeEditedOnWorkflow?"Yes":"No"}),e.jsx(n,{className:"text-right",children:e.jsx(g,{id:"can-record-be-edited-on-workflow",checked:de,onCheckedChange:Pa,disabled:Ia})})]}),e.jsxs(p,{children:[e.jsx(n,{className:"font-medium",children:t("setting.canIncomingAssignmentsBeSeparated")}),e.jsx(n,{children:s.canIncomingAssignmentsBeSeparated?"Yes":"No"}),e.jsx(n,{className:"text-right",children:e.jsx(g,{id:"can-incoming-assignements-be-separated",checked:Ce,onCheckedChange:Fa,disabled:Ba})})]}),e.jsxs(p,{children:[e.jsx(n,{className:"font-medium",children:t("setting.canROReturn")}),e.jsx(n,{children:s.canROReturn?"Yes":"No"}),e.jsx(n,{className:"text-right",children:e.jsx(g,{id:"can-ro-return",checked:_e,onCheckedChange:Ha,disabled:Ea})})]}),e.jsxs(p,{children:[e.jsx(n,{className:"font-medium",children:t("setting.internalMemoHasReferenceNumber")}),e.jsx(n,{children:s.internalMemoHasReferenceNumber?"Yes":"No"}),e.jsx(n,{className:"text-right",children:e.jsx(g,{id:"internal-memo-has-reference-number",checked:Je,onCheckedChange:qa,disabled:La})})]}),e.jsxs(p,{children:[e.jsx(n,{className:"font-medium",children:t("setting.isMultipleDelegationAllowed","Multiple Delegation Allowed")}),e.jsx(n,{children:s.isMultipleDelegationAllowed?"Yes":"No"}),e.jsx(n,{className:"text-right",children:e.jsx(g,{id:"is-multiple-delegation-allowed",checked:Ze,onCheckedChange:Ka,disabled:Oa})})]}),e.jsxs(p,{children:[e.jsx(n,{className:"font-medium",children:t("setting.attachSignatureOnAttachment","Attach Signature On Attachment")}),e.jsx(n,{children:s.attachSignatureOnAttachment?"Yes":"No"}),e.jsx(n,{className:"text-right",children:e.jsx(g,{id:"attach-signature-on-attachment",checked:tt,onCheckedChange:Ya,disabled:za})})]}),e.jsxs(p,{children:[e.jsx(n,{className:"font-medium",children:t("setting.canHaveTagBasedReferenceNumber","Can Have Tag Based Reference Number")}),e.jsx(n,{children:s.canHaveTagBasedReferenceNumber?"Yes":"No"}),e.jsx(n,{className:"text-right",children:e.jsx(g,{id:"can-have-tag-based-reference-number",checked:se,onCheckedChange:St,disabled:wt})})]}),e.jsxs(p,{children:[e.jsx(n,{className:"font-medium",children:t("setting.canUserSkipLevel","User Can Skip Level")}),e.jsx(n,{children:s.canUserSkipLevel?"Yes":"No"}),e.jsx(n,{className:"text-right",children:e.jsx(g,{id:"can-user-skip-level",checked:M,onCheckedChange:yt,disabled:At})})]})]})]})})]}),e.jsx(Ae,{open:P,onOpenChange:v,children:e.jsxs(Be,{className:"dark:border-slate-700 dark:bg-slate-900",children:[e.jsxs(Pe,{children:[e.jsx(Fe,{className:"dark:text-slate-100",children:t("setting.editTitle")}),e.jsx(Nt,{className:"dark:text-slate-400",children:t("setting.editMsg")})]}),e.jsx("div",{className:"my-4",children:e.jsxs(Z,{value:k,onValueChange:a=>h(a),children:[e.jsx(ee,{className:"w-full dark:border-slate-700 dark:bg-slate-900",children:e.jsx(te,{placeholder:"Select scope"})}),e.jsx(ae,{className:"dark:border-slate-700 dark:bg-slate-900",children:kt?.map(a=>e.jsx(f,{value:a,children:a.replace(/_/g," ").toUpperCase()},a))})]})}),e.jsxs(Ct,{className:"flex justify-end gap-2",children:[e.jsx(U,{variant:"secondary",onClick:()=>v(!1),className:"dark:bg-slate-800 dark:text-slate-100 dark:hover:bg-slate-700",children:t("common.Cancel")}),e.jsx(U,{onClick:zt,disabled:st,className:"bg-primary hover:bg-primary/90 text-primary-foreground",children:st?t("contentManagement.saving"):t("delegation.save")})]})]})})]})},Ms=()=>{var y,q,X,S,W;const{user:x}=Ue(),c=Ee(),Y=((q=(y=x==null?void 0:x.employee)==null?void 0:y[0])==null?void 0:q.organizationId)||"",B=He(),L=d.useId(),{getList:u}=Me(),{data:R,isLoading:r}=u(Y,{take:1e3}),[I,P]=d.useState(""),[v,k]=d.useState(null);d.useEffect(()=>{var o,j;!I&&((j=(o=R==null?void 0:R.data)==null?void 0:o.items)!=null&&j.length)&&P(R.data.items[0].id)},[I,R]);const h=((X=R==null?void 0:R.data)==null?void 0:X.items)??[],V=h.length>0,_=(S=h.find(o=>o.id===I))==null?void 0:S.name,{data:F,isLoading:G}=Qe(I),$=((W=F==null?void 0:F.data)==null?void 0:W.items)||[],m=$.find(o=>o.unitId===I)||$[0],se=!!(m!=null&&m.attachSignatureOnAttachment),D=!!(m!=null&&m.id),M=v===null?se:v;d.useEffect(()=>{k(null)},[I,se]);const{mutate:ne,isPending:J}=b({mutationFn:({id:o,value:j})=>N.updateInternalPrefixSuffix({attachSignatureOnAttachment:j,unitId:I},o),onSuccess:()=>{c.invalidateQueries({queryKey:["unitConfig",I]})},onError:()=>{k(null)}}),ie=o=>{!(m!=null&&m.id)||J||(k(o),ne({id:m.id,value:o}))};return e.jsx("section",{className:"w-full",children:e.jsx("div",{className:"relative overflow-hidden rounded-2xl border border-slate-200/80 bg-gradient-to-br from-white via-slate-50 to-slate-100/60 p-4 shadow-sm sm:p-6 dark:border-slate-700/80 dark:bg-gradient-to-br dark:from-slate-950 dark:via-slate-900 dark:to-slate-900",children:e.jsxs("div",{className:"relative space-y-5",children:[e.jsx("header",{className:"flex flex-col gap-2 border-b border-slate-200 pb-4 dark:border-slate-700 sm:flex-row sm:items-start sm:justify-between",children:e.jsxs("div",{className:"space-y-1",children:[e.jsxs("p",{className:"inline-flex w-fit items-center gap-2 rounded-full bg-white/80 px-3 py-1 text-xs font-medium text-slate-700 ring-1 ring-slate-200 dark:bg-slate-800 dark:text-slate-300 dark:ring-slate-700",children:[e.jsx(ks,{className:"h-3.5 w-3.5"}),t("setting.attachmentSignature.badge")]}),e.jsxs("h2",{className:"text-xl font-semibold tracking-tight text-slate-900 dark:text-slate-100 sm:text-2xl flex gap-2",children:[e.jsx(Te,{className:"h-8 w-8 text-primary"}),t("setting.attachmentSignature.title")]}),e.jsx("p",{className:"max-w-2xl text-sm text-slate-600 dark:text-slate-400",children:t("setting.attachmentSignature.description")})]})}),e.jsxs("div",{className:"grid grid-cols-1 gap-4 lg:grid-cols-2",children:[e.jsxs("div",{className:"space-y-2 rounded-xl border border-slate-200/80 bg-white/80 p-4 backdrop-blur-sm dark:border-slate-700 dark:bg-slate-900/70",children:[e.jsx(jt,{className:"text-sm font-medium text-slate-800 dark:text-slate-200",children:t("setting.attachmentSignature.unit")}),e.jsxs(Z,{value:I,onValueChange:o=>P(o),children:[e.jsx(ee,{className:"h-11 w-full bg-white/90 dark:border-slate-700 dark:bg-slate-900","aria-label":t("setting.attachmentSignature.unit"),children:e.jsx(te,{placeholder:t("setting.attachmentSignature.selectUnit")})}),e.jsx(ae,{children:r?e.jsx(f,{value:"loading",disabled:!0,children:t("setting.attachmentSignature.loadingUnits")}):V?h?.map(o=>e.jsx(f,{value:o.id,children:B(o.name)},o.id)):e.jsx(f,{value:"no-units",disabled:!0,children:t("setting.attachmentSignature.noUnitsFound")})})]}),e.jsx("p",{className:"text-xs text-slate-500 dark:text-slate-400",children:t("setting.attachmentSignature.unitHint")})]}),e.jsxs("div",{className:"space-y-3 rounded-xl border border-slate-200/80 bg-white/80 p-4 backdrop-blur-sm dark:border-slate-700 dark:bg-slate-900/70",children:[e.jsxs("div",{className:"flex items-start justify-between gap-3",children:[e.jsxs("div",{className:"space-y-1",children:[e.jsx(jt,{htmlFor:L,className:"text-sm font-medium text-slate-800 dark:text-slate-200",children:t("setting.attachmentSignature.toggleLabel")}),e.jsx("p",{id:`${L}-description`,className:"text-xs text-slate-500 dark:text-slate-400",children:t("setting.attachmentSignature.toggleDescription")})]}),e.jsx(g,{id:L,checked:M,onCheckedChange:ie,"aria-describedby":`${L}-description`,disabled:!D||G||J})]}),e.jsxs("div",{className:"flex items-center gap-2 rounded-lg border border-dashed border-slate-300/80 bg-slate-50/80 px-3 py-2 text-sm text-slate-600 dark:border-slate-700 dark:bg-slate-800/80 dark:text-slate-300",children:[e.jsx(ys,{className:"h-4 w-4 text-indigo-600 dark:text-indigo-400"}),e.jsxs("span",{children:[t("setting.attachmentSignature.status")," ",e.jsx("span",{className:"font-semibold",children:M?t("profile.enabled"):t("profile.disabled")})]})]}),!D&&!G&&e.jsx("p",{className:"text-xs text-amber-600 dark:text-amber-400",children:t("setting.noConfigFound")})]})]}),e.jsxs("footer",{className:"flex flex-wrap items-center gap-2 rounded-lg border border-slate-200/80 bg-white/70 px-3 py-2 text-xs text-slate-600 dark:border-slate-700 dark:bg-slate-900/70 dark:text-slate-300",children:[e.jsx(ts,{className:"h-3.5 w-3.5 text-slate-500 dark:text-slate-400"}),e.jsxs("span",{children:[t("setting.attachmentSignature.activeUnit")," ",e.jsx("strong",{className:"font-semibold",children:_?B(_):t("common.None")})]})]})]})})})},gn=()=>e.jsx("div",{className:"mx-auto w-full max-w-[1500px] space-y-6 p-4 sm:p-6",children:e.jsxs("div",{className:"grid grid-cols-1 gap-6 md:grid-cols-2",children:[e.jsx(Ts,{}),e.jsx(Ds,{}),e.jsx(As,{}),e.jsx(Ms,{})]})});export{gn as default}; diff --git a/apps/edr-freight-web/backoffice/public/_um/assets/ContentManagementPage-CLduruyz.js b/apps/edr-freight-web/backoffice/public/_um/assets/ContentManagementPage-CLduruyz.js new file mode 100644 index 000000000..9d283f100 --- /dev/null +++ b/apps/edr-freight-web/backoffice/public/_um/assets/ContentManagementPage-CLduruyz.js @@ -0,0 +1,259 @@ +import{y as nt,l as re,w as ae,m as _e,u as Ie,k as dt,n as Ee,o as Ne,r as c,j as e,v as w,B as J,I as ke,aS as lt,t as ge,b4 as He,b5 as La,az as At,F as mt,b6 as Rt,aJ as vr,aK as Qe,aL as Ge,aM as We,aN as Ye,b7 as jr,aR as sa,aF as lr,b8 as be,aP as _a,b9 as Da,ba as Ia,aU as $a,R as pe,aI as za,a as qa,d as Ba,bb as Xt,bc as Ua}from"./index-Db-xuq0b.js";import{C as Ke,a as it,b as ot,c as ia,d as pt}from"./card-BBWyxDss.js";import{L as Ce}from"./label-CsFy6wpo.js";import{p as oa}from"./presignedAxios-BunkFbUr.js";import{P as ct}from"./plus-BdjS2Pc-.js";import{T as la,a as ca,b as Ze,c as Ve,d as da,e as Te}from"./table-D3n3VABd.js";import{S as ft,a as ht,b as xt,c as yt,d as bt}from"./select-BoQxM42A.js";import{u as ua,C as Ha}from"./index.esm-BG4gweZJ.js";import{T as Ka,u as Va}from"./useTemplate-CcP413sN.js";import{S as et}from"./skeleton-CIiqp2Y5.js";import{A as Bt,a as Ut,b as Ht,c as Kt,d as Vt,e as Qt,f as Gt,g as Wt}from"./alert-dialog-B5Y0wlSz.js";import{E as Qa}from"./eye-Bhud4znU.js";import{S as Ga}from"./square-pen-B91TPB19.js";import{C as ma}from"./chevron-left-DCpEaNzo.js";import{p as Pe,u as Wa}from"./useUnitConfiguration-CKdECszp.js";import{P as wr}from"./pencil-BBczLcc3.js";import{u as Ya}from"./useUnit-C4s9nepK.js";import{u as Er}from"./useSettings-BtSYqn9W.js";import{M as Xa}from"./multi-select-C9K8HXhI.js";import{u as Ja}from"./usePosition-Y8Dhf3qk.js";import{S as Za}from"./single-select-D9ZG1edj.js";import{P as en,a as tn,b as rn}from"./popover-X-j_SRnG.js";import{B as cr}from"./badge-D7JvaQeJ.js";import{C as an,a as nn,b as sn,c as on,d as ln,e as cn}from"./command-Cr0tMDDu.js";import{C as dn}from"./circle-alert-Cd_zeQMw.js";import{A as un}from"./AdvancedTable-CC9ioMU-.js";import{u as mn,o as gn,s as Lt}from"./zod-Df58YiJ6.js";import{F as pn,a as Jt,b as Zt,c as er,d as tr,e as rr}from"./form-BeLK5rTt.js";import{f as fn}from"./format-DvwV82px.js";import{X as hn}from"./XSSUploadValidator-BMXKraj-.js";import{L as kr}from"./loader-mVRAFg0f.js";import{A as xn}from"./arrow-left-CE5-YfaQ.js";import{B as yn}from"./building-B2uZxFCD.js";import"./Utils-BP0IYDrC.js";import"./Skeleton-BJECajc_.js";import"./unitService-CmGVtFHQ.js";import"./organizationService-DPKKJMFw.js";import"./separator-BaOOgzZX.js";import"./positionService-JD0NEiGK.js";import"./Popover-CzANYrEa.js";import"./use-uncontrolled-C3HRHW6t.js";import"./search-CM5F2ZRy.js";import"./utils-BncSPdK1.js";import"./refresh-cw-JB7N413f.js";import"./en-US-Cc-9gH5A.js";/** + * @license lucide-react v0.513.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const bn=[["path",{d:"m20 13.7-2.1-2.1a2 2 0 0 0-2.8 0L9.7 17",key:"q6ojf0"}],["path",{d:"M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H19a1 1 0 0 1 1 1v18a1 1 0 0 1-1 1H6.5a1 1 0 0 1 0-5H20",key:"k3hazp"}],["circle",{cx:"10",cy:"8",r:"2",key:"2qkj4p"}]],vn=nt("book-image",bn);/** + * @license lucide-react v0.513.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const jn=[["path",{d:"m7 15 5 5 5-5",key:"1hf1tw"}],["path",{d:"m7 9 5-5 5 5",key:"sgt6xg"}]],wn=nt("chevrons-up-down",jn);/** + * @license lucide-react v0.513.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const kn=[["rect",{width:"8",height:"4",x:"8",y:"2",rx:"1",ry:"1",key:"tgr4d6"}],["path",{d:"M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2",key:"116196"}],["path",{d:"M9 12v-1h6v1",key:"iehl6m"}],["path",{d:"M11 17h2",key:"12w5me"}],["path",{d:"M12 11v6",key:"1bwqyc"}]],Nn=nt("clipboard-type",kn);/** + * @license lucide-react v0.513.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Sn=[["path",{d:"M13 7 8.7 2.7a2.41 2.41 0 0 0-3.4 0L2.7 5.3a2.41 2.41 0 0 0 0 3.4L7 13",key:"orapub"}],["path",{d:"m8 6 2-2",key:"115y1s"}],["path",{d:"m18 16 2-2",key:"ee94s4"}],["path",{d:"m17 11 4.3 4.3c.94.94.94 2.46 0 3.4l-2.6 2.6c-.94.94-2.46.94-3.4 0L11 17",key:"cfq27r"}],["path",{d:"M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z",key:"1a8usu"}],["path",{d:"m15 5 4 4",key:"1mk7zo"}]],Cn=nt("pencil-ruler",Sn);/** + * @license lucide-react v0.513.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Pn=[["rect",{width:"18",height:"18",x:"3",y:"3",rx:"2",key:"afitv7"}],["path",{d:"M17 12h-2l-2 5-2-10-2 5H7",key:"15hlnc"}]],Tn=nt("square-activity",Pn);/** + * @license lucide-react v0.513.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const En=[["path",{d:"M5 22h14",key:"ehvnwv"}],["path",{d:"M19.27 13.73A2.5 2.5 0 0 0 17.5 13h-11A2.5 2.5 0 0 0 4 15.5V17a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1v-1.5c0-.66-.26-1.3-.73-1.77Z",key:"1sy9ra"}],["path",{d:"M14 13V8.5C14 7 15 7 15 5a3 3 0 0 0-3-3c-1.66 0-3 1-3 3s1 2 1 3.5V13",key:"cnxgux"}]],ga=nt("stamp",En);/** + * @license lucide-react v0.513.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const An=[["path",{d:"M12.586 2.586A2 2 0 0 0 11.172 2H4a2 2 0 0 0-2 2v7.172a2 2 0 0 0 .586 1.414l8.704 8.704a2.426 2.426 0 0 0 3.42 0l6.58-6.58a2.426 2.426 0 0 0 0-3.42z",key:"vktsd0"}],["circle",{cx:"7.5",cy:"7.5",r:".5",fill:"currentColor",key:"kqv944"}]],Rn=nt("tag",An);/** + * @license lucide-react v0.513.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Mn=[["path",{d:"m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3",key:"wmoenq"}],["path",{d:"M12 9v4",key:"juzpu7"}],["path",{d:"M12 17h.01",key:"p32p05"}]],On=nt("triangle-alert",Mn),Je={createSeal:async t=>re.post("/seals",t,{headers:ae()}),getSeals:async()=>re.get("/seals",{headers:ae()}),getSeal:async t=>re.get(`/seals/${t}`,{headers:ae()}),getSealListsByUnitId:async t=>re.get(`/seals/list/${t}`,{headers:ae()}),updateSeal:async(t,r)=>re.put(`/seals/${t}`,r,{headers:ae()}),deleteSeal:async t=>re.delete(`/seals/${t}`,{headers:ae()}),changeSealStatus:async(t,r)=>re.put(`/seals/change-status/${t}`,r,{headers:ae()}),updateSealUploadStatus:async(t,r)=>re.patch(`/seals/${t}/upload-status`,r,{headers:ae()})},Fn=()=>{const t=_e(),{t:r}=Ie(),{handleError:a}=dt(r),{data:n,isLoading:s,isError:i,refetch:o}=Ee({queryKey:["seals"],queryFn:async()=>(await Je.getSeals()).data,enabled:!1}),d=x=>Ee({queryKey:["seal",x],queryFn:async()=>{if(x)return(await Je.getSealListsByUnitId(x)).data},enabled:!!x}),m=x=>Ee({queryKey:["seal",x],queryFn:async()=>{if(x)return(await Je.getSeal(x)).data},enabled:!!x}),l=Ne({mutationFn:async x=>(await Je.createSeal(x)).data,onSuccess:()=>{t.invalidateQueries({queryKey:["seals"]})},onError:x=>{a(x)}}),f=Ne({mutationFn:async({id:x,payload:v})=>(await Je.updateSeal(x,v)).data,onSuccess:()=>{t.invalidateQueries({queryKey:["seal"]})},onError:x=>{a(x)}}),y=Ne({mutationFn:async x=>{await Je.changeSealStatus(x,{isCurrent:!1})},onSuccess:()=>{t.invalidateQueries({queryKey:["seals"]})},onError:x=>{a(x)}}),g=Ne({mutationFn:async({id:x,updateSealStatusPayload:v})=>(await Je.updateSealUploadStatus(x,v)).data,onSuccess:()=>{t.invalidateQueries({queryKey:["seals"]})},onError:x=>{a(x)}});return{seals:(n==null?void 0:n.items)||[],totalSeals:(n==null?void 0:n.count)||0,isLoadingSeals:s,isSealsError:i,refetchSeals:o,getSealsByUnitId:d,getSealsById:m,createSeal:l.mutateAsync,isCreatingSeal:l.isPending,updateSeal:f.mutate,isUpdatingSeal:f.isPending,deleteSeal:y.mutate,isDeletingSeal:y.isPending,updateSealStatus:g.mutateAsync,isUpdatingStatus:g.isPending}},Ln=({unitId:t})=>{const{createSeal:r,isCreatingSeal:a,isLoadingSeals:n,deleteSeal:s,getSealsByUnitId:i}=Fn(),[o,d]=c.useState(),[m,l]=c.useState([]),f=c.useRef(null),y=_e(),[g,x]=c.useState(0),[v,p]=c.useState(!1),[j,E]=c.useState(""),[b,k]=c.useState(""),N=1*1024*1024,{data:u}=i(t);c.useEffect(()=>{let $=!0;return(async()=>{if(!(u!=null&&u.items))return;const _=u.items.filter(W=>W.uploadedSuccessfully&&W.isCurrent),X=(await Promise.all(_?.map(async W=>{try{const ie=(await Je.getSeal(W.id)).data.presigned;return ie?{type:"seal",file:new File([],W.fileInfo.fileName),url:ie,uploadedAt:new Date(W.createdAt),id:W.id}:null}catch{return null}}))).filter(W=>W!==null);$&&l(X)})(),()=>{$=!1,m.forEach(_=>{!_.id&&_.url.startsWith("blob:")&&URL.revokeObjectURL(_.url)})}},[u==null?void 0:u.items]);const C=$=>{if($.type!=="image/png"){ge.error(w("contentManagement.onlyPngAllowed"));return}if($.size>N){ge.error("Image size should be less than 1 MB");return}const F=URL.createObjectURL($),_=new Image;_.onload=()=>{if(_.width!==_.height){ge.error(w("contentManagement.onlySquareAllowed")),URL.revokeObjectURL(F);return}d($),E(""),k(""),l([{type:"seal",url:F,file:$,uploadedAt:new Date,id:null}])},_.onerror=()=>{ge.error(w("contentManagement.invalidImage")),URL.revokeObjectURL(F)},_.src=F},M=async()=>{if(!o){ge.error(w("contentManagement.noFileSelected"));return}if(!j.trim()){k(w("contentManagement.sealNameMsg"));return}try{const $={fileInfo:{fileName:o.name,contentType:o.type,size:o.size,originalname:o.name},name:{am:j,en:j},unitId:t},F=await r($);p(!0),x(0),await oa.put(F.presigned,o,{headers:{"Content-Type":o.type}}),p(!1),x(100),d(void 0),E(""),k(""),f.current&&(f.current.value=""),y.invalidateQueries({queryKey:["seal"]}),ge.success(w("contentManagement.sealSuccessMsg"))}catch{ge.error(w("contentManagement.sealErrorMsg")),p(!1),x(0),d(void 0)}},I=async $=>{if($)try{await s($),l(F=>F.filter(_=>_.id!==$)),ge.success(w("contentManagement.sealRemove"))}catch{ge.error(w("contentManagement.sealRemove"))}},se=$=>new Intl.DateTimeFormat("en-US",{hour:"numeric",minute:"numeric",hour12:!0,month:"short",day:"numeric"}).format($),Y=a||v;return e.jsxs(Ke,{className:"dark:border-gray-700 dark:bg-gray-800",children:[e.jsxs(it,{children:[e.jsxs(ot,{className:"flex items-center dark:text-white",children:[e.jsx(ga,{className:"h-5 w-5 mr-2"}),w("contentManagement.seal")]}),e.jsxs(ia,{className:"dark:text-gray-400",children:[w("contentManagement.uploadSealMsg")," (PNG, max 1MB)"]})]}),e.jsxs(pt,{className:"space-y-4",children:[e.jsxs("div",{className:"flex justify-between items-center",children:[e.jsx(Ce,{htmlFor:"seal",className:"dark:text-gray-200",children:w("contentManagement.sealImage")}),e.jsxs(J,{variant:"outline",className:"bg-primary hover:bg-primary/90 text-primary-foreground",onClick:()=>{var $;return($=f.current)==null?void 0:$.click()},disabled:Y,children:[e.jsx(ct,{className:"h-4 w-4 mr-2"}),w("contentManagement.addSeal")]})]}),e.jsx(ke,{ref:f,type:"file",accept:".png,image/png",className:"hidden",onChange:$=>{var F;return((F=$.target.files)==null?void 0:F[0])&&C($.target.files[0])},disabled:Y}),m.length>0?e.jsx("div",{className:"grid grid-cols-1 sm:grid-cols-2 gap-3",children:m?.map($=>e.jsxs("div",{className:"border rounded-lg p-3 bg-gray-50 dark:bg-gray-700 dark:border-gray-600",children:[e.jsxs("div",{className:"flex justify-between items-center mb-2",children:[e.jsx("span",{className:"text-sm text-muted-foreground dark:text-gray-400",children:se($==null?void 0:$.uploadedAt)}),e.jsx(J,{variant:"ghost",size:"sm",onClick:()=>I(($==null?void 0:$.id)||null),className:"text-red-500 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-900/30",disabled:n,children:e.jsx(lt,{className:"h-4 w-4 text-red-500"})})]}),e.jsx("img",{src:$.url,alt:"Seal",className:"max-h-32 mx-auto object-contain"})]},($==null?void 0:$.id)||($==null?void 0:$.url)))}):e.jsxs("div",{className:"text-center p-4 border border-dashed rounded-lg dark:border-gray-600 dark:bg-gray-700/30",children:[e.jsx("p",{className:"text-muted-foreground dark:text-gray-400",children:w("contentManagement.noSealMsg")}),e.jsx("p",{className:"text-sm text-muted-foreground dark:text-gray-500 mt-1",children:w("contentManagement.addSealInstruction")})]}),o&&e.jsxs("div",{className:"space-y-2",children:[e.jsx(Ce,{htmlFor:"sealName",className:"dark:text-gray-200",children:w("contentManagement.sealName")}),e.jsx(ke,{value:j,onChange:$=>{E($.target.value),k("")},placeholder:"Enter seal name",className:"dark:bg-gray-700 dark:border-gray-600 dark:text-white"}),b&&e.jsx("p",{className:"text-sm text-red-500",children:b})]}),o&&e.jsxs(J,{className:"w-full bg-primary hover:bg-primary/90",onClick:M,disabled:Y||!o,children:[a&&"Creating seal...",v&&`Uploading ${g}%`,!Y&&w("contentManagement.saveSeal")]})]})]})},_n=async(t,r)=>He.get(`/record-templates/list/${t}`,{headers:ae(),params:r}),Dn=async t=>He.get(`/record-templates/${t}`,{headers:ae()}),In=async t=>He.post("/record-templates",t,{headers:ae()}),$n=async(t,r)=>He.put(`/record-templates/${t}`,r,{headers:ae()}),zn=async t=>He.delete(`/record-templates/${t}`,{headers:ae()}),qn=(t,r={})=>{const a=_e(),{skip:n=0,take:s=20,orderBy:i="createdAt:DESC",order:o,enabled:d=!0}=r,m={skip:n,take:s,orderBy:i,...o?{order:o}:{}},{data:l,isLoading:f,isFetching:y,isError:g,refetch:x}=Ee({queryKey:["letter-templates",t,m],queryFn:async()=>{const{data:C}=await _n(t,m);return C},enabled:!!t&&d,staleTime:300*1e3,retry:!1,placeholderData:La}),{mutate:v,isPending:p}=Ne({mutationFn:async C=>{const{data:M}=await Dn(C);return M}}),{mutate:j,isPending:E}=Ne({mutationFn:async C=>{const{data:M}=await In(C);return M},onSuccess:(C,M)=>{a.invalidateQueries({queryKey:["letter-templates",M.unitId]})}}),{mutate:b,isPending:k}=Ne({mutationFn:async C=>{const{data:M}=await $n(C.id,C.data);return M},onSuccess:(C,M)=>{a.invalidateQueries({queryKey:["letter-templates",M.data.unitId]}),a.invalidateQueries({queryKey:["letter-template",M.id]})}}),{mutate:N,isPending:u}=Ne({mutationFn:async({id:C})=>{await zn(C)},onSuccess:()=>{a.invalidateQueries({queryKey:["letter-templates",t]})}});return{letterTemplatesResponse:l,items:(l==null?void 0:l.items)??[],count:(l==null?void 0:l.count)??0,isLoading:f,isFetching:y,isError:g,refetch:x,getTemplateByDetails:v,isFetchingTemplate:p,createLetterTemplate:j,isCreating:E,updateLetterTemplate:b,isUpdating:k,deleteLetterTemplate:N,isDeleting:u}},Bn={signature:{uploadKey:"/signatures/get-file-upload-key",create:"/signatures",parentField:"employeeId"},stamp:{uploadKey:"/employee-stamps/get-file-upload-key",create:"/employee-stamps",parentField:"employeePositionId"},header:{uploadKey:"/headers/get-file-upload-key",create:"/headers",parentField:"unitId"},footer:{uploadKey:"/footers/get-file-upload-key",create:"/footers",parentField:"unitId"}};async function Un(t,r,a,n,s){const i={fileName:r.name,originalname:r.name,contentType:r.type||"application/octet-stream",size:r.size,...n!=null&&n.length?{positionId:n}:{},recordTypeKey:s},{data:o}=await a.post(t,i,{headers:ae()});return o}async function Hn(t,r){if(!r)throw new Error("Presigned URL is required");const a=await oa.put(r,t,{headers:{"Content-Type":t.type}});if(a.status<200||a.status>=300)throw new Error(`Upload failed with status ${a.status}: ${a.statusText}`)}async function Ar(t,r,a,n){const{type:s,file:i,name:o,parentId:d}=t,m=Bn[s],l=await Un(m.uploadKey,i,r,a,n),f=l.presignedUrl||l.presigned;if(await Hn(i,f||""),s==="header"||s==="footer"){const x={fileInfo:l.fileInfo,name:o,[m.parentField]:d,positionIds:a,isCurrent:!0,recordTypeKey:n},{data:v}=await r.post(m.create,x,{headers:ae()});return v}const y={fileInfo:l.fileInfo,name:o,[m.parentField]:d,...s==="signature"?{isCurrent:!0}:{}},{data:g}=await r.post(m.create,y,{headers:ae()});return g}const Me={uploadAndCreateHeader:(t,r,a,n,s)=>Ar({type:"header",file:t,name:{am:r,en:r},parentId:a},re,n,s),uploadAndCreateFooter:(t,r,a,n,s)=>Ar({type:"footer",file:t,name:{am:r,en:r},parentId:a},re,n,s),createHeader:async t=>re.post("/headers",t,{headers:ae()}),getHeaders:async()=>re.get("/headers",{headers:ae()}),getHeadersByUnitId:async(t,r,a)=>re.get(`/headers/list/${t}?take=100${r?`&positionId=${r}`:""}${a?`&recordTypeKey=${a}`:""}`,{headers:ae()}),getHeaderById:async t=>re.get(`/headers/${t}`,{headers:ae()}),updateHeader:async(t,r)=>re.put(`/headers/${t}`,r,{headers:ae()}),deleteHeader:async t=>re.delete(`/headers/${t}`,{headers:ae()}),updateHeaderUploadStatus:async(t,r)=>re.patch(`/headers/${t}/upload-status`,r,{headers:ae()}),changeHeaderStatus:async(t,r)=>re.put(`/headers/change-status/${t}`,r,{headers:ae()}),createFooter:async t=>re.post("/footers",t,{headers:ae()}),getFooters:async()=>re.get("/footers",{headers:ae()}),getFootersByUnitId:async(t,r,a)=>re.get(`/footers/list/${t}?take=300${r?`&positionId=${r}`:""}${a?`&recordTypeKey=${a}`:""}`,{headers:ae()}),getFooterById:async t=>re.get(`/footers/${t}`,{headers:ae()}),updateFooter:async(t,r)=>re.put(`/footers/${t}`,r,{headers:ae()}),deleteFooter:async t=>re.delete(`/footers/${t}`,{headers:ae()}),updateFooterUploadStatus:async(t,r)=>re.patch(`/footers/${t}/upload-status`,r,{headers:ae()}),changeFooterStatus:async(t,r)=>re.put(`/footers/change-status/${t}`,r,{headers:ae()})},pa=(t,r,a)=>Ee({queryKey:["headers-footers",t,r,a],queryFn:async()=>{const[n,s]=await Promise.all([Me.getHeadersByUnitId(t,r,a),Me.getFootersByUnitId(t,r,a)]),i=n.data.items.filter(f=>f.isCurrent&&f.uploadedSuccessfully),o=s.data.items.filter(f=>f.isCurrent&&f.uploadedSuccessfully),d=async(f,y)=>Promise.all(f?.map(async g=>{try{const x=y==="header"?await Me.getHeaderById(g.id):await Me.getFooterById(g.id);return{...g,presigned:x.data.presigned}}catch{return g}})),m=await d(i,"header"),l=await d(o,"footer");return{headers:m,footers:l}},enabled:!!t,staleTime:600*1e3}),Rr=({unitId:t,template:r,isSubmitting:a,onCancel:n,onSubmitCreate:s})=>{var g,x,v,p;const{register:i,handleSubmit:o,reset:d,control:m,formState:{errors:l}}=ua({defaultValues:{name:{en:((g=r==null?void 0:r.name)==null?void 0:g.en)??"",am:((x=r==null?void 0:r.name)==null?void 0:x.am)??""},key:(r==null?void 0:r.key)??"",subject:(r==null?void 0:r.subject)??"",body:(r==null?void 0:r.body)??"",sincerelyText:(r==null?void 0:r.sincerelyText)??"",unitId:t}}),{data:f}=pa(t);c.useMemo(()=>(f==null?void 0:f.headers)||[],[f==null?void 0:f.headers]),c.useMemo(()=>(f==null?void 0:f.footers)||[],[f==null?void 0:f.footers]);const y=j=>{s(j),r||d()};return e.jsxs("form",{onSubmit:o(y),className:"bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-lg p-8 shadow-sm space-y-8",children:[e.jsxs("div",{className:"grid grid-cols-1 md:grid-cols-2 gap-6",children:[e.jsxs("div",{children:[e.jsx("label",{className:"block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1",children:"Template Key"}),e.jsx(ke,{placeholder:"Template Key",...i("key",{required:"Template key is required"})}),l.key&&e.jsx("p",{className:"text-red-500 text-sm mt-1",children:l.key.message})]}),e.jsxs("div",{children:[e.jsx("label",{className:"block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1",children:"Subject"}),e.jsx(ke,{placeholder:"Subject",...i("subject",{required:"Subject is required"})}),l.subject&&e.jsx("p",{className:"text-red-500 text-sm mt-1",children:l.subject.message})]}),e.jsxs("div",{children:[e.jsx("label",{className:"block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1",children:"English Name"}),e.jsx(ke,{placeholder:"English Name",...i("name.en",{required:"English name is required"})}),((v=l.name)==null?void 0:v.en)&&e.jsx("p",{className:"text-red-500 text-sm mt-1",children:l.name.en.message})]}),e.jsxs("div",{children:[e.jsx("label",{className:"block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1",children:"Amharic Name"}),e.jsx(ke,{placeholder:"Amharic Name",...i("name.am",{required:"Amharic name is required"})}),((p=l.name)==null?void 0:p.am)&&e.jsx("p",{className:"text-red-500 text-sm mt-1",children:l.name.am.message})]})]}),e.jsxs("div",{children:[e.jsx("label",{className:"block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1",children:"Body"}),e.jsx(Ha,{name:"body",control:m,rules:{required:!0},render:({field:j,fieldState:E})=>e.jsxs(e.Fragment,{children:[e.jsx(Ka,{value:j.value,onEditorChange:j.onChange,placeholders:[{key:"delegatorName",label:"Delegator Name"},{key:"delegatorDepartment",label:"Delegator Department"},{key:"delegateeName",label:"Delegatee Name"},{key:"delegateeDepartment",label:"Delegatee Department"},{key:"startDate",label:"Start Date"},{key:"endDate",label:"End Date"},{key:"startDateTime",label:"Start Date & Time"},{key:"endDateTime",label:"End Date & Time"}]}),E.invalid&&e.jsx("p",{className:"text-red-500 text-sm mt-1",children:"Body is required"})]})})]}),e.jsxs("div",{children:[e.jsx("label",{className:"block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1",children:"Sincerely Text"}),e.jsx(ke,{placeholder:"Sincerely Text",...i("sincerelyText")})]}),e.jsxs("div",{className:"flex gap-2 pt-4 justify-end",children:[e.jsx(J,{type:"submit",disabled:a,children:a?"Saving...":r?"Update":"Save"}),e.jsx(J,{type:"button",variant:"outline",onClick:n,children:"Cancel"})]})]})},Kn=({unitId:t})=>{var ze,Se,H,ue;const{t:r}=Ie(),{handleError:a}=dt(r),[n,s]=c.useState(0),[i,o]=c.useState(10),{letterTemplatesResponse:d,count:m,isLoading:l,isFetching:f,isError:y,refetch:g,createLetterTemplate:x,updateLetterTemplate:v,deleteLetterTemplate:p,isCreating:j,isUpdating:E,isDeleting:b}=qn(t,{skip:n*i,take:i}),k=Math.max(1,Math.ceil(m/i)),{templates:N,isLoading:u,adoptTemplate:C,isAdoptingTemplate:M}=Va(),{data:I}=Ee({queryKey:["headers",t],queryFn:()=>Me.getHeadersByUnitId(t)}),{data:se}=Ee({queryKey:["footers",t],queryFn:()=>Me.getFootersByUnitId(t)}),Y=At(),[$,F]=c.useState(!1),[_,ne]=c.useState(!1),[X,W]=c.useState(!1),[xe,ie]=c.useState(!1),[Ae,je]=c.useState(!1),[z,O]=c.useState(null),[R,L]=c.useState(""),[T,h]=c.useState(""),U=z?Y(z.name):"",P=l||u,Z=y,ee=Array.isArray(N)?N:(N==null?void 0:N.items)||[],te=(d==null?void 0:d.items)||[],q=[...ee?.map(D=>({...D,isGlobal:!0})),...te?.map(D=>({...D,isGlobal:!1}))],de=D=>{const we=D.target;we!=null&&we.closest(".tox-tinymce-aux, .moxman-window, .tam-assetmanager-root")&&D.preventDefault()},fe=D=>{x(D,{onSuccess:we=>{ge.success(`${Y(we.name)} ${r("contentManagement.templateSuccessMsg")}`),g(),F(!1)},onError:we=>{a(we)}})},Q=D=>{z&&v({id:z.id,data:D},{onSuccess:()=>{ge.success(r("contentManagement.updateTemplate")),g(),ne(!1),O(null)},onError:we=>{a(we)}})},le=()=>{z&&p({id:z.id},{onSuccess:()=>{ge.success(r("contentManagement.deleteTemplate")),g(),ie(!1),O(null)},onError:D=>{a(D)}})},G=()=>{if(!z||!R||!T){ge.error(r("contentManagement.pleaseSelectHeaderAndFooter","Please select header and footer"));return}C({templateId:z.id,headerId:R,footerId:T},{onSuccess:()=>{ge.success(r("contentManagement.adoptSuccess","Successfully adopted global template")),g(),je(!1),O(null),L(""),h("")},onError:D=>{a(D)}})},oe=D=>{O(D),ne(!0)},Re=D=>{O(D),W(!0)},ye=D=>{O(D),ie(!0)},$e=D=>{O(D),je(!0)};return P?e.jsxs(Ke,{children:[e.jsx(it,{children:e.jsxs(ot,{className:"flex items-center",children:[e.jsx(mt,{className:"h-5 w-5 mr-2"}),r("contentManagement.letterTemplate")]})}),e.jsx(pt,{children:e.jsx("div",{className:"space-y-3",children:Array(3).fill(0)?.map((D,we)=>e.jsx(et,{className:"h-12 w-full"},we))})})]}):Z?e.jsxs(Ke,{children:[e.jsx(it,{children:e.jsxs(ot,{className:"flex items-center",children:[e.jsx(mt,{className:"h-5 w-5 mr-2"}),r("contentManagement.letterTemplate")]})}),e.jsx(pt,{children:e.jsx("p",{className:"text-sm text-red-600 dark:text-red-400",children:r("contentManagement.failedToLoadTemplates")})})]}):e.jsxs(e.Fragment,{children:[e.jsxs(Ke,{children:[e.jsx(it,{children:e.jsxs("div",{className:"flex flex-col sm:flex-row sm:items-center justify-between gap-4",children:[e.jsxs("div",{children:[e.jsxs(ot,{className:"flex items-center",children:[e.jsx(mt,{className:"h-5 w-5 mr-2"}),r("contentManagement.letterTemplate")]}),e.jsx("p",{className:"text-sm text-muted-foreground mt-1",children:r("contentManagement.createMsg")})]}),e.jsxs(J,{onClick:()=>F(!0),size:"sm",className:"shrink-0",children:[e.jsx(ct,{className:"h-4 w-4 mr-1"}),r("contentManagement.addTemplate")]})]})}),e.jsx(pt,{children:q.length===0?e.jsxs("div",{className:"text-center py-8",children:[e.jsx(mt,{className:"mx-auto mb-4 h-12 w-12 text-gray-400 dark:text-gray-500"}),e.jsx("p",{className:"text-gray-500 dark:text-gray-400",children:r("contentManagement.noTemplates")})]}):e.jsxs("div",{className:"overflow-x-auto",children:[e.jsxs(la,{children:[e.jsx(ca,{children:e.jsxs(Ze,{children:[e.jsx(Ve,{className:"min-w-[150px]",children:r("common.name")}),e.jsx(Ve,{className:"min-w-[200px] hidden md:table-cell",children:r("contentManagement.sincerelyText")}),e.jsx(Ve,{className:"min-w-[120px] hidden sm:table-cell",children:r("common.createdDate")}),e.jsx(Ve,{className:"text-right min-w-[120px]",children:r("common.actions")})]})}),e.jsx(da,{children:q?.map(D=>e.jsxs(Ze,{className:D.isGlobal?"bg-blue-50/50 hover:bg-blue-50 dark:bg-blue-950/30 dark:hover:bg-blue-900/40":"",children:[e.jsx(Te,{className:"font-medium",children:e.jsxs("div",{className:"flex flex-col",children:[e.jsxs("span",{className:"flex items-center gap-2",children:[Y(D.name),D.isGlobal&&e.jsx("span",{className:"rounded bg-blue-100 px-2 py-0.5 text-[10px] font-semibold text-blue-700 dark:bg-blue-900/50 dark:text-blue-200",children:"Global"})]}),e.jsx("span",{className:"text-xs text-gray-500 dark:text-gray-400 md:hidden",children:D.sincerelyText&&D.sincerelyText.length>30?`${D.sincerelyText.substring(0,30)}...`:D.sincerelyText||r("common.notAvailable")})]})}),e.jsx(Te,{className:"max-w-xs truncate hidden md:table-cell",children:D.sincerelyText||r("common.notAvailable")}),e.jsx(Te,{className:"hidden sm:table-cell",children:D.createdAt?new Date(D.createdAt).toLocaleDateString():r("common.notAvailable")}),e.jsx(Te,{className:"text-right",children:e.jsxs("div",{className:"flex items-center justify-end gap-1",children:[e.jsx(J,{variant:"ghost",size:"sm",onClick:()=>Re(D),title:r("contentManagement.viewTemplate"),children:e.jsx(Qa,{className:"h-4 w-4"})}),D.isGlobal?e.jsx(J,{variant:"outline",size:"sm",onClick:()=>$e(D),title:r("contentManagement.adoptTemplate","Adopt Template"),className:"h-8 border-blue-200 px-3 text-blue-700 hover:bg-blue-100 hover:text-blue-800 dark:border-blue-700/60 dark:text-blue-300 dark:hover:bg-blue-900/40 dark:hover:text-blue-200",children:r("common.adopt","Adopt")}):e.jsxs(e.Fragment,{children:[e.jsx(J,{variant:"ghost",size:"sm",onClick:()=>oe(D),title:r("contentManagement.editTemplate"),children:e.jsx(Ga,{className:"h-4 w-4"})}),e.jsx(J,{variant:"ghost",size:"sm",onClick:()=>ye(D),className:"text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300",title:r("contentManagement.deleteTemplate"),children:e.jsx(Rt,{className:"h-4 w-4"})})]})]})})]},D.id))})]}),e.jsxs("div",{className:"mt-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between",children:[e.jsxs("div",{className:"text-sm text-muted-foreground",children:[r("common.page","Page")," ",n+1," / ",k,m>0&&e.jsxs(e.Fragment,{children:[" ","• ",m," ",r("contentManagement.letterTemplate","templates")]})]}),e.jsxs("div",{className:"flex items-center gap-2",children:[e.jsxs(ft,{value:String(i),onValueChange:D=>{o(Number(D)),s(0)},children:[e.jsx(ht,{className:"h-8 w-[80px]",children:e.jsx(xt,{})}),e.jsx(yt,{children:[10,20,50,100]?.map(D=>e.jsx(bt,{value:String(D),children:D},D))})]}),e.jsxs(J,{variant:"outline",size:"sm",disabled:n===0||f,onClick:()=>s(D=>Math.max(0,D-1)),children:[e.jsx(ma,{className:"h-4 w-4"}),r("common.previous","Previous")]}),e.jsxs(J,{variant:"outline",size:"sm",disabled:n+1>=k||f,onClick:()=>s(D=>D+1),children:[r("common.next","Next"),e.jsx(vr,{className:"h-4 w-4"})]})]})]})]})})]}),e.jsx(Qe,{open:$,onOpenChange:F,children:e.jsxs(Ge,{className:"max-h-[90vh] w-full max-w-[95vw] overflow-y-auto lg:max-w-[1200px] dark:border-gray-700 dark:bg-gray-900",onInteractOutside:de,children:[e.jsx(We,{children:e.jsx(Ye,{children:r("contentManagement.createTemplate")})}),e.jsx(Rr,{unitId:t,isSubmitting:j,onCancel:()=>F(!1),onSubmitCreate:fe})]})}),e.jsx(Qe,{open:_,onOpenChange:ne,children:e.jsxs(Ge,{className:"max-h-[90vh] w-full max-w-[95vw] overflow-y-auto lg:max-w-[1200px] dark:border-gray-700 dark:bg-gray-900",onInteractOutside:de,children:[e.jsx(We,{children:e.jsx(Ye,{children:r("contentManagement.editTemplate")})}),e.jsx(Rr,{unitId:t,template:z,isSubmitting:E,onCancel:()=>{ne(!1),O(null)},onSubmitCreate:Q})]})}),e.jsx(Qe,{open:X,onOpenChange:W,children:e.jsxs(Ge,{className:"max-h-[90vh] w-full max-w-[95vw] overflow-y-auto lg:max-w-[1200px] dark:border-gray-700 dark:bg-gray-900",children:[e.jsx(We,{children:e.jsx(Ye,{children:r("contentManagement.viewTemplate")})}),z&&e.jsxs("div",{className:"space-y-4",children:[e.jsxs("div",{children:[e.jsx("label",{className:"text-sm font-medium text-gray-600 dark:text-gray-300",children:r("common.name")}),e.jsx("p",{className:"mt-1 text-sm text-gray-900 dark:text-gray-100",children:Y(z.name)})]}),e.jsxs("div",{children:[e.jsx("label",{className:"text-sm font-medium text-gray-600 dark:text-gray-300",children:r("contentManagement.sincerelyText")}),e.jsx("p",{className:"mt-1 text-sm text-gray-900 dark:text-gray-100",children:z.sincerelyText||r("common.notAvailable")})]}),e.jsxs("div",{children:[e.jsx("label",{className:"text-sm font-medium text-gray-600 dark:text-gray-300",children:r("contentManagement.body")}),e.jsx("div",{className:"mt-1 max-h-60 overflow-y-auto rounded-md border bg-gray-50 p-3 text-sm text-gray-900 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100",dangerouslySetInnerHTML:{__html:z.body}})]}),e.jsx("div",{className:"flex justify-end",children:e.jsx(J,{onClick:()=>W(!1),children:r("common.close")})})]})]})}),e.jsx(Qe,{open:Ae,onOpenChange:je,children:e.jsxs(Ge,{className:"max-w-md dark:border-gray-700 dark:bg-gray-900",children:[e.jsx(We,{children:e.jsx(Ye,{children:r("contentManagement.adoptTemplate","Adopt Template")})}),e.jsxs("div",{className:"space-y-4 py-4",children:[e.jsxs("div",{children:[e.jsx("label",{className:"mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300",children:r("contentManagement.selectHeader","Select Header")}),e.jsxs(ft,{value:R,onValueChange:L,children:[e.jsx(ht,{children:e.jsx(xt,{placeholder:r("contentManagement.selectHeader","Select Header")})}),e.jsx(yt,{children:(Se=(ze=I==null?void 0:I.data)==null?void 0:ze.items)==null?void 0:Se?.map(D=>e.jsx(bt,{value:D.id,children:Y(D.name)},D.id))})]})]}),e.jsxs("div",{children:[e.jsx("label",{className:"mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300",children:r("contentManagement.selectFooter","Select Footer")}),e.jsxs(ft,{value:T,onValueChange:h,children:[e.jsx(ht,{children:e.jsx(xt,{placeholder:r("contentManagement.selectFooter","Select Footer")})}),e.jsx(yt,{children:(ue=(H=se==null?void 0:se.data)==null?void 0:H.items)==null?void 0:ue?.map(D=>e.jsx(bt,{value:D.id,children:Y(D.name)},D.id))})]})]})]}),e.jsxs("div",{className:"flex justify-end gap-2",children:[e.jsx(J,{variant:"outline",onClick:()=>je(!1),children:r("common.cancel")}),e.jsx(J,{onClick:G,disabled:M||!R||!T,children:M?r("common.adopting","Adopting..."):r("common.adopt","Adopt")})]})]})}),e.jsx(Bt,{open:xe,onOpenChange:ie,children:e.jsxs(Ut,{children:[e.jsxs(Ht,{children:[e.jsx(Kt,{children:r("contentManagement.deleteTemplate")}),e.jsx(Vt,{children:r("contentManagement.deleteTemplateConfirm",{name:U}).replace("{{name}}",U).replace("{name}",U)})]}),e.jsxs(Qt,{children:[e.jsx(Gt,{children:r("common.cancel")}),e.jsx(Wt,{onClick:le,disabled:b,className:"bg-red-600 hover:bg-red-700",children:r(b?"common.deleting":"common.delete")})]})]})})]})},Vn=({activities:t})=>{const r=a=>new Intl.DateTimeFormat("en-US",{hour:"numeric",minute:"numeric",hour12:!0,month:"short",day:"numeric"}).format(a);return e.jsxs(Ke,{className:"bg-gradient-to-br from-gray-50 to-slate-50 dark:from-gray-800 dark:to-gray-900 dark:border-gray-700",children:[e.jsxs(it,{children:[e.jsxs(ot,{className:"flex items-center dark:text-white",children:[e.jsx(mt,{className:"h-5 w-5 mr-2"}),"Recent Activities"]}),e.jsx(ia,{className:"dark:text-gray-400",children:"Recent content management activities"})]}),e.jsx(pt,{children:e.jsx("div",{className:"space-y-4",children:t.length===0?e.jsx("p",{className:"text-sm text-muted-foreground dark:text-gray-400",children:"No recent activities."}):t?.map(({id:a,type:n,description:s,timestamp:i})=>e.jsxs("div",{className:"flex justify-between items-center",children:[e.jsx("span",{className:"text-sm font-medium capitalize dark:text-white",children:n}),e.jsx("span",{className:"text-sm dark:text-gray-300",children:s}),e.jsx("span",{className:"text-xs text-muted-foreground dark:text-gray-400",children:r(i)})]},a))})})]})},Qn=(t,r)=>Ee({queryKey:["remark",t,r],queryFn:async()=>(await Pe.getRemarkList(t,r)).data}),Gn=({unitId:t})=>{const[r,a]=c.useState(""),[n,s]=c.useState(""),[i,o]=c.useState(null),[d,m]=c.useState(!1),[l,f]=c.useState(!1),[y,g]=c.useState(null),[x,v]=c.useState(""),p=_e(),{data:j,isLoading:E}=Qn(t,{skip:0,take:300}),b=Ne({mutationFn:async()=>{if(!r)throw new Error(w("contentManagement.remarkRequired"));const M={unitId:t,remark:r,description:n};return i?Pe.editRemark(i,M):Pe.createRemark(M)},onSuccess:()=>{p.invalidateQueries({queryKey:["remark",t]}),N(),m(!1)},onError:M=>v(M.message||w("contentManagement.failed"))}),k=Ne({mutationFn:M=>Pe.deleteRemarks(M),onSuccess:()=>{p.invalidateQueries({queryKey:["remark",t]}),f(!1),g(null)},onError:()=>v(w("contentManagement.failedToDelete"))}),N=()=>{a(""),s(""),o(null),v("")},u=M=>{o(M.id),a(M.remark),s(M.description||""),m(!0)},C=()=>b.mutate();return e.jsxs(Ke,{className:"p-4 border shadow-sm space-y-4 dark:border-gray-700 dark:bg-gray-800",children:[e.jsxs(Qe,{open:d,onOpenChange:m,children:[e.jsx(jr,{asChild:!0,children:e.jsxs(J,{className:"w-[180px] bg-primary hover:bg-primary/90 text-primary-foreground flex items-center",children:[e.jsx(ct,{className:"h-4 w-4 mr-2"}),w("contentManagement.addRemark")]})}),e.jsxs(Ge,{className:"sm:max-w-xl w-full dark:bg-gray-800",children:[e.jsx(We,{children:e.jsx(Ye,{className:"dark:text-white",children:i?w("contentManagement.editRemark"):w("contentManagement.addRemark")})}),e.jsxs("div",{className:"grid gap-4 py-4",children:[e.jsxs("div",{className:"flex flex-col",children:[e.jsx(Ce,{htmlFor:"remark",className:"mb-1 dark:text-gray-200",children:w("header.Remark")}),e.jsx(ke,{id:"remark",value:r,onChange:M=>a(M.target.value),placeholder:w("header.Remark"),disabled:E,className:"w-full dark:bg-gray-700 dark:border-gray-600 dark:text-white"}),e.jsx(Ce,{htmlFor:"description",className:"mb-1 dark:text-gray-200",children:w("header.Description")}),e.jsx(ke,{id:"description",value:n,onChange:M=>s(M.target.value),placeholder:w("header.Description"),disabled:E,className:"w-full mb-2 dark:bg-gray-700 dark:border-gray-600 dark:text-white"})]}),x&&e.jsx("p",{className:"text-sm text-red-500",children:x})]}),e.jsxs("div",{className:"flex justify-end gap-2 pt-4 border-t border-gray-200 dark:border-gray-700",children:[e.jsx(J,{variant:"outline",onClick:()=>{N(),m(!1)},children:w("common.Cancel")}),e.jsx(J,{onClick:C,disabled:E,className:"bg-primary hover:bg-primary/90 text-primary-foreground",children:E?w("contentManagement.saving"):i?w("delegation.update"):w("delegation.save")})]})]})]}),e.jsx("h3",{className:"font-semibold text-md dark:text-white",children:w("contentManagement.commonRemarks")}),E?e.jsx("div",{className:"text-center py-4 dark:text-gray-400",children:w("contentManagement.loading")}):e.jsx("div",{className:"overflow-x-auto max-h-[380px] overflow-y-auto border rounded dark:border-gray-600",children:e.jsxs("table",{className:"min-w-full text-sm text-left",children:[e.jsx("thead",{children:e.jsxs("tr",{className:"border-b bg-gray-100 dark:bg-gray-700",children:[e.jsx("th",{className:"px-3 py-2 dark:text-white",children:"#"}),e.jsx("th",{className:"px-3 py-2 dark:text-white",children:w("header.Remark")}),e.jsx("th",{className:"px-3 py-2 dark:text-white",children:w("header.Description")}),e.jsx("th",{className:"px-3 py-2 dark:text-white",children:w("userRecord.Actions")})]})}),e.jsx("tbody",{children:j!=null&&j.items.length?j.items?.map((M,I)=>e.jsxs("tr",{className:"border-b hover:bg-gray-50 dark:hover:bg-gray-700 dark:border-gray-600",children:[e.jsx("td",{className:"px-3 py-2 dark:text-gray-300",children:I+1}),e.jsx("td",{className:"px-3 py-2 dark:text-gray-300",children:M.remark}),e.jsx("td",{className:"px-3 py-2 dark:text-gray-300",children:M.description}),e.jsxs("td",{className:"px-3 py-2 flex gap-2",children:[e.jsx(J,{variant:"outline",size:"sm",onClick:()=>u(M),children:e.jsx(wr,{className:"h-4 w-4"})}),e.jsx(J,{variant:"destructive",size:"sm",onClick:()=>{g(M.id),f(!0)},children:e.jsx(Rt,{className:"h-4 w-4"})})]})]},M.id)):e.jsx("tr",{children:e.jsx("td",{colSpan:4,className:"text-center py-4 text-gray-500 dark:text-gray-400",children:w("contentManagement.noRec")})})})]})}),e.jsx(Bt,{open:l,onOpenChange:M=>{k.isPending||(f(M),M||g(null))},children:e.jsxs(Ut,{children:[e.jsxs(Ht,{children:[e.jsx(Kt,{children:w("common.delete")}),e.jsx(Vt,{children:w("contentManagement.deleteMsg")})]}),e.jsxs(Qt,{children:[e.jsx(Gt,{disabled:k.isPending,children:w("common.cancel")}),e.jsx(Wt,{className:"bg-red-600 hover:bg-red-700",disabled:k.isPending||!y,onClick:()=>{y&&k.mutate(y)},children:k.isPending?w("common.deleting","Deleting..."):w("common.delete")})]})]})})]})},Wn=({unitId:t})=>{var R,L;const[r,a]=c.useState([]),[n,s]=c.useState([]),[i,o]=c.useState(!0),[d,m]=c.useState(""),[l,f]=c.useState(null),[y,g]=c.useState("header"),[x,v]=c.useState(null),[p,j]=c.useState(!1),[E,b]=c.useState(null),k=c.useRef(null),N=_e(),u=sa.language,[C,M]=c.useState([]),{data:I}=Wa(t??"",{enabled:!!t}),se=(L=(R=I==null?void 0:I.data)==null?void 0:R.items)==null?void 0:L[0],Y=(se==null?void 0:se.canCreateDirectRecord)??!1,$=At(),[F,_]=c.useState(null),ne=T=>{var P,Z;const h=String(T.key||"").toLowerCase(),U=[(P=T.name)==null?void 0:P.en,(Z=T.name)==null?void 0:Z.am].filter(Boolean).join(" ").toLowerCase();return h.includes("direct")||U.includes("direct")},{recordTypes:X}=Er(),{allDepratments:W}=Er(),xe=c.useCallback(T=>T?u==="am"?T.am||T.en:T.en||T.am:"",[u]),ie=c.useCallback(async()=>{o(!0);try{const[T,h]=await Promise.all([Me.getHeadersByUnitId(t),Me.getFootersByUnitId(t)]),U=T.data.items.filter(q=>q.isCurrent&&q.uploadedSuccessfully),P=h.data.items.filter(q=>q.isCurrent&&q.uploadedSuccessfully),Z=async(q,de)=>Promise.all(q?.map(async fe=>{try{const Q=de==="header"?await Me.getHeaderById(fe.id):await Me.getFooterById(fe.id);return{...fe,presigned:Q.data.presigned}}catch{return fe}})),[ee,te]=await Promise.all([Z(U,"header"),Z(P,"footer")]);a(ee),s(te)}catch{ge.error(w("contentManagement.failedToLoadHeaderFooter"))}finally{o(!1)}},[t]),Ae=async(T,h,U)=>{try{const P={isCurrent:U};h==="header"?await Me.changeHeaderStatus(T,P):await Me.changeFooterStatus(T,P),N.invalidateQueries({queryKey:["headers-footers",t]}),ge.success(w("contentManagement.statusChanged")),ie()}catch{ge.error(w("contentManagement.statusChangeFailed"))}},je=async()=>{if(!d||!l){b(w("contentManagement.provideMsg"));return}j(!0),b(null);try{const T=await(y==="header"?Me.uploadAndCreateHeader(l,d,t,C,F):Me.uploadAndCreateFooter(l,d,t,C,F));T!=null&&T.id&&(y==="header"?await Me.changeHeaderStatus(T.id,{isCurrent:!0}):await Me.changeFooterStatus(T.id,{isCurrent:!0})),ge.success(`${y} ${w("contentManagement.uploadSuccess")}`),k.current&&(k.current.value=""),f(null),m(""),v(null),b(null),M([]),_(null),ie()}catch{b(w("contentManagement.uploadFailed")),ge.error(w("contentManagement.uploadFailed"))}finally{j(!1)}};c.useEffect(()=>{ie()},[ie]);const z=(X==null?void 0:X.filter(T=>Y?!0:!ne(T))?.map(T=>({label:T.key=="direct"?$({en:"Direct Letter",am:"ቀጥታ ደብዳቤ"}):$(T.name),value:T.key})))||[],O=(T,h,U)=>e.jsxs(Ke,{className:"p-6 border shadow-sm dark:border-gray-700 dark:bg-gray-800",children:[e.jsxs("div",{className:"flex items-center justify-between mb-6",children:[e.jsx("h2",{className:"font-semibold text-xl dark:text-white",children:T}),e.jsxs("span",{className:"text-sm text-muted-foreground dark:text-gray-400",children:[h.length," ",h.length===1?"item":"items"]})]}),i?e.jsx(et,{className:"h-40"}):h.length===0?e.jsxs("div",{className:"text-center py-8",children:[e.jsx("div",{className:"w-16 h-16 mx-auto mb-4 bg-gray-100 dark:bg-gray-700 rounded-full flex items-center justify-center",children:e.jsx("span",{className:"text-2xl text-gray-400 dark:text-gray-500",children:U==="header"?"📄":"📋"})}),e.jsxs("p",{className:"text-sm text-muted-foreground dark:text-gray-400 mb-2",children:["No ",T.toLowerCase()," found"]}),e.jsxs("p",{className:"text-xs text-muted-foreground dark:text-gray-500",children:["Upload your first ",U," using the form below"]})]}):e.jsx("div",{className:"space-y-4",children:h?.map(P=>e.jsxs("div",{className:`border rounded-lg p-4 ${P.isCurrent?"bg-primary-50 dark:bg-primary-900/30 border-primary-200 dark:border-primary-800 shadow-sm":"bg-white dark:bg-gray-700"}`,children:[e.jsx("div",{className:"flex items-start justify-between mb-3",children:e.jsxs("div",{className:"flex-1 min-w-0",children:[e.jsxs("div",{className:"flex items-center gap-2 mb-1",children:[e.jsx("h3",{className:"font-semibold text-lg truncate dark:text-white",children:u==="en"?P.name.en:P.name.am}),P.isCurrent&&e.jsx("span",{className:"inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-primary-100 dark:bg-primary-900/50 text-primary-800 dark:text-primary-300 flex-shrink-0",children:w("contentManagement.currentlyActive")})]}),e.jsx("p",{className:"text-sm text-muted-foreground dark:text-gray-400 truncate",children:P.fileInfo.fileName})]})}),e.jsxs("div",{className:"flex items-center justify-between gap-4",children:[P.presigned&&e.jsx("div",{className:"flex-shrink-0",children:e.jsx("div",{className:"w-24 h-16 border rounded-md overflow-hidden bg-gray-50 dark:bg-gray-600",children:e.jsx("img",{src:P.presigned,alt:`${U} Preview`,className:"w-full h-full object-contain"})})}),e.jsx("div",{className:"flex items-center gap-2 ml-auto",children:e.jsx(J,{size:"sm",variant:"outline",onClick:()=>Ae(P.id,U,!1),className:"text-red-600 dark:text-red-400 border-red-300 dark:border-red-700 hover:bg-red-50 dark:hover:bg-red-900/30 whitespace-nowrap",children:w("contentManagement.delete")})})]})]},P.id))})]});return e.jsxs("div",{className:"space-y-8",children:[e.jsxs("div",{className:"grid lg:grid-cols-2 gap-8",children:[O("Headers",r,"header"),O("Footers",n,"footer")]}),e.jsxs(Ke,{className:"p-4 border shadow-sm space-y-4 dark:border-gray-700 dark:bg-gray-800",children:[e.jsxs("h2",{className:"font-semibold text-lg flex items-center gap-2 dark:text-white",children:[e.jsx(ct,{className:"h-5 w-5"})," ",w("contentManagement.addHeaderFooter")]}),e.jsxs("div",{className:"grid md:grid-cols-3 gap-4",children:[e.jsxs("div",{children:[e.jsx(Ce,{className:"dark:text-gray-200",children:w("contentManagement.name")}),e.jsx(ke,{value:d,onChange:T=>{m(T.target.value),b(null)},placeholder:w("contentManagement.name"),disabled:p,className:"dark:bg-gray-700 dark:border-gray-600 dark:text-white"})]}),e.jsxs("div",{children:[e.jsx(Ce,{className:"dark:text-gray-200",children:w("contentManagement.type")}),e.jsxs("select",{value:y,onChange:T=>g(T.target.value),className:"w-full rounded border px-3 py-2 text-sm dark:bg-gray-700 dark:border-gray-600 dark:text-white",disabled:p,children:[e.jsx("option",{value:"header",children:w("contentManagement.header")}),e.jsx("option",{value:"footer",children:w("contentManagement.footer")})]})]}),e.jsxs("div",{children:[e.jsx(Ce,{className:"dark:text-gray-200",children:w("contentManagement.uploadFile")}),e.jsx(ke,{type:"file",accept:"image/*",ref:k,onChange:T=>{var U;const h=((U=T.target.files)==null?void 0:U[0])||null;if(h){if(h.size>5*1024*1024){b("File size must be less than 5 MB"),k.current&&(k.current.value=""),f(null),v(null);return}f(h),v(URL.createObjectURL(h))}else f(null),v(null);b(null)},disabled:p})]}),e.jsxs("div",{children:[e.jsx(Ce,{className:"dark:text-gray-200",children:w("contentManagement.positions")}),e.jsx(Xa,{options:(W==null?void 0:W?.map(T=>({label:xe(T.name),value:T.id})))??[],value:C,onValueChange:M,placeholder:w("contentManagement.selectPositions"),maxCount:5,className:"w-full max-w-full sm:max-w-md overflow-x-auto",animation:0})]}),e.jsxs("div",{children:[e.jsx(Ce,{className:"dark:text-gray-200",children:w("addRecord.Record Type")}),e.jsxs(ft,{value:F??"",onValueChange:T=>_(T||null),children:[e.jsx(ht,{children:e.jsx(xt,{placeholder:w("addRecord.Select Record Type")})}),e.jsx(yt,{children:z?.map(T=>e.jsx(bt,{value:T.value,children:T.label},T.value))})]})]})]}),x&&e.jsxs("div",{className:"mt-2",children:[e.jsx(Ce,{className:"dark:text-gray-200",children:w("contentManagement.preview")}),e.jsx("div",{className:"border rounded p-2 inline-block mt-1 dark:border-gray-600",children:e.jsx("img",{src:x,alt:"Preview",className:"h-24 object-contain rounded"})})]}),E&&e.jsx("p",{className:"text-sm text-red-500",children:E}),e.jsx(J,{onClick:je,disabled:p,className:"bg-primary hover:bg-primary/90 text-primary-foreground w-full",children:p?w("PDF.uploading"):`${w("signatureUpload.upload")} ${y}`})]})]})},Yn=({items:t,isLoading:r,onEdit:a,onDelete:n,showPositionColumn:s=!1,showCountColumn:i=!1,positions:o=[]})=>{const d=_e(),m=b=>{var N,u;const k=o.find(C=>C.id===b);return((N=k==null?void 0:k.name)==null?void 0:N.en)||((u=k==null?void 0:k.name)==null?void 0:u.am)||b},[l,f]=c.useState(null),[y,g]=c.useState(""),x=b=>{var k,N;return((N=(k=b.recordSequences)==null?void 0:k[0])==null?void 0:N.count)??b.count??void 0},v=b=>{var k,N;return((N=(k=b.recordSequences)==null?void 0:k[0])==null?void 0:N.id)??b.sequenceId??void 0},p=b=>{const k=x(b)??0,N=v(b);N&&(f({itemId:N,currentCount:k}),g(String(k)))},j=()=>{f(null),g(""),d.invalidateQueries({queryKey:["prefixes-by-position"]}),d.invalidateQueries({queryKey:["unit-reference-numbers"]})},E=b=>{if(b.preventDefault(),!l)return;const k=parseInt(y,10);isNaN(k)||k<0||Pe.updateCount(l.itemId,k).then(()=>{j()})};return e.jsxs("div",{className:"overflow-x-auto border rounded-lg dark:border-gray-600",children:[e.jsxs("table",{className:"min-w-full text-sm text-left",children:[e.jsx("thead",{className:"bg-gray-50 dark:bg-gray-700 border-b dark:border-gray-600",children:e.jsxs("tr",{children:[e.jsx("th",{className:"px-4 py-3 dark:text-white",children:"#"}),e.jsx("th",{className:"px-4 py-3 dark:text-white",children:w("header.amharic")}),e.jsx("th",{className:"px-4 py-3 dark:text-white",children:w("header.english")}),e.jsx("th",{className:"px-4 py-3 dark:text-white",children:w("contentManagement.type")}),s&&e.jsx("th",{className:"px-4 py-3 dark:text-white",children:w("contentManagement.position")}),i&&e.jsx("th",{className:"px-4 py-3 dark:text-white",children:w("contentManagement.count")}),e.jsx("th",{className:"px-4 py-3 dark:text-white",children:w("userRecord.Actions")})]})}),e.jsx("tbody",{children:r?e.jsx("tr",{children:e.jsx("td",{colSpan:s?6:5,className:"text-center py-8 text-gray-500 dark:text-gray-400",children:w("contentManagement.loading")})}):t.length===0?e.jsx("tr",{children:e.jsx("td",{colSpan:s?6:5,className:"text-center py-8 text-gray-500 dark:text-gray-400",children:w("contentManagement.noRec")})}):t?.map((b,k)=>{var N,u;return e.jsxs("tr",{className:"border-b hover:bg-gray-50 dark:hover:bg-gray-700 dark:border-gray-600",children:[e.jsx("td",{className:"px-4 py-3 dark:text-gray-300",children:k+1}),e.jsx("td",{className:"px-4 py-3 dark:text-gray-300",children:((N=b.name)==null?void 0:N.am)||"-"}),e.jsx("td",{className:"px-4 py-3 dark:text-gray-300",children:((u=b.name)==null?void 0:u.en)||"-"}),e.jsxs("td",{className:"px-4 py-3 capitalize dark:text-gray-300",children:[b.type,b.isForCC&&e.jsx("span",{className:"ml-1 text-xs text-purple-600 dark:text-purple-400",children:"(CC)"})]}),s&&e.jsx("td",{className:"px-4 py-3 dark:text-gray-300",children:b.positionId?m(b.positionId):"-"}),i&&e.jsx("td",{className:"px-4 py-3 dark:text-gray-300",children:(()=>{const C=x(b);return C===void 0?"-":e.jsx("button",{onClick:()=>p(b),className:"text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300 underline cursor-pointer",children:C})})()}),e.jsx("td",{className:"px-4 py-3",children:e.jsxs("div",{className:"flex gap-2",children:[e.jsx(J,{variant:"ghost",size:"sm",onClick:()=>a(b),children:e.jsx(wr,{className:"h-4 w-4"})}),e.jsx(J,{variant:"ghost",size:"sm",className:"text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300",onClick:()=>n(b.id,b.type,b.recordTypeKey),children:e.jsx(Rt,{className:"h-4 w-4"})})]})})]},b.id)})})]}),l&&e.jsx("div",{className:"fixed inset-0 z-50 flex items-center justify-center bg-black/50",children:e.jsxs("div",{className:"bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6 w-80",children:[e.jsxs("div",{className:"flex justify-between items-center mb-4",children:[e.jsx("h3",{className:"text-lg font-semibold dark:text-white",children:w("contentManagement.updateCount")}),e.jsx("button",{onClick:j,className:"text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200",children:e.jsx(lt,{className:"h-5 w-5"})})]}),e.jsxs("form",{onSubmit:E,children:[e.jsxs("div",{className:"space-y-2 mb-4",children:[e.jsxs(Ce,{htmlFor:"countValue",className:"dark:text-gray-200",children:[w("contentManagement.value")," *"]}),e.jsx(ke,{id:"countValue",type:"number",min:"0",value:y,onChange:b=>g(b.target.value),placeholder:w("contentManagement.enterValue"),className:"dark:bg-gray-700 dark:border-gray-600 dark:text-white"}),e.jsxs("p",{className:"text-xs text-gray-500 dark:text-gray-400",children:[w("contentManagement.currentCount"),":"," ",l.currentCount]})]}),e.jsxs("div",{className:"flex gap-2 justify-end",children:[e.jsx(J,{type:"button",variant:"outline",onClick:j,children:w("common.cancel")}),e.jsx(J,{type:"submit",children:w("common.submit")})]})]})]})})]})},ut=(t,r)=>{var n,s;const a=t;return((s=(n=a==null?void 0:a.response)==null?void 0:n.data)==null?void 0:s.message)||(a==null?void 0:a.message)||r},wt=t=>{var n,s,i;const r=t,a=((s=(n=r==null?void 0:r.response)==null?void 0:n.data)==null?void 0:s.message)||(r==null?void 0:r.message)||"";return((i=r==null?void 0:r.response)==null?void 0:i.status)===400&&/unit not found/i.test(a)},Mr=t=>({data:t,status:200,statusText:"OK",headers:{},config:{}}),Yt={getReferenceNumbers:async t=>{try{return await re.get(`/unit-configurations/${t}/reference-number`)}catch(r){if(wt(r))return Mr([]);throw new Error(ut(r,"Failed to fetch reference numbers"))}},getTagBasedReferences:async(t,r)=>{try{return await re.get(`/record-prefixes/list/${t}/`)}catch(a){if(wt(a))return Mr([]);throw new Error(ut(a,"Failed to fetch reference numbers"))}},getUnitConfiguration:async t=>{try{return await re.get(`/unit-configurations/${t}`)}catch(r){throw new Error(ut(r,"Failed to fetch unit configuration"))}},addReferenceNumber:async(t,r)=>{try{return await re.post(`/unit-configurations/${t}/reference-number`,r)}catch(a){if(wt(a))return await re.post("/unit-configurations",{unitId:t}),await re.post(`/unit-configurations/${t}/reference-number`,r);throw new Error(ut(a,"Failed to add reference numbers"))}},updateInternalMemoReferencePrefix:async(t,r)=>{try{return await re.patch(`/unit-configurations/add-internal-memo-reference-number-prefix/${t}`,r)}catch(a){if(wt(a))return await re.post("/unit-configurations",{unitId:t}),await re.patch(`/unit-configurations/add-internal-memo-reference-number-prefix/${t}`,r);throw new Error(ut(a,"Failed to update internal memo reference prefix"))}},updateExternalReferencePrefix:async(t,r)=>{try{return await re.patch(`/unit-configurations/add-external-reference-number-prefix/${t}`,r)}catch(a){if(wt(a))return await re.post("/unit-configurations",{unitId:t}),await re.patch(`/unit-configurations/add-external-reference-number-prefix/${t}`,r);throw new Error(ut(a,"Failed to update external reference prefix"))}}},fa=t=>Ee({queryKey:["unit-reference-numbers",t],queryFn:()=>Yt.getReferenceNumbers(t),enabled:!!t}),Xn=()=>{const t=_e();return Ne({mutationFn:({unitId:r,payload:a})=>Yt.addReferenceNumber(r,a),onSuccess:(r,a)=>{t.invalidateQueries({queryKey:["unit-reference-numbers",a.unitId]}),t.invalidateQueries({queryKey:["unit-configuration",a.unitId]})}})},Jn=()=>{const t=_e();return Ne({mutationFn:({unitId:r,payload:a})=>Yt.updateInternalMemoReferencePrefix(r,a),onSuccess:(r,a)=>{t.invalidateQueries({queryKey:["unit-reference-numbers",a.unitId]}),t.invalidateQueries({queryKey:["unit-configuration",a.unitId]})}})},Zn=()=>{const t=_e();return Ne({mutationFn:({unitId:r,payload:a})=>Yt.updateExternalReferencePrefix(r,a),onSuccess:(r,a)=>{t.invalidateQueries({queryKey:["unit-reference-numbers",a.unitId]}),t.invalidateQueries({queryKey:["unit-configuration",a.unitId]})}})},es=({open:t,onClose:r,onSuccess:a,unitId:n,cardType:s,recordType:i,editingItem:o,positions:d=[]})=>{var Ae,je;const m=_e(),l=!!o,[f,y]=c.useState(""),[g,x]=c.useState(""),[v,p]=c.useState(""),j=["reference","externalReference","internalMemoReference"].includes(s),E=i==="positionPrefix",b=s.includes("CC"),k=s==="reference",N=s==="externalReference",u=s==="internalMemoReference",C=d.filter(z=>!!(z!=null&&z.id))?.map(z=>{var O,R;return{value:z.id,label:((O=z.name)==null?void 0:O.en)||((R=z.name)==null?void 0:R.am)||z.id}}),M=s==="internalPrefix"?"internal":s==="externalPrefix"?"external":s==="internalMemoPrefix"?"internal_memo":o==null?void 0:o.recordTypeKey,{data:I}=fa(j?n:""),se=Xn(),Y=Jn(),$=Zn(),{handleError:F}=dt(w),_=((je=(Ae=I==null?void 0:I.data)==null?void 0:Ae.items)==null?void 0:je[0])||(I==null?void 0:I.data)||{},ne=Array.isArray(_);ne&&(_==null||_.find(z=>z.name==="referenceNumberPrefix")),ne&&(_==null||_.find(z=>z.name==="externalReferenceNumberPrefix")),ne&&(_==null||_.find(z=>z.name==="internalMemoReferenceNumberPrefix")),c.useEffect(()=>{var z,O,R,L;o?(j?(y(((z=o.name)==null?void 0:z.am)||""),x(((O=o.name)==null?void 0:O.en)||"")):(y(((R=o.name)==null?void 0:R.am)||""),x(((L=o.name)==null?void 0:L.en)||"")),p(o.positionId||"")):(y(""),x(""),p(""))},[o,j]),c.useEffect(()=>{if(!(!j||o)){if(k){y(_.referenceNumberPrefix||""),x(_.referenceNumberPrefix||"");return}if(N){y(_.externalReferenceNumberPrefix||""),x(_.externalReferenceNumberPrefix||"");return}u&&(y(_.internalMemoReferenceNumberPrefix||""),x(_.internalMemoReferenceNumberPrefix||""))}},[o,j,k,N,u,_.referenceNumberPrefix,_.externalReferenceNumberPrefix,_.internalMemoReferenceNumberPrefix]);const{mutate:X,isPending:W}=Ne({mutationFn:async()=>{const z=i,O=j||E||s==="prefix"||s==="prefixCC"||s==="internalPrefix"||s==="externalPrefix"||s==="internalMemoPrefix";let R,L;if(j){if(k){await se.mutateAsync({unitId:n,payload:{referenceNumberPrefix:{am:f,en:g}}});return}if(N){await $.mutateAsync({unitId:n,payload:{externalReferenceNumberPrefix:{am:f,en:g}}});return}if(u){await Y.mutateAsync({unitId:n,payload:{internalMemoReferenceNumberPrefix:{am:f,en:g}}});return}}else if(E){if(M!=="internal"&&M!=="external"&&M!=="internal_memo")throw new Error("Position prefix requires internal, external, or internal_memo recordTypeKey");R={unitId:n,recordTypeKey:M,isForCC:!1,positionId:v,name:{am:f,en:g}}}else{const T={unitId:n,recordTypeKey:z,isForCC:b,name:{am:f,en:g}};s==="suffix"||s==="suffixCC"?L=T:R=T}if(l){if(O&&R)return Pe.editPrefix(o.id,R);if(L)return Pe.editSuffix(o.id,L);throw new Error("Invalid prefix/suffix payload for update")}if(O&&R)return Pe.createPrefix(R);if(L)return Pe.createSuffix(L);throw new Error("Invalid prefix/suffix payload for create")},onSuccess:()=>{m.invalidateQueries({queryKey:["prefixSuffix"]}),ge.success(w("msg.successfullyCompleted")),a()},onError:z=>{F(z)}}),xe=z=>{z.preventDefault(),X()},ie=()=>j?f.trim()!==""&&g.trim()!=="":E?f.trim()!==""&&g.trim()!==""&&v:f.trim()!==""&&g.trim()!=="";return e.jsx(Qe,{open:t,onOpenChange:r,children:e.jsxs(Ge,{className:"sm:max-w-[500px] dark:bg-gray-800",children:[e.jsx(We,{children:e.jsxs(Ye,{className:"dark:text-white",children:[l?w("contentManagement.edit"):w("contentManagement.add")," ",w(`contentManagement.${s}`)]})}),e.jsxs("form",{onSubmit:xe,className:"space-y-4",children:[j?e.jsxs("div",{className:"space-y-2",children:[e.jsxs("div",{className:"space-y-2",children:[e.jsxs(Ce,{htmlFor:"nameAm",className:"dark:text-gray-200",children:[w("contentManagement.amharicName")," *"]}),e.jsx(ke,{id:"nameAm",value:f,onChange:z=>y(z.target.value),placeholder:w("contentManagement.amharicName"),disabled:W,className:"dark:bg-gray-700 dark:border-gray-600 dark:text-white"})]}),e.jsxs("div",{className:"space-y-2",children:[e.jsxs(Ce,{htmlFor:"nameEn",className:"dark:text-gray-200",children:[w("contentManagement.englishName")," *"]}),e.jsx(ke,{id:"nameEn",value:g,onChange:z=>x(z.target.value),placeholder:w("contentManagement.englishName"),disabled:W,className:"dark:bg-gray-700 dark:border-gray-600 dark:text-white"})]})]}):e.jsxs(e.Fragment,{children:[e.jsxs("div",{className:"space-y-2",children:[e.jsxs(Ce,{htmlFor:"nameAm",className:"dark:text-gray-200",children:[w("contentManagement.amharicName")," *"]}),e.jsx(ke,{id:"nameAm",value:f,onChange:z=>y(z.target.value),placeholder:w("contentManagement.amharicName"),disabled:W,className:"dark:bg-gray-700 dark:border-gray-600 dark:text-white"})]}),e.jsxs("div",{className:"space-y-2",children:[e.jsxs(Ce,{htmlFor:"nameEn",className:"dark:text-gray-200",children:[w("contentManagement.englishName")," *"]}),e.jsx(ke,{id:"nameEn",value:g,onChange:z=>x(z.target.value),placeholder:w("contentManagement.englishName"),disabled:W,className:"dark:bg-gray-700 dark:border-gray-600 dark:text-white"})]})]}),E&&e.jsxs("div",{className:"space-y-2",children:[e.jsxs(Ce,{htmlFor:"position",className:"dark:text-gray-200",children:[w("contentManagement.position")," *"]}),e.jsx(Za,{options:C,value:v,onValueChange:p,placeholder:w("contentManagement.selectPosition"),className:W?"pointer-events-none opacity-60":""})]}),e.jsxs("div",{className:"flex justify-end gap-2 pt-4",children:[e.jsx(J,{type:"button",variant:"outline",onClick:r,disabled:W,children:w("common.Cancel")}),e.jsx(J,{type:"submit",disabled:!ie()||W,className:"bg-purple-600 hover:bg-purple-700",children:W?w("contentManagement.saving"):k&&(_.referenceNumberPrefix||_.externalReferenceNumberPrefix||_.internalMemoReferenceNumberPrefix)||l?w("common.update"):w("common.save")})]})]})]})})},ts=(t,r,a=0,n=10)=>Ee({queryKey:["prefixes",t,r,{cc:!1}],queryFn:()=>Pe.getPrefix(t,r,a,n),enabled:!!t&&!!r}),rs=(t,r,a=0,n=10)=>Ee({queryKey:["prefixes",t,r,{cc:!0}],queryFn:()=>Pe.getPrefixCC(t,r,a,n),enabled:!!t&&!!r}),as=(t,r,a=0,n=10)=>Ee({queryKey:["prefixes-by-position",t,r],queryFn:()=>Pe.getPrefixByPosition(t,r,a,n),enabled:!!t&&!!r}),ns=()=>{const t=_e();return Ne({mutationFn:({id:r})=>Pe.deletePrefix(r),onSuccess:(r,a)=>{t.invalidateQueries({queryKey:["prefixes",a.unitId,a.recordTypeKey,{cc:a.isForCC}]}),t.invalidateQueries({queryKey:["prefix",a.id]})}})},ss=(t,r,a=0,n=10)=>Ee({queryKey:["suffixes",t,r,{cc:!1}],queryFn:()=>Pe.getSuffix(t,r,a,n),enabled:!!t&&!!r}),is=(t,r,a=0,n=10)=>Ee({queryKey:["suffixes",t,r,{cc:!0}],queryFn:()=>Pe.getSuffixCC(t,r,a,n),enabled:!!t&&!!r}),os=()=>{const t=_e();return Ne({mutationFn:({id:r})=>Pe.deleteSuffix(r),onSuccess:(r,a)=>{t.invalidateQueries({queryKey:["suffixes",a.unitId,a.recordTypeKey,{cc:a.isForCC}]}),t.invalidateQueries({queryKey:["suffix",a.id]})}})},ls=({unitId:t,recordTagId:r,skip:a=0,take:n=10})=>{const{t:s}=Ie(),{handleError:i}=dt(s),o=_e(),{data:d,isLoading:m,isError:l,refetch:f}=Ee({queryKey:["recordTagPrefixes",t,r,a,n],queryFn:async()=>{const{data:p}=await Pe.getPrefixByRecordTagId(t,r,a,n);return p},enabled:!!t&&!!r,staleTime:300*1e3,retry:!1}),{mutate:y,isPending:g}=Ne({mutationFn:async p=>{const{data:j}=await Pe.createPrefix(p);return j},onSuccess:()=>{ge.success(s("prefixes.createdSuccess")||"Prefix created successfully ✅"),o.invalidateQueries({queryKey:["recordTagPrefixes",t,r]})},onError:p=>{i(p)}}),{mutate:x,isPending:v}=Ne({mutationFn:async p=>{const{data:j}=await Pe.deletePrefix(p);return j},onSuccess:()=>{ge.success(s("prefixes.deletedSuccess")||"Prefix deleted successfully 🗑️"),o.invalidateQueries({queryKey:["recordTagPrefixes",t,r]})},onError:p=>{i(p)}});return{prefixes:(d==null?void 0:d.items)??[],total:(d==null?void 0:d.count)??0,isLoadingPrefixes:m,isErrorPrefixes:l,refetchPrefixes:f,createPrefix:y,isCreatingPrefix:g,deletePrefix:x,isDeletingPrefix:v}},kt={getRecordTags:t=>He.get(`/record-tags/${t}`,{headers:ae()}),getRecordTagsListWithUnitById:t=>He.get(`/record-tags/list/${t}`,{headers:ae()}),createRecordTags:t=>He.post("/record-tags",{...t,recordTypeKey:"external"},{headers:ae()}),updateEmployee:(t,r)=>He.put(`/record-tags/${t}`,r,{headers:ae()}),deleteEmployee:t=>He.delete(`/record-tags/${t}`,{headers:ae()})},Mt=({unitId:t,recordTagId:r})=>{const{t:a}=Ie(),{handleError:n}=dt(a),{data:s,isLoading:i,isError:o,refetch:d}=Ee({queryKey:["recordTagsList",t],queryFn:async()=>{const{data:b}=await kt.getRecordTagsListWithUnitById(t);return b},enabled:!!t,staleTime:300*1e3,retry:!1}),{data:m,isLoading:l,isError:f,refetch:y}=Ee({queryKey:["recordTag",r],queryFn:async()=>{const{data:b}=await kt.getRecordTags(r);return b},enabled:!!r,staleTime:300*1e3,retry:!1}),{mutate:g,isPending:x}=Ne({mutationFn:async b=>{const{data:k}=await kt.createRecordTags(b);return k},onSuccess:()=>{ge.success("Record Tag created successfully ✅"),d()},onError:b=>{n(b)}}),{mutate:v,isPending:p}=Ne({mutationFn:async({id:b,payload:k})=>{const{data:N}=await kt.updateEmployee(b,k);return N},onSuccess:()=>{ge.success("Record Tag updated successfully ✨"),d()},onError:b=>{n(b)}}),{mutate:j,isPending:E}=Ne({mutationFn:async b=>{const{data:k}=await kt.deleteEmployee(b);return k},onSuccess:()=>{ge.success("Record Tag deleted successfully 🗑️"),d()},onError:b=>{n(b)}});return{recordTagsList:s,isLoadingRecordTagsList:i,isErrorRecordTagsList:o,refetchRecordTagsList:d,recordTag:m,isLoadingRecordTag:l,isErrorRecordTag:f,refetchRecordTag:y,createRecordTag:g,isCreatingRecordTag:x,updateRecordTag:v,isUpdatingRecordTag:p,deleteRecordTag:j,isDeletingRecordTag:E}};function cs({unitId:t,selectedTagIds:r,onChange:a,placeholder:n="Select tags...",multiple:s=!0,disabled:i=!1,className:o,maxDisplayTags:d=3,error:m}){const{t:l}=Ie(),[f,y]=c.useState(!1),g=At(),{recordTagsList:x,isLoadingRecordTagsList:v,isErrorRecordTagsList:p}=Mt({unitId:t}),j=(x==null?void 0:x.items)??[],E=j.filter(u=>r.includes(u.id)),b=c.useCallback(u=>{s?a(r.includes(u)?r.filter(C=>C!==u):[...r,u]):(a(r.includes(u)?[]:[u]),y(!1))},[s,a,r]),k=c.useCallback((u,C)=>{u.stopPropagation(),a(r.filter(M=>M!==C))},[a,r]),N=c.useCallback(u=>{u.stopPropagation(),a([])},[a]);return e.jsxs("div",{className:be("flex flex-col gap-1.5",o),children:[e.jsxs(en,{open:f,onOpenChange:y,children:[e.jsx(tn,{asChild:!0,children:e.jsxs(J,{variant:"outline",role:"combobox","aria-expanded":f,disabled:i||v,className:be("w-full justify-between min-h-[40px] h-auto px-3 py-2",!s&&E.length>0&&"justify-start gap-2",m&&"border-destructive ring-destructive","hover:bg-accent"),children:[v?e.jsxs("div",{className:"flex items-center gap-2 text-muted-foreground",children:[e.jsx(lr,{className:"h-4 w-4 animate-spin"}),e.jsx("span",{className:"text-sm",children:l("common.loading")})]}):E.length===0?e.jsx("span",{className:"text-muted-foreground text-sm",children:n}):s?e.jsxs("div",{className:"flex flex-wrap items-center gap-1.5 flex-1",children:[E.slice(0,d)?.map(u=>e.jsxs(cr,{variant:"secondary",className:"gap-1 px-2 py-0.5 text-xs font-medium cursor-default",style:{backgroundColor:u.color?`${u.color}20`:void 0,color:u.color,borderColor:u.color},children:[u.name,e.jsx(lt,{className:"h-3 w-3 cursor-pointer hover:text-destructive",onClick:C=>k(C,u.id)})]},u.id)),E.length>d&&e.jsxs(cr,{variant:"secondary",className:"text-xs",children:["+",E.length-d]})]}):e.jsx("div",{className:"flex items-center gap-2 flex-1",children:e.jsx("span",{className:"text-sm",children:g(E[0].name)})}),e.jsxs("div",{className:"flex items-center gap-1 shrink-0 ml-2",children:[E.length>0&&!i&&e.jsx(lt,{className:"h-4 w-4 text-muted-foreground hover:text-foreground cursor-pointer",onClick:N}),e.jsx(wn,{className:"h-4 w-4 text-muted-foreground shrink-0"})]})]})}),e.jsx(rn,{className:"w-[--radix-popover-trigger-width] p-0",align:"start",children:e.jsxs(an,{children:[e.jsx(nn,{placeholder:l("common.search")||"Search tags..."}),e.jsxs(sn,{children:[e.jsx(on,{children:p?e.jsx("div",{className:"py-6 text-center text-sm text-destructive",children:l("common.errorLoading")}):l("common.noResults")||"No tags found."}),e.jsx(ln,{children:j?.map(u=>{const C=r.includes(u.id);return e.jsx(cn,{value:u.id,onSelect:()=>b(u.id),className:"cursor-pointer",children:e.jsxs("div",{className:"flex items-center gap-3 flex-1",children:[e.jsx("div",{className:be("flex h-4 w-4 items-center justify-center rounded-sm border border-primary",C?"bg-primary text-primary-foreground":"opacity-50"),children:C&&e.jsx(_a,{className:"h-3 w-3"})}),e.jsx("span",{className:"flex-1 text-sm",children:g(u.name)})]})},u.id)})})]})]})})]}),m&&e.jsx("p",{className:"text-xs text-destructive",children:m})]})}function ds({className:t,...r}){return e.jsx("nav",{role:"navigation","aria-label":"pagination","data-slot":"pagination",className:be("mx-auto flex w-full justify-center",t),...r})}function us({className:t,...r}){return e.jsx("ul",{"data-slot":"pagination-content",className:be("flex flex-row items-center gap-1",t),...r})}function ar({...t}){return e.jsx("li",{"data-slot":"pagination-item",...t})}function Nr({className:t,isActive:r,size:a="icon",...n}){return e.jsx("a",{"aria-current":r?"page":void 0,"data-slot":"pagination-link","data-active":r,className:be(Da({variant:r?"outline":"ghost",size:a}),t),...n})}function ms({className:t,...r}){return e.jsxs(Nr,{"aria-label":"Go to previous page",size:"default",className:be("gap-1 px-2.5 sm:pl-2.5",t),...r,children:[e.jsx(ma,{}),e.jsx("span",{className:"hidden sm:block",children:"Previous"})]})}function gs({className:t,...r}){return e.jsxs(Nr,{"aria-label":"Go to next page",size:"default",className:be("gap-1 px-2.5 sm:pr-2.5",t),...r,children:[e.jsx("span",{className:"hidden sm:block",children:"Next"}),e.jsx(vr,{})]})}function ps({unitId:t,className:r}){const{t:a}=Ie(),n=At(),[s,i]=c.useState(""),[o,d]=c.useState(!1),[m,l]=c.useState(null),[f,y]=c.useState(1),g=10,x=(f-1)*g,{recordTagsList:v,isLoadingRecordTagsList:p}=Mt({unitId:t}),{prefixes:j,total:E,isLoadingPrefixes:b,isErrorPrefixes:k,createPrefix:N,isCreatingPrefix:u,deletePrefix:C,isDeletingPrefix:M}=ls({unitId:t,recordTagId:s||void 0,skip:x,take:g}),I=Math.ceil(E/g);c.useEffect(()=>{y(1)},[s]);const se=c.useMemo(()=>{var F;return(F=v==null?void 0:v.items)==null?void 0:F.find(_=>_.id===s)},[v,s]),Y=c.useCallback(F=>{var xe,ie;F.preventDefault();const _=new FormData(F.currentTarget),ne=(xe=_.get("nameAm"))==null?void 0:xe.trim(),X=(ie=_.get("nameEn"))==null?void 0:ie.trim();if(!ne||!X)return;N({name:{am:ne,en:X},unitId:t,isForCC:!1,recordTagId:s||void 0},{onSuccess:()=>{d(!1)}})},[N,s,t]),$=c.useCallback(()=>{m&&C(m,{onSuccess:()=>l(null)})},[C,m]);return e.jsxs("div",{className:be("space-y-6",r),children:[e.jsxs("div",{className:"flex flex-col sm:flex-row items-start sm:items-end gap-4",children:[e.jsxs("div",{className:"flex-1 w-full sm:w-auto space-y-2",children:[e.jsx(Ce,{className:"text-sm font-medium",children:a("nav.selectTag")||"Select Record Tag"}),e.jsx(cs,{unitId:t,selectedTagIds:s?[s]:[],onChange:F=>i(F[0]||""),placeholder:a("nav.selectTag")||"Choose a tag to view prefixes...",multiple:!1,disabled:p})]}),e.jsxs(Qe,{open:o,onOpenChange:d,children:[e.jsx(jr,{asChild:!0,children:e.jsxs(J,{className:"shrink-0",disabled:!s||b,children:[e.jsx(ct,{className:"h-4 w-4 mr-2"}),a("contentManagement.addPrefix")||"Add Prefix"]})}),e.jsxs(Ge,{className:"sm:max-w-[500px]",children:[e.jsx(We,{children:e.jsx(Ye,{children:a("contentManagement.addPrefix")||"Add New Prefix"})}),e.jsxs("form",{id:"add-prefix-form",onSubmit:Y,className:"space-y-4",children:[e.jsxs("div",{className:"space-y-2",children:[e.jsxs(Ce,{htmlFor:"nameAm",className:"dark:text-gray-200",children:[a("contentManagement.amharicName")," *"]}),e.jsx(ke,{id:"nameAm",name:"nameAm",placeholder:a("contentManagement.amharicName"),required:!0,autoFocus:!0,className:"dark:bg-gray-700 dark:border-gray-600 dark:text-white"})]}),e.jsxs("div",{className:"space-y-2",children:[e.jsxs(Ce,{htmlFor:"nameEn",className:"dark:text-gray-200",children:[a("contentManagement.englishName")," *"]}),e.jsx(ke,{id:"nameEn",name:"nameEn",placeholder:a("contentManagement.englishName"),required:!0,className:"dark:bg-gray-700 dark:border-gray-600 dark:text-white"})]})]}),e.jsxs(Ia,{children:[e.jsx(J,{type:"button",variant:"outline",onClick:()=>d(!1),children:a("common.cancel")||"Cancel"}),e.jsxs(J,{type:"submit",form:"add-prefix-form",disabled:u,children:[u&&e.jsx(lr,{className:"h-4 w-4 mr-2 animate-spin"}),a("common.save")||"Save"]})]})]})]})]}),se&&e.jsxs("div",{className:"flex items-center gap-2 text-sm text-muted-foreground",children:[e.jsx("span",{children:"Showing prefixes for:"}),e.jsx(cr,{variant:"outline",children:n(se.name)}),e.jsxs("span",{className:"text-xs",children:["(",E," items)"]})]}),e.jsx("div",{className:"rounded-md border",children:e.jsxs(la,{children:[e.jsx(ca,{children:e.jsxs(Ze,{children:[e.jsx(Ve,{className:"w-[80px]",children:a("common.no")||"#"}),e.jsx(Ve,{children:a("contentManagement.amharicName")}),e.jsx(Ve,{children:a("contentManagement.englishName")}),e.jsx(Ve,{children:a("contentManagement.createdAt")||"Created"}),e.jsx(Ve,{className:"w-[100px] text-right",children:a("common.actions")||"Actions"})]})}),e.jsxs(da,{children:[b&&e.jsx(e.Fragment,{children:Array.from({length:5})?.map((F,_)=>e.jsxs(Ze,{children:[e.jsx(Te,{children:e.jsx(et,{className:"h-4 w-8"})}),e.jsx(Te,{children:e.jsx(et,{className:"h-4 w-24"})}),e.jsx(Te,{children:e.jsx(et,{className:"h-4 w-20"})}),e.jsx(Te,{children:e.jsx(et,{className:"h-4 w-32"})}),e.jsx(Te,{children:e.jsx(et,{className:"h-4 w-24"})}),e.jsx(Te,{children:e.jsx(et,{className:"h-8 w-8 ml-auto"})})]},`skeleton-${_}`))}),k&&!b&&e.jsx(Ze,{children:e.jsx(Te,{colSpan:6,className:"h-32 text-center",children:e.jsxs("div",{className:"flex flex-col items-center gap-2 text-destructive",children:[e.jsx(dn,{className:"h-8 w-8"}),e.jsx("p",{className:"text-sm",children:a("common.errorLoading")||"Failed to load prefixes"}),e.jsx(J,{variant:"outline",size:"sm",onClick:()=>window.location.reload(),children:a("common.retry")||"Retry"})]})})}),!b&&!k&&e.jsxs(e.Fragment,{children:[!s&&e.jsx(Ze,{children:e.jsx(Te,{colSpan:6,className:"h-32 text-center",children:e.jsxs("div",{className:"flex flex-col items-center gap-2 text-muted-foreground",children:[e.jsx("span",{className:"text-2xl",children:"🏷️"}),e.jsx("p",{className:"text-sm",children:a("nav.selectTag")||"Select a tag above to view its prefixes"})]})})}),s&&j.length===0&&e.jsx(Ze,{children:e.jsx(Te,{colSpan:6,className:"h-32 text-center",children:e.jsxs("div",{className:"flex flex-col items-center gap-2 text-muted-foreground",children:[e.jsx(ct,{className:"h-8 w-8 opacity-50"}),e.jsx("p",{className:"text-sm",children:a("nav.emptyState")||"No prefixes found for this tag"}),e.jsx(J,{variant:"outline",size:"sm",onClick:()=>d(!0),children:a("nav.addNew")||"Add your first prefix"})]})})}),j?.map((F,_)=>{var ne,X;return e.jsxs(Ze,{className:"group",children:[e.jsx(Te,{className:"text-muted-foreground text-sm",children:x+_+1}),e.jsx(Te,{className:"font-medium",children:((ne=F.name)==null?void 0:ne.am)||e.jsx("span",{className:"text-muted-foreground text-sm",children:"—"})}),e.jsx(Te,{children:((X=F.name)==null?void 0:X.en)||e.jsx("span",{className:"text-muted-foreground text-sm",children:"—"})}),e.jsx(Te,{className:"text-muted-foreground text-sm",children:new Date(F.createdAt).toLocaleDateString()}),e.jsx(Te,{className:"text-right",children:e.jsx(J,{variant:"ghost",size:"icon",className:"h-8 w-8 text-muted-foreground hover:text-destructive opacity-0 group-hover:opacity-100 transition-opacity",onClick:()=>l(F.id),disabled:M,children:e.jsx(Rt,{className:"h-4 w-4"})})})]},F.id)})]})]})]})}),I>1&&e.jsx(ds,{children:e.jsxs(us,{children:[e.jsx(ar,{children:e.jsx(ms,{onClick:()=>y(F=>Math.max(1,F-1)),className:be(f===1&&"pointer-events-none opacity-50")})}),Array.from({length:I},(F,_)=>_+1)?.map(F=>e.jsx(ar,{children:e.jsx(Nr,{isActive:f===F,onClick:()=>y(F),children:F})},F)),e.jsx(ar,{children:e.jsx(gs,{onClick:()=>y(F=>Math.min(I,F+1)),className:be(f===I&&"pointer-events-none opacity-50")})})]})}),e.jsx(Bt,{open:!!m,onOpenChange:F=>!F&&l(null),children:e.jsxs(Ut,{children:[e.jsxs(Ht,{children:[e.jsx(Kt,{children:a("prefixes.deleteConfirmTitle")||"Delete Prefix?"}),e.jsx(Vt,{children:a("prefixes.deleteConfirmDescription")||"This action cannot be undone. This will permanently delete the prefix."})]}),e.jsxs(Qt,{children:[e.jsx(Gt,{disabled:M,children:a("common.cancel")||"Cancel"}),e.jsxs(Wt,{onClick:$,disabled:M,className:"bg-destructive text-destructive-foreground hover:bg-destructive/90",children:[M&&e.jsx(lr,{className:"h-4 w-4 mr-2 animate-spin"}),a("common.delete")||"Delete"]})]})]})})]})}const fs=[{id:"internal",label:"nav.internal"},{id:"external",label:"nav.external"},{id:"internal_memo",label:"nav.internal_memo"},{id:"branch",label:"nav.branch"},{id:"tag-based-reference",label:"nav.tagBasedReference"},{id:"referenceNumber",label:"userRecord.Reference"},{id:"positionPrefix",label:"contentManagement.positionPrefixes"}],Nt=t=>t==="positionPrefix"?"internalPrefix":t==="referenceNumber"?"reference":"prefix",hs=({unitId:t,initialTab:r="internal"})=>{var H,ue,D,we,S,B,V;const{handleError:a}=dt(w),[n,s]=c.useState(r),[i,o]=c.useState(Nt(r)),[d,m]=c.useState(!1),[l,f]=c.useState(null),y=_e(),{mutateAsync:g}=ns(),{mutateAsync:x}=os(),{usePositionListByUnitId:v}=Ja(),{data:p}=v(t,{take:1e3,skip:0}),j=(p==null?void 0:p.items)||[];c.useEffect(()=>{s(r),o(Nt(r))},[r]),c.useEffect(()=>{o(Nt(n))},[n]);const E=A=>{f({cardType:A,recordType:n}),m(!0)},b=A=>{f({cardType:A.type,recordType:n,editingItem:A}),m(!0)},k=async(A,K,he)=>{if(K==="reference"||K==="externalReference"||K==="internalMemoReference"){ge.error(w("msg.errorOccurred"));return}const Oe=K==="prefix"||K==="prefixCC"||K==="internalPrefix"||K==="externalPrefix"||K==="internalMemoPrefix",ce=K==="prefixCC"||K==="suffixCC",ve=he||(K==="internalPrefix"?"internal":K==="externalPrefix"?"external":K==="internalMemoPrefix"?"internal_memo":n);try{Oe?(await g({id:A,unitId:t,recordTypeKey:ve,isForCC:ce}),n==="positionPrefix"&&y.invalidateQueries({queryKey:["prefixes-by-position",t,ve]})):await x({id:A,unitId:t,recordTypeKey:ve,isForCC:ce}),y.invalidateQueries({queryKey:["prefixSuffix"]}),ge.success(w("contentManagement.deleted"))}catch(qe){a(qe)}},N=()=>{m(!1),f(null)},u=()=>{const A=(l==null?void 0:l.cardType)??i,K=(l==null?void 0:l.recordType)??n,he=A==="prefixCC"||A==="suffixCC",Ue=A==="prefix"||A==="prefixCC"||A==="internalPrefix"||A==="externalPrefix"||A==="internalMemoPrefix"||K==="referenceNumber",Oe=K==="referenceNumber"?A:K==="positionPrefix"?A==="internalPrefix"?"internal":A==="internalMemoPrefix"?"internal_memo":"external":K;Ue?(K==="positionPrefix"&&y.invalidateQueries({queryKey:["prefixes-by-position",t,Oe]}),y.invalidateQueries({queryKey:["prefixes",t,Oe,{cc:he}]})):y.invalidateQueries({queryKey:["suffixes",t,Oe,{cc:he}]}),y.invalidateQueries({queryKey:["prefixSuffix"]}),N()},M=n==="positionPrefix"?[{id:"internalPrefix",label:"contentManagement.internalPrefix"},{id:"externalPrefix",label:"contentManagement.externalPrefix"},{id:"internalMemoPrefix",label:"contentManagement.internalMemoPrefix"}]:n==="referenceNumber"?[{id:"reference",label:"contentManagement.reference"},{id:"externalReference",label:"contentManagement.externalReference"},{id:"internalMemoReference",label:"contentManagement.internalMemoReference"}]:[{id:"prefix",label:"contentManagement.prefix"},{id:"suffix",label:"contentManagement.suffix"},{id:"prefixCC",label:"contentManagement.prefixCC"},{id:"suffixCC",label:"contentManagement.suffixCC"}];c.useEffect(()=>{var A;M?.some(K=>K.id===i)||o(((A=M[0])==null?void 0:A.id)||Nt(n))},[M,i,n]);const I=i==="prefixCC"||i==="suffixCC",se=n==="referenceNumber",Y=n==="positionPrefix",$=n==="tag-based-reference",F=i==="prefix"||i==="prefixCC"||i==="internalPrefix"||i==="externalPrefix"||i==="internalMemoPrefix"||se,_=i==="internalPrefix"?"internal":i==="externalPrefix"?"external":i==="internalMemoPrefix"?"internal_memo":"",ne=n==="positionPrefix"?_:n,{data:X,isLoading:W,error:xe}=fa(se?t:""),{data:ie,isLoading:Ae,error:je}=ts(t,F&&!I&&!se&&!Y&&!$?ne:"",0,1e3),{data:z,isLoading:O,error:R}=as(t,Y?_:"",0,1e3),{data:L,isLoading:T,error:h}=rs(t,F&&I&&!se?ne:"",0,1e3),{data:U,isLoading:P,error:Z}=ss(t,!F&&!I&&!se?ne:"",0,1e3),{data:ee,isLoading:te,error:q}=is(t,!F&&I&&!se?ne:"",0,1e3),de=c.useMemo(()=>[xe,R,je,h,Z,q].find(Boolean),[xe,R,je,h,Z,q]),fe=c.useRef(null);c.useEffect(()=>{!de||de===fe.current||(fe.current=de,a(de))},[de,a]);const Q=(X==null?void 0:X.data)||[],le=Q==null?void 0:Q.find(A=>A.name==="referenceNumberPrefix"),G=Q==null?void 0:Q.find(A=>A.name==="externalReferenceNumberPrefix"),oe=Q==null?void 0:Q.find(A=>A.name==="internalMemoReferenceNumberPrefix"),Re=A=>{if(!A)return"";if(typeof A=="string")try{const K=JSON.parse(A);return(K==null?void 0:K.am)??A}catch{return A}return typeof A=="object"?(A==null?void 0:A.am)??"":""},ye=[{id:"referenceNumberPrefix",type:"reference",recordTypeKey:"reference",isForCC:!1,name:{am:Re(le==null?void 0:le.number),en:Re((H=le==null?void 0:le.number)==null?void 0:H.en)},count:(le==null?void 0:le.count)??0,sequenceId:le==null?void 0:le.sequenceId},{id:"externalReferenceNumberPrefix",type:"externalReference",recordTypeKey:"externalReference",isForCC:!1,name:{am:Re((ue=G==null?void 0:G.number)==null?void 0:ue.am),en:Re((D=G==null?void 0:G.number)==null?void 0:D.en)},count:(G==null?void 0:G.count)??0,sequenceId:G==null?void 0:G.sequenceId},{id:"internalMemoReferenceNumberPrefix",type:"internalMemoReference",recordTypeKey:"internalMemoReference",isForCC:!1,name:{am:Re((we=oe==null?void 0:oe.number)==null?void 0:we.am),en:Re((S=oe==null?void 0:oe.number)==null?void 0:S.en)},count:(oe==null?void 0:oe.count)??0,sequenceId:oe==null?void 0:oe.sequenceId}].filter(A=>A.type===i),$e=Y?z:F?I?L:ie:I?ee:U,ze=se?ye:((V=(B=$e==null?void 0:$e.data)==null?void 0:B.items)==null?void 0:V?.map(A=>({...A,type:n==="positionPrefix"?A.recordTypeKey==="internal"?"internalPrefix":A.recordTypeKey==="internal_memo"?"internalMemoPrefix":"externalPrefix":F?I?"prefixCC":"prefix":I?"suffixCC":"suffix",isForCC:I||A.isForCC})))||[],Se=W||O||Ae||T||P||te;return e.jsxs("div",{className:"space-y-6 p-4",children:[e.jsx("div",{className:"flex border-b border-gray-200 dark:border-gray-700",children:fs?.map(A=>e.jsx("button",{onClick:()=>{s(A.id),o(Nt(A.id))},className:`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${n===A.id?"border-purple-600 text-purple-600 dark:text-purple-400":"border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:border-gray-300 dark:hover:border-gray-600"}`,children:w(A.label)},A.id))}),n==="tag-based-reference"?e.jsx(ps,{unitId:t,className:"w-full"}):e.jsxs(e.Fragment,{children:[e.jsx("div",{className:"flex flex-wrap gap-4",children:M?.map(A=>e.jsx(Ke,{onClick:()=>o(A.id),className:`inline-flex w-fit min-w-[260px] max-w-full shadow-sm hover:shadow-md transition-shadow cursor-pointer dark:bg-gray-800 dark:border-gray-700 ${i===A.id?"ring-2 ring-purple-600 dark:ring-purple-400":""}`,children:e.jsx(it,{className:"pb-2 items-center",children:e.jsx(ot,{className:"text-sm font-medium text-gray-700 dark:text-gray-200 text-center whitespace-nowrap",children:w(A.label)})})},A.id))}),e.jsx("div",{className:"flex justify-end mb-4",children:e.jsxs(J,{onClick:()=>E(i),children:[e.jsx(ct,{className:"h-4 w-4 mr-2"}),w("contentManagement.add")]})}),e.jsx(Yn,{items:ze,isLoading:Se,onEdit:b,onDelete:k,showPositionColumn:n==="positionPrefix",showCountColumn:n==="positionPrefix"||n==="referenceNumber",positions:j}),d&&l&&e.jsx(es,{open:d,onClose:N,onSuccess:u,unitId:t,cardType:l.cardType,recordType:l.recordType,editingItem:l.editingItem,positions:j})]})]})},xs=async t=>(await re.get(`/units/${t}/requirement-satisfied`,{headers:ae()})).data;function ys({unitId:t}){const[r,a]=c.useState(t||""),[n,s]=c.useState(!0);c.useEffect(()=>{const l=f=>a(f.detail);return window.addEventListener("unitChanged",l),()=>window.removeEventListener("unitChanged",l)},[]);const{data:i,isLoading:o,refetch:d}=Ee({queryKey:["admin-setup-requirements",r],queryFn:()=>xs(r),enabled:!!r}),m=i==null?void 0:i.requirements;return c.useEffect(()=>{r&&d()},[r,d]),!m||Object.values(m).every(Boolean)||!n?null:e.jsxs("div",{className:"relative flex items-center gap-6 p-2 border rounded bg-amber-50 shadow-2xl top-10 left-1/2 -translate-x-1/2 z-[1000] w-full",children:[e.jsx("h3",{className:"font-semibold text-amber-700 whitespace-nowrap",children:"Finish site setup"}),o?e.jsx("p",{className:"text-sm text-slate-600",children:"Loading requirements..."}):e.jsxs("div",{className:"flex flex-wrap gap-6 text-sm text-slate-600",children:[!m.hasFooter&&e.jsx("span",{children:"⚠️ Missing Footer"}),!m.hasSeal&&e.jsx("span",{children:"⚠️ Missing Seal"}),!m.hasHeader&&e.jsx("span",{children:"⚠️ Missing Header"}),!m.internalPrefix&&e.jsx("span",{children:"⚠️ Missing Internal Prefix"}),!m.externalPrefix&&e.jsx("span",{children:"⚠️ Missing External Prefix"}),!m.internalSuffix&&e.jsx("span",{children:"⚠️ Missing Internal Suffix"}),!m.externalSuffix&&e.jsx("span",{children:"⚠️ Missing External Suffix"})]}),e.jsx("button",{onClick:()=>s(!1),className:"absolute top-2 right-2 text-amber-700 hover:text-red-600",children:e.jsx(lt,{size:16})})]})}const bs=gn({nameAm:Lt().min(1,"Amharic name is required"),nameEn:Lt().min(1,"English name is required"),key:Lt().min(1,"Key is required"),unitId:Lt().min(1,"Unit is required")});function ha({defaultValues:t,unitId:r,mode:a,id:n,onSuccess:s}){const i=ua({resolver:mn(bs),defaultValues:{nameAm:(t==null?void 0:t.nameAm)??"",nameEn:(t==null?void 0:t.nameEn)??"",key:(t==null?void 0:t.key)??"",unitId:(t==null?void 0:t.unitId)??r}}),{createRecordTag:o,updateRecordTag:d,isCreatingRecordTag:m,isUpdatingRecordTag:l}=Mt({unitId:r}),f=y=>{if(a==="create"){const g={name:{am:y.nameAm,en:y.nameEn},key:y.key,unitId:y.unitId};o(g,{onSuccess:()=>{i.reset(),s==null||s()}})}else if(a==="edit"&&n){const g={name:{am:y.nameAm,en:y.nameEn},key:y.key,unitId:y.unitId};d({id:n,payload:g},{onSuccess:()=>{s==null||s()}})}};return e.jsx(pn,{...i,children:e.jsxs("form",{onSubmit:i.handleSubmit(f),className:"space-y-4",children:[e.jsx(Jt,{control:i.control,name:"nameAm",render:({field:y})=>e.jsxs(Zt,{children:[e.jsx(er,{children:"Amharic Name"}),e.jsx(tr,{children:e.jsx(ke,{placeholder:"Enter Amharic name",...y})}),e.jsx(rr,{})]})}),e.jsx(Jt,{control:i.control,name:"nameEn",render:({field:y})=>e.jsxs(Zt,{children:[e.jsx(er,{children:"English Name"}),e.jsx(tr,{children:e.jsx(ke,{placeholder:"Enter English name",...y})}),e.jsx(rr,{})]})}),e.jsx(Jt,{control:i.control,name:"key",render:({field:y})=>e.jsxs(Zt,{children:[e.jsx(er,{children:"Key"}),e.jsx(tr,{children:e.jsx(ke,{placeholder:"Unique key",...y})}),e.jsx(rr,{})]})}),e.jsx(J,{type:"submit",disabled:m||l,children:a==="create"?m?"Creating...":"Create":l?"Updating...":"Update"})]})})}function vs({row:t,unitId:r}){var x,v,p;const[a,n]=c.useState(!1),[s,i]=c.useState(!1),[o,d]=c.useState(!1),{deleteRecordTag:m,refetchRecordTagsList:l}=Mt({unitId:r}),{t:f}=Ie(),{handleError:y}=dt(f),g=()=>{d(!0),m(t.id,{onSuccess:()=>{l(),i(!1)},onError:j=>{y(j)},onSettled:()=>{d(!1)}})};return e.jsxs(e.Fragment,{children:[e.jsxs("div",{className:"flex items-center gap-2",children:[e.jsx(J,{variant:"outline",size:"sm",onClick:()=>n(!0),children:e.jsx(wr,{className:"h-4 w-4"})}),e.jsx(J,{variant:"destructive",size:"sm",onClick:()=>i(!0),children:e.jsx(Rt,{className:"h-4 w-4"})})]}),e.jsx(Qe,{open:a,onOpenChange:n,children:e.jsxs(Ge,{children:[e.jsx(We,{children:e.jsx(Ye,{children:"Edit Record Tag"})}),e.jsx(ha,{unitId:r,mode:"edit",id:t.id,defaultValues:{nameAm:((x=t.name)==null?void 0:x.am)??"",nameEn:((v=t.name)==null?void 0:v.en)??"",key:t.key,unitId:t.unitId},onSuccess:()=>{n(!1),l()}})]})}),e.jsx(Bt,{open:s,onOpenChange:i,children:e.jsxs(Ut,{children:[e.jsxs(Ht,{children:[e.jsx(Kt,{children:"Are you sure you want to delete this record tag?"}),e.jsxs(Vt,{children:["This action cannot be undone. The record tag ",e.jsx("b",{children:(p=t.name)==null?void 0:p.en})," ","will be permanently removed."]})]}),e.jsxs(Qt,{children:[e.jsx(Gt,{disabled:o,children:"Cancel"}),e.jsx(Wt,{onClick:g,disabled:o,className:"bg-red-600 hover:bg-red-700",children:o?"Deleting...":"Delete"})]})]})})]})}const js=t=>[{accessorKey:"name",header:()=>w("recordTag.name"),cell:({row:r})=>{var s;const a=At(),n=(s=r.original)==null?void 0:s.name;return e.jsx("span",{children:a(n)})}},{accessorKey:"key",header:()=>w("recordTag.key"),cell:({row:r})=>{var n;const a=(n=r.original)==null?void 0:n.key;return e.jsx("span",{children:a||"--"})}},{accessorKey:"createdAt",header:()=>w("recordTag.CreatedAt"),cell:({row:r})=>{var n;const a=(n=r.original)==null?void 0:n.createdAt;return e.jsx("span",{children:a?fn(new Date(a),"MMM d, yyyy HH:mm"):"--"})}},{id:"actions",header:()=>w("recordTag.Actions"),cell:({row:r})=>e.jsx(vs,{row:r.original,unitId:t})}];function ws({unitId:t}){const[r,a]=c.useState(0),[n,s]=c.useState(!1),{t:i}=Ie(),{recordTagsList:o,isLoadingRecordTagsList:d,refetchRecordTagsList:m}=Mt({unitId:t??""}),l=f=>{a(f)};return d?e.jsx("div",{children:i("loading")}):e.jsx("div",{className:"p-6 space-y-6",children:e.jsxs(Ke,{className:"col-span-2 shadow-none border-none bg-transparent px-0",children:[e.jsxs(it,{className:"flex flex-row justify-between items-center px-0",children:[e.jsx(ot,{className:"text-xl font-semibold",children:i("recordTag.title")}),e.jsxs(Qe,{open:n,onOpenChange:s,children:[e.jsx(jr,{asChild:!0,children:e.jsx(J,{children:i("recordTag.createNew")})}),e.jsxs(Ge,{children:[e.jsx(We,{children:e.jsx(Ye,{children:i("recordTag.createTitle")})}),e.jsx(ha,{unitId:t,mode:"create",onSuccess:()=>{s(!1),m()}})]})]})]}),e.jsx(pt,{className:"px-0",children:e.jsx(un,{columns:js(t),data:(o==null?void 0:o.items)||[],tableName:i("recordTag.tableName"),toolBarPosition:"right",itemCount:(o==null?void 0:o.count)||0,pageIndex:r,onPageChange:l,nextFunction:()=>l(r+1),prevFunction:()=>l(Math.max(r-1,0))})})]})})}const ks="adf98293-41ba-4bda-bdb4-70e30a70c1b7",Fe={"x-tenant-id":ks},Le=He,st=t=>({...t,name:t.name||{am:"",en:""},description:t.description||{am:"",en:""}}),St={async getResourceTypes(){return(await Le.get("/resource_types",{headers:Fe})).data},async getAllTemplates(t){try{const r=await Le.get("/templates",{params:t,headers:Fe});let a=(r.data.items||r.data||[])?.map(st);if(t!=null&&t.search&&typeof t.search=="string"){const n=t.search.toLowerCase();a=a.filter(s=>{var i,o,d,m,l,f,y,g;return((o=(i=s.name)==null?void 0:i.en)==null?void 0:o.toLowerCase().includes(n))||((m=(d=s.name)==null?void 0:d.am)==null?void 0:m.toLowerCase().includes(n))||((f=(l=s.description)==null?void 0:l.en)==null?void 0:f.toLowerCase().includes(n))||((g=(y=s.description)==null?void 0:y.am)==null?void 0:g.toLowerCase().includes(n))})}return{count:a.length,items:a}}catch{return{count:0,items:[]}}},async getTemplates(t){const{resourceTypeId:r,search:a,...n}=t||{};if(!r)return{count:0,items:[]};let i=((await Le.get(`/resources/list-with-presigned/${r}`,{params:n,headers:Fe})).data.items||[])?.map(st);if(a&&typeof a=="string"){const o=a.toLowerCase();i=i.filter(d=>{var m,l,f,y,g,x,v,p;return((l=(m=d.name)==null?void 0:m.en)==null?void 0:l.toLowerCase().includes(o))||((y=(f=d.name)==null?void 0:f.am)==null?void 0:y.toLowerCase().includes(o))||((x=(g=d.description)==null?void 0:g.en)==null?void 0:x.toLowerCase().includes(o))||((p=(v=d.description)==null?void 0:v.am)==null?void 0:p.toLowerCase().includes(o))})}return{count:i.length,items:i}},async getTemplate(t){const r=await Le.get(`/resources/with-presigned?id=${t}`,{headers:Fe});return st(r.data)},async getTemplateWithPresigned(t,r){var a;try{const i=((await Le.get(`/resources/list-with-presigned/${t}`,{headers:Fe})).data.items||[])?.map(st).find(o=>o.id===r);if(!i)throw new Error("Template not found");return i}catch(n){if(((a=n.response)==null?void 0:a.status)===400)try{const i=await Le.get(`/resources/with-presigned?id=${r}`,{headers:Fe});return st(i.data)}catch{throw n}throw n}},async createTemplate(t){const r=await Le.post("/resources",t,{headers:Fe});return st(r.data)},async updateTemplate(t,r){const a=await Le.put(`/resources/${t}`,r,{headers:Fe});return st(a.data)},async deleteTemplate(t){await Le.delete(`/resources/${t}`,{headers:Fe})},async uploadFile(t,r){var s,i,o,d;if(!r)return null;const a=await hn.validateFileForUpload(t,{allowedMimeTypes:["image/png","image/jpeg","image/gif","image/webp","application/pdf"],allowedExtensions:[".png",".jpg",".jpeg",".gif",".webp",".pdf"],maxSizeMB:50,uploadContext:"signature"});if(!a.isValid)throw new Error(((s=a.clientValidation.fileName)==null?void 0:s.error)||((i=a.clientValidation.extension)==null?void 0:i.error)||((o=a.clientValidation.mimeType)==null?void 0:o.error)||((d=a.clientValidation.size)==null?void 0:d.error)||"File validation failed");const n=await fetch(r,{method:"PUT",headers:{"Content-Type":t.type},body:t});if(!n.ok){const m=await n.text();throw new Error(`Upload failed: ${n.status} ${n.statusText} +${m}`)}return n},async updateUploadStatus(t,r){await Le.put(`/resources/change-status/${t}`,{isCurrent:r},{headers:Fe})},async generateSampleTemplate(t,r){try{const a=await Le.post(`/templates/${t}/sample`,r,{responseType:"arraybuffer",headers:Fe}),n=new Blob([a.data],{type:"application/pdf"});return{pdf:URL.createObjectURL(n),contentType:"application/pdf"}}catch(a){throw a}},async getTemplateSettingsByUnit(t){try{const a=(await Le.get(`/template-settings/by-unit/${t}`,{headers:Fe})).data||{};return{count:a.count||0,items:a.items||[]}}catch{return{count:0,items:[]}}},async saveTemplateSettingValues(t){try{await Promise.all(t?.map(r=>Le.post("/template-setting-values",r,{headers:Fe})))}catch(r){throw r}},async saveBulkTemplateSettingValues(t){try{await Le.post("/template-setting-values/bulk",t,{headers:Fe})}catch(r){throw r}}};var nr={exports:{}},me={};/** @license React v16.13.1 + * react-is.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var Or;function Ns(){if(Or)return me;Or=1;var t=typeof Symbol=="function"&&Symbol.for,r=t?Symbol.for("react.element"):60103,a=t?Symbol.for("react.portal"):60106,n=t?Symbol.for("react.fragment"):60107,s=t?Symbol.for("react.strict_mode"):60108,i=t?Symbol.for("react.profiler"):60114,o=t?Symbol.for("react.provider"):60109,d=t?Symbol.for("react.context"):60110,m=t?Symbol.for("react.async_mode"):60111,l=t?Symbol.for("react.concurrent_mode"):60111,f=t?Symbol.for("react.forward_ref"):60112,y=t?Symbol.for("react.suspense"):60113,g=t?Symbol.for("react.suspense_list"):60120,x=t?Symbol.for("react.memo"):60115,v=t?Symbol.for("react.lazy"):60116,p=t?Symbol.for("react.block"):60121,j=t?Symbol.for("react.fundamental"):60117,E=t?Symbol.for("react.responder"):60118,b=t?Symbol.for("react.scope"):60119;function k(u){if(typeof u=="object"&&u!==null){var C=u.$$typeof;switch(C){case r:switch(u=u.type,u){case m:case l:case n:case i:case s:case y:return u;default:switch(u=u&&u.$$typeof,u){case d:case f:case v:case x:case o:return u;default:return C}}case a:return C}}}function N(u){return k(u)===l}return me.AsyncMode=m,me.ConcurrentMode=l,me.ContextConsumer=d,me.ContextProvider=o,me.Element=r,me.ForwardRef=f,me.Fragment=n,me.Lazy=v,me.Memo=x,me.Portal=a,me.Profiler=i,me.StrictMode=s,me.Suspense=y,me.isAsyncMode=function(u){return N(u)||k(u)===m},me.isConcurrentMode=N,me.isContextConsumer=function(u){return k(u)===d},me.isContextProvider=function(u){return k(u)===o},me.isElement=function(u){return typeof u=="object"&&u!==null&&u.$$typeof===r},me.isForwardRef=function(u){return k(u)===f},me.isFragment=function(u){return k(u)===n},me.isLazy=function(u){return k(u)===v},me.isMemo=function(u){return k(u)===x},me.isPortal=function(u){return k(u)===a},me.isProfiler=function(u){return k(u)===i},me.isStrictMode=function(u){return k(u)===s},me.isSuspense=function(u){return k(u)===y},me.isValidElementType=function(u){return typeof u=="string"||typeof u=="function"||u===n||u===l||u===i||u===s||u===y||u===g||typeof u=="object"&&u!==null&&(u.$$typeof===v||u.$$typeof===x||u.$$typeof===o||u.$$typeof===d||u.$$typeof===f||u.$$typeof===j||u.$$typeof===E||u.$$typeof===b||u.$$typeof===p)},me.typeOf=k,me}var Fr;function xa(){return Fr||(Fr=1,nr.exports=Ns()),nr.exports}var ya=xa();function Ss(t){function r(O,R,L,T,h){for(var U=0,P=0,Z=0,ee=0,te,q,de=0,fe=0,Q,le=Q=te=0,G=0,oe=0,Re=0,ye=0,$e=L.length,ze=$e-1,Se,H="",ue="",D="",we="",S;G<$e;){if(q=L.charCodeAt(G),G===ze&&P+ee+Z+U!==0&&(P!==0&&(q=P===47?10:47),ee=Z=U=0,$e++,ze++),P+ee+Z+U===0){if(G===ze&&(0te)&&(ye=(H=H.replace(" ",":")).length),0T&&(T=(R=R.trim()).charCodeAt(0)),T){case 38:return R.replace(E,"$1"+O.trim());case 58:return O.trim()+R.replace(E,"$1"+O.trim());default:if(0<1*L&&0P.charCodeAt(8))break;case 115:h=h.replace(P,"-webkit-"+P)+";"+h;break;case 207:case 102:h=h.replace(P,"-webkit-"+(102L.charCodeAt(0)&&(L=L.trim()),z=L,L=[z],01?r-1:0),n=1;n0?" Args: "+a.join(", "):""))}var Os=(function(){function t(a){this.groupSizes=new Uint32Array(512),this.length=512,this.tag=a}var r=t.prototype;return r.indexOfGroup=function(a){for(var n=0,s=0;s=this.groupSizes.length){for(var s=this.groupSizes,i=s.length,o=i;a>=o;)(o<<=1)<0&&Ot(16,""+a);this.groupSizes=new Uint32Array(o),this.groupSizes.set(s),this.length=o;for(var d=i;d=this.length||this.groupSizes[a]===0)return n;for(var s=this.groupSizes[a],i=this.indexOfGroup(a),o=i+s,d=i;d=Ct&&(Ct=r+1),It.set(t,r),zt.set(r,t)},_s="style["+vt+'][data-styled-version="5.3.11"]',Ds=new RegExp("^"+vt+'\\.g(\\d+)\\[id="([\\w\\d-]+)"\\].*?"([^"]*)'),Is=function(t,r,a){for(var n,s=a.split(","),i=0,o=s.length;i=0;l--){var f=m[l];if(f&&f.nodeType===1&&f.hasAttribute(vt))return f}})(a),i=s!==void 0?s.nextSibling:null;n.setAttribute(vt,"active"),n.setAttribute("data-styled-version","5.3.11");var o=zs();return o&&n.setAttribute("nonce",o),a.insertBefore(n,i),n},qs=(function(){function t(a){var n=this.element=ba(a);n.appendChild(document.createTextNode("")),this.sheet=(function(s){if(s.sheet)return s.sheet;for(var i=document.styleSheets,o=0,d=i.length;o=0){var s=document.createTextNode(n),i=this.nodes[a];return this.element.insertBefore(s,i||null),this.length++,!0}return!1},r.deleteRule=function(a){this.element.removeChild(this.nodes[a]),this.length--},r.getRule=function(a){return a0&&(y+=g+",")})),i+=""+l+f+'{content:"'+y+`"}/*!sc*/ +`}}}return i})(this)},t})(),Ks=/(a)(d)/gi,zr=function(t){return String.fromCharCode(t+(t>25?39:97))};function ur(t){var r,a="";for(r=Math.abs(t);r>52;r=r/52|0)a=zr(r%52)+a;return(zr(r%52)+a).replace(Ks,"$1-$2")}var gt=function(t,r){for(var a=r.length;a;)t=33*t^r.charCodeAt(--a);return t},ja=function(t){return gt(5381,t)};function Vs(t){for(var r=0;r>>0);if(!a.hasNameForId(s,d)){var m=n(o,"."+d,void 0,s);a.insertRules(s,d,m)}i.push(d),this.staticRulesId=d}else{for(var l=this.rules.length,f=gt(this.baseHash,n.hash),y="",g=0;g>>0);if(!a.hasNameForId(s,j)){var E=n(y,"."+j,void 0,s);a.insertRules(s,j,E)}i.push(j)}}return i.join(" ")},t})(),Ws=/^\s*\/\/.*$/gm,Ys=[":","[",".","#"];function Xs(t){var r,a,n,s,i=rt,o=i.options,d=o===void 0?rt:o,m=i.plugins,l=m===void 0?$t:m,f=new Ss(d),y=[],g=(function(p){function j(E){if(E)try{p(E+"}")}catch{}}return function(E,b,k,N,u,C,M,I,se,Y){switch(E){case 1:if(se===0&&b.charCodeAt(0)===64)return p(b+";"),"";break;case 2:if(I===0)return b+"/*|*/";break;case 3:switch(I){case 102:case 112:return p(k[0]+b),"";default:return b+(Y===0?"/*|*/":"")}case-2:b.split("/*|*/}").forEach(j)}}})((function(p){y.push(p)})),x=function(p,j,E){return j===0&&Ys.indexOf(E[a.length])!==-1||E.match(s)?p:"."+r};function v(p,j,E,b){b===void 0&&(b="&");var k=p.replace(Ws,""),N=j&&E?E+" "+j+" { "+k+" }":k;return r=b,a=j,n=new RegExp("\\"+a+"\\b","g"),s=new RegExp("(\\"+a+"\\b){2,}"),f(E||!j?"":j,N)}return f.use([].concat(l,[function(p,j,E){p===2&&E.length&&E[0].lastIndexOf(a)>0&&(E[0]=E[0].replace(n,x))},g,function(p){if(p===-2){var j=y;return y=[],j}}])),v.hash=l.length?l.reduce((function(p,j){return j.name||Ot(15),gt(p,j.name)}),5381).toString():"",v}var wa=pe.createContext();wa.Consumer;var ka=pe.createContext(),Js=(ka.Consumer,new va),mr=Xs();function Zs(){return c.useContext(wa)||Js}function ei(){return c.useContext(ka)||mr}var ti=(function(){function t(r,a){var n=this;this.inject=function(s,i){i===void 0&&(i=mr);var o=n.name+i.hash;s.hasNameForId(n.id,o)||s.insertRules(n.id,o,i(n.rules,o,"@keyframes"))},this.toString=function(){return Ot(12,String(n.name))},this.name=r,this.id="sc-keyframes-"+r,this.rules=a}return t.prototype.getName=function(r){return r===void 0&&(r=mr),this.name+r.hash},t})(),ri=/([A-Z])/,ai=/([A-Z])/g,ni=/^ms-/,si=function(t){return"-"+t.toLowerCase()};function qr(t){return ri.test(t)?t.replace(ai,si).replace(ni,"-ms-"):t}var Br=function(t){return t==null||t===!1||t===""};function jt(t,r,a,n){if(Array.isArray(t)){for(var s,i=[],o=0,d=t.length;o1?r-1:0),n=1;n?@[\\\]^`{|}~-]+/g,ci=/(^-|-$)/g;function ir(t){return t.replace(li,"-").replace(ci,"")}var di=function(t){return ur(ja(t)>>>0)};function Dt(t){return typeof t=="string"&&!0}var gr=function(t){return typeof t=="function"||typeof t=="object"&&t!==null&&!Array.isArray(t)},ui=function(t){return t!=="__proto__"&&t!=="constructor"&&t!=="prototype"};function mi(t,r,a){var n=t[a];gr(r)&&gr(n)?Na(n,r):t[a]=r}function Na(t){for(var r=arguments.length,a=new Array(r>1?r-1:0),n=1;n=0||(Y[I]=C[I]);return Y})(r,["componentId"]),u=k&&k+"-"+(Dt(b)?b:ir(Ir(b)));return Ca(b,Xe({},N,{attrs:g,componentId:u}),a)},Object.defineProperty(v,"defaultProps",{get:function(){return this._foldedDefaultProps},set:function(b){this._foldedDefaultProps=n?Na({},t.defaultProps,b):b}}),Object.defineProperty(v,"toString",{value:function(){return"."+v.styledComponentId}}),s&&Rs(v,t,{attrs:!0,componentStyle:!0,displayName:!0,foldedComponentIds:!0,shouldForwardProp:!0,styledComponentId:!0,target:!0,withComponent:!0}),v}var De=function(t){return(function r(a,n,s){if(s===void 0&&(s=rt),!ya.isValidElementType(n))return Ot(1,String(n));var i=function(){return a(n,s,ii.apply(void 0,arguments))};return i.withConfig=function(o){return r(a,n,Xe({},s,{},o))},i.attrs=function(o){return r(a,n,Xe({},s,{attrs:Array.prototype.concat(s.attrs,o).filter(Boolean)}))},i})(Ca,t)};["a","abbr","address","area","article","aside","audio","b","base","bdi","bdo","big","blockquote","body","br","button","canvas","caption","cite","code","col","colgroup","data","datalist","dd","del","details","dfn","dialog","div","dl","dt","em","embed","fieldset","figcaption","figure","footer","form","h1","h2","h3","h4","h5","h6","head","header","hgroup","hr","html","i","iframe","img","input","ins","kbd","keygen","label","legend","li","link","main","map","mark","marquee","menu","menuitem","meta","meter","nav","noscript","object","ol","optgroup","option","output","p","param","picture","pre","progress","q","rp","rt","ruby","s","samp","script","section","select","small","source","span","strong","style","sub","summary","sup","table","tbody","td","textarea","tfoot","th","thead","time","title","tr","track","u","ul","var","video","wbr","circle","clipPath","defs","ellipse","foreignObject","g","image","line","linearGradient","marker","mask","path","pattern","polygon","polyline","radialGradient","rect","stop","svg","text","textPath","tspan"].forEach((function(t){De[t]=De(t)}));var pr={accent:"#0096ff",background:"#ffffff",color:"#000000",inputBg:"#f3f4f8",inputColor:"#000000",inputError:"#e3005e",shadow:"0 5px 10px rgba(0, 0, 0, 0.04)",radius:4,spacing:8,fontSize:"13px"},at;(function(t){t[t.slider=0]="slider",t[t.text=1]="text",t[t.color=2]="color"})(at||(at={}));var tt=function(t,r,a){if(a||arguments.length===2)for(var n=0,s=r.length,i;n=0,f=l?d.slice(0,m):d,y=l?d.slice(m+1,d.length).trim():"";s.push({property:f,value:y,id:o})}),fr(s),s},xi=function(t){var r=[];t.forEach(function(n){n.property.trim()&&r.push("".concat(n.property.trim(),": ").concat(n.value.trim()))});var a=r.join(`; +`);return a},yi=function(t,r,a){c.useEffect(function(){if(r){var n=Aa(t);a(function(s){return JSON.stringify(s)===JSON.stringify(n)?s:n})}},[t,r])},bi=function(t){var r=c.useCallback(function(){var a=document.activeElement;if(!(!a||a.tagName.toLowerCase()!=="input")){var n=Number(a.getAttribute("data-editor-order")),s=t.current;if(s){var i=s.querySelector('input[data-editor-order="'.concat(n+1,'"]'));i&&i.select()}}},[]);return r},Ft=c.createContext(pr),vi=function(t,r){return Object.defineProperty?Object.defineProperty(t,"raw",{value:r}):t.raw=r,t},hr=function(){return hr=Object.assign||function(t){for(var r,a=1,n=arguments.length;a`url('data:image/svg+xml;base64,${btoa('')}')`,ra=t=>{const r={};let a=0,n="";const s=i=>{if(i=i.trim(),!i)return;const o=i.indexOf(":");if(o<=0)return;const d=i.slice(0,o).trim(),m=i.slice(o+1).trim();d&&m&&(r[d]=m)};for(let i=0;iObject.entries(t)?.map(([r,a])=>`${r}: ${a};`).join(" "),Yi=({setting:t,index:r,onChange:a,isDefault:n,onToggleDefault:s})=>{const{t:i}=Ie(),[o,d]=c.useState(!1),m=c.useMemo(()=>typeof t.value=="string"?t.value:"",[t.value]),l=c.useMemo(()=>ra(m),[m]);c.useMemo(()=>{const p=new Set;return t.templateSettingValues&&t.templateSettingValues.length>0&&t.templateSettingValues.forEach(j=>{j.value&&typeof j.value=="object"&&Object.keys(j.value).forEach(E=>p.add(E))}),Array.from(p)},[t.templateSettingValues]);const f=c.useMemo(()=>{const p=t.code.toLowerCase();return p.includes("header")||p.includes("footer")},[t.code]),y=p=>{const j={...p};return j["list-style"]!=="disclosure-closed"&&delete j["list-style-image"],j["list-style"]==="disclosure-closed"&&(j["list-style-image"]=Wi()),j},g=c.useCallback((p,j)=>{let E={...l,[p]:j};p==="list-style"&&["disclosure-closed","disclosure-open","none"].includes(j)&&delete E["list-style-type"],E=y(E);const b=aa(E);a(r,b)},[l,r,a]);c.useEffect(()=>{const p=ra(m),j=y(p),E=aa(j);E!==m&&a(r,E)},[]);const x=c.useCallback(p=>{a(r,p)},[r,a]),v=c.useCallback(p=>{a(r,Gi[p])},[r,a]);return!t||!t.code||!t.id?null:e.jsxs("div",{className:"mt-4 p-4 bg-gray-50 dark:bg-gray-700 rounded-lg border border-gray-200 dark:border-gray-600 space-y-4",children:[e.jsxs("div",{className:"flex items-center justify-between",children:[e.jsx("h4",{className:"text-sm font-medium text-gray-700 dark:text-gray-200",children:t.code.replace(/-/g," ")}),e.jsxs("label",{className:"flex items-center gap-2 cursor-pointer",children:[e.jsx("input",{type:"checkbox",checked:n,onChange:()=>s(t.id),className:"w-4 h-4 rounded border-gray-300 dark:border-gray-500"}),e.jsx("span",{className:"text-xs font-medium text-gray-600 dark:text-gray-300",children:n?"Default":"Custom"})]})]}),e.jsxs("div",{className:"space-y-2",children:[e.jsx("p",{className:"text-xs font-medium text-gray-600 dark:text-gray-300",children:i("template.quickPresets","Quick Presets")}),e.jsxs("div",{className:"grid grid-cols-2 gap-2",children:[e.jsx("button",{onClick:()=>v("heading"),className:"px-2 py-1 text-xs bg-white dark:bg-gray-600 border border-gray-300 dark:border-gray-500 rounded hover:bg-purple-50 dark:hover:bg-purple-900/30 transition-colors dark:text-gray-200",children:"Heading"}),e.jsx("button",{onClick:()=>v("body"),className:"px-2 py-1 text-xs bg-white dark:bg-gray-600 border border-gray-300 dark:border-gray-500 rounded hover:bg-purple-50 dark:hover:bg-purple-900/30 transition-colors dark:text-gray-200",children:"Body"}),e.jsx("button",{onClick:()=>v("emphasis"),className:"px-2 py-1 text-xs bg-white dark:bg-gray-600 border border-gray-300 dark:border-gray-500 rounded hover:bg-purple-50 dark:hover:bg-purple-900/30 transition-colors dark:text-gray-200",children:"Emphasis"}),e.jsx("button",{onClick:()=>v("list"),className:"px-2 py-1 text-xs bg-white dark:bg-gray-600 border border-gray-300 dark:border-gray-500 rounded hover:bg-purple-50 dark:hover:bg-purple-900/30 transition-colors dark:text-gray-200",children:"List"}),e.jsx("button",{onClick:()=>v("title"),className:"px-2 py-1 text-xs bg-white dark:bg-gray-600 border border-gray-300 dark:border-gray-500 rounded hover:bg-purple-50 dark:hover:bg-purple-900/30 transition-colors col-span-2 dark:text-gray-200",children:"Title"})]})]}),e.jsxs("div",{className:"space-y-3 border-t border-gray-200 dark:border-gray-600 pt-3",children:[e.jsx("p",{className:"text-xs font-medium text-gray-600 dark:text-gray-300",children:i("template.commonProperties","Common Properties")}),f?e.jsxs(e.Fragment,{children:[e.jsxs("div",{children:[e.jsx("label",{className:"block text-xs font-medium text-gray-600 dark:text-gray-300 mb-1",children:i("template.width","Width")}),e.jsx("input",{type:"text",value:l.width||"100%",onChange:p=>g("width",p.target.value),className:"w-full px-3 py-2 border border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500 text-sm"})]}),e.jsxs("div",{children:[e.jsx("label",{className:"block text-xs font-medium text-gray-600 dark:text-gray-300 mb-1",children:i("template.height","Height")}),e.jsx("input",{type:"text",value:l.height||"auto",onChange:p=>g("height",p.target.value),className:"w-full px-3 py-2 border border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500 text-sm"})]}),e.jsxs("div",{children:[e.jsx("label",{className:"block text-xs font-medium text-gray-600 dark:text-gray-300 mb-1",children:i("template.margin","Margin")}),e.jsxs("div",{className:"grid grid-cols-2 gap-2",children:[e.jsxs("div",{children:[e.jsx("label",{className:"text-xs text-gray-500 dark:text-gray-400",children:"Top"}),e.jsx("input",{type:"text",value:l["margin-top"]||"0",onChange:p=>g("margin-top",p.target.value),className:"w-full px-2 py-1 border border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 rounded text-sm"})]}),e.jsxs("div",{children:[e.jsx("label",{className:"text-xs text-gray-500 dark:text-gray-400",children:"Right"}),e.jsx("input",{type:"text",value:l["margin-right"]||"0",onChange:p=>g("margin-right",p.target.value),className:"w-full px-2 py-1 border border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 rounded text-sm"})]}),e.jsxs("div",{children:[e.jsx("label",{className:"text-xs text-gray-500 dark:text-gray-400",children:"Bottom"}),e.jsx("input",{type:"text",value:l["margin-bottom"]||"0",onChange:p=>g("margin-bottom",p.target.value),className:"w-full px-2 py-1 border border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 rounded text-sm"})]}),e.jsxs("div",{children:[e.jsx("label",{className:"text-xs text-gray-500 dark:text-gray-400",children:"Left"}),e.jsx("input",{type:"text",value:l["margin-left"]||"0",onChange:p=>g("margin-left",p.target.value),className:"w-full px-2 py-1 border border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 rounded text-sm"})]})]})]}),e.jsxs("div",{children:[e.jsx("label",{className:"block text-xs font-medium text-gray-600 dark:text-gray-300 mb-1",children:i("template.padding","Padding")}),e.jsxs("div",{className:"grid grid-cols-2 gap-2",children:[e.jsxs("div",{children:[e.jsx("label",{className:"text-xs text-gray-500 dark:text-gray-400",children:"Top"}),e.jsx("input",{type:"text",value:l["padding-top"]||"0",onChange:p=>g("padding-top",p.target.value),className:"w-full px-2 py-1 border border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 rounded text-sm"})]}),e.jsxs("div",{children:[e.jsx("label",{className:"text-xs text-gray-500 dark:text-gray-400",children:"Right"}),e.jsx("input",{type:"text",value:l["padding-right"]||"0",onChange:p=>g("padding-right",p.target.value),className:"w-full px-2 py-1 border border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 rounded text-sm"})]}),e.jsxs("div",{children:[e.jsx("label",{className:"text-xs text-gray-500 dark:text-gray-400",children:"Bottom"}),e.jsx("input",{type:"text",value:l["padding-bottom"]||"0",onChange:p=>g("padding-bottom",p.target.value),className:"w-full px-2 py-1 border border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 rounded text-sm"})]}),e.jsxs("div",{children:[e.jsx("label",{className:"text-xs text-gray-500 dark:text-gray-400",children:"Left"}),e.jsx("input",{type:"text",value:l["padding-left"]||"0",onChange:p=>g("padding-left",p.target.value),className:"w-full px-2 py-1 border border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 rounded text-sm"})]})]})]})]}):e.jsxs(e.Fragment,{children:[e.jsxs("div",{children:[e.jsx("label",{className:"block text-xs font-medium text-gray-600 dark:text-gray-300 mb-1",children:i("template.fontSize","Font Size")}),e.jsxs("div",{className:"flex gap-2",children:[e.jsx("input",{type:"number",value:(l["font-size"]||"14px").replace("px",""),onChange:p=>g("font-size",`${p.target.value}px`),placeholder:"14",className:"flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500 text-sm"}),e.jsx("span",{className:"flex items-center text-gray-600 dark:text-gray-400 text-sm",children:"px"})]})]}),e.jsxs("div",{children:[e.jsx("label",{className:"block text-xs font-medium text-gray-600 dark:text-gray-300 mb-1",children:i("template.fontWeight","Font Weight")}),e.jsxs("select",{value:l["font-weight"]||"normal",onChange:p=>g("font-weight",p.target.value),className:"w-full px-3 py-2 border border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500 text-sm",children:[e.jsx("option",{value:"normal",children:"Normal (400)"}),e.jsx("option",{value:"500",children:"Medium (500)"}),e.jsx("option",{value:"600",children:"Semi-bold (600)"}),e.jsx("option",{value:"bold",children:"Bold (700)"}),e.jsx("option",{value:"800",children:"Extra Bold (800)"}),e.jsx("option",{value:"900",children:"Black (900)"})]})]}),e.jsxs("div",{children:[e.jsx("label",{className:"block text-xs font-medium text-gray-600 dark:text-gray-300 mb-1",children:i("template.fontFamily","Font Family")}),e.jsxs("select",{value:l["font-family"]||"serif",onChange:p=>g("font-family",p.target.value),className:"w-full px-3 py-2 border border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500 text-sm",children:[e.jsx("option",{value:"serif",children:"Serif"}),e.jsx("option",{value:"sans-serif",children:"Sans-serif"}),e.jsx("option",{value:"monospace",children:"Monospace"}),e.jsx("option",{value:"cursive",children:"Cursive"}),e.jsx("option",{value:"fantasy",children:"Fantasy"}),e.jsx("option",{value:"Georgia, serif",children:"Georgia"}),e.jsx("option",{value:"'Times New Roman', serif",children:"Times New Roman"}),e.jsx("option",{value:"Arial, sans-serif",children:"Arial"}),e.jsx("option",{value:"Verdana, sans-serif",children:"Verdana"}),e.jsx("option",{value:"'Courier New', monospace",children:"Courier New"})]})]}),e.jsxs("div",{children:[e.jsx("label",{className:"block text-xs font-medium text-gray-600 dark:text-gray-300 mb-1",children:i("template.textDecoration","Text Decoration")}),e.jsxs("select",{value:l["text-decoration"]||"none",onChange:p=>g("text-decoration",p.target.value),className:"w-full px-3 py-2 border border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500 text-sm",children:[e.jsx("option",{value:"none",children:"None"}),e.jsx("option",{value:"underline",children:"Underline"}),e.jsx("option",{value:"overline",children:"Overline"}),e.jsx("option",{value:"line-through",children:"Line Through"}),e.jsx("option",{value:"underline overline",children:"Underline & Overline"})]})]}),e.jsxs("div",{children:[e.jsx("label",{className:"block text-xs font-medium text-gray-600 mb-1",children:i("template.textAlign","Text Align")}),e.jsxs("select",{value:l["text-align"]||"left",onChange:p=>g("text-align",p.target.value),className:"w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500 text-sm",children:[e.jsx("option",{value:"Empty",children:"Empty"}),e.jsx("option",{value:"left",children:"Left"}),e.jsx("option",{value:"center",children:"Center"}),e.jsx("option",{value:"right",children:"Right"}),e.jsx("option",{value:"justify",children:"Justify"})]})]}),e.jsxs("div",{children:[e.jsx("label",{className:"block text-xs font-medium text-gray-600 dark:text-gray-300 mb-1",children:i("template.listStyle","List Style")}),e.jsxs("select",{value:l["list-style"]||"disc",onChange:p=>g("list-style",p.target.value),className:"w-full px-3 py-2 border border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500 text-sm",children:[e.jsx("option",{value:"disc",children:"Bullet"}),e.jsx("option",{value:"circle",children:"Circle"}),e.jsx("option",{value:"square",children:"Square"}),e.jsx("option",{value:"disclosure-closed",children:"Black Right Triangle (▶)"}),e.jsx("option",{value:"decimal",children:"Numbers (1, 2, 3)"}),e.jsx("option",{value:"lower-alpha",children:"Lowercase Letters (a, b, c)"}),e.jsx("option",{value:"upper-alpha",children:"Uppercase Letters (A, B, C)"}),e.jsx("option",{value:"lower-roman",children:"Lowercase Roman (i, ii, iii)"}),e.jsx("option",{value:"upper-roman",children:"Uppercase Roman (I, II, III)"}),e.jsx("option",{value:"none",children:"None"})]})]})]})]}),e.jsxs("button",{onClick:()=>d(!o),className:"w-full flex items-center justify-between px-3 py-2 text-xs font-medium text-gray-600 dark:text-gray-300 hover:bg-white dark:hover:bg-gray-600 rounded transition-colors border border-gray-300 dark:border-gray-500",children:[e.jsx("span",{children:i("template.advancedCSS","Advanced CSS Editor")}),e.jsx(za,{className:`h-4 w-4 transition-transform ${o?"rotate-180":""}`})]}),o&&e.jsxs("div",{className:"border-t border-gray-200 dark:border-gray-600 pt-3 space-y-2",children:[e.jsx("p",{className:"text-xs font-medium text-gray-600 dark:text-gray-300",children:i("template.customCSS","Custom CSS")}),e.jsx(Qi,{style:m,onChange:x})]})]})},Xi={collaborators:[{am:"ዮሃንስ ዶ",en:"John Doe"},{am:"ማርያም ስሚዝ",en:"Mary Smith"}],preferredLanguage:"am",content:{isWithDelegateSignature:!1,delegatorName:"",body:`This is a sample letter body demonstrating the template styling. + +The body can contain multiple paragraphs and formatting. + +- First bullet point item +- Second bullet point item +- Third bullet point item + +This allows you to see how list styles are applied.`,date:new Date().toISOString().split("T")[0],internalCC:["Finance Department","HR Department","Legal Department"],externalCC:["External Partner A","External Partner B"],prefixCC:"CC:",suffixCC:"---",from:["John Doe","Mary Smith"],subject:"Sample Template Subject – Style Preview",sincerelyText:"Sincerely",to:["Department Head","Project Manager","Team Lead"],prefix:"RE:",suffix:"---"},recordType:"external"},Ji=()=>`url('data:image/svg+xml;base64,${btoa(` + +`)}')`,Zi=t=>{const r=[];let a=0,n="";for(let i=0;i0){const l=d.slice(0,m).trim(),f=d.slice(m+1).trim();l&&f&&r.push([l,f])}}n=""}else n+=o}const s=n.trim();if(s){const i=s.indexOf(":");if(i>0){const o=s.slice(0,i).trim(),d=s.slice(i+1).trim();o&&d&&r.push([o,d])}}return r},eo=t=>{const r={...t},a=["disclosure-closed","disclosure-open","none"];if(r["list-style"]){const n=r["list-style"].split(" ")[0];if(a.includes(n))return delete r["list-style-type"],r;r["list-style-type"]||(r["list-style-type"]=n)}return r["list-style-type"]&&!r["list-style"]&&(r["list-style"]=r["list-style-type"]),r},na=t=>{const r={};Zi(t).forEach(([n,s])=>{r[n]=s});const a=eo(r);return a["list-style"]==="disclosure-closed"&&(a["list-style-image"]=Ji()),a};function br(t,r){const[a,n]=c.useState(t);return c.useEffect(()=>{const s=setTimeout(()=>{n(t)},r);return()=>{clearTimeout(s)}},[t,r]),a}const to=({isOpen:t,onConfirm:r,onCancel:a})=>{const{t:n}=Ie();return t?e.jsx("div",{className:"fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50 backdrop-blur-sm",children:e.jsxs("div",{className:"bg-white rounded-xl shadow-xl max-w-md w-full mx-4 overflow-hidden",children:[e.jsxs("div",{className:"flex items-center justify-between p-4 border-b border-gray-200",children:[e.jsxs("div",{className:"flex items-center gap-2 text-amber-600",children:[e.jsx(On,{className:"h-5 w-5"}),e.jsx("h3",{className:"text-lg font-semibold",children:n("common.warning","Warning")})]}),e.jsx("button",{onClick:a,className:"p-1 hover:bg-gray-100 rounded-lg transition-colors",children:e.jsx(lt,{className:"h-5 w-5 text-gray-500"})})]}),e.jsx("div",{className:"p-6",children:e.jsx("p",{className:"text-gray-700",children:n("template.unsavedChangesWarning","You have unsaved changes. Are you sure you want to leave?")})}),e.jsxs("div",{className:"flex justify-end gap-3 p-4 border-t border-gray-200 bg-gray-50",children:[e.jsx("button",{onClick:a,className:"px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors",children:n("common.cancel","Cancel")}),e.jsx("button",{onClick:r,className:"px-4 py-2 text-sm font-medium text-white bg-red-600 rounded-lg hover:bg-red-700 transition-colors",children:n("common.leave","Leave")})]})]})}):null},ro=({visible:t})=>t?e.jsxs("div",{className:"flex items-center gap-2 text-amber-600",children:[e.jsx("div",{className:"w-2 h-2 rounded-full bg-amber-600 animate-pulse"}),e.jsx("span",{className:"text-sm font-medium",children:"Unsaved changes"})]}):null,ao=({pdfUrl:t,loading:r,error:a,onApply:n})=>{const{t:s}=Ie(),[i,o]=c.useState(!0),[d,m]=c.useState(!1);c.useEffect(()=>{t&&(o(!0),m(!1))},[t]);const l=()=>o(!1),f=()=>{o(!1),m(!0)};return e.jsxs("div",{className:"flex flex-col h-full",children:[e.jsx("div",{className:"p-4 border-b",children:e.jsx("button",{onClick:n,disabled:r,className:"w-full px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors",children:r?s("template.generatingPreview","Generating..."):s("template.applyChanges","Apply Changes")})}),a&&e.jsxs("div",{className:"mx-4 mt-4 p-3 bg-red-50 border border-red-200 rounded-lg",children:[e.jsx("p",{className:"text-sm text-red-800",children:a}),e.jsx("button",{onClick:n,className:"mt-2 text-sm text-red-600 hover:text-red-800 font-medium underline",children:s("common.retry","Retry")})]}),e.jsx("div",{className:"flex-1 overflow-hidden relative",children:t?e.jsxs(e.Fragment,{children:[i&&e.jsx("div",{className:"absolute inset-0 flex items-center justify-center bg-white bg-opacity-75 z-10",children:e.jsxs("div",{className:"flex flex-col items-center gap-2",children:[e.jsx(kr,{className:"h-8 w-8 animate-spin text-purple-600"}),e.jsx("span",{className:"text-sm text-gray-600",children:s("template.loadingPdf","Loading PDF...")})]})}),d&&e.jsx("div",{className:"absolute inset-0 flex items-center justify-center bg-gray-50",children:e.jsxs("div",{className:"text-center p-6",children:[e.jsx("p",{className:"text-red-600 font-medium mb-2",children:s("template.pdfLoadError","Failed to load PDF preview")}),e.jsx("a",{href:t,target:"_blank",rel:"noopener noreferrer",className:"text-purple-600 hover:text-purple-700 underline text-sm",children:s("template.openInNewTab","Open in new tab")})]})}),e.jsx("iframe",{src:t,className:"w-full h-full border-0",title:"PDF Preview",onLoad:l,onError:f})]}):e.jsx("div",{className:"flex items-center justify-center h-full text-gray-500",children:s("template.clickApplyToPreview",'Click "Apply Changes" to generate preview')})})]})},no=({templateId:t,onBack:r})=>{var H,ue,D,we;const{t:a}=Ie(),{userDetails:n}=qa(),s=(ue=(H=n==null?void 0:n.employee)==null?void 0:H[0])==null?void 0:ue.unitId,[i,o]=c.useState(!1),d=c.useRef(null),{data:m,isLoading:l}=pa(s),[f,y]=c.useState(null),[g,x]=c.useState([]),[v,p]=c.useState(!1),[j,E]=c.useState(null),[b,k]=c.useState(!1),[N,u]=c.useState(""),[C,M]=c.useState(""),[I,se]=c.useState([]),[Y,$]=c.useState([]),[F,_]=c.useState(!1),[ne,X]=c.useState(null),[W,xe]=c.useState(null),[ie,Ae]=c.useState(!0),[je,z]=c.useState(!0),O=c.useRef(null),[R,L]=c.useState({}),T=c.useRef({}),h=c.useCallback(S=>{const B=S.toLowerCase();return B.includes("header")?"header":B.includes("footer")?"footer":B.includes("subject")?"subject":B.includes("body")||B.includes("content")?"body":B.includes("receiver")?"receiver":B.includes("prefix")||B.includes("suffix")?"prefix-suffix":B.includes("cc")?"cc":B.includes("list")?"list":B==="letter-fonts"?"fonts":B==="letter-page-margin"?"page-margin":B.includes("reference-number")?"reference-number":"other"},[]),U={"letter-list-style":["receiver"],"letter-cc-list-style":["cc"]},P=c.useCallback(S=>U[S]?U[S]:[h(S)],[]),Z=c.useMemo(()=>{const S=new Map;return g.forEach(B=>{P(B.code).forEach(A=>{if(!S.has(A)){const K={header:a("template.headerStyle","Header Style"),footer:a("template.footerStyle","Footer Style"),subject:a("template.subjectStyle","Subject Style"),body:a("template.bodyStyle","Body Style"),receiver:a("template.receiverStyle","Receiver Style"),"prefix-suffix":a("template.prefixSuffix","Prefix / Suffix"),cc:a("template.ccStyles","CC Styles"),list:a("template.listStyle","List Style"),fonts:a("template.fonts","Fonts"),"page-margin":a("template.pageMargin","Page Margin"),"reference-number":a("template.referenceNumberStyles","Reference Number Styles"),other:a("template.other","Other")};S.set(A,{code:A,label:K[A]||A,settingIds:[]})}S.get(A).settingIds.push(B.id)})}),Array.from(S.values())},[g,a,P]),ee=c.useCallback(async()=>{try{const S=await St.getTemplate(t);y(S)}catch{X(a("error.loadingTemplate","Error loading template"))}},[t,a]),te=c.useCallback(async()=>{try{p(!0),X(null);const S=await St.getTemplateSettingsByUnit(t),V=(Array.isArray(S==null?void 0:S.items)?S.items:[])?.map(K=>{var Oe;if(!K.id||!K.code)return null;let he;if(K.value!==null&&K.value!==void 0)he=K.value;else if(((Oe=K.templateSettingValues)==null?void 0:Oe.length)>0){const ce=K.templateSettingValues.find(ve=>ve.unitId!==null&&ve.unitId!==void 0);if((ce==null?void 0:ce.value)!=null)he=ce.value;else{const ve=K.templateSettingValues.find(qe=>qe.value!==null&&qe.value!==void 0);he=ve==null?void 0:ve.value}}let Ue="";return typeof he=="object"&&he!==null&&Object.keys(he).length>0?Ue=Object.entries(he).filter(([,ce])=>ce!=null&&ce!=="null")?.map(([ce,ve])=>`${ce}: ${ve};`).join(" "):typeof he=="string"&&he.trim()&&(Ue=he),{...K,value:Ue}}).filter(K=>K!==null);x(V);const A={};V.forEach(K=>{K&&(A[K.id]=!0)}),L(A)}catch{X(a("error.loadingSettings","Error loading style settings"))}finally{p(!1)}},[t,a]),q=c.useCallback((S,B)=>{var A;x(K=>{const he=[...K];return he[S]={...he[S],value:B},he});const V=(A=g[S])==null?void 0:A.id;V&&L(K=>({...K,[V]:!1})),_(!0)},[g]),de=c.useCallback(S=>{L(B=>{var A,K;const V=!B[S];if(V){const he=g.findIndex(Ue=>Ue.id===S);if(he>=0){const Oe=((K=(A=g[he].templateSettingValues)==null?void 0:A[0])==null?void 0:K.value)||{};let ce="";typeof Oe=="object"&&Oe!==null&&(ce=Object.entries(Oe)?.map(([ve,qe])=>`${ve}: ${qe};`).join(" ")),x(ve=>{const qe=[...ve];return qe[he]={...qe[he],value:ce},qe})}}return{...B,[S]:V}})},[g]),fe=c.useCallback(async()=>{var S,B;if(!(!t||!s))try{k(!0),X(null);let V;if(N){const ce=I.find(ve=>ve.id===N);ce!=null&&ce.fileInfo&&(V=ce.fileInfo)}else I.length>0&&(V=I[0].fileInfo);let A;if(C){const ce=Y.find(ve=>ve.id===C);ce!=null&&ce.fileInfo&&(A=ce.fileInfo)}else Y.length>0&&(A=Y[0].fileInfo);const K={...Xi,unitId:s,...V&&{header:V},...A&&{footer:A}},he=g?.map(ce=>{const ve=typeof ce.value=="string"?ce.value:"",qe=na(ve);return{id:ce.id,code:ce.code,value:qe}}),Ue={data:K,settings:he},Oe=await St.generateSampleTemplate(t,Ue);if(Oe.pdf)E(Oe.pdf);else throw new Error(a("error.noPdfInResponse","No PDF in response"))}catch(V){const A=((B=(S=V==null?void 0:V.response)==null?void 0:S.data)==null?void 0:B.message)||(V==null?void 0:V.message)||a("error.generatingPreview","Error generating preview");X(A),ge.error(A)}finally{k(!1)}},[t,s,N,C,I,Y,g,a]),Q=c.useCallback(async()=>{var S,B;if(t)try{p(!0);const V=g?.map(A=>{const K=typeof A.value=="string"?A.value:"",he=na(K);return{templateSettingId:A.id,resourceId:null,value:he}});await St.saveBulkTemplateSettingValues(V),_(!1),ge.success(a("template.saveSuccess","Style settings saved successfully"))}catch(V){const A=((B=(S=V==null?void 0:V.response)==null?void 0:S.data)==null?void 0:B.message)||(V==null?void 0:V.message)||a("error.savingSettings","Error saving style settings");ge.error(A)}finally{p(!1)}},[t,g,a]),le=c.useCallback(()=>{F?(o(!0),d.current=r):r()},[F,r]),G=c.useCallback(()=>{o(!1),d.current&&(d.current(),d.current=null)},[]),oe=c.useCallback(()=>{o(!1),d.current=null},[]),Re=c.useCallback(S=>{u(S),_(!0)},[]),ye=c.useCallback(S=>{M(S),_(!0)},[]),$e=c.useCallback(S=>{var V;xe(S);const B=g.find(A=>h(A.code)===S);B&&T.current[B.id]&&((V=T.current[B.id])==null||V.scrollIntoView({behavior:"smooth",block:"start"}))},[g,h]);c.useEffect(()=>{var S,B;m&&(se(m.headers||[]),$(m.footers||[]),!N&&((S=m.headers)==null?void 0:S.length)>0&&u(m.headers[0].id),!C&&((B=m.footers)==null?void 0:B.length)>0&&M(m.footers[0].id))},[m,N,C]),c.useEffect(()=>{t&&(ee(),te())},[t,ee,te]),c.useEffect(()=>{Z.length>0&&!W&&xe(Z[0].code)},[Z,W]),c.useEffect(()=>{const S=B=>{F&&(B.preventDefault(),B.returnValue="")};return window.addEventListener("beforeunload",S),()=>window.removeEventListener("beforeunload",S)},[F]);const ze=c.useMemo(()=>JSON.stringify(g?.map(S=>({id:S.id,value:S.value}))),[g]),Se=br(ze,500);return c.useEffect(()=>{if(ie){if(je){z(!1);return}Se!==O.current&&(O.current=Se,fe())}},[Se,ie,fe]),v&&g.length===0?e.jsx("div",{className:"flex items-center justify-center h-screen",children:e.jsx(kr,{className:"h-8 w-8 animate-spin text-purple-600"})}):e.jsxs("div",{className:"flex flex-col h-screen bg-gray-50",children:[e.jsx("div",{className:"bg-white border-b border-gray-200 px-6 py-4",children:e.jsxs("div",{className:"flex items-center justify-between",children:[e.jsxs("div",{className:"flex items-center gap-4",children:[e.jsxs("button",{onClick:le,className:"flex items-center gap-2 text-gray-600 hover:text-gray-900 transition-colors",children:[e.jsx(xn,{className:"h-5 w-5"}),e.jsx("span",{className:"font-medium",children:a("common.back","Back")})]}),e.jsx("div",{className:"h-6 w-px bg-gray-300"}),e.jsx("h1",{className:"text-xl font-semibold text-gray-900",children:((D=f==null?void 0:f.name)==null?void 0:D.en)||a("template.styleSettings","Style Settings")})]}),e.jsxs("div",{className:"flex items-center gap-4",children:[e.jsxs("label",{className:"flex items-center gap-2 text-sm text-gray-600",children:[e.jsx("input",{type:"checkbox",checked:ie,onChange:S=>Ae(S.target.checked),className:"w-4 h-4 rounded border-gray-300"}),a("template.livePreview","Live Preview")]}),e.jsx(ro,{visible:F})]})]})}),ne&&e.jsxs("div",{className:"mx-6 mt-4 p-4 bg-red-50 border border-red-200 rounded-lg",children:[e.jsx("p",{className:"text-sm text-red-800",children:ne}),e.jsx("button",{onClick:te,className:"mt-2 text-sm text-red-600 hover:text-red-800 font-medium",children:a("common.retry","Retry")})]}),e.jsxs("div",{className:"flex-1 flex overflow-hidden",children:[e.jsxs("div",{className:"w-1/2 flex overflow-hidden border-r border-gray-200",children:[e.jsx("div",{className:"w-56 flex-shrink-0 bg-gray-50 border-r border-gray-200 overflow-y-auto",children:e.jsxs("div",{className:"p-4",children:[e.jsx("h3",{className:"text-sm font-semibold text-gray-700 mb-3",children:a("template.styleCategories","Style Categories")}),e.jsx("nav",{className:"space-y-1",children:Z?.map(S=>e.jsx("button",{onClick:()=>$e(S.code),className:be("w-full text-left px-3 py-2 rounded-lg text-sm transition-colors",W===S.code?"bg-purple-100 text-purple-700 font-medium":"text-gray-600 hover:bg-gray-100"),children:e.jsxs("div",{className:"flex items-center justify-between",children:[e.jsx("span",{children:S.label}),e.jsx("span",{className:"text-xs text-gray-400",children:S.settingIds.length})]})},S.code))})]})}),e.jsx("div",{className:"flex-1 overflow-y-auto p-6",children:e.jsxs("div",{className:"space-y-4",children:[e.jsx("h2",{className:"text-lg font-semibold text-gray-900",children:a("template.styleEditor","Style Editor")}),e.jsxs("div",{className:"bg-white border border-gray-200 rounded-lg p-4 space-y-4",children:[e.jsx("h3",{className:"text-sm font-semibold text-gray-800",children:a("template.headerFooterSelection","Header & Footer")}),e.jsxs("div",{className:"grid grid-cols-2 gap-4",children:[e.jsxs("div",{children:[e.jsx("label",{className:"block text-sm font-medium text-gray-700 mb-2",children:a("addRecord.Header","Header")}),e.jsxs("select",{value:N,onChange:S=>Re(S.target.value),className:"w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500",children:[e.jsx("option",{value:"",children:I!=null&&I.length?a("addRecord.Select Header","Select Header"):a("addRecord.Loading Headers...","Loading Headers...")}),I==null?void 0:I?.map(S=>{var B,V;return e.jsx("option",{value:S.id,children:((B=S.name)==null?void 0:B.en)||((V=S.name)==null?void 0:V.am)},S.id)})]})]}),e.jsxs("div",{children:[e.jsx("label",{className:"block text-sm font-medium text-gray-700 mb-2",children:a("addRecord.Footer","Footer")}),e.jsxs("select",{value:C,onChange:S=>ye(S.target.value),className:"w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500",children:[e.jsx("option",{value:"",children:Y!=null&&Y.length?a("addRecord.Select Footer","Select Footer"):a("addRecord.Loading Footers...","Loading Footers...")}),Y==null?void 0:Y?.map(S=>{var B,V;return e.jsx("option",{value:S.id,children:((B=S.name)==null?void 0:B.en)||((V=S.name)==null?void 0:V.am)},S.id)})]})]})]}),N&&I.length>0&&(()=>{var B,V;const S=I.find(A=>A.id===N);return S!=null&&S.presigned?e.jsxs("div",{className:"bg-gray-50 rounded-lg p-4 border-2 border-dashed border-gray-300",children:[e.jsx("p",{className:"text-sm font-medium text-gray-600 mb-2",children:a("addRecord.Header Preview","Header Preview")}),e.jsx("img",{src:S.presigned,alt:((B=S.name)==null?void 0:B.en)||((V=S.name)==null?void 0:V.am)||"Header",className:"max-w-full h-auto"})]}):null})(),C&&Y.length>0&&(()=>{var B,V;const S=Y.find(A=>A.id===C);return S!=null&&S.presigned?e.jsxs("div",{className:"bg-gray-50 rounded-lg p-4 border-2 border-dashed border-gray-300",children:[e.jsx("p",{className:"text-sm font-medium text-gray-600 mb-2",children:a("addRecord.Footer Preview","Footer Preview")}),e.jsx("img",{src:S.presigned,alt:((B=S.name)==null?void 0:B.en)||((V=S.name)==null?void 0:V.am)||"Footer",className:"max-w-full h-auto"})]}):null})()]}),g.length===0&&!v?e.jsx("div",{className:"text-center py-8 text-gray-500",children:a("template.noSettings","No style settings available")}):W&&e.jsxs("div",{className:"mt-4",children:[e.jsx("h3",{className:"text-lg font-semibold text-gray-900 mb-4",children:((we=Z.find(S=>S.code===W))==null?void 0:we.label)||W}),g.filter(S=>P(S.code).includes(W))?.map(S=>{const B=g.findIndex(V=>V.id===S.id);return e.jsx("div",{ref:V=>{T.current[S.id]=V},children:e.jsx(Yi,{setting:S,index:B,onChange:q,isDefault:R[S.id]??!0,onToggleDefault:de})},S.id)}),g.filter(S=>P(S.code).includes(W)).length===0&&e.jsx("p",{className:"text-gray-500 text-sm",children:a("template.noSettingsForCategory","No settings in this category")})]})]})})]}),e.jsx("div",{className:"w-1/2 bg-white",children:e.jsx(ao,{pdfUrl:j,loading:b,error:ne,onApply:fe})})]}),e.jsx("div",{className:"bg-white border-t border-gray-200 px-6 py-4",children:e.jsx("div",{className:"flex items-center justify-end",children:e.jsx("button",{onClick:Q,disabled:v||!F,className:"px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors font-medium",children:v?a("template.saving","Saving..."):a("common.save","Save")})})}),e.jsx(to,{isOpen:i,onConfirm:G,onCancel:oe})]})},so=()=>{const{t}=Ie(),[r,a]=c.useState([]),[n,s]=c.useState(null),[i,o]=c.useState(!0),[d,m]=c.useState(null);c.useEffect(()=>{l()},[]);const l=async()=>{var g;try{o(!0),m(null);const x=await St.getAllTemplates();a(x.items||[]),((g=x.items)==null?void 0:g.length)>0&&s(x.items[0].id)}catch{m(t("error.loadingTemplates","Error loading templates"))}finally{o(!1)}},f=g=>{s(g)},y=()=>{s(null)};return i?e.jsx("div",{className:"flex items-center justify-center h-96",children:e.jsx(kr,{className:"h-8 w-8 animate-spin text-purple-600"})}):d?e.jsx("div",{className:"p-4 bg-red-50 border border-red-200 rounded-lg text-red-700",children:d}):r.length===0?e.jsx("div",{className:"text-center py-12",children:e.jsx("p",{className:"text-gray-500",children:t("template.noTemplates","No templates available")})}):e.jsxs("div",{className:"space-y-4",children:[e.jsx("div",{className:"bg-white rounded-lg border border-gray-200 p-4",children:e.jsxs("div",{className:"flex items-center gap-4",children:[e.jsx("label",{className:"text-sm font-medium text-gray-700",children:t("template.selectTemplate","Select Template")}),e.jsxs(ft,{value:n||"",onValueChange:f,children:[e.jsx(ht,{className:"w-64",children:e.jsx(xt,{placeholder:t("template.selectTemplate")})}),e.jsx(yt,{children:r?.map(g=>{var x,v;return e.jsx(bt,{value:g.id,children:((x=g.name)==null?void 0:x.en)||((v=g.name)==null?void 0:v.am)},g.id)})})]})]})}),n&&e.jsx(no,{templateId:n,onBack:y})]})},io=()=>{var N,u;const{user:t}=Ba(),r=sa.language,[a,n]=c.useState(!1),[s,i]=c.useState(!1),[o,d]=c.useState(""),[m,l]=c.useState(0),f=br(o,300),y=t!=null&&t.employee&&t.employee.length>0?t.employee[0].organizationId:void 0,{getList:g}=Ya(),{data:x,isLoading:v}=y?g(y,{take:300,skip:0}):{data:void 0,isLoading:!1},p=br(C=>{window.dispatchEvent(new CustomEvent("unitChanged",{detail:C}))},300);c.useEffect(()=>{f&&window.dispatchEvent(new CustomEvent("unitChanged",{detail:f}))},[f]);const j=c.useCallback(C=>{d(C),p(C)},[p]);c.useEffect(()=>{const C=()=>{window.innerWidth>=768&&n(!1)};return window.addEventListener("resize",C),()=>window.removeEventListener("resize",C)},[]),c.useEffect(()=>{var C,M;!v&&((M=(C=x==null?void 0:x.data)==null?void 0:C.items)!=null&&M.length)&&!o&&d(x.data.items[0].id)},[v,x,o]);const E=[{label:"contentManagement.seal",icon:e.jsx(ga,{className:"h-5 w-5 flex-shrink-0"}),element:e.jsx(Ln,{unitId:o})},{label:"contentManagement.letterTemplate",icon:e.jsx(Ua,{className:"h-5 w-5 flex-shrink-0"}),element:e.jsx(Kn,{unitId:o})},{label:"recordTag.title",icon:e.jsx(Rn,{className:"h-5 w-5 flex-shrink-0"}),element:e.jsx(ws,{unitId:o})},{label:"contentManagement.prefix",icon:e.jsx(Cn,{className:"h-5 w-5 flex-shrink-0"}),element:e.jsx(hs,{unitId:o,initialTab:"internal"})},{label:"contentManagement.commonRemarks",icon:e.jsx(Nn,{className:"h-5 w-5 flex-shrink-0"}),element:e.jsx(Gn,{unitId:o})},{label:"contentManagement.headerAndFooter",icon:e.jsx(vn,{className:"h-5 w-5 flex-shrink-0"}),element:e.jsx(Wn,{unitId:o})},{label:"template.sample",icon:e.jsx(mt,{className:"h-5 w-5 flex-shrink-0"}),element:e.jsx(so,{})},{label:"dashboard.recentActivities",icon:e.jsx(Tn,{className:"h-5 w-5 flex-shrink-0"}),element:e.jsx(Vn,{activities:[]})}],b=E[m],k=(b==null?void 0:b.label)==="template.sample";return e.jsx("div",{className:"min-h-screen bg-gradient-to-br from-gray-50 to-gray-100/30 dark:from-gray-900 dark:to-gray-800",children:e.jsxs("div",{className:"w-full h-full p-4 lg:p-6 space-y-6",children:[e.jsxs("div",{className:"flex flex-col lg:flex-row justify-between items-start lg:items-center gap-4",children:[e.jsxs("div",{className:"flex-1 min-w-0",children:[e.jsx("h1",{className:"text-2xl lg:text-3xl font-bold text-gray-900 dark:text-white tracking-tight",children:w("organization.contentManagement")}),e.jsx("p",{className:"text-gray-600 dark:text-gray-400 mt-2 text-sm lg:text-base",children:w("organization.contentMsg")})]}),e.jsx("div",{className:"w-full lg:w-auto",children:e.jsx(ys,{})})]}),((u=(N=x==null?void 0:x.data)==null?void 0:N.items)==null?void 0:u.length)>0&&e.jsx("div",{className:"bg-white dark:bg-gray-800 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 p-4 lg:p-6 transition-all duration-200 hover:shadow-md",children:e.jsxs("div",{className:"flex flex-col sm:flex-row sm:items-center gap-4",children:[e.jsxs("div",{className:"flex items-center gap-3 flex-shrink-0",children:[e.jsx("div",{className:"p-2 bg-purple-50 dark:bg-purple-900/30 rounded-lg",children:e.jsx(yn,{className:"h-5 w-5 text-purple-600 dark:text-purple-400"})}),e.jsx("div",{children:e.jsx("label",{className:"block text-sm font-semibold text-gray-900 dark:text-white",children:w("organization.selectUnit")})})]}),e.jsx("div",{className:"flex-1 min-w-0",children:e.jsxs(ft,{value:o,onValueChange:j,disabled:v,children:[e.jsx(ht,{className:be("w-full border-gray-300 dark:border-gray-600 rounded-xl shadow-sm transition-all duration-200 dark:bg-gray-700 dark:text-white","focus:ring-2 focus:ring-purple-500 focus:border-purple-500","hover:border-gray-400 dark:hover:border-gray-500",v&&"opacity-50 cursor-not-allowed"),children:e.jsx(xt,{placeholder:v?w("common.loading"):w("organization.selectUnit")})}),e.jsx(yt,{className:"rounded-xl border border-gray-200 dark:border-gray-600 shadow-lg dark:bg-gray-800",children:x==null?void 0:x.data.items?.map(C=>e.jsx(bt,{value:C.id,className:"rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors dark:text-gray-200",children:e.jsxs("div",{className:"flex items-center gap-2",children:[e.jsx("span",{className:"flex-1 truncate",children:r==="en"?C.name.en:C.name.am}),C.id===o&&e.jsx("div",{className:"w-2 h-2 bg-purple-600 dark:bg-purple-400 rounded-full"})]})},C.id))})]})})]})}),e.jsxs("div",{className:be("flex w-full min-h-[calc(100vh-12rem)] bg-transparent rounded-2xl",k?"overflow-visible":"overflow-hidden"),children:[a&&e.jsx("div",{className:"fixed inset-0 bg-black/50 dark:bg-black/70 z-40 md:hidden backdrop-blur-sm transition-opacity duration-300",onClick:()=>n(!1)}),e.jsxs("div",{className:be("md:sticky top-0 flex flex-col bg-white dark:bg-gray-800 border-r border-gray-200/60 dark:border-gray-700 transition-all duration-300 ease-in-out","backdrop-blur-sm bg-white/95 md:bg-white dark:bg-gray-800/95",a?"absolute left-0 top-0 z-40 w-72 lg:w-80":"hidden md:flex",s?"md:w-16 lg:w-20":"md:w-72 lg:w-80","h-[calc(100vh-2rem)] md:h-screen rounded-2xl md:rounded-none shadow-xl md:shadow-none"),children:[e.jsxs("div",{className:be("flex items-center p-4 border-b border-gray-200/60 dark:border-gray-700 transition-all duration-300",s?"justify-center":"justify-between"),children:[!s&&e.jsx("div",{className:"flex items-center gap-3 min-w-0",children:e.jsx("h2",{className:"text-lg font-bold text-gray-900 dark:text-white truncate",children:w("organization.contentManagement")})}),e.jsxs("div",{className:"flex items-center gap-1",children:[e.jsx("button",{onClick:()=>i(!s),className:be("hidden md:flex p-2 rounded-xl hover:bg-purple-200 dark:hover:bg-purple-900/50 transition-all duration-200","hover:shadow-sm border border-transparent hover:border-purple-200 dark:hover:border-purple-800"),title:s?w("expandSidebar"):w("collapseSidebar"),children:s?e.jsx(Xt,{className:"h-4 w-4 text-gray-600 dark:text-gray-300"}):e.jsx(Xt,{className:"h-4 w-4 text-gray-600 dark:text-gray-300"})}),e.jsx("button",{onClick:()=>n(!1),className:"md:hidden p-2 rounded-xl hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors",children:e.jsx(lt,{className:"h-4 w-4 text-gray-600 dark:text-gray-300"})})]})]}),e.jsx("nav",{className:"flex-1 p-3 space-y-1 overflow-y-auto",children:E?.map((C,M)=>e.jsxs("button",{onClick:()=>{l(M),n(!1)},className:be("w-full flex items-center gap-3 p-3 rounded-xl text-sm font-medium cursor-pointer transition-all duration-200","border border-transparent hover:border-gray-200 dark:hover:border-gray-600 hover:shadow-sm",s?"justify-center":"",m===M?"bg-gradient-to-r from-purple-50 to-purple-50 dark:from-purple-900/30 dark:to-purple-900/20 text-purple-700 dark:text-purple-300 border-purple-200 dark:border-purple-700 shadow-sm":"text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 hover:text-gray-900 dark:hover:text-white"),children:[e.jsx("span",{className:be("transition-transform duration-200",m===M&&"scale-110"),children:C.icon}),!s&&e.jsx("span",{className:"flex-1 text-left truncate font-semibold",children:w(C.label)})]},M))})]}),e.jsxs("div",{className:be("flex-1 flex flex-col min-w-0 transition-all duration-300","md:ml-0"),children:[e.jsxs("div",{className:"md:hidden flex items-center justify-between p-4 bg-white/80 dark:bg-gray-800/80 backdrop-blur-sm border-b border-gray-200/60 dark:border-gray-700 rounded-t-2xl",children:[e.jsx("button",{onClick:()=>n(!0),className:"p-2 rounded-xl hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors shadow-sm border border-gray-200 dark:border-gray-600",children:e.jsx(Xt,{className:"h-5 w-5 text-gray-600 dark:text-gray-300"})}),e.jsx("div",{className:"flex-1 text-center",children:e.jsx("h1",{className:"text-lg font-semibold text-gray-900 dark:text-white truncate",children:w((b==null?void 0:b.label)||"contentmanagement")})}),e.jsx("div",{className:"w-9"})]}),e.jsxs("div",{className:be("flex-1 p-4 md:p-6",k?"overflow-visible":"overflow-auto"),children:[e.jsxs("div",{className:"flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400 mb-6 flex-wrap",children:[e.jsx("span",{className:"text-gray-400 dark:text-gray-500",children:w("content")}),b&&e.jsxs(e.Fragment,{children:[e.jsx(vr,{className:"h-4 w-4 text-gray-400 dark:text-gray-500"}),e.jsx("span",{className:"text-gray-900 dark:text-white font-semibold bg-gray-100 dark:bg-gray-700 px-3 py-1 rounded-full text-sm",children:w(b.label)})]})]}),e.jsx("div",{className:be("w-full transition-opacity duration-300"),children:e.jsx("div",{className:be("rounded-2xl shadow-sm border border-gray-200/60 dark:border-gray-700",k?"overflow-visible":"overflow-hidden"),children:b==null?void 0:b.element})})]})]})]})]})})},Zo=()=>e.jsx(io,{});export{Zo as default}; diff --git a/apps/edr-freight-web/backoffice/public/_um/assets/DashboardPage-Ci5_Ntjr.js b/apps/edr-freight-web/backoffice/public/_um/assets/DashboardPage-Ci5_Ntjr.js new file mode 100644 index 000000000..6fa5aa6b3 --- /dev/null +++ b/apps/edr-freight-web/backoffice/public/_um/assets/DashboardPage-Ci5_Ntjr.js @@ -0,0 +1 @@ +import{j as e,v as n,U as z,aO as C,A as T,f as B,u as O,aF as h,B as c,L as u}from"./index-Db-xuq0b.js";import{C as d,d as g,a as b,b as f}from"./card-BBWyxDss.js";import{T as L,a as S,b as j,c as x,d as E,e as p}from"./table-D3n3VABd.js";import{B as D}from"./badge-D7JvaQeJ.js";import{f as P}from"./formatDistanceToNow-DU8jvZv6.js";import{c as y}from"./utils-BncSPdK1.js";import{u as N}from"./useOrganizations-DiuNBweX.js";import{S as H}from"./SmartOfficeAuditPage-DLN72875.js";import{C as M}from"./circle-alert-Cd_zeQMw.js";import{P as U}from"./plus-BdjS2Pc-.js";import"./en-US-Cc-9gH5A.js";import"./endOfMonth-DmxQbzXi.js";import"./organizationsService-BEVk8qa1.js";import"./avatar-C2-XEZ4v.js";import"./format-DvwV82px.js";import"./shield-uC_usZTb.js";import"./lock-BCybB-Lq.js";import"./eye-Bhud4znU.js";import"./square-pen-B91TPB19.js";import"./download-CX6rsOqt.js";import"./select-BoQxM42A.js";import"./popover-X-j_SRnG.js";import"./Popover-CzANYrEa.js";import"./use-uncontrolled-C3HRHW6t.js";import"./search-CM5F2ZRy.js";import"./label-CsFy6wpo.js";import"./radio-group-DqG5gUnT.js";import"./Radio-C3SNvFel.js";import"./get-auto-contrast-value-Da6zqqWm.js";import"./InputsGroupFieldset-COkNgcEo.js";import"./checkbox-Dd0VhnrO.js";import"./Checkbox-IILksgz0.js";import"./refresh-cw-JB7N413f.js";import"./eye-off-CDMvHElM.js";const _=({organizations:t})=>!t||t.length===0?e.jsx("div",{className:"text-sm text-muted-foreground text-center py-4 border dark:border-gray-700 rounded-xl p-6 dark:bg-gray-800",children:"No organizations found"}):e.jsx("div",{className:"rounded-xl overflow-hidden shadow-sm border bg-white dark:bg-gray-800 dark:border-gray-700",children:e.jsxs(L,{children:[e.jsx(S,{className:"bg-white dark:bg-gray-800 text-muted-foreground",children:e.jsxs(j,{children:[e.jsx(x,{className:"px-6 py-3",children:n("organization.organizationName")}),e.jsx(x,{className:"px-6 py-3",children:n("organization.createdOn")}),e.jsx(x,{className:"px-6 py-3",children:n("dashboard.Status")})]})}),e.jsx(E,{children:t?.map((a,r)=>{var s;return e.jsxs(j,{className:r%2?"bg-purple-50/20 dark:bg-gray-700/40":"bg-white dark:bg-gray-800",children:[e.jsx(p,{className:"px-6 py-3 font-medium",children:((s=a.name)==null?void 0:s.en)||"N/A"}),e.jsx(p,{className:"px-6 py-3",children:a.createdAt?P(new Date(a.createdAt),{addSuffix:!0}):"N/A"}),e.jsx(p,{className:"px-6 py-3",children:e.jsx(D,{variant:a.status==="Active"?"default":"outline",className:a.status==="Active"?"bg-primary-100 text-primary-800 hover:bg-primary-100 dark:bg-primary-900/50 dark:text-primary-300":"bg-gray-100 text-gray-800 hover:bg-gray-100 dark:bg-gray-700 dark:text-gray-300",children:a.status==="Active"?n("statusBar.activate"):n("statusBar.deactivate")})})]},a.id)})})]})}),F={building:T,clock:C,user:z},v=({title:t,value:a,indicator:r,color:s,icon:m,variant:l="default",onClick:o})=>{const i=F[m];return e.jsx(d,{onClick:o,className:y("p-4 rounded-xl shadow-sm transition-colors",l==="primary"?"bg-purple-600 text-white":"bg-white dark:bg-gray-800 dark:border-gray-700",o&&"cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700"),children:e.jsxs(g,{className:"p-0",children:[e.jsxs("div",{className:"flex justify-between items-center mb-4",children:[e.jsx("div",{className:"text-sm font-medium opacity-80",children:t}),e.jsx(i,{className:"w-5 h-5 opacity-60"})]}),e.jsx("div",{className:"text-3xl font-bold leading-snug",children:a}),r&&e.jsx("div",{className:y("text-xs mt-1",s),children:r})]})})},Ne=()=>{const t=B(),a=10,{t:r}=O(),{organizationsResponse:s,isLoading:m,isError:l,refetch:o}=N("Org",{take:a,orderBy:"createdAt",order:"createdAt:Desc"}),{organizationsAdminsResponse:i,isLoading:w,isError:k,refetch:A}=N("Admin",{take:a,orderBy:"updatedAt",order:"updatedAt: DESC"});return m||w?e.jsx("div",{className:"p-6 flex items-center justify-center h-64",children:e.jsxs("div",{className:"flex flex-col items-center gap-2",children:[e.jsx(h,{className:"h-8 w-8 animate-spin text-primary"}),e.jsx("div",{className:"text-sm text-muted-foreground",children:"Loading dashboard..."})]})}):l||k?e.jsx("div",{className:"p-6 flex items-center justify-center h-64",children:e.jsxs("div",{className:"flex flex-col items-center gap-4",children:[e.jsx(M,{className:"h-12 w-12 text-red-500"}),e.jsx("div",{className:"text-red-500 font-medium",children:"Error loading dashboard"}),e.jsx("p",{className:"text-gray-500 dark:text-gray-400 text-center max-w-md mb-4",children:"There was an issue loading the dashboard data. This could be due to network issues or server problems."}),e.jsxs(c,{onClick:()=>{o(),A()},className:"bg-primary hover:bg-primary/90 text-primary-foreground",children:[e.jsx(h,{className:"mr-2 h-4 w-4 animate-spin"}),r("organization.retry")]})]})}):e.jsxs("div",{className:"p-6 space-y-10",children:[e.jsxs(d,{className:"col-span-2 shadow-none border-none bg-transparent px-0",children:[e.jsxs(b,{className:"flex flex-row justify-between items-center px-0",children:[e.jsx(f,{className:"text-xl font-semibold",children:r("organization.statistics")}),e.jsx(u,{to:"/user-management/organizations/new",children:e.jsxs(c,{className:"bg-primary hover:bg-primary/90 text-primary-foreground px-5 py-2 rounded-md text-sm font-medium shadow-md",children:[e.jsx(U,{className:"w-4 h-4 mr-2"}),r("organization.newOrganization")]})})]}),e.jsx(g,{className:"px-0",children:e.jsxs("section",{className:"grid grid-cols-1 md:grid-cols-3 gap-6",children:[e.jsx(v,{title:r("organization.totalOrganizations"),value:(s==null?void 0:s.count)||0,color:"text-primary-500",icon:"building",variant:"primary",onClick:()=>{t("/user-management/organizations")}}),e.jsx(v,{title:r("organization.organizationAdmins"),value:(i==null?void 0:i.count)||0,color:"text-red-500",icon:"user",variant:"default",onClick:()=>{t("/user-management/organization_admins")}})]})})]}),e.jsxs("section",{className:"grid grid-cols-1 md:grid-cols-[2fr_1fr] gap-4",children:[e.jsx(d,{className:"min-w-0 shadow-none border-none bg-transparent px-0",children:e.jsx(H,{})}),e.jsxs(d,{className:" min-w-0 shadow-none border-none bg-transparent px-0",children:[e.jsxs(b,{className:"flex flex-row justify-between items-center px-0",children:[e.jsx(f,{className:"text-base font-semibold",children:r("organization.recentOrganizations")}),e.jsx(u,{to:"/organizations",children:e.jsx(c,{variant:"link",className:"text-purple-600 dark:text-purple-400 text-sm px-0",children:r("organization.viewMore")})})]}),e.jsx(g,{className:"px-0 overflow-x-auto",children:e.jsx(_,{organizations:(s==null?void 0:s.items)||[]})})]})]})]})};export{Ne as default}; diff --git a/apps/edr-freight-web/backoffice/public/_um/assets/EditOrganizationPage-C2QJGrYB.js b/apps/edr-freight-web/backoffice/public/_um/assets/EditOrganizationPage-C2QJGrYB.js new file mode 100644 index 000000000..558892d5d --- /dev/null +++ b/apps/edr-freight-web/backoffice/public/_um/assets/EditOrganizationPage-C2QJGrYB.js @@ -0,0 +1 @@ +import{r as n,j as r,bj as c}from"./index-Db-xuq0b.js";import{u as z}from"./useOrganizations-DiuNBweX.js";import{O}from"./OrganizationForm-Dc20iiRT.js";import"./organizationsService-BEVk8qa1.js";import"./select-BoQxM42A.js";import"./card-BBWyxDss.js";import"./label-CsFy6wpo.js";import"./useOrganizationTypes-B6vCp97C.js";import"./index.esm-BG4gweZJ.js";import"./zod-Df58YiJ6.js";import"./switch-BNCD27Bd.js";import"./Switch-DSHMk-sj.js";import"./InputsGroupFieldset-COkNgcEo.js";import"./use-uncontrolled-C3HRHW6t.js";const f=({id:i})=>{const[a,e]=n.useState(),{editOrganization:s,isEditing:m,getOrganizationByDetails:p}=z("Org"),g=()=>{p(i,{onSuccess:t=>{e(t)}})};n.useEffect(()=>{g()},[]);const d=t=>{const o={key:t.key,name:t.name,organizationTypeId:t.organizationTypeId,isGovernmentOrganization:t.isGovernmentOrganization};t.parentId&&t.parentId!==""&&(o.parentId=t.parentId),s({id:i,payload:o})};return r.jsx(O,{isLoading:m,onSubmit:t=>d(t),type:"Edit",organizationDetails:a})},h=()=>{const{id:i}=c();return i&&r.jsx(f,{id:i})};export{h as default}; diff --git a/apps/edr-freight-web/backoffice/public/_um/assets/ForgotPassword-CyFdgyUu.js b/apps/edr-freight-web/backoffice/public/_um/assets/ForgotPassword-CyFdgyUu.js new file mode 100644 index 000000000..b9b30fb11 --- /dev/null +++ b/apps/edr-freight-web/backoffice/public/_um/assets/ForgotPassword-CyFdgyUu.js @@ -0,0 +1 @@ +import{u as b,f as w,r as l,j as e,H as j,I as N,B as h,h as y,t as p}from"./index-Db-xuq0b.js";import{b as v}from"./Utils-BP0IYDrC.js";import{L as k}from"./lock-BCybB-Lq.js";import{P as C}from"./phone-Ce15UUGI.js";import{M as f}from"./mail-bwr0seHR.js";import{R as P}from"./refresh-cw-JB7N413f.js";import{A as S}from"./arrow-left-CE5-YfaQ.js";const F=()=>{const{t:s}=b(),t=w(),[n,u]=l.useState(""),[d,c]=l.useState(!1),[m,a]=l.useState(""),g=async o=>{o.preventDefault(),a("");let r=n;if(!v(r)){a(s("forgotpassword.invalidphone"));return}r.startsWith("0")&&(r="+251"+r.slice(1)),c(!0);try{await y(r),p.success(s("forgotpassword.success"),{description:s("forgotpassword.successdesc")}),setTimeout(()=>t("/"),3e3)}catch(i){const x=(i==null?void 0:i.message)||s("forgotpassword.fail");a(x),p.error(s("forgotpassword.fail"),{description:x})}finally{c(!1)}};return e.jsxs("div",{className:"min-h-screen bg-gradient-to-br from-cyan-50 via-white to-primary-50 relative overflow-hidden",children:[e.jsx("div",{className:"absolute top-0 right-0 w-96 h-96 bg-cyan-100/30 rounded-full blur-3xl"}),e.jsx("div",{className:"absolute bottom-0 left-0 w-96 h-96 bg-primary-100/30 rounded-full blur-3xl"}),e.jsxs("button",{onClick:()=>t("/"),className:"absolute top-6 left-6 z-10 flex items-center gap-2 px-4 py-2 bg-white/80 backdrop-blur-sm hover:bg-white rounded-full shadow-md hover:shadow-lg transition-all duration-300 group",children:[e.jsx(j,{className:"w-4 h-4 text-primary group-hover:scale-110 transition-transform"}),e.jsx("span",{className:"text-sm font-medium text-gray-700",children:s("forgotpassword.home")})]}),e.jsx("div",{className:"relative min-h-screen flex items-center justify-center p-4",children:e.jsxs("div",{className:"w-full max-w-md",children:[e.jsxs("div",{className:"bg-white rounded-2xl shadow-xl border border-gray-100 overflow-hidden",children:[e.jsxs("div",{className:"bg-gradient-to-r from-primary to-primary-500 p-8 text-center relative",children:[e.jsx("div",{className:"absolute inset-0 bg-white/5"}),e.jsxs("div",{className:"relative",children:[e.jsx("div",{className:"w-16 h-16 bg-white/20 backdrop-blur-sm rounded-full flex items-center justify-center mx-auto mb-4",children:e.jsx(k,{className:"w-8 h-8 text-white"})}),e.jsx("h1",{className:"text-2xl font-bold text-white mb-2",children:s("forgotpassword.title")}),e.jsx("p",{className:"text-cyan-50 text-sm",children:s("forgotpassword.subtitle")})]})]}),e.jsx("div",{className:"p-8",children:e.jsxs("form",{onSubmit:g,className:"space-y-6",children:[e.jsxs("div",{className:"space-y-2",children:[e.jsxs("label",{className:"text-sm font-medium text-gray-700 flex items-center gap-2",children:[e.jsx(C,{className:"w-4 h-4 text-primary"}),s("forgotpassword.phone")]}),e.jsx("div",{className:"relative",children:e.jsx(N,{type:"tel",placeholder:s("forgotpassword.phoneplaceholder"),className:"h-12 rounded-lg border-gray-200 px-4 text-sm focus:border-primary focus:ring-primary transition-all",value:n,onChange:o=>{u(o.target.value),a("")},required:!0})}),m&&e.jsxs("div",{className:"flex items-start gap-2 p-3 bg-red-50 border border-red-100 rounded-lg",children:[e.jsx("div",{className:"w-1 h-1 bg-red-500 rounded-full mt-1.5"}),e.jsx("p",{className:"text-sm text-red-600 flex-1",children:m})]})]}),e.jsxs("div",{className:"flex items-start gap-3 p-4 bg-cyan-50 border border-cyan-100 rounded-lg",children:[e.jsx(f,{className:"w-5 h-5 text-primary flex-shrink-0 mt-0.5"}),e.jsxs("div",{className:"flex-1",children:[e.jsx("p",{className:"text-sm text-gray-700 font-medium mb-1",children:s("forgotpassword.checkphone")}),e.jsx("p",{className:"text-xs text-gray-600",children:s("forgotpassword.checkdesc")})]})]}),e.jsxs("div",{className:"space-y-3",children:[e.jsx(h,{type:"submit",className:"w-full h-12 bg-primary hover:bg-primary-500 text-white text-sm font-medium rounded-lg shadow-md hover:shadow-lg transition-all duration-300",disabled:d,children:d?e.jsxs("span",{className:"flex items-center justify-center gap-2",children:[e.jsx(P,{className:"animate-spin h-5 w-5"}),s("forgotpassword.sending")]}):e.jsxs("span",{className:"flex items-center justify-center gap-2",children:[e.jsx(f,{className:"h-5 w-5"}),s("forgotpassword.sendresetlink")]})}),e.jsxs(h,{type:"button",variant:"outline",className:"w-full h-12 text-sm font-medium border-gray-200 hover:bg-gray-50 rounded-lg transition-all duration-300 bg-transparent",onClick:()=>t("/login"),children:[e.jsx(S,{className:"h-4 w-4 mr-2"}),s("forgotpassword.backtologin")]})]})]})})]}),e.jsxs("p",{className:"text-center text-sm text-gray-500 mt-6",children:[s("forgotpassword.remember")," ",e.jsx("button",{onClick:()=>t("/login"),className:"text-primary hover:text-primary-500 font-medium transition-colors",children:s("forgotpassword.signin")})]})]})})]})};export{F as ForgotPassword,F as default}; diff --git a/apps/edr-freight-web/backoffice/public/_um/assets/FormFields-BA7SVWfi.js b/apps/edr-freight-web/backoffice/public/_um/assets/FormFields-BA7SVWfi.js new file mode 100644 index 000000000..af3c3279b --- /dev/null +++ b/apps/edr-freight-web/backoffice/public/_um/assets/FormFields-BA7SVWfi.js @@ -0,0 +1 @@ +import{r as i,j as n,B as k,aI as u}from"./index-Db-xuq0b.js";import"./form-BeLK5rTt.js";import"./multi-select-C9K8HXhI.js";import"./single-select-D9ZG1edj.js";i.createContext({open:!1,setOpen:()=>{}});const O=({label:h,options:x,value:o,onChange:j,collapsible:f=!1,localizedName:r})=>{const[l,b]=i.useState(!f),[m,S]=i.useState(new Set),w=t=>{const e=new Set(m);e.has(t)?e.delete(t):e.add(t),S(e)},g=t=>{j(t)},p=(t,e=[])=>{for(const s of t){const a=typeof s.name=="string"?s.name:(r==null?void 0:r(s.name))??s.name.en;if(s.id===o)return[...e,a].join(" / ");if(s.children&&s.children.length>0){const c=p(s.children,[...e,a]);if(c)return c}}return null},d=t=>t.flatMap(e=>{const s=e.children&&e.children.length>0,a=m.has(e.id),c=s?e.children?.some(v=>v.id===o):!1;if(s&&e.children.length===1)return d(e.children);const C=typeof e.name=="string"?e.name:(r==null?void 0:r(e.name))??e.name.en;return n.jsxs("div",{className:"ml-4 mb-1",children:[n.jsxs("div",{className:"flex items-center space-x-2",children:[s&&n.jsx("button",{type:"button",onClick:()=>w(e.id),className:"w-4 h-4 flex items-center justify-center",children:n.jsx(u,{className:`h-3 w-3 transition-transform ${a?"rotate-180":""}`})}),n.jsxs("label",{className:"flex items-center space-x-2",children:[n.jsx("input",{type:"radio",name:"unit-select",checked:o===e.id||c,onChange:()=>g(e.id)}),n.jsx("span",{className:c?"font-semibold":"",children:C})]})]}),s&&a&&n.jsx("div",{className:"ml-4",children:d(e.children)})]},e.id)}),y=p(x);return n.jsxs("div",{className:"mb-4",children:[n.jsx("label",{className:"block font-semibold mb-1",children:h}),f&&n.jsxs(k,{type:"button",variant:"outline",onClick:()=>b(!l),className:"w-full justify-between mb-2",children:[n.jsx("span",{children:y??`Select ${h.toLowerCase()}`}),n.jsx(u,{className:`h-4 w-4 transition-transform ${l?"rotate-180":""}`})]}),l&&n.jsx("div",{className:"border rounded-md p-2 bg-background max-h-96 overflow-y-auto",children:d(x)})]})};export{O as S}; diff --git a/apps/edr-freight-web/backoffice/public/_um/assets/InputsGroupFieldset-COkNgcEo.js b/apps/edr-freight-web/backoffice/public/_um/assets/InputsGroupFieldset-COkNgcEo.js new file mode 100644 index 000000000..22b2860c1 --- /dev/null +++ b/apps/edr-freight-web/backoffice/public/_um/assets/InputsGroupFieldset-COkNgcEo.js @@ -0,0 +1 @@ +import{r as W,V as B,j as e,Q as o,ae as x,_ as C,Z as R,at as w}from"./index-Db-xuq0b.js";var u={root:"m_5f75b09e",body:"m_5f6e695e",labelWrapper:"m_d3ea56bb",label:"m_8ee546b8",description:"m_328f68c0",error:"m_8e8a99cc"};const N=u,D=W.forwardRef(({__staticSelector:t,__stylesApiProps:l,className:s,classNames:f,styles:_,unstyled:h,children:I,label:i,description:d,id:p,disabled:b,error:n,size:r,labelPosition:j="left",bodyElement:c="div",labelElement:m="label",variant:v,style:y,vars:E,mod:S,...F},g)=>{const a=B({name:t,props:l,className:s,style:y,classes:u,classNames:f,styles:_,unstyled:h});return e.jsx(o,{...a("root"),ref:g,__vars:{"--label-fz":R(r),"--label-lh":C(r,"label-lh")},mod:[{"label-position":j},S],variant:v,size:r,...F,children:e.jsxs(o,{component:c,htmlFor:c==="label"?p:void 0,...a("body"),children:[I,e.jsxs("div",{...a("labelWrapper"),"data-disabled":b||void 0,children:[i&&e.jsx(o,{component:m,htmlFor:m==="label"?p:void 0,...a("label"),"data-disabled":b||void 0,children:i}),d&&e.jsx(x.Description,{size:r,__inheritStyles:!1,...a("description"),children:d}),n&&typeof n!="boolean"&&e.jsx(x.Error,{size:r,__inheritStyles:!1,...a("error"),children:n})]})]})})});D.displayName="@mantine/core/InlineInput";function Q({children:t,role:l}){const s=w();return s?e.jsx("div",{role:l,"aria-labelledby":s.labelId,"aria-describedby":s.describedBy,children:t}):e.jsx(e.Fragment,{children:t})}export{Q as I,D as a,N as b}; diff --git a/apps/edr-freight-web/backoffice/public/_um/assets/LoginPage-CpY519LO.js b/apps/edr-freight-web/backoffice/public/_um/assets/LoginPage-CpY519LO.js new file mode 100644 index 000000000..9a5dd87f6 --- /dev/null +++ b/apps/edr-freight-web/backoffice/public/_um/assets/LoginPage-CpY519LO.js @@ -0,0 +1 @@ +import{u as H,r as n,a as U,j as e,I as T,B as M,b as $,c as Y,d as W,g as Z,L as A,S as G,M as J,U as Q,e as X}from"./index-Db-xuq0b.js";import{S as _,a as ee,b as re,c as ae,d as L}from"./select-BoQxM42A.js";import{i as q,a as F}from"./Utils-BP0IYDrC.js";import{C as R,a as te,b as se,c as le,d as B}from"./card-BBWyxDss.js";import{A as oe}from"./arrow-left-CE5-YfaQ.js";import{L as ie}from"./lock-BCybB-Lq.js";import{E as ne}from"./eye-Bhud4znU.js";import{E as de}from"./eye-off-CDMvHElM.js";import{P as ce}from"./phone-Ce15UUGI.js";import{M as me}from"./mail-bwr0seHR.js";const xe=({isOpen:h,onClose:b,isLoading:m=!1,title:j,description:E,length:t=6,identifier:I,identifierType:w})=>{const{t:d}=H(),[x,u]=n.useState(Array(t).fill("")),c=n.useRef([]),{verifyMFA:v}=U(),P=j||d("otp.verifyIdentity")||"Verify Your Identity",k=E||d("otp.enterCodeSent")||"Enter the verification code sent to your email",D=d("common.verify")||"Verify",f=d("common.cancel")||"Cancel",[N,g]=n.useState("");n.useEffect(()=>{var a;(a=c.current[0])==null||a.focus()},[]),n.useEffect(()=>{h&&(u(Array(t).fill("")),setTimeout(()=>{var a;(a=c.current[0])==null||a.focus()},100))},[h,t]);const p=(a,s)=>{var o;if(!/^[a-zA-Z0-9]?$/.test(s))return;const l=[...x];l[a]=s,u(l),s&&ai!=="")&&a===t-1&&r(l.join(""))},y=(a,s)=>{var l;s.key==="Backspace"&&!x[a]&&a>0&&((l=c.current[a-1])==null||l.focus())},C=a=>{var o;a.preventDefault();const l=a.clipboardData.getData("text").slice(0,t).split("").filter(i=>/^\d?$/.test(i));if(l.length>0){const i=[...x];l.forEach((V,z)=>{zV===""),K=S===-1?t-1:Math.min(S,t-1);(o=c.current[K])==null||o.focus()}},r=a=>{let s=I;if(w==="phone"){if(!q(s)){g(d("auth.err"));return}s.startsWith("0")&&(s="+251"+s.slice(1))}else if(w==="email"&&!F(s)){g("Please enter a valid email address");return}v({email:s,otp:a})},O=()=>{var a;u(Array(t).fill("")),(a=c.current[0])==null||a.focus()};return h?e.jsxs("div",{className:"fixed inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm dark:bg-black/80",children:[e.jsxs(R,{className:"w-96 shadow-2xl border-0 animate-in fade-in-90 zoom-in-90 bg-white dark:bg-gray-800",children:[e.jsx(te,{className:"pb-4",children:e.jsxs("div",{className:"flex items-center gap-3",children:[e.jsx("div",{className:"p-2 rounded-lg bg-primary-100 dark:bg-primary-900/30 text-primary-600 dark:text-primary-400",children:e.jsx("svg",{className:"w-5 h-5",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24",children:e.jsx("path",{strokeLinecap:"round",strokeLinejoin:"round",strokeWidth:2,d:"M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"})})}),e.jsxs("div",{children:[e.jsx(se,{className:"text-xl dark:text-white",children:P}),e.jsx(le,{className:"dark:text-gray-400",children:k})]})]})}),e.jsxs(B,{className:"space-y-6",children:[e.jsx("div",{className:"flex justify-center gap-2",children:x?.map((a,s)=>e.jsx(T,{ref:l=>{l&&(c.current[s]=l)},type:"text",inputMode:"numeric",pattern:"[0-9]*",maxLength:1,value:a,onChange:l=>p(s,l.target.value),onKeyDown:l=>y(s,l),onPaste:C,className:"w-12 h-12 text-center text-lg font-semibold focus:ring-2 focus:ring-primary-500 border-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-white border-gray-200 dark:border-gray-600 dark:focus:border-primary-500",disabled:m},s))}),e.jsxs("div",{className:"flex gap-3 pt-2",children:[e.jsx(M,{type:"button",variant:"outline",onClick:b,disabled:m,className:"flex-1 hover:bg-primary-100 dark:hover:bg-primary-900/30 transition-all duration-200 border-gray-200 dark:border-gray-600 text-gray-700 dark:text-gray-300",children:f}),e.jsx(M,{type:"button",onClick:()=>r(x.join("")),disabled:m||x.join("").length!==t,className:"flex-1 bg-gradient-to-r from-primary-600 to-primary-700 hover:from-primary-700 hover:to-primary-800 text-white shadow-lg hover:shadow-xl transition-all duration-200 disabled:opacity-50",children:m?e.jsxs("div",{className:"flex items-center gap-2",children:[e.jsx("div",{className:"animate-spin rounded-full h-4 w-4 border-b-2 border-white"}),d("common.verifying")||"Verifying..."]}):D})]}),e.jsxs("div",{className:"flex justify-between items-center text-sm",children:[e.jsx("button",{type:"button",onClick:O,className:"text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300 font-medium transition-colors disabled:opacity-50",disabled:m,children:d("otp.clearCode")||"Clear Code"}),e.jsx("button",{type:"button",className:"text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300 font-medium transition-colors disabled:opacity-50",disabled:m,children:d("otp.resendCode")||"Resend Code"})]}),N&&e.jsxs("p",{className:"text-red-500 dark:text-red-400 text-sm mt-2 flex items-center gap-1",children:[e.jsx("span",{className:"w-1 h-1 bg-red-500 dark:bg-red-400 rounded-full"}),N]})]})]}),m&&e.jsx("div",{className:"absolute inset-0 bg-background/50 backdrop-blur-sm rounded-lg flex items-center justify-center",children:e.jsx(R,{className:"w-80 shadow-2xl border-0 bg-white dark:bg-gray-800",children:e.jsxs(B,{className:"p-6 flex flex-col items-center gap-4",children:[e.jsxs("div",{className:"flex items-center gap-3",children:[e.jsx("div",{className:"animate-spin rounded-full h-6 w-6 border-b-2 border-primary-600"}),e.jsx("span",{className:"text-lg font-semibold text-primary-800 dark:text-primary-300",children:d("common.verifying")||"Verifying..."})]}),e.jsx("p",{className:"text-muted-foreground dark:text-gray-400 text-center text-sm",children:d("otp.verifyingCode")||"Please wait while we verify your code"})]})})})]}):null},he=()=>{const{login:h,isLoggingIn:b}=U(),{config:m}=$(),{isDarkMode:j,toggleDarkMode:E}=Y(),[t,I]=n.useState("email"),[w,d]=n.useState(""),{showOtpModal:x,setShowOtpModal:u,setRememberMePreference:c}=W(),[v,P]=n.useState(""),[k,D]=n.useState(""),[f,N]=n.useState("password"),[g,p]=n.useState(""),[y,C]=n.useState(!1),{t:r}=H();n.useEffect(()=>{const o=Z();C(o),c(o)},[c]);const O=async o=>{o.preventDefault(),p("");try{let i=v;if(t==="phone"){if(!q(i)){p(r("auth.err"));return}i.startsWith("0")&&(i="+251"+i.slice(1))}else if(t==="email"&&!F(i)){p(r("auth.invalidEmail"));return}const S={email:i,password:k};d(i),c(y),h({payload:S,rememberMeValue:y})}catch{}},a=()=>{switch(t){case"email":return r("auth.email");case"phone":return r("auth.phoneNumber")+" (e.g., +251912345678)";case"username":return r("auth.username");default:return""}},s=()=>{switch(t){case"email":return e.jsx(me,{className:"absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400 dark:text-gray-500"});case"phone":return e.jsx(ce,{className:"absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400 dark:text-gray-500"});case"username":return e.jsx(Q,{className:"absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400 dark:text-gray-500"});default:return null}},l=()=>t==="phone"?"tel":"text";return e.jsxs(e.Fragment,{children:[e.jsxs("div",{className:"min-h-screen bg-gradient-to-br from-gray-50 to-gray-100 dark:from-gray-900 dark:to-gray-800 p-4 md:p-6 lg:p-8 flex items-center justify-center",children:[e.jsxs(A,{to:"/",className:"fixed top-4 left-4 md:top-6 md:left-6 z-50 flex items-center gap-2 px-4 py-2 bg-white dark:bg-gray-800 rounded-full shadow-md hover:shadow-lg transition-all duration-300 hover:scale-105 text-gray-700 dark:text-gray-200 hover:text-primary group",children:[e.jsx(oe,{className:"w-5 h-5 text-gray-600 dark:text-gray-400 transition-transform group-hover:-translate-x-1"}),e.jsx("span",{className:"text-sm font-medium hidden sm:inline",children:r("Back to Home")})]}),e.jsx("button",{onClick:E,className:"fixed top-4 right-4 md:top-6 md:right-6 z-50 p-2 rounded-full bg-white dark:bg-gray-800 shadow-md hover:shadow-lg transition-all duration-300 hover:scale-105 border border-gray-200 dark:border-gray-700",title:j?"Switch to light mode":"Switch to dark mode",children:j?e.jsx(G,{className:"w-5 h-5 text-yellow-500"}):e.jsx(J,{className:"w-5 h-5 text-gray-600 dark:text-gray-400"})}),e.jsxs("div",{className:"w-full max-w-6xl bg-white dark:bg-gray-800 rounded-2xl shadow-2xl overflow-hidden flex flex-col lg:flex-row",children:[e.jsxs("div",{className:"lg:w-1/2 w-full p-6 sm:p-8 md:p-10 lg:p-12 xl:p-16 flex flex-col justify-center",children:[e.jsxs("div",{className:"mb-8",children:[e.jsx("img",{src:m.logo||"/assets/smart-office-logo.svg",alt:"Smart Office Logo",className:"h-10 md:h-12 mb-8"}),e.jsx("h1",{className:"text-3xl sm:text-4xl lg:text-5xl font-bold text-gray-900 dark:text-white mb-3",children:r("auth.welcomeBack")}),e.jsx("p",{className:"text-base text-gray-500 dark:text-gray-400",children:r("auth.enterCredentials")})]}),e.jsxs("form",{onSubmit:O,className:"space-y-6",children:[e.jsxs("div",{className:"space-y-4",children:[e.jsxs("div",{children:[e.jsx("label",{className:"block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2",children:r("Login Method")}),e.jsxs(_,{value:t,onValueChange:o=>I(o),children:[e.jsx(ee,{className:"w-full h-12 border-2 border-gray-200 dark:border-gray-600 focus:border-primary bg-white dark:bg-gray-700 transition-colors",children:e.jsx(re,{placeholder:"Method"})}),e.jsxs(ae,{className:"dark:bg-gray-700 dark:text-white",children:[e.jsx(L,{value:"email",className:"dark:hover:bg-gray-600 dark:focus:bg-gray-600",children:r("auth.email")}),e.jsx(L,{value:"phone",className:"dark:hover:bg-gray-600 dark:focus:bg-gray-600",children:r("auth.phoneNumber")}),e.jsx(L,{value:"username",className:"dark:hover:bg-gray-600 dark:focus:bg-gray-600",children:r("auth.username")})]})]})]}),e.jsxs("div",{children:[e.jsx("label",{className:"block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2",children:r(t==="email"?"auth.email":t==="phone"?"auth.phoneNumber":"auth.username")}),e.jsxs("div",{className:"relative",children:[s(),e.jsx(T,{type:l(),placeholder:a(),className:"h-12 rounded-lg border-2 border-gray-200 dark:border-gray-600 px-4 text-sm pl-11 pr-4 focus:border-primary transition-colors text-gray-900 dark:text-white bg-white dark:bg-gray-700 placeholder-gray-500 dark:placeholder-gray-400",value:v,onChange:o=>{P(o.target.value),g&&p("")},required:!0})]}),g&&e.jsxs("p",{className:"text-red-500 dark:text-red-400 text-sm mt-2 flex items-center gap-1",children:[e.jsx("span",{className:"w-1 h-1 bg-red-500 dark:bg-red-400 rounded-full"}),g]})]}),e.jsxs("div",{children:[e.jsx("label",{className:"block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2",children:r("auth.password")}),e.jsxs("div",{className:"relative",children:[e.jsx(ie,{className:"absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400 dark:text-gray-500"}),e.jsx(T,{type:f,placeholder:r("auth.password"),className:"h-12 rounded-lg border-2 border-gray-200 dark:border-gray-600 px-4 text-sm pl-11 pr-4 focus:border-primary transition-colors text-gray-900 dark:text-white bg-white dark:bg-gray-700 placeholder-gray-500 dark:placeholder-gray-400",value:k,onChange:o=>D(o.target.value),required:!0}),e.jsx(M,{type:"button",variant:"ghost",size:"icon",onClick:()=>N(f==="password"?"text":"password"),className:"absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300",children:f==="password"?e.jsx(ne,{className:"w-5 h-5"}):e.jsx(de,{className:"w-5 h-5"})})]})]})]}),e.jsxs("div",{className:"flex flex-col sm:flex-row justify-between items-start sm:items-center gap-3",children:[e.jsxs("label",{className:"flex items-center gap-2 cursor-pointer group",children:[e.jsx("input",{type:"checkbox",className:"w-4 h-4 accent-primary cursor-pointer bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-500",checked:y,onChange:o=>C(o.target.checked)}),e.jsx("span",{className:"text-sm text-gray-600 dark:text-gray-400 group-hover:text-gray-900 dark:group-hover:text-gray-200 transition-colors",children:r("auth.rememberMe")})]}),e.jsx(A,{to:"/forgot-password",className:"text-sm text-primary hover:text-primary-700 font-medium transition-colors",children:r("auth.forgotPassword")})]}),e.jsx(M,{type:"submit",className:"w-full h-12 bg-primary hover:bg-primary-700 text-white text-base font-medium rounded-lg shadow-lg hover:shadow-xl transition-all duration-300 hover:scale-[1.02] disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100",disabled:b,children:b?e.jsxs("span",{className:"flex items-center gap-2",children:[e.jsxs("svg",{className:"animate-spin h-5 w-5",viewBox:"0 0 24 24",children:[e.jsx("circle",{className:"opacity-25",cx:"12",cy:"12",r:"10",stroke:"currentColor",strokeWidth:"4",fill:"none"}),e.jsx("path",{className:"opacity-75",fill:"currentColor",d:"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"})]}),r("auth.loggingIn")]}):r("auth.login")}),e.jsxs("div",{className:"text-center pt-2",children:[e.jsxs("span",{className:"text-sm text-gray-600 dark:text-gray-400",children:[r("Don't have an account?")," "]}),e.jsx(A,{to:"/external-portal/signup",className:"text-sm text-primary hover:text-primary-700 font-medium transition-colors",children:r("Sign Up")})]})]})]}),e.jsxs("div",{className:"hidden lg:flex lg:w-1/2 bg-gradient-to-br from-primary to-primary-700 text-white flex-col justify-center p-10 xl:p-16 relative overflow-hidden",children:[e.jsx("div",{className:"absolute top-0 right-0 w-64 h-64 bg-white/10 rounded-full blur-3xl"}),e.jsx("div",{className:"absolute bottom-0 left-0 w-96 h-96 bg-white/5 rounded-full blur-3xl"}),e.jsxs("div",{className:"relative z-10",children:[e.jsxs("h2",{className:"text-3xl xl:text-4xl font-bold mb-4 leading-tight",children:[r("auth.welcomeHeadline")," ",e.jsx("br",{})," ",r("auth.paperlessOffice")]}),e.jsx("p",{className:"text-base xl:text-lg mb-8 text-white/90 leading-relaxed",children:r("auth.welcomeSubtext")}),e.jsxs("div",{className:"relative w-full max-w-lg mx-auto mt-12",children:[e.jsx("div",{className:"relative rounded-2xl overflow-hidden shadow-2xl border-4 border-white/20",children:e.jsx("img",{src:"/assets/MainDashboard.png",alt:"Main Dashboard",className:"w-full"})}),e.jsx("div",{className:"absolute -right-4 top-1/2 -translate-y-1/2 shadow-2xl rounded-xl",children:e.jsx("img",{src:"/assets/StatsCard.png",alt:"Stats Overlay",className:"w-32 xl:w-40"})})]})]})]})]})]}),x&&e.jsx(xe,{isOpen:!0,onClose:()=>u(!1),identifier:w,identifierType:t})]})},Ne=()=>{const{loading:h}=W();return h?e.jsx(X,{label:"Loading SmartOffice..."}):e.jsx(he,{})};export{Ne as LoginPage}; diff --git a/apps/edr-freight-web/backoffice/public/_um/assets/MigratedDataManagementPage--fzsv4lI.js b/apps/edr-freight-web/backoffice/public/_um/assets/MigratedDataManagementPage--fzsv4lI.js new file mode 100644 index 000000000..cea0794d0 --- /dev/null +++ b/apps/edr-freight-web/backoffice/public/_um/assets/MigratedDataManagementPage--fzsv4lI.js @@ -0,0 +1 @@ +import{n as x,j as r,aA as b,aB as C,B as y,aC as E,aE as M,r as d,az as w,u as S,f as v}from"./index-Db-xuq0b.js";import{A as z}from"./AdvancedTable-CC9ioMU-.js";import{C as I,a as K,b as B,d as T}from"./card-BBWyxDss.js";import{a as A}from"./userService-BHeFzZdn.js";import{E as U}from"./ellipsis-CbtaBcvT.js";import{E as L}from"./eye-Bhud4znU.js";import{f as P}from"./organizationService-DPKKJMFw.js";import{S as k}from"./FormFields-BA7SVWfi.js";import"./table-D3n3VABd.js";import"./select-BoQxM42A.js";import"./chevron-left-DCpEaNzo.js";import"./utils-BncSPdK1.js";import"./badge-D7JvaQeJ.js";import"./command-Cr0tMDDu.js";import"./search-CM5F2ZRy.js";import"./popover-X-j_SRnG.js";import"./Popover-CzANYrEa.js";import"./use-uncontrolled-C3HRHW6t.js";import"./separator-BaOOgzZX.js";import"./refresh-cw-JB7N413f.js";import"./form-BeLK5rTt.js";import"./index.esm-BG4gweZJ.js";import"./label-CsFy6wpo.js";import"./multi-select-C9K8HXhI.js";import"./single-select-D9ZG1edj.js";const q=(n,l)=>x({queryKey:["migratedData",n,l],queryFn:async()=>{const{data:m}=await A(n,l);return m}}),F=(n,l,m)=>{const g=t=>{m(`/user-management/migrated-records-management/view/${t}`)};return[{accessorKey:"record.referenceNumber",header:"Reference Number",cell:({row:t})=>{var e,a;return(a=(e=t.original)==null?void 0:e.record)==null?void 0:a.referenceNumber}},{accessorKey:"record.letterNumber",header:"Letter Number",cell:({row:t})=>{var e,a;return(a=(e=t.original)==null?void 0:e.record)==null?void 0:a.letterNumber}},{accessorKey:"record.metadata.uploadedBy.en",header:"Uploaded By",cell:({row:t})=>{var e,a,s;return n(((s=(a=(e=t.original)==null?void 0:e.record)==null?void 0:a.metadata)==null?void 0:s.uploadedBy)??{en:"-",am:"-"})}},{accessorKey:"record.metadata.organizationName.en",header:"Organization",cell:({row:t})=>{var e,a,s;return n(((s=(a=(e=t.original)==null?void 0:e.record)==null?void 0:a.metadata)==null?void 0:s.organizationName)??{en:"-",am:"-"})}},{accessorKey:"record.dispatchedDate",header:"Dispatched Date",cell:({row:t})=>{var e,a;return new Date((a=(e=t.original)==null?void 0:e.record)==null?void 0:a.dispatchedDate).toLocaleString()}},{accessorKey:"status",header:"Status",cell:({row:t})=>{var e;return(e=t.original)==null?void 0:e.status}},{accessorKey:"record.content",header:"Subject",cell:({row:t})=>{var e,a,s;return((s=(a=(e=t.original)==null?void 0:e.record)==null?void 0:a.content[0])==null?void 0:s.subject)||"-"}},{id:"actions",cell:({row:t})=>{const e=t.original.record;return r.jsxs(b,{children:[r.jsx(C,{asChild:!0,children:r.jsx(y,{variant:"ghost",size:"sm",children:r.jsx(U,{className:"h-4 w-4"})})}),r.jsx(E,{align:"end",children:r.jsxs(M,{onClick:()=>g(e.id),children:[r.jsx(L,{className:"h-4 w-4 mr-2"}),l("userRecord.View")]})})]})}}]};function V(){const[n,l]=d.useState(0),m=10,[g,t]=d.useState(!1),e=w(),{t:a}=S(),s=v(),{data:o,isLoading:O,error:H}=x({queryKey:["organizations"],queryFn:P,staleTime:300*1e3}),[u,h]=d.useState(null),j=d.useMemo(()=>(o==null?void 0:o.items?.map(i=>({id:i.id,name:i.name,hierarchyType:"organization",value:i.units.length===1?i.units[0].id:"",children:Array.isArray(i.units)&&i.units.length>0?i.units?.map(c=>({id:c.id,name:c.name,hierarchyType:"unit",value:c.id,children:[]})):[]})))||[],[o,e]);d.useEffect(()=>{if(!u){const i=o==null?void 0:o.items.flatMap(c=>c.units).find(c=>c.id);i&&h(i.id)}},[o]),d.useEffect(()=>{u&&sessionStorage.setItem("selectedUnitId",u)},[u]),d.useEffect(()=>{const i=sessionStorage.getItem("selectedUnitId");i&&h(i)},[]);const{data:p,isLoading:D}=q(u??"",{skip:n*m,take:m,orderBy:"migratedAt:DESC"}),f=i=>{l(i)},N=()=>{t(!0)};return D?r.jsx("div",{children:a("loading")}):r.jsx("div",{className:"p-6 space-y-6",children:r.jsxs(I,{className:"col-span-2 shadow-none border-none bg-transparent px-0",children:[r.jsxs(K,{className:"flex flex-row justify-between items-center px-0",children:[r.jsx(B,{className:"text-xl font-semibold ",children:a("migration.migratedData")}),r.jsx(y,{onClick:N,children:a(g?"migration.exporting":"migration.exportData")})]}),r.jsx("div",{className:"mb-4",children:r.jsx(k,{label:a("selectUnit"),options:j,value:u,onChange:h,collapsible:!0})}),r.jsx(T,{className:"px-0",children:r.jsx(z,{columns:F(e,a,s),data:(p==null?void 0:p.items)||[],tableName:"Migrated Data",toolBarPosition:"right",itemCount:(p==null?void 0:p.count)||0,pageIndex:n,onPageChange:f,nextFunction:()=>f(n+1),prevFunction:()=>f(Math.max(n-1,0))})})]})})}function he(){return r.jsx(V,{})}export{he as default}; diff --git a/apps/edr-freight-web/backoffice/public/_um/assets/NewOrganizationPage-C0u3U4kS.js b/apps/edr-freight-web/backoffice/public/_um/assets/NewOrganizationPage-C0u3U4kS.js new file mode 100644 index 000000000..87df458fc --- /dev/null +++ b/apps/edr-freight-web/backoffice/public/_um/assets/NewOrganizationPage-C0u3U4kS.js @@ -0,0 +1 @@ +import{j as i}from"./index-Db-xuq0b.js";import{u as m}from"./useOrganizations-DiuNBweX.js";import{O as e}from"./OrganizationForm-Dc20iiRT.js";import"./organizationsService-BEVk8qa1.js";import"./select-BoQxM42A.js";import"./card-BBWyxDss.js";import"./label-CsFy6wpo.js";import"./useOrganizationTypes-B6vCp97C.js";import"./index.esm-BG4gweZJ.js";import"./zod-Df58YiJ6.js";import"./switch-BNCD27Bd.js";import"./Switch-DSHMk-sj.js";import"./InputsGroupFieldset-COkNgcEo.js";import"./use-uncontrolled-C3HRHW6t.js";const p=()=>{const{createOrganization:o,isCreating:n}=m("Org"),a=t=>{const r={key:t.key,name:t.name,organizationTypeId:t.organizationTypeId,isGovernmentOrganization:t.isGovernmentOrganization};t.parentId&&t.parentId!==""&&(r.parentId=t.parentId),o(r)};return i.jsx(e,{isLoading:n,onSubmit:t=>a(t),type:"Create"})},w=()=>i.jsx(p,{});export{w as default}; diff --git a/apps/edr-freight-web/backoffice/public/_um/assets/OrgAdminDashboard-DhPmvSML.js b/apps/edr-freight-web/backoffice/public/_um/assets/OrgAdminDashboard-DhPmvSML.js new file mode 100644 index 000000000..8b5b1ed42 --- /dev/null +++ b/apps/edr-freight-web/backoffice/public/_um/assets/OrgAdminDashboard-DhPmvSML.js @@ -0,0 +1,6 @@ +import{y as C,f as A,d as S,z as M,F as x,v as a,j as e,B as u,A as D}from"./index-Db-xuq0b.js";import{C as n,d as l,a as U,b as z}from"./card-BBWyxDss.js";import{u as $}from"./useOrganizationReport-D3QtWTPL.js";import{A as L,a as E}from"./alert-8Xu28MAD.js";import{S as c}from"./skeleton-CIiqp2Y5.js";import{S as R}from"./SmartOfficeAuditPage-DLN72875.js";import{U as b}from"./users-DNqydOCy.js";import{C as f}from"./circle-alert-Cd_zeQMw.js";import{R as j}from"./refresh-cw-JB7N413f.js";import"./organizationsService-BEVk8qa1.js";import"./Skeleton-BJECajc_.js";import"./table-D3n3VABd.js";import"./badge-D7JvaQeJ.js";import"./avatar-C2-XEZ4v.js";import"./format-DvwV82px.js";import"./en-US-Cc-9gH5A.js";import"./shield-uC_usZTb.js";import"./lock-BCybB-Lq.js";import"./eye-Bhud4znU.js";import"./square-pen-B91TPB19.js";import"./download-CX6rsOqt.js";import"./select-BoQxM42A.js";import"./popover-X-j_SRnG.js";import"./Popover-CzANYrEa.js";import"./use-uncontrolled-C3HRHW6t.js";import"./endOfMonth-DmxQbzXi.js";import"./search-CM5F2ZRy.js";import"./label-CsFy6wpo.js";import"./radio-group-DqG5gUnT.js";import"./Radio-C3SNvFel.js";import"./get-auto-contrast-value-Da6zqqWm.js";import"./InputsGroupFieldset-COkNgcEo.js";import"./checkbox-Dd0VhnrO.js";import"./Checkbox-IILksgz0.js";import"./eye-off-CDMvHElM.js";/** + * @license lucide-react v0.513.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const _=[["path",{d:"M20 7h-3a2 2 0 0 1-2-2V2",key:"x099mo"}],["path",{d:"M9 18a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h7l4 4v10a2 2 0 0 1-2 2Z",key:"18t6ie"}],["path",{d:"M3 7.6v12.8A1.6 1.6 0 0 0 4.6 22h9.8",key:"1nja0z"}]],B=C("files",_),je=()=>{var g,h;const t=A(),{user:i}=S(),y=((h=(g=i==null?void 0:i.employee)==null?void 0:g[0])==null?void 0:h.organizationId)||"fecaa9b3-0b9d-4772-a7c7-bcb19d46122a",{report:s,isLoading:d,isError:v,error:m,refetch:N}=$(y),k=()=>{var r,o,p;return[{id:"employees",title:a("dashboard.totalEmployees"),value:((r=s==null?void 0:s.employeesCount)==null?void 0:r.toLocaleString())||"0",icon:b,color:"from-blue-500 to-blue-600"},{id:"units",title:a("dashboard.totalUnits"),value:((o=s==null?void 0:s.unitsCount)==null?void 0:o.toLocaleString())||"0",icon:D,color:"from-primary-500 to-primary-600"},{id:"positions",title:a("dashboard.totalPositions"),value:((p=s==null?void 0:s.positionsCount)==null?void 0:p.toLocaleString())||"0",icon:x,color:"from-purple-500 to-purple-600"}]},w=[{id:"user-mgmt",title:a("dashboard.userManagement"),description:a("dashboard.userManagementDesc"),icon:b,action:()=>t("/user-management/user_management"),color:"bg-gradient-to-r from-blue-500 to-cyan-600"},{id:"content-mgmt",title:a("dashboard.contentManagement"),description:a("dashboard.contentManagementDesc"),icon:B,action:()=>t("/user-management/content-management"),color:"bg-gradient-to-r from-purple-500 to-indigo-600"},{id:"excel-upload",title:a("dashboard.excelUploader"),description:a("dashboard.excelUploaderDesc"),icon:M,action:()=>t("/user-management/bulk-upload"),color:"bg-gradient-to-r from-primary-500 to-primary-600"},{id:"position-settings",title:a("dashboard.positionSettings"),description:a("dashboard.positionSettingsDesc"),icon:x,action:()=>t("/user-management/position-management"),color:"bg-gradient-to-r from-orange-500 to-red-600"},{id:"archive-users",title:a("dashboard.archiveUsers"),description:a("dashboard.archiveUsersDesc"),icon:f,action:()=>t("/user-management/archives"),color:"bg-gradient-to-r from-gray-500 to-slate-600"}];return e.jsxs("div",{className:"mx-auto p-6 space-y-6",children:[e.jsxs("div",{className:"flex justify-between items-center",children:[e.jsxs("div",{children:[e.jsx("h1",{className:"text-3xl font-bold text-gray-900 dark:text-gray-100",children:a("dashboard.organizationDashboard")}),e.jsx("p",{className:"text-muted-foreground dark:text-gray-400",children:a("dashboard.orgMsg")})]}),e.jsxs(u,{variant:"outline",size:"sm",onClick:()=>N(),disabled:d,children:[e.jsx(j,{className:`h-4 w-4 mr-2 ${d?"animate-spin":""}`}),a("dashboard.refresh")]})]}),v&&e.jsxs(L,{variant:"destructive",children:[e.jsx(f,{className:"h-4 w-4"}),e.jsxs(E,{children:[a("dashboard.errorMsg"),m instanceof Error&&`: ${m.message}`]})]}),e.jsx("div",{className:"grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8",children:d?Array(3).fill(0)?.map((r,o)=>e.jsx(n,{className:"hover:shadow-lg transition-shadow duration-200 dark:bg-gray-800 dark:border-gray-700",children:e.jsx(l,{className:"p-6",children:e.jsxs("div",{className:"flex items-center justify-between",children:[e.jsxs("div",{className:"w-full",children:[e.jsx(c,{className:"h-4 w-24 mb-2"}),e.jsx(c,{className:"h-8 w-16"})]}),e.jsx(c,{className:"h-12 w-12 rounded-full"})]})})},`skeleton-stat-${o}`)):k()?.map(r=>e.jsx(n,{className:"hover:shadow-lg transition-shadow duration-200 border-l-4 border-l-blue-500 dark:bg-gray-800 dark:border-gray-700 dark:border-l-blue-500",children:e.jsx(l,{className:"p-6",children:e.jsxs("div",{className:"flex items-center justify-between",children:[e.jsxs("div",{children:[e.jsx("p",{className:"text-sm font-medium text-muted-foreground dark:text-gray-400",children:r.title}),e.jsx("h3",{className:"text-3xl font-bold mt-2 text-gray-900 dark:text-gray-100",children:r.value})]}),e.jsx("div",{className:`p-4 rounded-full bg-gradient-to-r ${r.color} shadow-lg`,children:e.jsx(r.icon,{className:"h-6 w-6 text-white"})})]})})},`stat-${r.id}`))}),e.jsxs(n,{className:"dark:bg-gray-800 dark:border-gray-700",children:[e.jsx(U,{children:e.jsxs(z,{className:"flex items-center dark:text-gray-100",children:[e.jsx(j,{className:"h-5 w-5 mr-2"}),a("landingPage.quickActions")]})}),e.jsx(l,{children:e.jsx("div",{className:"grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4",children:w?.map(r=>e.jsx(u,{onClick:r.action,className:`h-auto p-6 ${r.color} text-white hover:opacity-90 hover:scale-105 transition-all duration-200`,children:e.jsxs("div",{className:"flex flex-col items-center space-y-3 text-center",children:[e.jsx(r.icon,{className:"h-8 w-8"}),e.jsxs("div",{children:[e.jsx("div",{className:"font-semibold text-base",children:r.title}),e.jsx("div",{className:"text-sm opacity-90 mt-1",children:r.description})]})]})},`action-${r.id}`))})})]}),e.jsx(R,{})]})};export{je as default}; diff --git a/apps/edr-freight-web/backoffice/public/_um/assets/OrganizationAdminsPage-BvY4PVex.js b/apps/edr-freight-web/backoffice/public/_um/assets/OrganizationAdminsPage-BvY4PVex.js new file mode 100644 index 000000000..b1de661a1 --- /dev/null +++ b/apps/edr-freight-web/backoffice/public/_um/assets/OrganizationAdminsPage-BvY4PVex.js @@ -0,0 +1,6 @@ +import{y as Y,j as e,B as c,v as E,m as Z,r as o,u as _,az as H,aK as B,aL as R,aM as ee,aN as ae,bo as se,aF as I,aP as O,I as te,ba as re,t as h,L as ie}from"./index-Db-xuq0b.js";import{C as ne,a as oe,b as de,d as ce}from"./card-BBWyxDss.js";import{A as le}from"./AdvancedTable-CC9ioMU-.js";import{S as me,a as ue}from"./StatusCell-D7zi31G6.js";import{u as Q}from"./useOrganizations-DiuNBweX.js";import{L as T}from"./label-CsFy6wpo.js";import{S as L}from"./scroll-area-zSBvHuIO.js";import{u as xe}from"./useEmployees-CygwM5u6.js";import{c as q}from"./utils-BncSPdK1.js";import{u as ge}from"./useUnit-C4s9nepK.js";import{U as pe}from"./user-plus-CBq7Z0dQ.js";import{P as he}from"./plus-BdjS2Pc-.js";import"./table-D3n3VABd.js";import"./select-BoQxM42A.js";import"./chevron-left-DCpEaNzo.js";import"./badge-D7JvaQeJ.js";import"./command-Cr0tMDDu.js";import"./search-CM5F2ZRy.js";import"./popover-X-j_SRnG.js";import"./Popover-CzANYrEa.js";import"./use-uncontrolled-C3HRHW6t.js";import"./separator-BaOOgzZX.js";import"./refresh-cw-JB7N413f.js";import"./useOrganizationAdmins-Bl-gbkwr.js";import"./use-toast-CdbbItn1.js";import"./alert-dialog-B5Y0wlSz.js";import"./phone-Ce15UUGI.js";import"./eye-Bhud4znU.js";import"./organizationsService-BEVk8qa1.js";import"./unitService-CmGVtFHQ.js";/** + * @license lucide-react v0.513.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const je=[["path",{d:"m21 16-4 4-4-4",key:"f6ql7i"}],["path",{d:"M17 20V4",key:"1ejh1v"}],["path",{d:"m3 8 4-4 4 4",key:"11wl7u"}],["path",{d:"M7 4v16",key:"1glfcx"}]],K=Y("arrow-up-down",je),fe=l=>[{accessorKey:"name",header:({column:s})=>e.jsxs(c,{variant:"ghost",onClick:()=>s.toggleSorting(s.getIsSorted()==="asc"),className:"p-0 hover:bg-transparent text-gray-700 dark:text-gray-300",style:{padding:0},children:[E("organization.name"),e.jsx(K,{className:"p-0 h-4 w-4"})]}),cell:({row:s})=>e.jsx("div",{className:"font-medium",children:l(s.original.name)})},{accessorKey:"status",header:({column:s})=>e.jsxs(c,{variant:"ghost",onClick:()=>s.toggleSorting(s.getIsSorted()==="asc"),className:"p-0 hover:bg-transparent text-gray-700 dark:text-gray-300",style:{padding:0},children:[E("dashboard.Status"),e.jsx(K,{className:"h-4 w-4"})]}),cell:({row:s})=>{const g=s.original.id;return e.jsx(me,{id:g,adminsCount:s.original.adminsCount})}}];function ye({onSuccess:l,onClose:s,isOpen:g}){var F;const d=Z(),[m,C]=o.useState(""),[x,n]=o.useState(""),[j,f]=o.useState(!1),[p,y]=o.useState(""),{t:r}=_(),[z,A]=o.useState(0),w=10,$=H(),{organizationsResponse:S,isLoading:V}=Q("Org",{take:300}),{employeesResponseByOrg:t,isLoading:G,refetch:J}=xe({organizationId:m||void 0,params:{take:w,skip:z*w}}),{data:b,isLoading:Ne}=ge().getList(m||"",{take:300,skip:0}),[D,U]=o.useState("");o.useEffect(()=>{var a,i;(i=(a=b==null?void 0:b.data)==null?void 0:a.items)!=null&&i.length?U(b.data.items[0].id):U("")},[m,b]);const W=async a=>{if(a.preventDefault(),!x||!m){h.error("All fields are required");return}const i=t==null?void 0:t.items.find(u=>u.user.id===x);if(!i)return h.error("User not found");if(!D){h.error("No unit available for this organization");return}f(!0);const N={unitId:D,userId:i.user.id};ue(N).then(()=>{d.invalidateQueries({queryKey:["organizationAdmins"]}),h.success(r("organization.userAssignedSuccess")),f(!1),s(),l()}).catch(u=>{var k,v;h.error(r("organization.userAssignFailed"),{description:(v=(k=u==null?void 0:u.response)==null?void 0:k.data)==null?void 0:v.message}),f(!1)})},X=()=>{C(""),n(""),J()},P=o.useMemo(()=>t!=null&&t.items?t.items.filter(a=>{var k,v,M;const i=((v=(k=a.user.name)==null?void 0:k.en)==null?void 0:v.toLowerCase())||"",N=((M=a.user.email)==null?void 0:M.toLowerCase())||"",u=p.toLowerCase();return i.includes(u)||N.includes(u)}):[],[t,p]);return e.jsx(B,{open:g,onOpenChange:s,children:e.jsxs(R,{className:"sm:max-w-[800px]",children:[e.jsxs(ee,{children:[e.jsx(ae,{children:r("organization.assignAdminToOrganization")}),e.jsx(se,{children:r("organization.assignAdminInstructions")})]}),e.jsxs("form",{onSubmit:W,className:"space-y-6",children:[e.jsxs(L,{className:"h-[450px] border border-gray-200 dark:border-gray-700 rounded-md p-2",children:[e.jsxs("div",{className:"space-y-2",children:[e.jsx(T,{children:r("organization.organizations")}),V?e.jsx("div",{className:"flex items-center justify-center h-64",children:e.jsx(I,{className:"h-6 w-6 animate-spin text-primary"})}):e.jsx(L,{className:"h-[200px] border border-gray-200 dark:border-gray-700 rounded-md p-2",children:e.jsx("div",{className:"space-y-1",children:(F=S==null?void 0:S.items)==null?void 0:F?.map(a=>e.jsxs("button",{onClick:()=>{C(a.id),n(""),A(0)},type:"button",className:q("w-full flex items-center justify-between px-3 py-2 rounded-md text-left",m===a.id?"bg-primary-200 text-primary-800 dark:bg-primary-900/40 dark:text-primary-300":"hover:bg-primary-100 dark:hover:bg-primary-900/30"),children:[e.jsx("span",{children:$(a.name)}),m===a.id&&e.jsx(O,{className:"h-4 w-4"})]},`org-${a.id}`))})})]}),e.jsxs("div",{className:"space-y-2",children:[e.jsx(T,{children:r("organization.users")}),G?e.jsx("div",{className:"flex items-center justify-center h-40",children:e.jsx(I,{className:"h-6 w-6 animate-spin text-primary"})}):e.jsxs("div",{className:"space-y-2",children:[e.jsx(te,{placeholder:r("organization.searchUsers"),value:p,onChange:a=>y(a.target.value),className:"border border-gray-200 dark:border-gray-700 rounded-md dark:bg-gray-800 dark:text-gray-100"}),e.jsx(L,{className:"h-[200px] border border-gray-200 dark:border-gray-700 rounded-md p-2",children:e.jsxs("div",{className:"space-y-1",children:[P?.map(a=>{var i;return e.jsxs("button",{onClick:()=>n(a.user.id),type:"button",className:q("w-full flex items-center justify-between px-3 py-2 rounded-md text-left",x===a.user.id?"bg-purple-200 text-purple-800 dark:bg-purple-900/40 dark:text-purple-300":"hover:bg-purple-100 dark:hover:bg-purple-900/30"),children:[e.jsx("span",{children:((i=a.user.name)==null?void 0:i.en)||a.user.email}),x===a.user.id&&e.jsx(O,{className:"h-4 w-4"})]},`emp-${a.id}`)}),P.length===0&&e.jsx("p",{className:"text-sm text-gray-500 dark:text-gray-400 text-center mt-2",children:r("organization.noUsersFound")})]})})]})]}),e.jsxs("div",{className:"flex items-center justify-between mt-2 px-2",children:[e.jsx(c,{type:"button",variant:"outline",size:"sm",onClick:()=>A(a=>Math.max(a-1,0)),disabled:z===0,children:"<"}),e.jsxs("span",{className:"text-sm text-gray-500 dark:text-gray-400",children:[r("common.page")," ",z+1]}),e.jsx(c,{type:"button",variant:"outline",size:"sm",onClick:()=>{const a=(t==null?void 0:t.count)??0,i=Math.ceil(a/w)-1;A(N=>Math.min(N+1,i))},disabled:!(t!=null&&t.count)||(z+1)*w>=t.count,children:">"})]})]}),e.jsx(re,{children:e.jsxs("div",{className:"flex justify-end gap-2",children:[e.jsx(c,{type:"button",variant:"outline",onClick:()=>{X(),s()},disabled:j,children:r("common.Cancel")}),e.jsx(c,{type:"submit",disabled:j,children:r(j?"organization.assigning":"organization.assignUser")})]})})]})]})})}function be(){const[l,s]=o.useState(0),g=10,{t:d}=_(),m=H(),[C,x]=o.useState(!1),{organizationsAdminsResponse:n,isLoading:j,isError:f,refetch:p}=Q("Admin",{take:g,skip:l*g,orderBy:"createdAt",order:"createdAt:Desc"}),y=r=>{s(r)};return j?e.jsx("div",{className:"p-6 flex items-center justify-center h-64",children:e.jsxs("div",{className:"flex flex-col items-center gap-2",children:[e.jsx(I,{className:"h-8 w-8 animate-spin text-primary"}),e.jsx("div",{className:"text-sm text-muted-foreground",children:d("organization.loadingAdmins")})]})}):f?e.jsx("div",{className:"p-6 flex items-center justify-center h-64",children:e.jsxs("div",{className:"flex flex-col items-center gap-4",children:[e.jsx("div",{className:"text-red-500 font-medium",children:d("organization.errorLoadingAdmins")}),e.jsx(c,{variant:"outline",onClick:()=>p(),children:d("organization.retry")})]})}):e.jsxs(e.Fragment,{children:[e.jsx("div",{className:"p-6 space-y-6 dark:bg-gray-900",children:e.jsxs(ne,{className:"col-span-2 shadow-none border-none bg-transparent dark:bg-transparent px-0",children:[e.jsx(oe,{className:"flex flex-row justify-between items-center px-0",children:e.jsx(de,{className:"text-xl font-semibold dark:text-white",children:d("organization.organizationAdmins")})}),e.jsx(ce,{className:"px-0 dark:bg-transparent",children:e.jsx(le,{columns:fe(m),data:(n==null?void 0:n.items)||[],tableName:"Organization Admins",toolBarPosition:"right",extraToolbar:e.jsxs("div",{className:"flex gap-2",children:[e.jsxs(c,{onClick:()=>x(!0),className:"bg-[#4A6CF7] hover:bg-[#3a5ad4] text-white px-5 py-2 rounded-md text-sm font-medium shadow-md",children:[e.jsx(pe,{className:"w-4 h-4 mr-2"}),d("organization.assignAdmin")]}),e.jsx(ie,{to:"/user-management/add_admin",children:e.jsxs(c,{className:"px-5 py-2 rounded-md text-sm font-medium shadow-md",children:[e.jsx(he,{className:"w-4 h-4 mr-2"}),d("organization.addAdmin")]})})]}),itemCount:(n==null?void 0:n.count)||0,pageIndex:l,onPageChange:y,nextFunction:()=>y(l+1),prevFunction:()=>y(Math.max(l-1,0))})})]})}),e.jsx(ye,{isOpen:C,onClose:()=>x(!1),onSuccess:()=>{p(),h.success(d("organization.adminAssignedSuccess"))}})]})}const Be=()=>e.jsx(be,{});export{Be as default}; diff --git a/apps/edr-freight-web/backoffice/public/_um/assets/OrganizationDetailPage-C-bRL7xT.js b/apps/edr-freight-web/backoffice/public/_um/assets/OrganizationDetailPage-C-bRL7xT.js new file mode 100644 index 000000000..62babee12 --- /dev/null +++ b/apps/edr-freight-web/backoffice/public/_um/assets/OrganizationDetailPage-C-bRL7xT.js @@ -0,0 +1,6 @@ +import{y as h,az as f,j as a,A as k,H as j,bj as N}from"./index-Db-xuq0b.js";import{B as g}from"./badge-D7JvaQeJ.js";import{C as v,a as w,b as z,d as C}from"./card-BBWyxDss.js";import{S as p}from"./separator-BaOOgzZX.js";import{a as O}from"./useOrganizations-DiuNBweX.js";import{u as D}from"./useOrganizationTypes-B6vCp97C.js";import{B as y}from"./building-B2uZxFCD.js";import{S as A}from"./shield-check-BeHB0C5s.js";import"./organizationsService-BEVk8qa1.js";/** + * @license lucide-react v0.513.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const S=[["path",{d:"M20 10c0 4.993-5.539 10.193-7.399 11.799a1 1 0 0 1-1.202 0C9.539 20.193 4 14.993 4 10a8 8 0 0 1 16 0",key:"1r0f0z"}],["circle",{cx:"12",cy:"10",r:"3",key:"ilqhr7"}]],B=h("map-pin",S),L=({id:d})=>{var x;const l=f(),{organizationsDetailResponse:o,isDetailLoading:u,isDetailError:b}=O("Org",d),{organizationTypesResponse:n}=D();if(u)return a.jsx("div",{children:"Loading..."});if(b||!o)return a.jsx("div",{children:"Error loading organization."});const r=o.items,c=r.organizationTypeId,m=e=>new Date(e).toLocaleString("en-US",{year:"numeric",month:"long",day:"numeric",hour:"2-digit",minute:"2-digit"});return a.jsxs(v,{className:"w-full max-w-3xl mx-auto shadow-xl rounded-2xl border border-gray-200 bg-white dark:bg-zinc-900 transition hover:shadow-2xl",children:[a.jsxs(w,{className:"flex items-center gap-3 pb-2 border-b border-gray-200 dark:border-gray-700",children:[a.jsx(k,{className:"w-7 h-7 text-primary"}),a.jsx(z,{className:"text-2xl font-bold",children:l(r.name)})]}),a.jsxs(C,{className:"space-y-4",children:[a.jsxs("div",{className:"flex flex-wrap gap-2",children:[a.jsx(g,{variant:r.isGovernmentOrganization?"default":"outline",className:`px-3 py-1 rounded-xl ${r.isGovernmentOrganization?"bg-primary-100 text-primary-800 dark:bg-primary-800 dark:text-primary-100":"bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-100"}`,children:r.isGovernmentOrganization?"Government":"Private"}),a.jsx(g,{variant:"default",className:`px-3 py-1 rounded-xl ${r.status==="Active"?"bg-primary-100 text-primary-800 dark:bg-primary-800 dark:text-primary-100":"bg-red-100 text-red-800 dark:bg-red-800 dark:text-red-100"}`,children:r.status})]}),a.jsx(p,{className:"my-2"}),a.jsxs("div",{className:"grid grid-cols-1 sm:grid-cols-2 gap-4 text-sm text-gray-700 dark:text-gray-300",children:[a.jsxs("div",{className:"order-1",children:[a.jsx("p",{className:"font-semibold",children:"Created At:"}),a.jsx("p",{children:m(r.createdAt)})]}),a.jsxs("div",{className:"order-2",children:[a.jsx("p",{className:"font-semibold",children:"Updated At:"}),a.jsx("p",{children:m(r.updatedAt)})]}),a.jsxs("div",{className:"sm:col-span-2 order-3",children:[a.jsx("p",{className:"font-semibold",children:"Key:"}),a.jsx("p",{className:"break-words",children:r.key})]})]}),a.jsx(p,{className:"my-2"}),a.jsx("div",{className:"flex flex-wrap gap-2 items-center",children:c&&((x=n==null?void 0:n.items)==null?void 0:x.filter(e=>e.id===c)?.map(e=>{let t,s,i;switch(e.key){case"super_admin":t=A,s="bg-purple-100 dark:bg-purple-800",i="text-purple-800 dark:text-purple-100";break;case"woreda":t=B,s="bg-blue-100 dark:bg-blue-800",i="text-blue-800 dark:text-blue-100";break;case"subcity":t=j,s="bg-primary-100 dark:bg-primary-800",i="text-primary-800 dark:text-primary-100";break;case"office":t=y,s="bg-yellow-100 dark:bg-yellow-800",i="text-yellow-800 dark:text-yellow-100";break;default:t=y,s="bg-gray-100 dark:bg-gray-800",i="text-gray-800 dark:text-gray-100"}return a.jsxs("span",{className:`flex items-center gap-1 px-3 py-1 rounded-xl font-medium ${s} ${i}`,children:[a.jsx(t,{className:"w-4 h-4"}),l(e.name)]},e.id)}))})]})]})},U=()=>{const{id:d}=N();return d?a.jsx(L,{id:d}):a.jsx("div",{className:"text-gray-900 dark:text-gray-100",children:"Organization ID is missing"})};export{U as default}; diff --git a/apps/edr-freight-web/backoffice/public/_um/assets/OrganizationForm-Dc20iiRT.js b/apps/edr-freight-web/backoffice/public/_um/assets/OrganizationForm-Dc20iiRT.js new file mode 100644 index 000000000..7ec468145 --- /dev/null +++ b/apps/edr-freight-web/backoffice/public/_um/assets/OrganizationForm-Dc20iiRT.js @@ -0,0 +1 @@ +import{f as G,az as L,r as f,j as e,v as r,I as h,B as N}from"./index-Db-xuq0b.js";import{S as v,a as k,b as O,c as T,d as n}from"./select-BoQxM42A.js";import{C as V,a as q,b as $,c as A,d as P}from"./card-BBWyxDss.js";import{L as t}from"./label-CsFy6wpo.js";import{u as B}from"./useOrganizationTypes-B6vCp97C.js";import{u as K}from"./index.esm-BG4gweZJ.js";import{u as _,o as S,b as H,s as l}from"./zod-Df58YiJ6.js";import{u as U}from"./useOrganizations-DiuNBweX.js";import{S as J}from"./switch-BNCD27Bd.js";const M=S({name:S({am:l().min(1,"Amharic name is required"),en:l().min(1,"English name is required")}),key:l().min(1,"Key is required"),organizationTypeId:l().min(1,"Organization Type is required"),parentId:l().optional(),isGovernmentOrganization:H()}),se=({onSubmit:C,isLoading:p,type:o,organizationDetails:i})=>{var y,j,b,z;const d="h-10 rounded-md border border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 px-3 text-sm focus:outline-none focus:ring-2 focus:ring-primary",a=K({resolver:_(M),defaultValues:{name:{am:"",en:""},key:"",organizationTypeId:"",parentId:void 0,isGovernmentOrganization:!0}}),I=G(),u=L(),{organizationTypesResponse:m,isLoading:g,isError:w}=B(),{organizationsResponse:c,isLoading:x,isError:E}=U("Org",{take:1e3});return f.useEffect(()=>{const s=a.watch("name.en");if(s){const F=`${s||""}_001`.replace(/\s+/g,"_").toUpperCase();a.setValue("key",F)}},[a.watch("name.en")]),f.useEffect(()=>{i&&a.reset({name:i.name,key:i.key,organizationTypeId:i.organizationTypeId,parentId:i.parentId??void 0,isGovernmentOrganization:i.isGovernmentOrganization??!0})},[i]),e.jsx("div",{className:"px-2 md:px-4",children:e.jsxs(V,{className:"max-w-5xl mx-auto shadow-md border-gray-200 dark:border-gray-700 overflow-hidden",children:[e.jsxs(q,{className:"border-b border-gray-200 dark:border-gray-700 bg-gray-50/70 dark:bg-gray-900/40",children:[e.jsxs($,{className:"text-xl md:text-2xl font-semibold text-gray-800 dark:text-gray-100",children:[o==="Create"?r("organization.createNew"):r("organization.edit")," ",r("organization.organizations")]}),e.jsx(A,{className:"text-gray-500 dark:text-gray-400",children:r("organization.enterDetails")})]}),e.jsx(P,{className:"p-4 md:p-8",children:e.jsxs("form",{onSubmit:a.handleSubmit(C),className:"space-y-8",children:[e.jsx("div",{className:"rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900/30 p-4 md:p-6",children:e.jsxs("div",{className:"grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-5",children:[e.jsxs("div",{className:"space-y-2",children:[e.jsxs(t,{htmlFor:"name.am",children:[r("organization.nameAmharic")," ",e.jsx("span",{className:"text-red-500",children:"*"})]}),e.jsx(h,{...a.register("name.am"),className:d}),((y=a.formState.errors.name)==null?void 0:y.am)&&e.jsx("p",{className:"text-red-500 text-xs mt-1",children:a.formState.errors.name.am.message})]}),e.jsxs("div",{className:"space-y-2",children:[e.jsxs(t,{htmlFor:"name.en",children:[r("organization.nameEnglish")," ",e.jsx("span",{className:"text-red-500",children:"*"})]}),e.jsx(h,{...a.register("name.en"),className:d}),((j=a.formState.errors.name)==null?void 0:j.en)&&e.jsx("p",{className:"text-red-500 text-xs mt-1",children:a.formState.errors.name.en.message})]}),e.jsxs("div",{className:"space-y-2",children:[e.jsxs(t,{htmlFor:"code",children:[r("organization.key"),e.jsx("span",{className:"text-red-500",children:"*"})]}),e.jsx(h,{...a.register("key"),disabled:o==="Edit",readOnly:o==="Edit",className:d}),a.formState.errors.key&&e.jsx("p",{className:"text-red-500 text-xs mt-1",children:a.formState.errors.key.message})]}),e.jsxs("div",{className:"space-y-2",children:[e.jsxs(t,{htmlFor:"organizationType",children:[r("organization.organizationType")," ",e.jsx("span",{className:"text-red-500",children:"*"})]}),e.jsxs(v,{value:a.watch("organizationTypeId"),onValueChange:s=>a.setValue("organizationTypeId",s),disabled:g,children:[e.jsx(k,{className:d,children:e.jsx(O,{placeholder:g?r("organization.loading"):r("organization.selectOrganizationType")})}),e.jsx(T,{children:g?e.jsx(n,{value:"loading",disabled:!0,children:r("organization.loadingOrganizationTypes")}):w?e.jsx(n,{value:"error",disabled:!0,children:r("organization.errorLoadingOrganizationTypes")}):(b=m==null?void 0:m.items)!=null&&b.length?m.items?.map(s=>e.jsx(n,{value:s.id,children:u(s.name)},s.id)):e.jsx(n,{value:"none",disabled:!0,children:r("organization.noOrganizationTypesAvailable")})})]}),a.formState.errors.organizationTypeId&&e.jsx("p",{className:"text-red-500 text-xs mt-1",children:a.formState.errors.organizationTypeId.message})]}),e.jsxs("div",{className:"space-y-2",children:[e.jsx(t,{htmlFor:"organizationType",children:r("organization.parentOrganization")}),e.jsxs(v,{value:a.watch("parentId"),onValueChange:s=>a.setValue("parentId",s),disabled:x,children:[e.jsx(k,{className:d,children:e.jsx(O,{placeholder:x?r("organization.loading"):r("organization.selectOrganization")})}),e.jsx(T,{children:x?e.jsx(n,{value:"loading",disabled:!0,children:"Loading organization types..."}):E?e.jsx(n,{value:"error",disabled:!0,children:"Error loading organization types"}):(z=c==null?void 0:c.items)!=null&&z.length?c.items?.map(s=>e.jsx(n,{value:s.id,children:u(s.name)},s.id)):e.jsx(n,{value:"none",disabled:!0,children:r("organization.noOrganization")})})]}),a.formState.errors.organizationTypeId&&e.jsx("p",{className:"text-red-500 text-xs mt-1",children:a.formState.errors.organizationTypeId.message})]}),e.jsxs("div",{className:"space-y-2 md:col-span-2 xl:col-span-1",children:[e.jsx(t,{htmlFor:"isPublic",children:r("organization.isOrganizationPublic")}),e.jsx("div",{className:"h-10 px-3 rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 flex items-center justify-end",children:e.jsx(J,{id:"isPublic",checked:a.watch("isGovernmentOrganization"),onCheckedChange:s=>a.setValue("isGovernmentOrganization",s)})}),a.formState.errors.isGovernmentOrganization&&e.jsx("p",{className:"text-red-500 text-xs mt-1",children:a.formState.errors.isGovernmentOrganization.message})]})]})}),e.jsxs("div",{className:"flex flex-col-reverse sm:flex-row sm:justify-end gap-3 pt-2",children:[e.jsx(N,{variant:"outline",type:"button",onClick:()=>{I(-1)},className:"w-full sm:w-auto",children:r("common.Cancel")}),e.jsx(N,{type:"submit",disabled:p,className:"w-full sm:w-auto bg-primary hover:bg-primary/90 text-primary-foreground",children:p?`${o==="Create"?"Creating":"Editing"}...`:`${o==="Create"?r("common.create"):r("common.Edit")} `})]})]})})]})})};export{se as O}; diff --git a/apps/edr-freight-web/backoffice/public/_um/assets/OrganizationsPage-Cai8R-Dj.js b/apps/edr-freight-web/backoffice/public/_um/assets/OrganizationsPage-Cai8R-Dj.js new file mode 100644 index 000000000..3d13cb152 --- /dev/null +++ b/apps/edr-freight-web/backoffice/public/_um/assets/OrganizationsPage-Cai8R-Dj.js @@ -0,0 +1,8 @@ +import{y as Ie,ad as Be,K as $,N as L,j as e,bw as Ue,bx as Te,by as $e,bz as Le,bA as Fe,bB as _e,aq as Ge,V as He,bC as Re,ai as Ve,ap as J,W as Ke,X as Ze,_ as We,as as qe,r as x,bD as Qe,aF as le,v as w,b8 as G,az as ye,u as X,k as Je,I as he,B as M,b6 as Xe,t as E,f as Ye,aA as ea,aB as aa,aC as ta,aD as ra,aE as Q,bn as sa,aH as na,aR as ia,bE as oa,aI as la,aJ as da,L as ca}from"./index-Db-xuq0b.js";import{u as be}from"./useOrganizations-DiuNBweX.js";import{A as ma}from"./AdvancedTable-CC9ioMU-.js";import{A as ve,h as je,a as Ne,b as Ce,c as we,d as ke,e as ze,f as Se,g as Oe}from"./alert-dialog-B5Y0wlSz.js";import{S as ua,a as pa,b as ga,c as ha,d as xa}from"./select-BoQxM42A.js";import{a as fa,u as ya,b as ba,c as va,d as ja,e as Na,f as Ca,g as wa,h as ka,i as za,j as Sa}from"./useConfig-B72ZZHmV.js";import{u as Oa}from"./useUnit-C4s9nepK.js";import{R as Aa,a as xe}from"./radio-group-DqG5gUnT.js";import{u as Da}from"./useArchived-D2u5xEKl.js";import{E as Pa}from"./ellipsis-CbtaBcvT.js";import{E as Ea}from"./eye-Bhud4znU.js";import{S as Ma}from"./square-pen-B91TPB19.js";import{B as fe}from"./badge-D7JvaQeJ.js";import{S as Ia}from"./StatusCell-D7zi31G6.js";import{S as Ba}from"./switch-BNCD27Bd.js";import{f as Ua}from"./format-DvwV82px.js";import{U as Ta}from"./users-DNqydOCy.js";import{C as $a,a as La,b as Fa,d as _a}from"./card-BBWyxDss.js";import{L as Ga}from"./loader-mVRAFg0f.js";import{P as Ha}from"./plus-BdjS2Pc-.js";import"./organizationsService-BEVk8qa1.js";import"./table-D3n3VABd.js";import"./chevron-left-DCpEaNzo.js";import"./utils-BncSPdK1.js";import"./command-Cr0tMDDu.js";import"./search-CM5F2ZRy.js";import"./popover-X-j_SRnG.js";import"./Popover-CzANYrEa.js";import"./use-uncontrolled-C3HRHW6t.js";import"./separator-BaOOgzZX.js";import"./refresh-cw-JB7N413f.js";import"./unitService-CmGVtFHQ.js";import"./Radio-C3SNvFel.js";import"./get-auto-contrast-value-Da6zqqWm.js";import"./InputsGroupFieldset-COkNgcEo.js";import"./positionService-JD0NEiGK.js";import"./useOrganizationAdmins-Bl-gbkwr.js";import"./use-toast-CdbbItn1.js";import"./phone-Ce15UUGI.js";import"./Switch-DSHMk-sj.js";import"./en-US-Cc-9gH5A.js";/** + * @license lucide-react v0.513.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Ra=[["path",{d:"M10 5a2 2 0 0 0-1.344.519l-6.328 5.74a1 1 0 0 0 0 1.481l6.328 5.741A2 2 0 0 0 10 19h10a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2z",key:"1yo7s0"}],["path",{d:"m12 9 6 6",key:"anjzzh"}],["path",{d:"m18 9-6 6",key:"1fp51s"}]],Va=Ie("delete",Ra),[Ka,H]=Be("Drawer component was not found in tree");var B={root:"m_f11b401e",header:"m_5a7c2c9",content:"m_b8a05bbd",inner:"m_31cd769a"};const Za={},Y=$((a,t)=>{const r=L("DrawerBody",Za,a),{classNames:i,className:l,style:m,styles:d,vars:u,...g}=r,s=H();return e.jsx(Ue,{ref:t,...s.getStyles("body",{classNames:i,style:m,styles:d,className:l}),...g})});Y.classes=B;Y.displayName="@mantine/core/DrawerBody";const Wa={},ee=$((a,t)=>{const r=L("DrawerCloseButton",Wa,a),{classNames:i,className:l,style:m,styles:d,vars:u,...g}=r,s=H();return e.jsx(Te,{ref:t,...s.getStyles("close",{classNames:i,style:m,styles:d,className:l}),...g})});ee.classes=B;ee.displayName="@mantine/core/DrawerCloseButton";const qa={},ae=$((a,t)=>{const r=L("DrawerContent",qa,a),{classNames:i,className:l,style:m,styles:d,vars:u,children:g,radius:s,__hidden:b,...N}=r,o=H(),z=o.scrollAreaComponent||$e;return e.jsx(Le,{...o.getStyles("content",{className:l,style:m,styles:d,classNames:i}),innerProps:o.getStyles("inner",{className:l,style:m,styles:d,classNames:i}),ref:t,...N,radius:s||o.radius||0,"data-hidden":b||void 0,children:e.jsx(z,{style:{height:"calc(100vh - var(--drawer-offset) * 2)"},children:g})})});ae.classes=B;ae.displayName="@mantine/core/DrawerContent";const Qa={},te=$((a,t)=>{const r=L("DrawerHeader",Qa,a),{classNames:i,className:l,style:m,styles:d,vars:u,...g}=r,s=H();return e.jsx(Fe,{ref:t,...s.getStyles("header",{classNames:i,style:m,styles:d,className:l}),...g})});te.classes=B;te.displayName="@mantine/core/DrawerHeader";const Ja={},re=$((a,t)=>{const r=L("DrawerOverlay",Ja,a),{classNames:i,className:l,style:m,styles:d,vars:u,...g}=r,s=H();return e.jsx(_e,{ref:t,...s.getStyles("overlay",{classNames:i,style:m,styles:d,className:l}),...g})});re.classes=B;re.displayName="@mantine/core/DrawerOverlay";function Xa(a){switch(a){case"top":return"flex-start";case"bottom":return"flex-end";default:return}}function Ya(a){if(a==="top"||a==="bottom")return"0 0 calc(100% - var(--drawer-offset, 0rem) * 2)"}const et={top:"slide-down",bottom:"slide-up",left:"slide-right",right:"slide-left"},at={top:"slide-down",bottom:"slide-up",right:"slide-right",left:"slide-left"},tt={closeOnClickOutside:!0,withinPortal:!0,lockScroll:!0,trapFocus:!0,returnFocus:!0,closeOnEscape:!0,keepMounted:!1,zIndex:J("modal"),position:"left"},rt=Ke((a,{position:t,size:r,offset:i})=>({root:{"--drawer-size":We(r,"drawer-size"),"--drawer-flex":Ya(t),"--drawer-height":t==="left"||t==="right"?void 0:"var(--drawer-size)","--drawer-align":Xa(t),"--drawer-justify":t==="right"?"flex-end":void 0,"--drawer-offset":Ze(i)}})),se=$((a,t)=>{const r=L("DrawerRoot",tt,a),{classNames:i,className:l,style:m,styles:d,unstyled:u,vars:g,scrollAreaComponent:s,position:b,transitionProps:N,radius:o,...z}=r,{dir:c}=Ge(),S=He({name:"Drawer",classes:B,props:r,className:l,style:m,classNames:i,styles:d,unstyled:u,vars:g,varsResolver:rt}),A=(c==="rtl"?at:et)[b];return e.jsx(Ka,{value:{scrollAreaComponent:s,getStyles:S,radius:o},children:e.jsx(Re,{ref:t,...S("root"),transitionProps:{transition:A,...N},"data-offset-scrollbars":s===Ve.Autosize||void 0,unstyled:u,...z})})});se.classes=B;se.displayName="@mantine/core/DrawerRoot";const[st,nt]=qe();function Ae({children:a}){const[t,r]=x.useState([]),[i,l]=x.useState(J("modal"));return e.jsx(st,{value:{stack:t,addModal:(m,d)=>{r(u=>[...new Set([...u,m])]),l(u=>typeof d=="number"&&typeof u=="number"?Math.max(u,d):u)},removeModal:m=>r(d=>d.filter(u=>u!==m)),getZIndex:m=>`calc(${i} + ${t.indexOf(m)} + 1)`,currentId:t[t.length-1],maxZIndex:i},children:a})}Ae.displayName="@mantine/core/DrawerStack";const it={},ne=$((a,t)=>{const r=L("DrawerTitle",it,a),{classNames:i,className:l,style:m,styles:d,vars:u,...g}=r,s=H();return e.jsx(Qe,{ref:t,...s.getStyles("title",{classNames:i,style:m,styles:d,className:l}),...g})});ne.classes=B;ne.displayName="@mantine/core/DrawerTitle";const ot={closeOnClickOutside:!0,withinPortal:!0,lockScroll:!0,trapFocus:!0,returnFocus:!0,closeOnEscape:!0,keepMounted:!1,zIndex:J("modal"),withOverlay:!0,withCloseButton:!0},P=$((a,t)=>{const{title:r,withOverlay:i,overlayProps:l,withCloseButton:m,closeButtonProps:d,children:u,opened:g,stackId:s,zIndex:b,...N}=L("Drawer",ot,a),o=nt(),z=!!r||m,c=o&&s?{closeOnEscape:o.currentId===s,trapFocus:o.currentId===s,zIndex:o.getZIndex(s)}:{},S=i===!1?!1:s&&o?o.currentId===s:g;return x.useEffect(()=>{o&&s&&(g?o.addModal(s,b||J("modal")):o.removeModal(s))},[g,s,b]),e.jsxs(se,{ref:t,opened:g,zIndex:o&&s?o.getZIndex(s):b,...N,...c,children:[i&&e.jsx(re,{visible:S,transitionProps:o&&s?{duration:0}:void 0,...l}),e.jsxs(ae,{__hidden:o&&s&&g?s!==o.currentId:!1,children:[z&&e.jsxs(te,{children:[r&&e.jsx(ne,{children:r}),m&&e.jsx(ee,{...d})]}),e.jsx(Y,{children:u})]})]})});P.classes=B;P.displayName="@mantine/core/Drawer";P.Root=se;P.Overlay=re;P.Content=ae;P.Body=Y;P.Header=te;P.Title=ne;P.CloseButton=ee;P.Stack=Ae;const lt=({organizationName:a,isActive:t,onConfirm:r,isLoading:i,trigger:l})=>e.jsxs(ve,{children:[e.jsx(je,{asChild:!0,children:l}),e.jsxs(Ne,{children:[e.jsxs(Ce,{children:[e.jsxs(we,{children:[t?"Deactivate":"Activate"," Organization"]}),e.jsxs(ke,{children:["Are you sure you want to ",t?"deactivate":"activate"," ",e.jsx("span",{className:"font-medium",children:a}),"? This action can be reversed later."]})]}),e.jsxs(ze,{children:[e.jsx(Se,{disabled:i,className:"cursor-pointer",children:"Cancel"}),e.jsxs(Oe,{onClick:r,disabled:i,className:t?"bg-red-600 hover:bg-red-700 cursor-pointer":"",children:[i&&e.jsx(le,{className:"h-4 w-4 animate-spin"}),i?t?"Deactivating...":"Activating...":t?"Deactivate":"Activate"]})]})]})]}),dt=({organizationName:a,onDelete:t,onCancel:r,isLoading:i,trigger:l})=>e.jsxs(ve,{children:[e.jsx(je,{asChild:!0,children:l}),e.jsxs(Ne,{children:[e.jsxs(Ce,{children:[e.jsx(we,{children:w("organization.deleteOrganization")}),e.jsxs(ke,{children:[w("organization.confirmDelete")," ",e.jsx("strong",{children:a}),"? ",w("organization.cannotUndo")]})]}),e.jsxs(ze,{children:[e.jsx(Se,{onClick:r,disabled:i,className:"cursor-pointer",children:w("common.Cancel")}),e.jsx(Oe,{className:"bg-red-600 hover:bg-red-700 text-white cursor-pointer",disabled:i,onClick:t,children:i?w("organization.deleting"):w("organization.delete")})]})]})]}),de=x.createContext({open:!1,setOpen:()=>{}});function ct({children:a,open:t,onOpenChange:r,defaultOpen:i=!1}){const[l,m]=x.useState(i),d=t!==void 0,u=d?t:l,g=x.useCallback(s=>{d||m(s),r==null||r(s)},[d,r]);return e.jsx(de.Provider,{value:{open:u,setOpen:g},children:a})}function mt({children:a,className:t,...r}){const{open:i,setOpen:l}=x.useContext(de);return e.jsx(P,{opened:i,onClose:()=>l(!1),position:"right",size:400,withCloseButton:!0,classNames:{content:G("p-6",t),overlay:"bg-black/50"},...r,children:a})}function ut({className:a,children:t,...r}){return e.jsx("div",{className:G("mb-4",a),...r,children:t})}function pt({className:a,children:t,...r}){return e.jsx("h2",{className:G("text-lg font-semibold",a),...r,children:t})}function gt({className:a,children:t,...r}){return e.jsx("p",{className:G("text-sm text-muted-foreground",a),...r,children:t})}function ht({className:a,children:t,...r}){return e.jsx("div",{className:G("mt-4 flex justify-end gap-2",a),...r,children:t})}function xt({children:a,asChild:t,className:r,...i}){const{setOpen:l}=x.useContext(de);return t&&x.isValidElement(a)?x.cloneElement(a,{onClick:m=>{var d,u;(u=(d=a.props).onClick)==null||u.call(d,m),l(!1)}}):e.jsx("button",{type:"button",className:G("cursor-pointer",r),onClick:()=>l(!1),...i,children:a})}const ft=ct;function yt({manageOrgConfigOpen:a,setManageOrgConfigOpen:t,organizationId:r}){var ce,me,ue,pe;const{getList:i}=Oa(),[l,m]=x.useState(""),[d,u]=x.useState(""),[g,s]=x.useState(""),[b,N]=x.useState(null),{data:o}=r?i(r,{take:300,skip:0}):{data:void 0},z=ye(),{t:c}=X(),{handleError:S}=Je(c),A=fa(r),U=ya(r),[p,T]=x.useState(!1),D=p?A.data:U.data,ie=p?A.isLoading:U.isLoading,I=p?A.refetch:U.refetch,R=ba(),K=va(),V=ja(),Z=Na(),W=Ca(),y=wa(),v=p?R.isPending||K.isPending:Z.isPending||W.isPending,f=p?V:y,h=(()=>{var O,k;const n=((k=(O=U.data)==null?void 0:O.data)==null?void 0:k.items)??[];return Array.isArray(n)?n.find(j=>((j==null?void 0:j.organizationId)??(j==null?void 0:j.organization_id))===r)??n[0]??null:null})(),C=(()=>{var O,k;if(!l)return null;const n=((k=(O=A.data)==null?void 0:O.data)==null?void 0:k.items)??[];return Array.isArray(n)?n.find(j=>((j==null?void 0:j.unitId)??(j==null?void 0:j.unit_id))===l)??null:null})();x.useEffect(()=>{p||h!=null&&h.id&&b!==h.id&&(N(h.id),u((h.maximumNumberOfUnits??0).toString()))},[p,h,b]),x.useEffect(()=>{p&&C!=null&&C.id&&b!==C.id&&(N(C.id),u((C.numberOfSubUnits??0).toString()),s((C.numberOfEmployeesPerUnit??0).toString()))},[p,C,b]);const oe=()=>{if(p&&!l){E.warning(c("superAdmin.messages.selectUnitWarning"));return}if(p?!d||!g:!d){E.warning(c("superAdmin.messages.fillAllFields"));return}if(p){const k={unitId:l,numberOfSubUnits:Number(d),numberOfEmployeesPerUnit:Number(g)},j=b||(C==null?void 0:C.id)||null;j?K.mutate({id:j,data:k},{onSuccess:()=>{E.success(c("superAdmin.messages.configUpdated")),F(),I()},onError:S}):R.mutate(k,{onSuccess:()=>{E.success(c("superAdmin.messages.configAdded")),F(),I()},onError:_=>q(_)});return}const n={organizationId:r,maximumNumberOfUnits:Number(d),...(h==null?void 0:h.canStartReceivingRecord)!==void 0&&{canStartReceivingRecord:h.canStartReceivingRecord},...(h==null?void 0:h.canCreateBranchByItself)!==void 0&&{canCreateBranchByItself:h.canCreateBranchByItself}},O=b||(h==null?void 0:h.id)||null;O?W.mutate({id:O,data:n},{onSuccess:()=>{E.success(c("superAdmin.messages.configUpdated")),F(),I()},onError:S}):Z.mutate(n,{onSuccess:()=>{E.success(c("superAdmin.messages.configAdded")),F(),I()},onError:k=>q(k)})},q=n=>{var j,_,ge;const O=(j=n==null?void 0:n.response)==null?void 0:j.status,k=((ge=(_=n==null?void 0:n.response)==null?void 0:_.data)==null?void 0:ge.message)??(n==null?void 0:n.message)??"";if(O===400&&/duplicate/i.test(k)){E.warning(c("superAdmin.messages.configAlreadyExists")),I();return}S(n)},F=()=>{m(""),u(""),s(""),N(null)},De=n=>{p?(N(n.id),m(n.unitId??""),u((n.numberOfSubUnits??0).toString()),s((n.numberOfEmployeesPerUnit??0).toString())):(N(n.id),u((n.maximumNumberOfUnits??0).toString()))},Pe=n=>{n!=null&&n.id&&f.mutate(n.id,{onSuccess:()=>{E.success(c("superAdmin.messages.configDeleted")),b===n.id&&F(),I()},onError:S})},Ee=c(p?"superAdmin.manageConfig.unit":"superAdmin.manageConfig.organization"),Me=c(p?"superAdmin.manageConfig.unitBased":"superAdmin.manageConfig.organizationLevel");return e.jsx(ft,{open:a,onOpenChange:t,children:e.jsxs(mt,{className:"max-h-[85vh] overflow-hidden flex flex-col top-1/6 bg-white dark:bg-gray-900",children:[e.jsxs("div",{className:"overflow-y-auto flex-1",children:[e.jsxs(ut,{className:"pb-4",children:[e.jsx(pt,{className:"text-lg text-primary-700 dark:text-primary-400",children:c("superAdmin.manageConfig.title",{type:Ee})}),e.jsxs(gt,{className:"text-primary-600/80 dark:text-primary-300/80 space-y-3",children:[e.jsx("div",{children:c("superAdmin.manageConfig.description",{level:Me})}),e.jsxs(Aa,{value:p?"unit":"org",onValueChange:n=>{T(n==="unit"),F()},className:"inline-flex rounded-md bg-gray-100 dark:bg-gray-800 p-1",children:[e.jsx(xe,{value:"org",id:"org-radio",className:"hidden"}),e.jsx("label",{htmlFor:"org-radio",className:`cursor-pointer px-4 py-1 rounded-md text-sm font-medium transition-all + ${p?"text-primary-700 dark:text-primary-300 hover:bg-primary-200 dark:hover:bg-primary-900/30":"bg-primary-600 text-white"}`,children:c("superAdmin.manageConfig.organization")}),e.jsx(xe,{value:"unit",id:"unit-radio",className:"hidden"}),e.jsx("label",{htmlFor:"unit-radio",className:`cursor-pointer px-4 py-1 rounded-md text-sm font-medium transition-all + ${p?"bg-primary-600 text-white":"text-primary-700 dark:text-primary-300 hover:bg-primary-200 dark:hover:bg-primary-900/30"}`,children:c("superAdmin.manageConfig.unit")})]}),p&&((me=(ce=o==null?void 0:o.data)==null?void 0:ce.items)==null?void 0:me.length)>0&&e.jsxs("div",{className:"pt-2",children:[e.jsx("label",{className:"block text-sm font-medium text-primary-700 dark:text-primary-300",children:c("superAdmin.manageConfig.selectUnit")}),e.jsxs(ua,{value:l,onValueChange:n=>m(n),children:[e.jsx(pa,{className:"mt-1 border-primary-400 dark:border-primary-700 dark:bg-gray-800 dark:text-gray-100 focus:ring-primary-500",children:e.jsx(ga,{placeholder:c("superAdmin.manageConfig.selectUnitPlaceholder")})}),e.jsx(ha,{children:o==null?void 0:o.data.items?.map(n=>e.jsx(xa,{value:n.id,children:z(n.name)},n.id))})]})]})]})]}),e.jsxs("div",{className:"px-6 space-y-4 pb-4",children:[e.jsxs("div",{className:`grid gap-4 ${p?"grid-cols-2":"grid-cols-1"}`,children:[e.jsxs("div",{children:[e.jsx("label",{className:"text-sm font-medium text-primary-700 dark:text-primary-300",children:p?c("superAdmin.manageConfig.subOrganizations"):c("superAdmin.manageConfig.maxUnits","Maximum Number of Units")}),e.jsx(he,{type:"number",min:"0",value:d,onChange:n=>u(n.target.value),placeholder:c("superAdmin.manageConfig.subOrgsPlaceholder"),className:"border-primary-400 dark:border-primary-700 dark:bg-gray-800 dark:text-gray-100 focus:ring-primary-500"})]}),p&&e.jsxs("div",{children:[e.jsx("label",{className:"text-sm font-medium text-primary-700 dark:text-primary-300",children:c("superAdmin.manageConfig.employeesPerOrg")}),e.jsx(he,{type:"number",min:"0",value:g,onChange:n=>s(n.target.value),placeholder:c("superAdmin.manageConfig.employeesPlaceholder"),className:"border-primary-400 dark:border-primary-700 dark:bg-gray-800 dark:text-gray-100 focus:ring-primary-500"})]})]}),e.jsxs(M,{onClick:oe,className:"w-full flex items-center gap-2 bg-primary-600 hover:bg-primary-700 text-white",disabled:v,children:[v&&e.jsx(le,{className:"h-4 w-4 animate-spin"}),b||!p&&(h!=null&&h.id)||p&&(C!=null&&C.id)?c("superAdmin.manageConfig.updateConfiguration"):c("superAdmin.manageConfig.addConfiguration")]}),e.jsx("div",{className:"border border-primary-300 dark:border-primary-800 rounded-lg p-3 space-y-3 mt-4",children:ie?e.jsx("p",{className:"text-sm text-primary-600/70 text-center py-4",children:c("superAdmin.manageConfig.loading")}):(pe=(ue=D==null?void 0:D.data)==null?void 0:ue.items)!=null&&pe.length?e.jsx("div",{className:"max-h-48 overflow-y-auto",children:D.data.items?.map(n=>{var O,k,j;return e.jsxs("div",{className:"flex items-center justify-between border-b border-primary-100 pb-2 last:border-none py-2",children:[e.jsxs("div",{children:[p&&e.jsx("p",{className:"font-semibold capitalize text-primary-700 text-sm",children:z((j=(k=(O=o==null?void 0:o.data)==null?void 0:O.items)==null?void 0:k.find(_=>_.id===n.unitId))==null?void 0:j.name)||n.unitId}),e.jsx("p",{className:"text-xs text-primary-600/80",children:p?e.jsxs(e.Fragment,{children:[n.numberOfSubUnits," ",c("superAdmin.manageConfig.subOrganizations")," •"," ",n.numberOfEmployeesPerUnit," ",c("superAdmin.manageConfig.employeesPerOrg")]}):e.jsxs(e.Fragment,{children:[c("superAdmin.manageConfig.maxUnits","Maximum Number of Units"),": ",n.maximumNumberOfUnits??0]})})]}),e.jsxs("div",{className:"flex gap-2",children:[e.jsx(M,{variant:"ghost",size:"sm",onClick:()=>De(n),className:"h-8 px-2 text-xs text-primary-700 dark:text-primary-300 hover:bg-primary-100 dark:hover:bg-primary-900/30",children:c("superAdmin.manageConfig.edit")}),e.jsx(M,{variant:"ghost",size:"sm",onClick:()=>Pe(n),disabled:f.isPending&&f.variables===n.id,className:"h-8 w-8 p-0 text-primary-700 dark:text-primary-300 hover:bg-primary-100 dark:hover:bg-primary-900/30",children:f.isPending&&f.variables===n.id?e.jsx(le,{className:"h-3.5 w-3.5 animate-spin"}):e.jsx(Xe,{size:14})})]})]},n.id)})}):e.jsx("p",{className:"text-sm text-primary-600/70 text-center py-4",children:c("superAdmin.manageConfig.noConfigurations")})})]})]}),e.jsx(ht,{className:"pt-4 border-t border-primary-200 dark:border-primary-800 bg-primary-50/40 dark:bg-primary-900/10",children:e.jsx(xt,{asChild:!0,children:e.jsx(M,{variant:"outline",onClick:F,className:"w-full border-primary-500 dark:border-primary-700 text-primary-700 dark:text-primary-300 hover:bg-primary-100 dark:hover:bg-primary-900/30",children:c("superAdmin.manageConfig.close")})})})]})})}const bt=({rowData:a})=>{const[t,r]=x.useState(!1),{t:i}=X(),l=Ye(),[m,d]=x.useState(!1),{activateOrganization:u,deactivateOrganization:g,isOrganizationActivating:s,isOrganizationDeactivating:b,deleteOrganization:N,isOrganizationDeleting:o}=be("Org"),{softDeleteOrganization:z,isArchivingOrganization:c}=Da(),S=()=>{r(!1),l(`/user-management/organizations/edit/${a==null?void 0:a.id}`)},A=()=>{r(!1),l(`/user-management/organizations/detail/${a==null?void 0:a.id}`)},U=()=>{r(!1),d(!0)},p=a.status==="Active";return e.jsxs("div",{className:"flex gap-3 align-center",children:[e.jsx(lt,{organizationName:a.name.en,isActive:p,onConfirm:()=>{p?g({id:a.id}):u({id:a.id})},isLoading:s||b,trigger:p?e.jsx(M,{variant:"outline",className:p?"text-red-600 dark:text-red-400 border-red-600 dark:border-red-500 hover:bg-red-50 dark:hover:bg-red-900/30":"text-primary-600 dark:text-primary-400 border-primary-600 dark:border-primary-500 hover:bg-primary-50 dark:hover:bg-primary-900/30",children:i("statusBar.deactivate")}):e.jsx(M,{variant:"outline",size:"sm",className:p?"text-red-600 dark:text-red-400 border-red-600 dark:border-red-500 hover:bg-red-50 dark:hover:bg-red-900/30":"text-primary-600 dark:text-primary-400 border-primary-600 dark:border-primary-500 hover:bg-primary-50 dark:hover:bg-primary-900/30",children:i("statusBar.activate")})}),e.jsxs(ea,{open:t,onOpenChange:r,children:[e.jsx(aa,{asChild:!0,children:e.jsxs(M,{variant:"ghost",className:"flex h-8 w-8 p-0 data-[state=open]:bg-muted","aria-haspopup":"menu","aria-expanded":t,children:[e.jsx(Pa,{className:"h-4 w-4"}),e.jsx("span",{className:"sr-only",children:"Open menu"})]})}),e.jsxs(ta,{align:"end",className:"w-[200px]",onInteractOutside:T=>{T.target.closest('[role="dialog"]')||r(!1)},children:[e.jsx(ra,{children:"Actions"}),e.jsxs(Q,{onSelect:A,children:[e.jsx(Ea,{className:"mr-2 h-4 w-4"}),i("organization.viewOrganization")]}),e.jsxs(Q,{onSelect:S,children:[e.jsx(Ma,{className:"mr-2 h-4 w-4"}),i("organization.editOrganization")]}),e.jsxs(Q,{onSelect:U,children:[e.jsx(sa,{className:"mr-2 h-4 w-4"}),i("organization.editOrganization")]}),e.jsxs(Q,{onSelect:()=>{r(!1),z(a.id)},disabled:c,className:"text-amber-700 focus:text-amber-700",children:[e.jsx(na,{className:"mr-2 h-4 w-4"}),i("archive.archiveOrganization","Archive Organization")]}),e.jsx(dt,{organizationName:a.name.en,onDelete:()=>{N({id:a.id})},onCancel:()=>{},isLoading:o,trigger:e.jsxs(M,{variant:"destructive",children:[e.jsx(Va,{className:"mr-2 h-4 w-4"}),i("organization.deleteOrganization")]})})]}),m&&e.jsx(yt,{manageOrgConfigOpen:m,setManageOrgConfigOpen:d,organizationId:a.id,isUnitConfig:!1})]})]})},vt=({organizationId:a})=>{const{t}=X(),[r,i]=x.useState(null),[l,m]=x.useState(!1),[d,u]=x.useState(!0);x.useEffect(()=>{(async()=>{try{u(!0);const o=(await ka(a)).data;if(o.count>0&&o.items&&o.items.length>0){const z=o.items[0];i(z)}else i(null)}catch{i(null)}finally{u(!1)}})()},[a]);const g=async b=>{try{m(!0);const N={canStartReceivingRecord:b,organizationId:a};if(r&&r.id)await za(r.id,N),i(o=>o?{...o,canStartReceivingRecord:b}:null);else{const o=await Sa(N);i(o.data)}E.success(t(b?"organization.visibilityEnabled":"organization.visibilityDisabled"))}catch{E.error(t("organization.visibilityUpdateFailed"))}finally{m(!1)}};if(d)return e.jsx("div",{className:"flex items-center justify-center",children:e.jsx("div",{className:"w-4 h-4 border-2 border-gray-300 dark:border-gray-600 border-t-primary-600 rounded-full animate-spin"})});const s=r?r.canStartReceivingRecord:!0;return e.jsxs("div",{className:"flex items-center gap-3",children:[e.jsxs("div",{className:"flex items-center gap-2",children:[e.jsx("span",{className:"text-sm text-gray-600 dark:text-gray-300",children:t(s?"organization.visible":"organization.hidden")}),e.jsx(Ba,{checked:s,onCheckedChange:g,disabled:l,className:"data-[state=checked]:bg-primary-600"})]}),l&&e.jsx("div",{className:"w-4 h-4 border-2 border-gray-300 dark:border-gray-600 border-t-primary-600 rounded-full animate-spin"})]})},jt=[{accessorKey:"name.en",header:()=>w("organization.organizationName"),size:300,minSize:200,maxSize:400,cell:({row:a})=>{const t=a.original;return ia.language,e.jsxs("div",{className:"font-medium",children:[t.parentId&&e.jsx("span",{className:"text-gray-400 dark:text-gray-500 mr-2",children:"└─"}),t.name.am]})}},{accessorKey:"createdAt",header:()=>w("organization.createdOn"),cell:({row:a})=>e.jsx("div",{className:"font-medium",children:Ua(new Date(a.original.createdAt),"MMM dd, yyyy")})},{accessorKey:"activeEmployeeCount",header:()=>w("organization.numberOfUsers"),cell:({row:a})=>e.jsxs("div",{className:"font-medium flex items-center",children:[e.jsx(Ta,{className:"h-4 w-4 mr-2 text-gray-500 dark:text-gray-400"}),a.original.activeEmployeeCount!==void 0?a.original.activeEmployeeCount:"-"]})},{accessorKey:"adminsCount",header:()=>w("organization.assignedAdmin"),cell:({row:a})=>e.jsx(Ia,{id:a.original.id,adminsCount:a.original.adminsCount||0})},{id:"status",header:()=>w("userRecord.Status"),cell:({row:a})=>{const t=a.original;return e.jsx("div",{children:e.jsx(fe,{className:`${t.status==="Active"?"bg-primary-100 text-primary-600 hover:bg-primary-100 dark:bg-primary-900/40 dark:text-primary-300 dark:hover:bg-primary-900/50":"bg-red-100 text-red-600 hover:bg-red-100 dark:bg-red-900/40 dark:text-red-300 dark:hover:bg-red-900/50"} rounded-full px-6 py-1 font-medium`,children:t.status==="Active"?w("statusBar.activate"):w("statusBar.deactivate")})})}},{id:"isGovernmentOrganization",header:()=>w("organization.isGovernmentOrganization"),cell:({row:a})=>{const t=a.original;return e.jsx("div",{children:e.jsx(fe,{className:`${t.isGovernmentOrganization?"bg-blue-600 text-white hover:bg-blue-400":"bg-red-600 text-white hover:bg-red-400"} rounded-full px-6 py-1 font-medium`,children:t.isGovernmentOrganization===!0?w("common.yes"):w("common.no")})})}},{id:"visibility",header:()=>w("organization.visibilityToOthers"),cell:({row:a})=>{const t=a.original;return e.jsx(vt,{organizationId:t.id})}},{id:"Actions",header:()=>w("userRecord.Actions"),cell:({row:a})=>{const t=a.original;return e.jsx(bt,{rowData:t})}}],Nt=a=>{const{permissions:t}=oa();return a?.some(r=>t.includes(r))},Ct=({perms:a,fallback:t=null,children:r})=>Nt(a)?r:t;function wt(){const[a,t]=x.useState(0),[r,i]=x.useState(new Set),[l,m]=x.useState(""),d=10;x.useEffect(()=>{t(0)},[l]);const{t:u}=X(),g=ye(),{organizationsResponse:s,isLoading:b,refetch:N}=be("Org",{take:1e3,skip:0,orderBy:"createdAt",order:"createdAt:Desc"}),o=y=>{t(y)},z=y=>{i(v=>{const f=new Set(v);return f.has(y)?f.delete(y):f.add(y),f})},c=x.useMemo(()=>{const y=new Map;return((s==null?void 0:s.items)||[]).forEach(v=>{y.set(v.id,v)}),y},[s==null?void 0:s.items]),S=y=>{const v=[],f=new Map;return y.forEach(h=>{h.parentId?(f.has(h.parentId)||f.set(h.parentId,[]),f.get(h.parentId).push(h)):v.push(h)}),{root:v,children:f}},A=x.useMemo(()=>(s==null?void 0:s.items)||[],[s==null?void 0:s.items]),{root:U,children:p}=S(A),T=l.trim().toLowerCase(),D=T.length>0,ie=x.useMemo(()=>D?A.filter(y=>{var v,f;return(((v=y.name)==null?void 0:v.en)||"").toLowerCase().includes(T)||(((f=y.name)==null?void 0:f.am)||"").toLowerCase().includes(T)}):[],[A,D,T]),I=D?ie:U,R=a*d,K=R+d,V=I.slice(R,K),Z=x.useMemo(()=>{const y=[];return D?(V.forEach(v=>{const f=v.parentId?c.get(v.parentId):null;y.push({...v,isChild:!!v.parentId,parentName:f?f.name.en:void 0})}),y):(V.forEach(v=>{y.push(v),r.has(v.id)&&(p.get(v.id)||[]).forEach(h=>{y.push({...h,isChild:!0,parentName:v.name.en})})}),y)},[V,p,r,D,c]),W=x.useMemo(()=>jt?.map(y=>"accessorKey"in y&&y.accessorKey==="name.en"?{...y,cell:({row:v})=>{const f=v.original,h=f.isChild||!1,C=f.parentName,oe=p.has(f.id),q=r.has(f.id);return e.jsxs("div",{className:"flex items-center gap-2 min-w-0",children:[oe&&e.jsx(M,{variant:"ghost",size:"sm",className:"h-6 w-6 p-0 flex-shrink-0",onClick:()=>z(f.id),children:q?e.jsx(la,{className:"h-4 w-4"}):e.jsx(da,{className:"h-4 w-4"})}),e.jsxs("div",{className:"font-medium flex items-center gap-2 min-w-0 flex-1 overflow-hidden",children:[h&&e.jsx("span",{className:"text-gray-400 dark:text-gray-500 flex-shrink-0",children:"└─"}),e.jsxs("div",{className:"min-w-0 flex-1",children:[e.jsx("div",{className:"truncate",title:f.name.en,children:g(f.name)}),C&&e.jsxs("div",{className:"text-sm text-gray-500 dark:text-gray-400 font-normal truncate",title:`under ${C}`,children:["(under ",C,")"]})]})]})]})}}:y),[p,r,g]);return b?e.jsx("div",{children:e.jsx(Ga,{})}):e.jsx("div",{className:"p-6 space-y-6",children:e.jsxs($a,{className:"col-span-2 shadow-none border-none bg-transparent px-0",children:[e.jsx(La,{className:"flex flex-row justify-between items-center px-0",children:e.jsx(Fa,{className:"text-xl font-semibold ",children:u("organization.organizations")})}),e.jsx(_a,{className:"px-0",children:e.jsx(ma,{columns:W,data:Z,tableName:"Organizations",toolBarPosition:"right",itemCount:I.length,pageSize:d,onGlobalFilterChange:m,extraToolbar:e.jsx(Ct,{perms:["create:organization","activate:organization"],children:e.jsx(ca,{to:"/user-management/organizations/new",children:e.jsxs(M,{className:"px-5 py-2 rounded-md text-sm font-medium shadow-md",children:[e.jsx(Ha,{className:"w-4 h-4 mr-2"}),u("organization.newOrganization")]})})}),pageIndex:a,onPageChange:o,nextFunction:()=>o(a+1),prevFunction:()=>o(Math.max(a-1,0)),refresh:N})})]})})}const ur=()=>e.jsx(wt,{});export{ur as default}; diff --git a/apps/edr-freight-web/backoffice/public/_um/assets/PendingExternalUsers-DTQ4H5AP.js b/apps/edr-freight-web/backoffice/public/_um/assets/PendingExternalUsers-DTQ4H5AP.js new file mode 100644 index 000000000..1619ded8f --- /dev/null +++ b/apps/edr-freight-web/backoffice/public/_um/assets/PendingExternalUsers-DTQ4H5AP.js @@ -0,0 +1 @@ +import{n as T,m as w,o as B,u as D,k as P,j as e,B as h,t as F,v as d,az as R,r as j,f as K,aF as S}from"./index-Db-xuq0b.js";import{A as q}from"./AdvancedTable-CC9ioMU-.js";import{C as L,a as I,b as M,d as Q}from"./card-BBWyxDss.js";import{b as _,c as H}from"./userService-BHeFzZdn.js";import{B as O}from"./badge-D7JvaQeJ.js";import{A as y,h as b,a as v,b as f,c as C,d as A,e as N,f as U,g as k}from"./alert-dialog-B5Y0wlSz.js";import{f as V}from"./format-DvwV82px.js";import{R as $,a as z}from"./radio-group-DqG5gUnT.js";import{L as E}from"./label-CsFy6wpo.js";import"./table-D3n3VABd.js";import"./select-BoQxM42A.js";import"./chevron-left-DCpEaNzo.js";import"./utils-BncSPdK1.js";import"./command-Cr0tMDDu.js";import"./search-CM5F2ZRy.js";import"./popover-X-j_SRnG.js";import"./Popover-CzANYrEa.js";import"./use-uncontrolled-C3HRHW6t.js";import"./separator-BaOOgzZX.js";import"./refresh-cw-JB7N413f.js";import"./en-US-Cc-9gH5A.js";import"./Radio-C3SNvFel.js";import"./get-auto-contrast-value-Da6zqqWm.js";import"./InputsGroupFieldset-COkNgcEo.js";const m={pendingUsers:["external-users","pending"],allExternalUsers:["external-users","all"]};var g=(s=>(s.external="external_organization",s.individual="individual",s))(g||{});const G=s=>T({queryKey:[...m.allExternalUsers,s],queryFn:async()=>(await H(s)).data}),J=()=>{const s=w();return B({mutationFn:({id:t,status:a})=>_(t,a),onSuccess:()=>{s.invalidateQueries({queryKey:m.pendingUsers}),s.invalidateQueries({queryKey:m.allExternalUsers})}})},W=({userId:s,userStatus:t})=>{const a=J(),{t:r}=D(),{handleError:l}=P(r),n=a.isPending,o=t==="pending"||t==="rejected"||t==="submitted",c=i=>{a.mutate({id:s,status:i},{onSuccess:()=>{F.success(i==="accepted"?"User successfully approved":"User successfully rejected")},onError:u=>{l(u)}})};return e.jsxs("div",{className:"flex gap-2",children:[e.jsxs(y,{children:[e.jsx(b,{asChild:!0,children:e.jsx(h,{className:"bg-primary-600 text-white hover:bg-primary-700",size:"sm",disabled:n||!o,children:r("statusBar.Approve")})}),e.jsxs(v,{children:[e.jsxs(f,{children:[e.jsx(C,{children:r("organization.approveUserPrompt")}),e.jsx(A,{children:r("organization.approveUserDescription")})]}),e.jsxs(N,{children:[e.jsx(U,{disabled:n,children:r("common.Cancel")}),e.jsx(k,{onClick:()=>c("accepted"),disabled:n,children:r(n?"organization.approving":"organization.yesApprove")})]})]})]}),t==="accepted"&&e.jsxs(y,{children:[e.jsx(b,{asChild:!0,children:e.jsx(h,{className:"bg-red-600 text-white hover:bg-red-700",size:"sm",disabled:n,children:r("statusBar.Reject")})}),e.jsxs(v,{children:[e.jsxs(f,{children:[e.jsx(C,{children:r("organization.rejectUserPrompt")}),e.jsx(A,{children:r("organization.rejectUserDescription")})]}),e.jsxs(N,{children:[e.jsx(U,{disabled:n,children:r("common.Cancel")}),e.jsx(k,{onClick:()=>c("rejected"),disabled:n,children:r(n?"organization.rejecting":"organization.yesReject")})]})]})]})]})},X=(s,t)=>[{accessorKey:"name.en",header:()=>d("organization.username"),cell:({row:a})=>e.jsx("div",{className:"font-medium",children:s(a.original.name)})},{accessorKey:"updatedAt",header:()=>d("organization.updatedOn"),cell:({row:a})=>e.jsx("div",{className:"font-medium",children:V(new Date(a.original.updatedAt),"MMM dd, yyyy")})},{accessorKey:"userType",header:()=>"User Type",cell:({row:a})=>{const r=a.original.userType,l=r==="external_organization"?"External Organization":"Employee";return e.jsx("div",{className:"font-medium",children:e.jsx("span",{className:`px-2 py-1 rounded-full text-xs font-medium ${r==="external_organization"?"bg-purple-100 text-purple-800 dark:bg-purple-900/40 dark:text-purple-300":"bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300"}`,children:l})})}},{id:"status",header:()=>d("dashboard.Status"),cell:({row:a})=>{const{status:r}=a.original,l=o=>{switch(o){case"accepted":return"bg-primary-100 text-primary-600 hover:bg-primary-100 dark:bg-primary-900/40 dark:text-primary-300 dark:hover:bg-primary-900/50";case"rejected":return"bg-red-100 text-red-600 hover:bg-red-100 dark:bg-red-900/40 dark:text-red-300 dark:hover:bg-red-900/50";case"pending":default:return"bg-blue-100 text-blue-600 hover:bg-blue-100 dark:bg-blue-900/40 dark:text-blue-300 dark:hover:bg-blue-900/50"}},n=o=>{switch(o){case"accepted":return"Accepted";case"rejected":return"Rejected";case"pending":default:return d("statusBar.Pending")}};return e.jsx("div",{children:e.jsx(O,{className:`${l(r)} rounded-full px-6 py-1 font-medium`,children:n(r)})})}},{id:"actions",header:()=>d("userRecord.Actions"),cell:({row:a})=>{const{id:r,status:l}=a.original,n=()=>{t(`/user-management/external_users/view/${r}`)};return e.jsxs("div",{className:"flex items-center gap-2",children:[e.jsx(W,{userId:r,userStatus:l}),e.jsx("button",{onClick:n,className:"px-2 py-1 bg-gray-100 dark:bg-gray-800 rounded hover:bg-gray-200 dark:hover:bg-gray-700 text-sm font-medium text-gray-800 dark:text-gray-200",children:d("View")})]})}}];function Ce(){const s=R(),[t,a]=j.useState(0),r=10,{t:l}=D(),n=K(),[o,c]=j.useState(void 0),{data:i,isLoading:u}=G({take:r,skip:t*r,orderBy:"updatedAt:Desc",userType:o}),p=x=>{a(x)};return u?e.jsx("div",{className:"flex justify-center p-6",children:e.jsx(S,{className:"animate-spin w-6 h-6 text-gray-500 dark:text-gray-400"})}):e.jsx("div",{className:"p-6 space-y-6",children:e.jsxs(L,{className:"col-span-2 shadow-none border-none bg-transparent px-0",children:[e.jsxs(I,{className:"flex flex-row justify-between items-center px-0",children:[e.jsx(M,{className:"text-xl font-semibold",children:"All Users"}),e.jsxs($,{value:o,onValueChange:x=>{c(x),a(0)},className:"flex gap-4",children:[e.jsxs("div",{className:"flex items-center space-x-2",children:[e.jsx(z,{value:g.external,id:"external"}),e.jsx(E,{htmlFor:"external",children:"External Organization"})]}),e.jsxs("div",{className:"flex items-center space-x-2",children:[e.jsx(z,{value:g.individual,id:"individual"}),e.jsx(E,{htmlFor:"individual",children:"Individual"})]})]})]}),e.jsx(Q,{className:"px-0",children:e.jsx(q,{columns:X(s,n),data:(i==null?void 0:i.items)||[],tableName:"ExternalUsers",toolBarPosition:"right",itemCount:(i==null?void 0:i.count)||0,pageIndex:t,onPageChange:p,nextFunction:()=>p(t+1),prevFunction:()=>p(Math.max(t-1,0))})})]})})}export{Ce as default}; diff --git a/apps/edr-freight-web/backoffice/public/_um/assets/Popover-CzANYrEa.js b/apps/edr-freight-web/backoffice/public/_um/assets/Popover-CzANYrEa.js new file mode 100644 index 000000000..8312ac58b --- /dev/null +++ b/apps/edr-freight-web/backoffice/public/_um/assets/Popover-CzANYrEa.js @@ -0,0 +1 @@ +import{av as Fe,r as g,ad as ze,K as Q,N,cn as je,ag as W,j as w,co as Z,a1 as G,cp as ke,Q as Ie,cq as Ae,X as _e,af as Me,ah as $e,aj as Ke,cr as Ve,cs as Le,E as X,ct as Ue,cu as Xe,cv as Y,cw as q,cx as B,cy as Ye,cz as qe,V as Be,ak as He,aq as Qe,a8 as We,cA as Ze,ao as Ge,ap as Je,W as eo,cB as oo,Y as to}from"./index-Db-xuq0b.js";import{u as ro}from"./use-uncontrolled-C3HRHW6t.js";function ao(e,r={active:!0}){return typeof e!="function"||!r.active?r.onKeyDown||Fe:t=>{var s;t.key==="Escape"&&(e(t),(s=r.onTrigger)==null||s.call(r))}}const H=["mousedown","touchstart"];function so(e,r,t){const s=g.useRef(null);return g.useEffect(()=>{const d=l=>{const{target:i}=l??{};if(Array.isArray(t)){const n=(i==null?void 0:i.hasAttribute("data-ignore-outside-clicks"))||!document.body.contains(i)&&i.tagName!=="HTML";t.every(c=>!!c&&!l.composedPath().includes(c))&&!n&&e()}else s.current&&!s.current.contains(i)&&e()};return(r||H).forEach(l=>document.addEventListener(l,d)),()=>{(r||H).forEach(l=>document.removeEventListener(l,d))}},[s,e,t]),s}const[no,J]=ze("Popover component was not found in the tree");var ee={dropdown:"m_38a85659",arrow:"m_a31dc6c1",overlay:"m_3d7bc908"};const io={},F=Q((e,r)=>{var C,E,y,m;const t=N("PopoverDropdown",io,e),{className:s,style:d,vars:l,children:i,onKeyDownCapture:n,variant:a,classNames:c,styles:f,...O}=t,o=J(),D=je({opened:o.opened,shouldReturnFocus:o.returnFocus}),R=o.withRoles?{"aria-labelledby":o.getTargetId(),id:o.getDropdownId(),role:"dialog",tabIndex:-1}:{},b=W(r,o.floating);return o.disabled?null:w.jsx(Z,{...o.portalProps,withinPortal:o.withinPortal,children:w.jsx(G,{mounted:o.opened,...o.transitionProps,transition:((C=o.transitionProps)==null?void 0:C.transition)||"fade",duration:((E=o.transitionProps)==null?void 0:E.duration)??150,keepMounted:o.keepMounted,exitDuration:typeof((y=o.transitionProps)==null?void 0:y.exitDuration)=="number"?o.transitionProps.exitDuration:(m=o.transitionProps)==null?void 0:m.duration,children:v=>w.jsx(ke,{active:o.trapFocus&&o.opened,innerRef:b,children:w.jsxs(Ie,{...R,...O,variant:a,onKeyDownCapture:ao(()=>{var S,x;(S=o.onClose)==null||S.call(o),(x=o.onDismiss)==null||x.call(o)},{active:o.closeOnEscape,onTrigger:D,onKeyDown:n}),"data-position":o.placement,"data-fixed":o.floatingStrategy==="fixed"||void 0,...o.getStyles("dropdown",{className:s,props:t,classNames:c,styles:f,style:[{...v,zIndex:o.zIndex,top:o.y??0,left:o.x??0,width:o.width==="target"?void 0:_e(o.width)},o.resolvedStyles.dropdown,f==null?void 0:f.dropdown,d]}),children:[i,w.jsx(Ae,{ref:o.arrowRef,arrowX:o.arrowX,arrowY:o.arrowY,visible:o.withArrow,position:o.placement,arrowSize:o.arrowSize,arrowRadius:o.arrowRadius,arrowOffset:o.arrowOffset,arrowPosition:o.arrowPosition,...o.getStyles("arrow",{props:t,classNames:c,styles:f})})]})})})})});F.classes=ee;F.displayName="@mantine/core/PopoverDropdown";const lo={refProp:"ref",popupType:"dialog"},oe=Q((e,r)=>{const{children:t,refProp:s,popupType:d,...l}=N("PopoverTarget",lo,e);if(!Me(t))throw new Error("Popover.Target component children should be an element or a component that accepts ref. Fragments, strings, numbers and other primitive values are not supported");const i=l,n=J(),a=W(n.reference,$e(t),r),c=n.withRoles?{"aria-haspopup":d,"aria-expanded":n.opened,"aria-controls":n.getDropdownId(),id:n.getTargetId()}:{};return g.cloneElement(t,{...i,...c,...n.targetProps,className:Ke(n.targetProps.className,i.className,t.props.className),[s]:a,...n.controlled?null:{onClick:n.onToggle}})});oe.displayName="@mantine/core/PopoverTarget";function co(e){if(e===void 0)return{shift:!0,flip:!0};const r={...e};return e.shift===void 0&&(r.shift=!0),e.flip===void 0&&(r.flip=!0),r}function uo(e,r){const t=co(e.middlewares),s=[Ue(e.offset)];return t.shift&&s.push(Xe(typeof t.shift=="boolean"?{limiter:Y(),padding:5}:{limiter:Y(),padding:5,...t.shift})),t.flip&&s.push(typeof t.flip=="boolean"?q():q(t.flip)),t.inline&&s.push(typeof t.inline=="boolean"?B():B(t.inline)),s.push(Ye({element:e.arrowRef,padding:e.arrowOffset})),(t.size||e.width==="target")&&s.push(qe({...typeof t.size=="boolean"?{}:t.size,apply({rects:d,availableWidth:l,availableHeight:i,...n}){var f;const c=((f=r().refs.floating.current)==null?void 0:f.style)??{};t.size&&(typeof t.size=="object"&&t.size.apply?t.size.apply({rects:d,availableWidth:l,availableHeight:i,...n}):Object.assign(c,{maxWidth:`${l}px`,maxHeight:`${i}px`})),e.width==="target"&&Object.assign(c,{width:`${d.reference.width}px`})}})),s}function fo(e){const[r,t]=ro({value:e.opened,defaultValue:e.defaultOpened,finalValue:!1,onChange:e.onChange}),s=g.useRef(r),d=()=>{r&&!e.disabled&&t(!1)},l=()=>!e.disabled&&t(!r),i=Ve({strategy:e.strategy,placement:e.position,middleware:uo(e,()=>i)});return Le({opened:r,position:e.position,positionDependencies:e.positionDependencies||[],floating:i}),X(()=>{var n;(n=e.onPositionChange)==null||n.call(e,i.placement)},[i.placement]),X(()=>{var n,a;r!==s.current&&(r?(a=e.onOpen)==null||a.call(e):(n=e.onClose)==null||n.call(e)),s.current=r},[r,e.onClose,e.onOpen]),{floating:i,controlled:typeof e.opened=="boolean",opened:r,onClose:d,onToggle:l}}const po={position:"bottom",offset:8,positionDependencies:[],transitionProps:{transition:"fade",duration:150},middlewares:{flip:!0,shift:!0,inline:!1},arrowSize:7,arrowOffset:5,arrowRadius:0,arrowPosition:"side",closeOnClickOutside:!0,withinPortal:!0,closeOnEscape:!0,trapFocus:!1,withRoles:!0,returnFocus:!1,withOverlay:!1,clickOutsideEvents:["mousedown","touchstart"],zIndex:Je("popover"),__staticSelector:"Popover",width:"max-content"},go=eo((e,{radius:r,shadow:t})=>({dropdown:{"--popover-radius":r===void 0?void 0:to(r),"--popover-shadow":oo(t)}}));function T(e){var M,$,K,V,L,U;const r=N("Popover",po,e),{children:t,position:s,offset:d,onPositionChange:l,positionDependencies:i,opened:n,transitionProps:a,onExitTransitionEnd:c,onEnterTransitionEnd:f,width:O,middlewares:o,withArrow:D,arrowSize:R,arrowOffset:b,arrowRadius:C,arrowPosition:E,unstyled:y,classNames:m,styles:v,closeOnClickOutside:S,withinPortal:x,portalProps:te,closeOnEscape:re,clickOutsideEvents:ae,trapFocus:se,onClose:ne,onDismiss:P,onOpen:ie,onChange:le,zIndex:de,radius:ce,shadow:ue,id:fe,defaultOpened:pe,__staticSelector:z,withRoles:ge,disabled:j,returnFocus:we,variant:he,keepMounted:me,vars:ye,floatingStrategy:k,withOverlay:ve,overlayProps:h,...xe}=r,I=Be({name:z,props:r,classes:ee,classNames:m,styles:v,unstyled:y,rootSelector:"dropdown",vars:ye,varsResolver:go}),{resolvedStyles:Pe}=He({classNames:m,styles:v,props:r}),A=g.useRef(null),[Oe,De]=g.useState(null),[Re,be]=g.useState(null),{dir:Ce}=Qe(),_=We(fe),u=fo({middlewares:o,width:O,position:Ze(Ce,s),offset:typeof d=="number"?d+(D?R/2:0):d,arrowRef:A,arrowOffset:b,onPositionChange:l,positionDependencies:i,opened:n,defaultOpened:pe,onChange:le,onOpen:ie,onClose:ne,onDismiss:P,strategy:k,disabled:j});so(()=>{S&&(u.onClose(),P==null||P())},ae,[Oe,Re]);const Ee=g.useCallback(p=>{De(p),u.floating.refs.setReference(p)},[u.floating.refs.setReference]),Se=g.useCallback(p=>{be(p),u.floating.refs.setFloating(p)},[u.floating.refs.setFloating]),Te=g.useCallback(()=>{var p;(p=a==null?void 0:a.onExited)==null||p.call(a),c==null||c()},[a==null?void 0:a.onExited,c]),Ne=g.useCallback(()=>{var p;(p=a==null?void 0:a.onEntered)==null||p.call(a),f==null||f()},[a==null?void 0:a.onEntered,f]);return w.jsxs(no,{value:{returnFocus:we,disabled:j,controlled:u.controlled,reference:Ee,floating:Se,x:u.floating.x,y:u.floating.y,arrowX:(K=($=(M=u.floating)==null?void 0:M.middlewareData)==null?void 0:$.arrow)==null?void 0:K.x,arrowY:(U=(L=(V=u.floating)==null?void 0:V.middlewareData)==null?void 0:L.arrow)==null?void 0:U.y,opened:u.opened,arrowRef:A,transitionProps:{...a,onExited:Te,onEntered:Ne},width:O,withArrow:D,arrowSize:R,arrowOffset:b,arrowRadius:C,arrowPosition:E,placement:u.floating.placement,trapFocus:se,withinPortal:x,portalProps:te,zIndex:de,radius:ce,shadow:ue,closeOnEscape:re,onDismiss:P,onClose:u.onClose,onToggle:u.onToggle,getTargetId:()=>`${_}-target`,getDropdownId:()=>`${_}-dropdown`,withRoles:ge,targetProps:xe,__staticSelector:z,classNames:m,styles:v,unstyled:y,variant:he,keepMounted:me,getStyles:I,resolvedStyles:Pe,floatingStrategy:k},children:[t,ve&&w.jsx(G,{transition:"fade",mounted:u.opened,duration:(a==null?void 0:a.duration)||250,exitDuration:(a==null?void 0:a.exitDuration)||250,children:p=>w.jsx(Z,{withinPortal:x,children:w.jsx(Ge,{...h,...I("overlay",{className:h==null?void 0:h.className,style:[p,h==null?void 0:h.style]})})})})]})}T.Target=oe;T.Dropdown=F;T.displayName="@mantine/core/Popover";T.extend=e=>e;export{T as P,so as u}; diff --git a/apps/edr-freight-web/backoffice/public/_um/assets/Radio-C3SNvFel.js b/apps/edr-freight-web/backoffice/public/_um/assets/Radio-C3SNvFel.js new file mode 100644 index 000000000..7909cf213 --- /dev/null +++ b/apps/edr-freight-web/backoffice/public/_um/assets/Radio-C3SNvFel.js @@ -0,0 +1 @@ +import{as as Q,K as N,N as $,V as B,aq as io,j as u,a0 as to,W as D,Y as T,a8 as X,ae as Y,X as F,Q as V,am as H,aa as S,an as J,_ as A,au as co}from"./index-Db-xuq0b.js";import{g as M}from"./get-auto-contrast-value-Da6zqqWm.js";import{I as lo,a as uo}from"./InputsGroupFieldset-COkNgcEo.js";import{u as po}from"./use-uncontrolled-C3HRHW6t.js";const[mo,Z]=Q(),[vo,fo]=Q();var oo={card:"m_9dc8ae12"};const ho={withBorder:!0},yo=D((o,{radius:a})=>({card:{"--card-radius":T(a)}})),U=N((o,a)=>{const e=$("RadioCard",ho,o),{classNames:s,className:c,style:p,styles:i,unstyled:r,vars:m,checked:R,mod:w,withBorder:I,value:g,onClick:C,name:h,onKeyDown:f,...j}=e,b=B({name:"RadioCard",classes:oo,props:e,className:c,style:p,classNames:s,styles:i,unstyled:r,vars:m,varsResolver:yo,rootSelector:"card"}),{dir:y}=io(),d=Z(),x=typeof R=="boolean"?R:(d==null?void 0:d.value)===g||!1,_=h||(d==null?void 0:d.name),z=n=>{if(f==null||f(n),["ArrowDown","ArrowUp","ArrowLeft","ArrowRight"].includes(n.nativeEvent.code)){n.preventDefault();const l=Array.from(document.querySelectorAll(`[role="radio"][name="${_||"__mantine"}"]`)),k=l.findIndex(G=>G===n.target),v=k+1>=l.length?0:k+1,t=k-1<0?l.length-1:k-1;n.nativeEvent.code==="ArrowDown"&&(l[v].focus(),l[v].click()),n.nativeEvent.code==="ArrowUp"&&(l[t].focus(),l[t].click()),n.nativeEvent.code==="ArrowLeft"&&(l[y==="ltr"?t:v].focus(),l[y==="ltr"?t:v].click()),n.nativeEvent.code==="ArrowRight"&&(l[y==="ltr"?v:t].focus(),l[y==="ltr"?v:t].click())}};return u.jsx(vo,{value:{checked:x},children:u.jsx(to,{ref:a,mod:[{"with-border":I,checked:x},w],...b("card"),...j,role:"radio","aria-checked":x,name:_,onClick:n=>{C==null||C(n),d==null||d.onChange(g||"")},onKeyDown:z})})});U.displayName="@mantine/core/RadioCard";U.classes=oo;const Ro={},W=N((o,a)=>{const{value:e,defaultValue:s,onChange:c,size:p,wrapperProps:i,children:r,name:m,readOnly:R,...w}=$("RadioGroup",Ro,o),I=X(m),[g,C]=po({value:e,defaultValue:s,finalValue:"",onChange:c}),h=f=>!R&&C(typeof f=="string"?f:f.currentTarget.value);return u.jsx(mo,{value:{value:g,onChange:h,size:p,name:I},children:u.jsx(Y.Wrapper,{size:p,ref:a,...i,...w,labelElement:"div",__staticSelector:"RadioGroup",children:u.jsx(lo,{role:"radiogroup",children:r})})})});W.classes=Y.Wrapper.classes;W.displayName="@mantine/core/RadioGroup";function eo({size:o,style:a,...e}){return u.jsx("svg",{xmlns:"http://www.w3.org/2000/svg",fill:"none",viewBox:"0 0 5 5",style:{width:F(o),height:F(o),...a},"aria-hidden":!0,...e,children:u.jsx("circle",{cx:"2.5",cy:"2.5",r:"2.5",fill:"currentColor"})})}var ro={indicator:"m_717d7ff6",icon:"m_3e4da632","indicator--outline":"m_2980836c"};const go={icon:eo},Co=D((o,{radius:a,color:e,size:s,iconColor:c,variant:p,autoContrast:i})=>{const r=H({color:e||o.primaryColor,theme:o}),m=r.isThemeColor&&r.shade===void 0?`var(--mantine-color-${r.color}-outline)`:r.color;return{indicator:{"--radio-size":A(s,"radio-size"),"--radio-radius":a===void 0?void 0:T(a),"--radio-color":p==="outline"?m:S(e,o),"--radio-icon-size":A(s,"radio-icon-size"),"--radio-icon-color":c?S(c,o):M(i,o)?J({color:e,theme:o,autoContrast:i}):void 0}}}),q=N((o,a)=>{const e=$("RadioIndicator",go,o),{classNames:s,className:c,style:p,styles:i,unstyled:r,vars:m,icon:R,radius:w,color:I,iconColor:g,autoContrast:C,checked:h,mod:f,variant:j,disabled:b,...y}=e,d=R,x=B({name:"RadioIndicator",classes:ro,props:e,className:c,style:p,classNames:s,styles:i,unstyled:r,vars:m,varsResolver:Co,rootSelector:"indicator"}),_=fo(),z=typeof h=="boolean"?h:(_==null?void 0:_.checked)||!1;return u.jsx(V,{ref:a,...x("indicator",{variant:j}),variant:j,mod:[{checked:z,disabled:b},f],...y,children:u.jsx(d,{...x("icon")})})});q.displayName="@mantine/core/RadioIndicator";q.classes=ro;var ao={root:"m_f3f1af94",inner:"m_89c4f5e4",icon:"m_f3ed6b2b",radio:"m_8a3dbb89","radio--outline":"m_1bfe9d39"};const xo={labelPosition:"right"},_o=D((o,{size:a,radius:e,color:s,iconColor:c,variant:p,autoContrast:i})=>{const r=H({color:s||o.primaryColor,theme:o}),m=r.isThemeColor&&r.shade===void 0?`var(--mantine-color-${r.color}-outline)`:r.color;return{root:{"--radio-size":A(a,"radio-size"),"--radio-radius":e===void 0?void 0:T(e),"--radio-color":p==="outline"?m:S(s,o),"--radio-icon-color":c?S(c,o):M(i,o)?J({color:s,theme:o,autoContrast:i}):void 0,"--radio-icon-size":A(a,"radio-icon-size")}}}),P=N((o,a)=>{const e=$("Radio",xo,o),{classNames:s,className:c,style:p,styles:i,unstyled:r,vars:m,id:R,size:w,label:I,labelPosition:g,description:C,error:h,radius:f,color:j,variant:b,disabled:y,wrapperProps:d,icon:x=eo,rootRef:_,iconColor:z,onChange:n,mod:l,...k}=e,v=B({name:"Radio",classes:ao,props:e,className:c,style:p,classNames:s,styles:i,unstyled:r,vars:m,varsResolver:_o}),t=Z(),G=(t==null?void 0:t.size)??w,so=e.size?w:G,{styleProps:no,rest:E}=co(k),K=X(R),L=t?{checked:t.value===E.value,name:E.name??t.name,onChange:O=>{t.onChange(O),n==null||n(O)}}:{};return u.jsx(uo,{...v("root"),__staticSelector:"Radio",__stylesApiProps:e,id:K,size:so,labelPosition:g,label:I,description:C,error:h,disabled:y,classNames:s,styles:i,unstyled:r,"data-checked":L.checked||void 0,variant:b,ref:_,mod:l,...no,...d,children:u.jsxs(V,{...v("inner"),mod:{"label-position":g},children:[u.jsx(V,{...v("radio",{focusable:!0,variant:b}),onChange:n,...E,...L,component:"input",mod:{error:!!h},ref:a,id:K,disabled:y,type:"radio"}),u.jsx(x,{...v("icon"),"aria-hidden":!0})]})})});P.classes=ao;P.displayName="@mantine/core/Radio";P.Group=W;P.Card=U;P.Indicator=q;export{P as R}; diff --git a/apps/edr-freight-web/backoffice/public/_um/assets/ResetPassword-DeJuLwFx.js b/apps/edr-freight-web/backoffice/public/_um/assets/ResetPassword-DeJuLwFx.js new file mode 100644 index 000000000..1a1875267 --- /dev/null +++ b/apps/edr-freight-web/backoffice/public/_um/assets/ResetPassword-DeJuLwFx.js @@ -0,0 +1 @@ +import{f as B,i as D,u as H,r as n,j as s,I as y,B as N,s as O,t as b}from"./index-Db-xuq0b.js";import{L as v}from"./lock-BCybB-Lq.js";import{E as C}from"./eye-off-CDMvHElM.js";import{E as P}from"./eye-Bhud4znU.js";import{R as T}from"./refresh-cw-JB7N413f.js";import{A as U}from"./arrow-left-CE5-YfaQ.js";const J=()=>{const p=B(),[u]=D(),{t:e}=H(),r=u.get("email")||"",c=u.get("verificationCode")||"",i=u.get("userId")||"",[t,S]=n.useState(""),[d,E]=n.useState(""),[f,w]=n.useState(!1),[g,a]=n.useState(""),[x,L]=n.useState(!1),[h,R]=n.useState(!1);n.useEffect(()=>{!i&&!r&&a("Missing required parameters in the reset link. Please request a new password reset link."),c||a("Missing verification code in the reset link. Please request a new password reset link.")},[i,r,c]);const k=async m=>{if(m.preventDefault(),a(""),!t){a(e("msg.newPasswordRequired"));return}if(t.length<8){a(e("msg.passwordMinLength"));return}if(!d){a(e("msg.confirmPasswordRequired"));return}if(t!==d){a(e("msg.passwordMismatch"));return}const q=/[A-Z]/.test(t),I=/[a-z]/.test(t),M=/\d/.test(t),A=/[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/.test(t),l=[];if(q||l.push(e("msg.uppercaseLetter")),I||l.push(e("msg.lowercaseLetter")),M||l.push(e("msg.number")),A||l.push(e("msg.specialCharacter")),l.length>0){a(e("msg.passwordComplexity")+" "+l.join(", "));return}w(!0);try{const o={verificationCode:c,newPassword:t,confirmPassword:d};i&&(o.userId=i),r&&(o.email=r),await O(o),b.success(e("msg.successChange"),{description:e("msg.passwordResetSuccess")}),setTimeout(()=>p("/"),2e3)}catch(o){const j=(o==null?void 0:o.message)||e("msg.failedChange");a(j),b.error(e("msg.failedChange"),{description:j})}finally{w(!1)}};return s.jsx("div",{className:"min-h-screen bg-gray-100 p-4 flex items-center justify-center",children:s.jsxs("div",{className:"w-full max-w-md bg-white rounded-xl shadow-lg p-6 md:p-8",children:[s.jsxs("div",{className:"mb-6 md:mb-8 text-center",children:[s.jsx("img",{src:"/assets/smart-office-logo.svg",alt:"Smart Office Logo",className:"h-8 md:h-10 mb-4 md:mb-6 mx-auto"}),s.jsx("h1",{className:"text-xl md:text-2xl font-bold text-gray-900 mb-2",children:"Reset Password"}),s.jsx("p",{className:"text-xs md:text-sm text-gray-500",children:"Set a new password for your account"}),r&&s.jsxs("p",{className:"text-xs text-gray-400 mt-1 truncate max-w-full px-2",children:["Email: ",r]}),i&&s.jsxs("p",{className:"text-xs text-gray-400 mt-1 truncate max-w-full px-2",children:["User ID: ",i]})]}),s.jsxs("form",{onSubmit:k,className:"space-y-4 md:space-y-6",children:[s.jsxs("div",{className:"space-y-3",children:[s.jsxs("div",{className:"relative",children:[s.jsx("div",{className:"absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none",children:s.jsx(v,{className:"h-4 w-4 md:h-5 md:w-5 text-gray-400"})}),s.jsx(y,{type:x?"text":"password",placeholder:"New Password",className:"h-10 rounded-md border px-4 text-sm ps-10 pr-10",value:t,onChange:m=>S(m.target.value),required:!0}),s.jsx("button",{type:"button",className:"absolute inset-y-0 right-0 pr-3 flex items-center",onClick:()=>L(!x),"aria-label":x?"Hide password":"Show password",children:x?s.jsx(C,{className:"h-4 w-4 text-gray-400"}):s.jsx(P,{className:"h-4 w-4 text-gray-400"})})]}),s.jsxs("div",{className:"relative",children:[s.jsx("div",{className:"absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none",children:s.jsx(v,{className:"h-4 w-4 md:h-5 md:w-5 text-gray-400"})}),s.jsx(y,{type:h?"text":"password",placeholder:"Confirm New Password",className:"h-10 rounded-md border px-4 text-sm ps-10 pr-10",value:d,onChange:m=>E(m.target.value),required:!0}),s.jsx("button",{type:"button",className:"absolute inset-y-0 right-0 pr-3 flex items-center",onClick:()=>R(!h),"aria-label":h?"Hide password":"Show password",children:h?s.jsx(C,{className:"h-4 w-4 text-gray-400"}):s.jsx(P,{className:"h-4 w-4 text-gray-400"})})]}),g&&s.jsx("div",{className:"bg-red-50 border border-red-200 rounded-md p-3 md:p-4",children:s.jsx("p",{className:"text-xs md:text-sm text-red-700 font-medium",children:g})})]}),s.jsxs("div",{className:"flex flex-col gap-3",children:[s.jsx(N,{type:"submit",className:"w-full h-10 bg-primary hover:bg-primary-300 text-white text-sm",disabled:f||!i&&!r||!c,children:f?s.jsxs("span",{className:"flex items-center justify-center",children:[s.jsx(T,{className:"animate-spin h-4 w-4 md:h-5 md:w-5 mr-2"}),"Resetting..."]}):"Reset Password"}),s.jsxs(N,{type:"button",variant:"outline",className:"w-full h-10 text-sm",onClick:()=>p("/"),children:[s.jsx(U,{className:"h-4 w-4 mr-2"}),"Back to Login"]})]})]})]})})};export{J as ResetPassword,J as default}; diff --git a/apps/edr-freight-web/backoffice/public/_um/assets/SetPassword-bCvH9rTk.js b/apps/edr-freight-web/backoffice/public/_um/assets/SetPassword-bCvH9rTk.js new file mode 100644 index 000000000..7b77965d4 --- /dev/null +++ b/apps/edr-freight-web/backoffice/public/_um/assets/SetPassword-bCvH9rTk.js @@ -0,0 +1 @@ +import{f as Q,u as X,k as ee,i as se,a as te,r as t,t as b,j as e,I as k,B as I}from"./index-Db-xuq0b.js";import{L as O}from"./lock-BCybB-Lq.js";import{E as U}from"./eye-off-CDMvHElM.js";import{E as B}from"./eye-Bhud4znU.js";import{R as V}from"./refresh-cw-JB7N413f.js";import{A as ae}from"./arrow-left-CE5-YfaQ.js";import{M as re}from"./mail-bwr0seHR.js";const xe=()=>{const f=Q(),{t:q}=X(),{handleError:x}=ee(q),[h]=se(),{setPassword:M,isSettingPassword:v,setPasswordError:i,setPasswordSuccess:S,resendVerificationCode:z,isResendingCode:P,resendError:p,resendSuccess:C}=te(),c=h.get("email"),D=h.get("verificationCode")||"",l=h.get("phoneNumber"),L=h.get("userId"),[n,F]=t.useState(""),[o,Z]=t.useState(""),[g,j]=t.useState(!1),[$,y]=t.useState(!1),[E,H]=t.useState(!1),[N,T]=t.useState(!1),[w,W]=t.useState(!1),[u,m]=t.useState(""),Y=a=>({minLength:a.length>=8,hasLowercase:/[a-z]/.test(a),hasUppercase:/[A-Z]/.test(a),hasNumber:/\d/.test(a),hasSymbol:/[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/.test(a)}),s=t.useMemo(()=>Y(n),[n]),A=Object.values(s).every(Boolean),R=o.length>0&&n===o,_=!!(n&&o&&A&&R),G=a=>{const d=a.target.value;F(d),u&&m("")},J=async a=>{if(a.preventDefault(),H(!0),m(""),!n||!o){m("Please fill in both fields");return}if(!A){const r=[];s.minLength||r.push("at least 8 characters"),s.hasLowercase||r.push("one lowercase letter"),s.hasUppercase||r.push("one uppercase letter"),s.hasNumber||r.push("one number"),s.hasSymbol||r.push("one symbol (!@#$%^&*...)"),m(`Password must contain: ${r.join(", ")}`);return}if(!R){m("Passwords don't match");return}const d={verificationCode:D,newPassword:n,confirmPassword:o};if(L&&(d.userId=L),c)d.email=c;else if(l){let r=l;r.startsWith("0")&&(r="+251"+r.slice(1)),d.phoneNumber=r}y(!0),M(d)},K=()=>{if(!l&&!c){b.error("User ID is missing. Cannot resend verification code.");return}j(!0),z({phoneNumber:l||"",email:c||void 0})};return t.useEffect(()=>{S&&(b.success("Password updated successfully! You can now login"),y(!1),f("/login"))},[S,f]),t.useEffect(()=>{E&&i!=null&&i.message&&(y(!1),x(i))},[i==null?void 0:i.message,x,E]),t.useEffect(()=>{C&&g&&(b.success("Verification code resent successfully!"),j(!1)),p&&g&&(x(p),j(!1))},[C,p,g,x]),e.jsx("div",{className:"flex items-center justify-center bg-gray-100 p-4",style:{height:"100vh"},children:e.jsxs("div",{className:"w-full max-w-md bg-white rounded-xl shadow-lg p-8",children:[e.jsxs("div",{className:"mb-8 text-center",children:[e.jsx("img",{src:"/assets/smart-office-logo.svg",alt:"Smart Office Logo",className:"h-10 mb-6 mx-auto"}),e.jsx("h1",{className:"text-2xl font-bold text-gray-900 mb-2",children:"Set Password"}),e.jsx("p",{className:"text-sm text-gray-500",children:"Set a new password for your account"}),c&&e.jsxs("p",{className:"text-xs text-gray-400 mt-1",children:["Email: ",c]}),l&&e.jsxs("p",{className:"text-xs text-gray-400 mt-1",children:["Phone: ",l]})]}),e.jsxs("form",{onSubmit:J,className:"space-y-6",children:[e.jsxs("div",{className:"space-y-4",children:[e.jsxs("div",{className:"relative",children:[e.jsx("div",{className:"absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none",children:e.jsx(O,{className:"h-5 w-5 text-gray-400"})}),e.jsx(k,{type:N?"text":"password",placeholder:"New Password",className:"h-10 rounded-md border px-4 text-sm ps-10",value:n,onChange:G,required:!0}),e.jsx("div",{className:"absolute inset-y-0 right-0 pr-3 flex items-center cursor-pointer",onClick:()=>T(!N),children:N?e.jsx(U,{className:"h-4 w-4 text-gray-400"}):e.jsx(B,{className:"h-4 w-4 text-gray-400"})})]}),n&&e.jsxs("div",{className:"space-y-2 p-3 bg-gray-50 rounded-md border border-gray-200",children:[e.jsx("p",{className:"text-xs font-medium text-gray-700",children:"Password Requirements:"}),e.jsxs("div",{className:"space-y-1",children:[e.jsxs("div",{className:"flex items-center gap-2",children:[e.jsx("div",{className:`w-4 h-4 rounded-full flex items-center justify-center text-xs ${s.minLength?"bg-primary-500":"bg-gray-300"}`,children:s.minLength&&e.jsx("span",{className:"text-white",children:"✓"})}),e.jsx("span",{className:`text-xs ${s.minLength?"text-primary-700":"text-gray-600"}`,children:"At least 8 characters"})]}),e.jsxs("div",{className:"flex items-center gap-2",children:[e.jsx("div",{className:`w-4 h-4 rounded-full flex items-center justify-center text-xs ${s.hasUppercase?"bg-primary-500":"bg-gray-300"}`,children:s.hasUppercase&&e.jsx("span",{className:"text-white",children:"✓"})}),e.jsx("span",{className:`text-xs ${s.hasUppercase?"text-primary-700":"text-gray-600"}`,children:"One uppercase letter (A-Z)"})]}),e.jsxs("div",{className:"flex items-center gap-2",children:[e.jsx("div",{className:`w-4 h-4 rounded-full flex items-center justify-center text-xs ${s.hasLowercase?"bg-primary-500":"bg-gray-300"}`,children:s.hasLowercase&&e.jsx("span",{className:"text-white",children:"✓"})}),e.jsx("span",{className:`text-xs ${s.hasLowercase?"text-primary-700":"text-gray-600"}`,children:"One lowercase letter (a-z)"})]}),e.jsxs("div",{className:"flex items-center gap-2",children:[e.jsx("div",{className:`w-4 h-4 rounded-full flex items-center justify-center text-xs ${s.hasNumber?"bg-primary-500":"bg-gray-300"}`,children:s.hasNumber&&e.jsx("span",{className:"text-white",children:"✓"})}),e.jsx("span",{className:`text-xs ${s.hasNumber?"text-primary-700":"text-gray-600"}`,children:"One number (0-9)"})]}),e.jsxs("div",{className:"flex items-center gap-2",children:[e.jsx("div",{className:`w-4 h-4 rounded-full flex items-center justify-center text-xs ${s.hasSymbol?"bg-primary-500":"bg-gray-300"}`,children:s.hasSymbol&&e.jsx("span",{className:"text-white",children:"✓"})}),e.jsx("span",{className:`text-xs ${s.hasSymbol?"text-primary-700":"text-gray-600"}`,children:"One symbol (!@#$%^&*...)"})]})]})]}),e.jsxs("div",{className:"relative",children:[e.jsx("div",{className:"absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none",children:e.jsx(O,{className:"h-5 w-5 text-gray-400"})}),e.jsx(k,{type:w?"text":"password",placeholder:"Confirm Password",className:"h-10 rounded-md border px-4 text-sm ps-10",value:o,onChange:a=>{Z(a.target.value),u&&m("")},required:!0}),e.jsx("div",{className:"absolute inset-y-0 right-0 pr-3 flex items-center cursor-pointer",onClick:()=>W(!w),children:w?e.jsx(U,{className:"h-4 w-4 text-gray-400"}):e.jsx(B,{className:"h-4 w-4 text-gray-400"})})]}),u&&e.jsx("p",{className:"text-sm text-red-500 mt-1",children:u})]}),e.jsxs("div",{className:"flex flex-col gap-4",children:[e.jsx(I,{type:"submit",className:"w-full h-10 bg-primary hover:bg-primary-300 text-white text-sm",disabled:v||$||!_,children:v?e.jsxs("span",{className:"flex items-center justify-center",children:[e.jsx(V,{className:"animate-spin h-5 w-5 mr-2"}),"Setting Password..."]}):"Set Password"}),e.jsxs(I,{type:"button",variant:"outline",className:"w-full h-10 text-sm",onClick:()=>f("/"),children:[e.jsx(ae,{className:"h-4 w-4 mr-2"}),"Back to Login"]})]})]}),e.jsx("div",{className:"mt-6 text-center",children:e.jsx("button",{type:"button",onClick:K,disabled:P||!c||!l||$,className:"text-sm text-indigo-600 hover:text-indigo-800 font-medium flex items-center justify-center space-x-1 mx-auto",children:P?e.jsxs(e.Fragment,{children:[e.jsx(V,{className:"animate-spin h-4 w-4 text-indigo-600 mr-2"}),e.jsx("span",{children:"Sending..."})]}):e.jsxs(e.Fragment,{children:[e.jsx(re,{className:"h-4 w-4 mr-2"}),e.jsx("span",{children:"Resend Verification Code"})]})})})]})})};export{xe as default}; diff --git a/apps/edr-freight-web/backoffice/public/_um/assets/SettingsPage-DLv7kKUv.js b/apps/edr-freight-web/backoffice/public/_um/assets/SettingsPage-DLv7kKUv.js new file mode 100644 index 000000000..b4bdc79fb --- /dev/null +++ b/apps/edr-freight-web/backoffice/public/_um/assets/SettingsPage-DLv7kKUv.js @@ -0,0 +1,6 @@ +import{y as de,ad as me,K as E,N as O,j as e,Q as W,O as ue,aq as he,a0 as pe,aa as ie,a8 as xe,V as be,W as ge,an as ye,Y as je,b8 as _,r as w,az as ve,bX as Ne,I as j,bY as fe,B as T,aK as ke,b7 as Te,aL as Ce,aM as Se,aN as we}from"./index-Db-xuq0b.js";import{C as Z,a as ee,b as ae,c as se,d as re}from"./card-BBWyxDss.js";import{g as De}from"./get-auto-contrast-value-Da6zqqWm.js";import{c as Fe}from"./create-scoped-keydown-handler-O-eo68DQ.js";import{u as Le}from"./use-uncontrolled-C3HRHW6t.js";import{T as V}from"./textarea-BNzPMo41.js";import{S as D}from"./switch-BNCD27Bd.js";import{S as U,a as R,b as P,c as I,d as h}from"./select-BoQxM42A.js";import{L as l}from"./label-CsFy6wpo.js";import{a as Ae,u as Ue}from"./useOrganizationReport-D3QtWTPL.js";import{S as Re}from"./shield-uC_usZTb.js";import{S as q}from"./save-BpdaNhEd.js";import"./Switch-DSHMk-sj.js";import"./InputsGroupFieldset-COkNgcEo.js";import"./organizationsService-BEVk8qa1.js";/** + * @license lucide-react v0.513.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Pe=[["ellipse",{cx:"12",cy:"5",rx:"9",ry:"3",key:"msslwz"}],["path",{d:"M3 5V19A9 3 0 0 0 21 19V5",key:"1wlel7"}],["path",{d:"M3 12A9 3 0 0 0 21 12",key:"mv7ke4"}]],Ie=de("database",Pe);function te(s,i){return t=>{if(typeof t!="string"||t.trim().length===0)throw new Error(i);return`${s}-${t}`}}const[Ee,H]=me("Tabs component was not found in the tree");var F={root:"m_89d60db1","list--default":"m_576c9d4",list:"m_89d33d6d",panel:"m_b0c91715",tab:"m_4ec4dce6",tabSection:"m_fc420b1f","tab--default":"m_539e827b","list--outline":"m_6772fbd5","tab--outline":"m_b59ab47c","tab--pills":"m_c3381914"};const Oe={},Y=E((s,i)=>{const t=O("TabsList",Oe,s),{children:c,className:x,grow:b,justify:m,classNames:n,styles:p,style:u,mod:g,...v}=t,d=H();return e.jsx(W,{...v,...d.getStyles("list",{className:x,style:u,classNames:n,styles:p,props:t,variant:d.variant}),ref:i,role:"tablist",variant:d.variant,mod:[{grow:b,orientation:d.orientation,placement:d.orientation==="vertical"&&d.placement,inverted:d.inverted},g],"aria-orientation":d.orientation,__vars:{"--tabs-justify":m},children:c})});Y.classes=F;Y.displayName="@mantine/core/TabsList";const _e={},Q=E((s,i)=>{const t=O("TabsPanel",_e,s),{children:c,className:x,value:b,classNames:m,styles:n,style:p,mod:u,keepMounted:g,...v}=t,d=H(),f=d.value===b,r=d.keepMounted||g||f?c:null;return e.jsx(W,{...v,...d.getStyles("panel",{className:x,classNames:m,styles:n,style:[p,f?void 0:{display:"none"}],props:t}),ref:i,mod:[{orientation:d.orientation},u],role:"tabpanel",id:d.getPanelId(b),"aria-labelledby":d.getTabId(b),children:r})});Q.classes=F;Q.displayName="@mantine/core/TabsPanel";const Me={},X=E((s,i)=>{const t=O("TabsTab",Me,s),{className:c,children:x,rightSection:b,leftSection:m,value:n,onClick:p,onKeyDown:u,disabled:g,color:v,style:d,classNames:f,styles:r,vars:y,mod:k,tabIndex:a,...M}=t,B=ue(),{dir:L}=he(),o=H(),C=n===o.value,z=K=>{o.onChange(o.allowTabDeactivation&&n===o.value?null:n),p==null||p(K)},S={classNames:f,styles:r,props:t};return e.jsxs(pe,{...M,...o.getStyles("tab",{className:c,style:d,variant:o.variant,...S}),disabled:g,unstyled:o.unstyled,variant:o.variant,mod:[{active:C,disabled:g,orientation:o.orientation,inverted:o.inverted,placement:o.orientation==="vertical"&&o.placement},k],ref:i,role:"tab",id:o.getTabId(n),"aria-selected":C,tabIndex:a!==void 0?a:C||o.value===null?0:-1,"aria-controls":o.getPanelId(n),onClick:z,__vars:{"--tabs-color":v?ie(v,B):void 0},onKeyDown:Fe({siblingSelector:'[role="tab"]',parentSelector:'[role="tablist"]',activateOnFocus:o.activateTabWithKeyboard,loop:o.loop,orientation:o.orientation||"horizontal",dir:L,onKeyDown:u}),children:[m&&e.jsx("span",{...o.getStyles("tabSection",S),"data-position":"left",children:m}),x&&e.jsx("span",{...o.getStyles("tabLabel",S),children:x}),b&&e.jsx("span",{...o.getStyles("tabSection",S),"data-position":"right",children:b})]})});X.classes=F;X.displayName="@mantine/core/TabsTab";const ne="Tabs.Tab or Tabs.Panel component was rendered with invalid value or without value",Be={keepMounted:!0,orientation:"horizontal",loop:!0,activateTabWithKeyboard:!0,allowTabDeactivation:!1,unstyled:!1,inverted:!1,variant:"default",placement:"left"},ze=ge((s,{radius:i,color:t,autoContrast:c})=>({root:{"--tabs-radius":je(i),"--tabs-color":ie(t,s),"--tabs-text-color":De(c,s)?ye({color:t,theme:s,autoContrast:c}):void 0}})),N=E((s,i)=>{const t=O("Tabs",Be,s),{defaultValue:c,value:x,onChange:b,orientation:m,children:n,loop:p,id:u,activateTabWithKeyboard:g,allowTabDeactivation:v,variant:d,color:f,radius:r,inverted:y,placement:k,keepMounted:a,classNames:M,styles:B,unstyled:L,className:o,style:C,vars:z,autoContrast:S,mod:K,...le}=t,A=xe(u),[oe,ce]=Le({value:x,defaultValue:c,finalValue:null,onChange:b}),J=be({name:"Tabs",props:t,classes:F,className:o,style:C,classNames:M,styles:B,unstyled:L,vars:z,varsResolver:ze});return e.jsx(Ee,{value:{placement:k,value:oe,orientation:m,id:A,loop:p,activateTabWithKeyboard:g,getTabId:te(`${A}-tab`,ne),getPanelId:te(`${A}-panel`,ne),onChange:ce,allowTabDeactivation:v,variant:d,color:f,radius:r,inverted:y,keepMounted:a,unstyled:L,getStyles:J},children:e.jsx(W,{ref:i,id:A,variant:d,mod:[{orientation:m,inverted:m==="horizontal"&&y,placement:m==="vertical"&&k},K],...J("root"),...le,children:n})})});N.classes=F;N.displayName="@mantine/core/Tabs";N.Tab=X;N.Panel=Q;N.List=Y;function Ke({className:s,value:i,defaultValue:t,onValueChange:c,children:x,...b}){return e.jsx(N,{value:i,defaultValue:t,onChange:m=>m&&(c==null?void 0:c(m)),className:_("flex flex-col gap-2",s),...b,children:x})}function Ve({className:s,children:i,...t}){return e.jsx(N.List,{className:_("bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-1",s),...t,children:i})}function $({className:s,value:i,children:t,disabled:c,...x}){return e.jsx(N.Tab,{value:i,disabled:c,className:_("data-[active]:bg-background data-[active]:text-primary data-[active]:shadow-sm","inline-flex flex-1 items-center justify-center gap-1.5 rounded-md px-2 py-1 text-sm font-medium whitespace-nowrap","transition-[color,box-shadow] disabled:pointer-events-none disabled:opacity-50 hover:text-primary/80","[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",s),...x,children:t})}function G({className:s,value:i,children:t,...c}){return e.jsx(N.Panel,{value:i,className:_("flex-1 outline-none",s),...c,children:t})}function na(){const[s,i]=w.useState({systemName:"Smart Office",siteUrl:"https://smartoffice.example.com",timezone:"UTC+3",language:"en",enableMaintenance:!1}),{mutateDocumetary:t,isLoading:c}=Ae(),{documentRequirementData:x}=Ue(),[b,m]=w.useState(!1);ve();const[n,p]=w.useState({minimumPasswordLength:"8",requireSpecialCharacters:!0,passwordExpirationDays:"90",maxLoginAttempts:"5",sessionTimeout:"30",enableTwoFactor:!1,allowedIpRanges:"0.0.0.0/0"}),[u,g]=w.useState({backupSchedule:"daily",backupTime:"01:00",backupRetentionDays:"30",backupLocation:"cloud",enableAutoBackup:!0}),v=a=>{a.preventDefault()},d=a=>{a.preventDefault()},f=a=>{a.preventDefault()},[r,y]=w.useState({id:"",title:{am:"",en:""},description:{am:"",en:""},key:"",order:0,isOptional:!1,type:"individual"}),k=async()=>{try{const a={title:r.title,description:r.description,key:r.key,order:r.order,isOptional:r.isOptional,type:r.type};await t(a),y({id:"",title:{am:"",en:""},description:{am:"",en:""},key:"",order:0,isOptional:!1,type:"individual"}),m(!1)}catch{}};return e.jsxs("div",{className:"p-6 space-y-6",children:[e.jsxs(Z,{className:"shadow-lg border-gray-200 dark:border-gray-700",children:[e.jsxs(ee,{children:[e.jsx(ae,{className:"text-xl font-semibold",children:"System Settings"}),e.jsx(se,{children:"Configure system-wide settings for your organization"})]}),e.jsx(re,{children:e.jsxs(Ke,{defaultValue:"general",className:"w-full",children:[e.jsxs(Ve,{className:"grid grid-cols-3 mb-8",children:[e.jsxs($,{value:"general",className:"flex items-center gap-2",children:[e.jsx(Ne,{className:"h-4 w-4"}),"General"]}),e.jsxs($,{value:"security",className:"flex items-center gap-2",children:[e.jsx(Re,{className:"h-4 w-4"}),"Security"]}),e.jsxs($,{value:"backup",className:"flex items-center gap-2",children:[e.jsx(Ie,{className:"h-4 w-4"}),"Backup"]})]}),e.jsx(G,{value:"general",children:e.jsxs("form",{onSubmit:v,className:"space-y-6",children:[e.jsxs("div",{className:"grid grid-cols-2 gap-6",children:[e.jsxs("div",{className:"space-y-2",children:[e.jsx(l,{htmlFor:"systemName",children:"System Name"}),e.jsx(j,{id:"systemName",value:s.systemName,onChange:a=>i({...s,systemName:a.target.value})})]}),e.jsxs("div",{className:"space-y-2",children:[e.jsx(l,{htmlFor:"siteUrl",children:"Site URL"}),e.jsx(j,{id:"siteUrl",value:s.siteUrl,onChange:a=>i({...s,siteUrl:a.target.value})})]}),e.jsxs("div",{className:"space-y-2",children:[e.jsx(l,{htmlFor:"timezone",children:"Default Timezone"}),e.jsxs(U,{value:s.timezone,onValueChange:a=>i({...s,timezone:a}),children:[e.jsx(R,{id:"timezone",children:e.jsx(P,{placeholder:"Select timezone"})}),e.jsxs(I,{children:[e.jsx(h,{value:"UTC-12",children:"UTC-12 (Baker Island)"}),e.jsx(h,{value:"UTC-8",children:"UTC-8 (Los Angeles)"}),e.jsx(h,{value:"UTC-5",children:"UTC-5 (New York)"}),e.jsx(h,{value:"UTC+0",children:"UTC+0 (London)"}),e.jsx(h,{value:"UTC+1",children:"UTC+1 (Paris)"}),e.jsx(h,{value:"UTC+3",children:"UTC+3 (Addis Ababa)"}),e.jsx(h,{value:"UTC+5:30",children:"UTC+5:30 (New Delhi)"}),e.jsx(h,{value:"UTC+8",children:"UTC+8 (Beijing)"}),e.jsx(h,{value:"UTC+9",children:"UTC+9 (Tokyo)"}),e.jsx(h,{value:"UTC+12",children:"UTC+12 (Auckland)"})]})]})]}),e.jsxs("div",{className:"space-y-2",children:[e.jsx(l,{htmlFor:"language",children:"Default Language"}),e.jsxs(U,{value:s.language,onValueChange:a=>i({...s,language:a}),children:[e.jsx(R,{id:"language",children:e.jsx(P,{placeholder:"Select language"})}),e.jsx(I,{children:fe?.map(a=>e.jsx(h,{value:a.value,children:a.label},a.value))})]})]})]}),e.jsxs("div",{className:"flex items-center justify-between border-t border-gray-200 dark:border-gray-700 pt-4",children:[e.jsxs("div",{className:"flex items-center space-x-2",children:[e.jsx(D,{id:"enableMaintenance",checked:s.enableMaintenance,onCheckedChange:a=>i({...s,enableMaintenance:a})}),e.jsx(l,{htmlFor:"enableMaintenance",children:"Enable Maintenance Mode"})]}),e.jsxs(T,{type:"submit",className:"bg-primary hover:bg-primary/90 text-primary-foreground",children:[e.jsx(q,{className:"h-4 w-4 mr-2"}),"Save Changes"]})]})]})}),e.jsx(G,{value:"security",children:e.jsxs("form",{onSubmit:d,className:"space-y-6",children:[e.jsxs("div",{className:"grid grid-cols-2 gap-6",children:[e.jsxs("div",{className:"space-y-2",children:[e.jsx(l,{htmlFor:"minimumPasswordLength",children:"Minimum Password Length"}),e.jsx(j,{id:"minimumPasswordLength",type:"number",min:"6",max:"30",value:n.minimumPasswordLength,onChange:a=>p({...n,minimumPasswordLength:a.target.value})})]}),e.jsxs("div",{className:"space-y-2",children:[e.jsx(l,{htmlFor:"passwordExpirationDays",children:"Password Expiration (Days)"}),e.jsx(j,{id:"passwordExpirationDays",type:"number",value:n.passwordExpirationDays,onChange:a=>p({...n,passwordExpirationDays:a.target.value})})]}),e.jsxs("div",{className:"space-y-2",children:[e.jsx(l,{htmlFor:"maxLoginAttempts",children:"Max Failed Login Attempts"}),e.jsx(j,{id:"maxLoginAttempts",type:"number",value:n.maxLoginAttempts,onChange:a=>p({...n,maxLoginAttempts:a.target.value})})]}),e.jsxs("div",{className:"space-y-2",children:[e.jsx(l,{htmlFor:"sessionTimeout",children:"Session Timeout (Minutes)"}),e.jsx(j,{id:"sessionTimeout",type:"number",value:n.sessionTimeout,onChange:a=>p({...n,sessionTimeout:a.target.value})})]}),e.jsxs("div",{className:"space-y-2 col-span-2",children:[e.jsx(l,{htmlFor:"allowedIpRanges",children:"Allowed IP Ranges (CIDR)"}),e.jsx(V,{id:"allowedIpRanges",value:n.allowedIpRanges,onChange:a=>p({...n,allowedIpRanges:a.target.value}),placeholder:"e.g. 192.168.1.0/24, 10.0.0.0/8",className:"min-h-20"}),e.jsx("p",{className:"text-sm text-gray-500 dark:text-gray-400",children:"Enter one CIDR range per line. Use 0.0.0.0/0 for unrestricted access."})]})]}),e.jsxs("div",{className:"grid grid-cols-2 gap-6",children:[e.jsxs("div",{className:"flex items-center space-x-2",children:[e.jsx(D,{id:"requireSpecialCharacters",checked:n.requireSpecialCharacters,onCheckedChange:a=>p({...n,requireSpecialCharacters:a})}),e.jsx(l,{htmlFor:"requireSpecialCharacters",children:"Require Special Characters in Password"})]}),e.jsxs("div",{className:"flex items-center space-x-2",children:[e.jsx(D,{id:"enableTwoFactor",checked:n.enableTwoFactor,onCheckedChange:a=>p({...n,enableTwoFactor:a})}),e.jsx(l,{htmlFor:"enableTwoFactor",children:"Enable Two-Factor Authentication"})]})]}),e.jsx("div",{className:"flex justify-end border-t border-gray-200 dark:border-gray-700 pt-4",children:e.jsxs(T,{type:"submit",className:"bg-primary hover:bg-primary/90 text-primary-foreground",children:[e.jsx(q,{className:"h-4 w-4 mr-2"}),"Save Changes"]})})]})}),e.jsx(G,{value:"backup",children:e.jsxs("form",{onSubmit:f,className:"space-y-6",children:[e.jsxs("div",{className:"grid grid-cols-2 gap-6",children:[e.jsxs("div",{className:"space-y-2",children:[e.jsx(l,{htmlFor:"backupSchedule",children:"Backup Schedule"}),e.jsxs(U,{value:u.backupSchedule,onValueChange:a=>g({...u,backupSchedule:a}),children:[e.jsx(R,{id:"backupSchedule",children:e.jsx(P,{placeholder:"Select schedule"})}),e.jsxs(I,{children:[e.jsx(h,{value:"hourly",children:"Hourly"}),e.jsx(h,{value:"daily",children:"Daily"}),e.jsx(h,{value:"weekly",children:"Weekly"}),e.jsx(h,{value:"monthly",children:"Monthly"})]})]})]}),e.jsxs("div",{className:"space-y-2",children:[e.jsx(l,{htmlFor:"backupTime",children:"Backup Time"}),e.jsx(j,{id:"backupTime",type:"time",value:u.backupTime,onChange:a=>g({...u,backupTime:a.target.value})})]}),e.jsxs("div",{className:"space-y-2",children:[e.jsx(l,{htmlFor:"backupRetentionDays",children:"Retention Period (Days)"}),e.jsx(j,{id:"backupRetentionDays",type:"number",value:u.backupRetentionDays,onChange:a=>g({...u,backupRetentionDays:a.target.value})})]}),e.jsxs("div",{className:"space-y-2",children:[e.jsx(l,{htmlFor:"backupLocation",children:"Backup Storage Location"}),e.jsxs(U,{value:u.backupLocation,onValueChange:a=>g({...u,backupLocation:a}),children:[e.jsx(R,{id:"backupLocation",children:e.jsx(P,{placeholder:"Select location"})}),e.jsxs(I,{children:[e.jsx(h,{value:"local",children:"Local Storage"}),e.jsx(h,{value:"cloud",children:"Cloud Storage"}),e.jsx(h,{value:"both",children:"Both (Local & Cloud)"})]})]})]})]}),e.jsxs("div",{className:"flex items-center justify-between border-t border-gray-200 dark:border-gray-700 pt-4",children:[e.jsxs("div",{className:"flex items-center space-x-2",children:[e.jsx(D,{id:"enableAutoBackup",checked:u.enableAutoBackup,onCheckedChange:a=>g({...u,enableAutoBackup:a})}),e.jsx(l,{htmlFor:"enableAutoBackup",children:"Enable Automatic Backup"})]}),e.jsxs("div",{className:"space-x-4",children:[e.jsx(T,{type:"button",variant:"outline",children:"Backup Now"}),e.jsxs(T,{type:"submit",className:"bg-primary hover:bg-primary/90 text-primary-foreground",children:[e.jsx(q,{className:"h-4 w-4 mr-2"}),"Save Changes"]})]})]})]})})]})})]}),e.jsx("div",{className:"p-6 space-y-6",children:e.jsxs(Z,{className:"shadow-lg border-gray-200 dark:border-gray-700",children:[e.jsxs(ee,{className:"flex justify-between items-center",children:[e.jsxs("div",{children:[e.jsx(ae,{className:"text-xl font-semibold",children:"Document Requirements"}),e.jsx(se,{children:"Manage required and optional document submissions"})]}),e.jsxs(ke,{open:b,onOpenChange:m,children:[e.jsx(Te,{asChild:!0,children:e.jsx(T,{className:"bg-primary hover:bg-primary/90 text-primary-foreground",onClick:()=>m(!0),children:"+ Add Requirement"})}),e.jsxs(Ce,{className:"max-w-lg",children:[e.jsx(Se,{children:e.jsx(we,{children:"Add New Document Requirement"})}),e.jsxs("div",{className:"space-y-4",children:[e.jsxs("div",{children:[e.jsx(l,{htmlFor:"titleEn",children:"Title (English)"}),e.jsx(j,{id:"titleEn",value:r.title.en,onChange:a=>y({...r,title:{...r.title,en:a.target.value}})})]}),e.jsxs("div",{children:[e.jsx(l,{htmlFor:"titleAm",children:"Title (Amharic)"}),e.jsx(j,{id:"titleAm",value:r.title.am,onChange:a=>y({...r,title:{...r.title,am:a.target.value}})})]}),e.jsxs("div",{children:[e.jsx(l,{htmlFor:"descEn",children:"Description (English)"}),e.jsx(V,{id:"descEn",value:r.description.en,onChange:a=>y({...r,description:{...r.description,en:a.target.value}})})]}),e.jsxs("div",{children:[e.jsx(l,{htmlFor:"descAm",children:"Description (Amharic)"}),e.jsx(V,{id:"descAm",value:r.description.am,onChange:a=>y({...r,description:{...r.description,am:a.target.value}})})]}),e.jsxs("div",{children:[e.jsx(l,{htmlFor:"key",children:"Key"}),e.jsx(j,{id:"key",value:r.key,onChange:a=>y({...r,key:a.target.value})})]}),e.jsxs("div",{children:[e.jsx(l,{htmlFor:"order",children:"Order"}),e.jsx(j,{id:"order",type:"number",value:r.order,onChange:a=>y({...r,order:Number(a.target.value)})})]}),e.jsxs("div",{children:[e.jsx(l,{htmlFor:"type",children:"Type"}),e.jsxs("select",{id:"type",value:r.type,onChange:a=>y({...r,type:a.target.value}),className:"w-full border border-gray-300 dark:border-gray-600 rounded-md px-2 py-1 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100",children:[e.jsx("option",{value:"individual",children:"Individual"}),e.jsx("option",{value:"organization",children:"External Organization"})]})]}),e.jsxs("div",{className:"flex items-center space-x-2",children:[e.jsx(D,{id:"isOptional",checked:r.isOptional,onCheckedChange:a=>y({...r,isOptional:a})}),e.jsx(l,{htmlFor:"isOptional",children:"Optional"})]}),e.jsx(T,{onClick:k,disabled:c,className:"bg-primary hover:bg-primary/90 text-primary-foreground w-full",children:c?"Saving...":"Save Requirement"})]})]})]})]}),e.jsx(re,{children:e.jsx("div",{className:"overflow-x-auto",children:e.jsxs("table",{className:"w-full border border-gray-200 dark:border-gray-700 text-sm",children:[e.jsx("thead",{className:"bg-gray-100 dark:bg-gray-800/60",children:e.jsxs("tr",{children:[e.jsx("th",{className:"p-2 border border-gray-200 dark:border-gray-700",children:"Title (EN)"}),e.jsx("th",{className:"p-2 border border-gray-200 dark:border-gray-700",children:"Title (AM)"}),e.jsx("th",{className:"p-2 border border-gray-200 dark:border-gray-700",children:"Key"}),e.jsx("th",{className:"p-2 border border-gray-200 dark:border-gray-700",children:"Order"}),e.jsx("th",{className:"p-2 border border-gray-200 dark:border-gray-700",children:"Type"}),e.jsx("th",{className:"p-2 border border-gray-200 dark:border-gray-700",children:"Active"}),e.jsx("th",{className:"p-2 border border-gray-200 dark:border-gray-700",children:"Optional"})]})}),e.jsx("tbody",{children:x==null?void 0:x.items?.map(a=>e.jsxs("tr",{className:"text-center dark:text-gray-200",children:[e.jsx("td",{className:"p-2 border border-gray-200 dark:border-gray-700",children:a.title.en}),e.jsx("td",{className:"p-2 border border-gray-200 dark:border-gray-700",children:a.title.am}),e.jsx("td",{className:"p-2 border border-gray-200 dark:border-gray-700",children:a.key}),e.jsx("td",{className:"p-2 border border-gray-200 dark:border-gray-700",children:a.order}),e.jsx("td",{className:"p-2 border border-gray-200 dark:border-gray-700",children:a.type}),e.jsx("td",{className:"p-2 border border-gray-200 dark:border-gray-700",children:a.isActive?"✅":"❌"}),e.jsx("td",{className:"p-2 border border-gray-200 dark:border-gray-700",children:a.isOptional?"✅":"❌"})]},a.id))})]})})})]})})]})}export{na as default}; diff --git a/apps/edr-freight-web/backoffice/public/_um/assets/Skeleton-BJECajc_.js b/apps/edr-freight-web/backoffice/public/_um/assets/Skeleton-BJECajc_.js new file mode 100644 index 000000000..485100d90 --- /dev/null +++ b/apps/edr-freight-web/backoffice/public/_um/assets/Skeleton-BJECajc_.js @@ -0,0 +1 @@ +import{K as S,N as x,V as h,j as f,Q as N,W as R,Y as g,X as a}from"./index-Db-xuq0b.js";var l={root:"m_18320242","skeleton-fade":"m_299c329c"};const j={visible:!0,animate:!0},_=R((r,{width:o,height:s,radius:e,circle:t})=>({root:{"--skeleton-height":a(s),"--skeleton-width":t?a(s):a(o),"--skeleton-radius":t?"1000px":e===void 0?void 0:g(e)}})),n=S((r,o)=>{const s=x("Skeleton",j,r),{classNames:e,className:t,style:i,styles:c,unstyled:m,vars:d,width:b,height:w,circle:P,visible:u,radius:V,animate:p,mod:v,...k}=s,y=h({name:"Skeleton",classes:l,props:s,className:t,style:i,classNames:e,styles:c,unstyled:m,vars:d,varsResolver:_});return f.jsx(N,{ref:o,...y("root"),mod:[{visible:u,animate:p},v],...k})});n.classes=l;n.displayName="@mantine/core/Skeleton";export{n as S}; diff --git a/apps/edr-freight-web/backoffice/public/_um/assets/SmartOfficeAuditPage-BQ86seID.css b/apps/edr-freight-web/backoffice/public/_um/assets/SmartOfficeAuditPage-BQ86seID.css new file mode 100644 index 000000000..be3c64223 --- /dev/null +++ b/apps/edr-freight-web/backoffice/public/_um/assets/SmartOfficeAuditPage-BQ86seID.css @@ -0,0 +1 @@ +.rdp{--rdp-cell-size: 40px;--rdp-caption-font-size: 18px;--rdp-accent-color: #0000ff;--rdp-background-color: #e7edff;--rdp-accent-color-dark: #3003e1;--rdp-background-color-dark: #180270;--rdp-outline: 2px solid var(--rdp-accent-color);--rdp-outline-selected: 3px solid var(--rdp-accent-color);--rdp-selected-color: #fff;margin:1em}.rdp-vhidden{box-sizing:border-box;padding:0;margin:0;background:transparent;border:0;-moz-appearance:none;-webkit-appearance:none;appearance:none;position:absolute!important;top:0;width:1px!important;height:1px!important;padding:0!important;overflow:hidden!important;clip:rect(1px,1px,1px,1px)!important;border:0!important}.rdp-button_reset{appearance:none;position:relative;margin:0;padding:0;cursor:default;color:inherit;background:none;font:inherit;-moz-appearance:none;-webkit-appearance:none}.rdp-button_reset:focus-visible{outline:none}.rdp-button{border:2px solid transparent}.rdp-button[disabled]:not(.rdp-day_selected){opacity:.25}.rdp-button:not([disabled]){cursor:pointer}.rdp-button:focus-visible:not([disabled]){color:inherit;background-color:var(--rdp-background-color);border:var(--rdp-outline)}.rdp-button:hover:not([disabled]):not(.rdp-day_selected){background-color:var(--rdp-background-color)}.rdp-months{display:flex}.rdp-month{margin:0 1em}.rdp-month:first-child{margin-left:0}.rdp-month:last-child{margin-right:0}.rdp-table{margin:0;max-width:calc(var(--rdp-cell-size) * 7);border-collapse:collapse}.rdp-with_weeknumber .rdp-table{max-width:calc(var(--rdp-cell-size) * 8);border-collapse:collapse}.rdp-caption{display:flex;align-items:center;justify-content:space-between;padding:0;text-align:left}.rdp-multiple_months .rdp-caption{position:relative;display:block;text-align:center}.rdp-caption_dropdowns{position:relative;display:inline-flex}.rdp-caption_label{position:relative;z-index:1;display:inline-flex;align-items:center;margin:0;padding:0 .25em;white-space:nowrap;color:currentColor;border:0;border:2px solid transparent;font-family:inherit;font-size:var(--rdp-caption-font-size);font-weight:700}.rdp-nav{white-space:nowrap}.rdp-multiple_months .rdp-caption_start .rdp-nav{position:absolute;top:50%;left:0;transform:translateY(-50%)}.rdp-multiple_months .rdp-caption_end .rdp-nav{position:absolute;top:50%;right:0;transform:translateY(-50%)}.rdp-nav_button{display:inline-flex;align-items:center;justify-content:center;width:var(--rdp-cell-size);height:var(--rdp-cell-size);padding:.25em;border-radius:100%}.rdp-dropdown_year,.rdp-dropdown_month{position:relative;display:inline-flex;align-items:center}.rdp-dropdown{-webkit-appearance:none;-moz-appearance:none;appearance:none;position:absolute;z-index:2;top:0;bottom:0;left:0;width:100%;margin:0;padding:0;cursor:inherit;opacity:0;border:none;background-color:transparent;font-family:inherit;font-size:inherit;line-height:inherit}.rdp-dropdown[disabled]{opacity:unset;color:unset}.rdp-dropdown:focus-visible:not([disabled])+.rdp-caption_label{background-color:var(--rdp-background-color);border:var(--rdp-outline);border-radius:6px}.rdp-dropdown_icon{margin:0 0 0 5px}.rdp-head{border:0}.rdp-head_row,.rdp-row{height:100%}.rdp-head_cell{vertical-align:middle;font-size:.75em;font-weight:700;text-align:center;height:100%;height:var(--rdp-cell-size);padding:0;text-transform:uppercase}.rdp-tbody{border:0}.rdp-tfoot{margin:.5em}.rdp-cell{width:var(--rdp-cell-size);height:100%;height:var(--rdp-cell-size);padding:0;text-align:center}.rdp-weeknumber{font-size:.75em}.rdp-weeknumber,.rdp-day{display:flex;overflow:hidden;align-items:center;justify-content:center;box-sizing:border-box;width:var(--rdp-cell-size);max-width:var(--rdp-cell-size);height:var(--rdp-cell-size);margin:0;border:2px solid transparent;border-radius:100%}.rdp-day_today:not(.rdp-day_outside){font-weight:700}.rdp-day_selected,.rdp-day_selected:focus-visible,.rdp-day_selected:hover{color:var(--rdp-selected-color);opacity:1;background-color:var(--rdp-accent-color)}.rdp-day_outside{opacity:.5}.rdp-day_selected:focus-visible{outline:var(--rdp-outline);outline-offset:2px;z-index:1}.rdp:not([dir=rtl]) .rdp-day_range_start:not(.rdp-day_range_end){border-top-right-radius:0;border-bottom-right-radius:0}.rdp:not([dir=rtl]) .rdp-day_range_end:not(.rdp-day_range_start){border-top-left-radius:0;border-bottom-left-radius:0}.rdp[dir=rtl] .rdp-day_range_start:not(.rdp-day_range_end){border-top-left-radius:0;border-bottom-left-radius:0}.rdp[dir=rtl] .rdp-day_range_end:not(.rdp-day_range_start){border-top-right-radius:0;border-bottom-right-radius:0}.rdp-day_range_end.rdp-day_range_start{border-radius:100%}.rdp-day_range_middle{border-radius:0}.calendars{background-color:#fff;color:#222;border:1px solid #4297d7;-moz-border-radius:.25em;-webkit-border-radius:.25em;border-radius:.25em;font-family:Arial,Helvetica,Sans-serif;font-size:90%}.calendars-rtl{direction:rtl}.calendars-popup{z-index:1000}.calendars-disable{position:absolute;z-index:100;background-color:#fff;opacity:.5;filter:alpha(opacity=50)}.calendars a{color:#222;text-decoration:none}.calendars a.calendars-disabled{color:#888;cursor:auto}.calendars button{margin:.25em;padding:.125em 0;background-color:#5c9ccc;color:#fff;border:none;-moz-border-radius:.25em;-webkit-border-radius:.25em;border-radius:.25em;font-weight:700}.calendars-nav,.calendars-ctrl{float:left;width:100%;background-color:#fff;font-size:90%;font-weight:700}.calendars-ctrl{background-color:#d0e5f5}.calendars-cmd{width:30%}.calendars-cmd:hover{background-color:#dfeffc}button.calendars-cmd:hover{background-color:#79b7e7}.calendars-cmd-prevJump,.calendars-cmd-nextJump{width:8%}a.calendars-cmd{height:1.5em}button.calendars-cmd{text-align:center}.calendars-cmd-prev,.calendars-cmd-prevJump,.calendars-cmd-clear{float:left;padding-left:2%}.calendars-cmd-current,.calendars-cmd-today{float:left;width:35%;text-align:center}.calendars-cmd-next,.calendars-cmd-nextJump,.calendars-cmd-close{float:right;padding-right:2%;text-align:right}.calendars-rtl .calendars-cmd-prev,.calendars-rtl .calendars-cmd-prevJump,.calendars-rtl .calendars-cmd-clear{float:right;padding-left:0%;padding-right:2%;text-align:right}.calendars-rtl .calendars-cmd-current,.calendars-rtl .calendars-cmd-today{float:right}.calendars-rtl .calendars-cmd-next,.calendars-rtl .calendars-cmd-nextJump,.calendars-rtl .calendars-cmd-close{float:left;padding-left:2%;padding-right:0%;text-align:left}.calendars-month-nav{float:left;text-align:center}.calendars-month-nav div{float:left;width:12.5%;margin:1%;padding:1%}.calendars-month-nav span{color:#888}.calendars-month-row{clear:left}.calendars-month{float:left;width:15em;border:1px solid #5c9ccc;text-align:center}.calendars-month-header,.calendars-month-header select,.calendars-month-header input{height:1.5em;background-color:#5c9ccc;color:#fff;font-weight:700}.calendars-month-header select,.calendars-month-header input{height:1.4em;border:none}.calendars-month-header input{position:absolute;display:none}.calendars-month table{width:100%;border-collapse:collapse}.calendars-month thead{border-bottom:1px solid #aaa}.calendars-month th,.calendars-month td{margin:0;padding:0;font-weight:400;text-align:center}.calendars-month th{border:1px solid #fff;border-bottom:1px solid #c5dbec}.calendars-month td{border:1px solid #c5dbec}.calendars-month td.calendars-week *{background-color:#d0e5f5;color:#222;border:none}.calendars-month a{display:block;width:100%;padding:.125em 0;background-color:#dfeffc;color:#000;text-decoration:none}.calendars-month span{display:block;width:100%;padding:.125em 0}.calendars-month td span{color:#888}.calendars-month td .calendars-other-month{background-color:#fff}.calendars-month td .calendars-today{background-color:#fad42e}.calendars-month td .calendars-highlight{background-color:#79b7e7}.calendars-month td .calendars-selected{background-color:#4297d7;color:#fff}.calendars-status{clear:both;text-align:center}.calendars-clear-fix{clear:both}.ethiopian-picker-surface .calendars{width:100%!important;border:0;border-radius:.5rem;color:#1f2937;font-family:inherit;font-size:.875rem;box-shadow:none}.ethiopian-picker-surface .calendars-nav,.ethiopian-picker-surface .calendars-ctrl{background:transparent;border-bottom:1px solid rgb(229 231 235);padding:.35rem .4rem}.ethiopian-picker-surface .calendars-month{width:100%;border:0}.ethiopian-picker-surface .calendars-month-header,.ethiopian-picker-surface .calendars-month-header select,.ethiopian-picker-surface .calendars-month-header input{height:2.1rem;background:transparent;color:#111827;font-weight:600}.ethiopian-picker-surface .calendars-month th,.ethiopian-picker-surface .calendars-month td{border:0;padding:.2rem}.ethiopian-picker-surface .calendars-month th{color:#6b7280;font-weight:500;font-size:.75rem}.ethiopian-picker-surface .calendars-month a,.ethiopian-picker-surface .calendars-month span{border-radius:.35rem;background:transparent;color:#111827;padding:.38rem 0}.ethiopian-picker-surface .calendars-month a:hover,.ethiopian-picker-surface .calendars-month td .calendars-today{background:#dcfce7;color:#166534}.ethiopian-picker-surface .calendars-month td .calendars-selected{background:#22c55e;color:#fff}.ethiopian-picker-surface .calendars-month td .calendars-other-month{color:#9ca3af}.ethiopian-picker-surface .calendars-nav .calendars-cmd-prev,.ethiopian-picker-surface .calendars-nav .calendars-cmd-next,.ethiopian-picker-surface .calendars-nav .calendars-cmd-prevJump,.ethiopian-picker-surface .calendars-nav .calendars-cmd-nextJump,.calendars-popup .calendars-nav .calendars-cmd-prev,.calendars-popup .calendars-nav .calendars-cmd-next,.calendars-popup .calendars-nav .calendars-cmd-prevJump,.calendars-popup .calendars-nav .calendars-cmd-nextJump{display:inline-flex;align-items:center;justify-content:center;min-width:2rem;height:2rem;border:1px solid rgb(187 247 208);border-radius:.5rem;background:#f0fdf4;color:#15803d;line-height:1;transition:all .2s ease}.ethiopian-picker-surface .calendars-nav .calendars-cmd-prev:hover,.ethiopian-picker-surface .calendars-nav .calendars-cmd-next:hover,.ethiopian-picker-surface .calendars-nav .calendars-cmd-prevJump:hover,.ethiopian-picker-surface .calendars-nav .calendars-cmd-nextJump:hover,.calendars-popup .calendars-nav .calendars-cmd-prev:hover,.calendars-popup .calendars-nav .calendars-cmd-next:hover,.calendars-popup .calendars-nav .calendars-cmd-prevJump:hover,.calendars-popup .calendars-nav .calendars-cmd-nextJump:hover{background:#22c55e;border-color:#22c55e;color:#fff}.ethiopian-picker-surface .calendars-nav,.calendars-popup .calendars-nav{display:flex;align-items:center;justify-content:space-between;gap:.5rem}.ethiopian-picker-surface .calendars-nav .calendars-cmd,.calendars-popup .calendars-nav .calendars-cmd{float:none;width:auto;padding-left:0;padding-right:0}.ethiopian-picker-surface .calendars-nav .calendars-cmd-today,.calendars-popup .calendars-nav .calendars-cmd-today{flex:1;border-radius:.5rem;color:#15803d;font-weight:600;text-align:center}.ethiopian-picker-surface .calendars-nav .calendars-cmd.calendars-disabled,.calendars-popup .calendars-nav .calendars-cmd.calendars-disabled{opacity:.45;cursor:not-allowed}.dark .ethiopian-picker-surface .calendars,[data-theme=dark] .ethiopian-picker-surface .calendars,.dark .calendars-popup .calendars,[data-theme=dark] .calendars-popup .calendars{background:#111827;color:#e5e7eb;border-color:#374151}.dark .ethiopian-picker-surface .calendars-nav,.dark .ethiopian-picker-surface .calendars-ctrl,[data-theme=dark] .ethiopian-picker-surface .calendars-nav,[data-theme=dark] .ethiopian-picker-surface .calendars-ctrl,.dark .calendars-popup .calendars-nav,.dark .calendars-popup .calendars-ctrl,[data-theme=dark] .calendars-popup .calendars-nav,[data-theme=dark] .calendars-popup .calendars-ctrl{background:#111827;border-color:#374151}.dark .ethiopian-picker-surface .calendars-month-header,.dark .ethiopian-picker-surface .calendars-month-header select,.dark .ethiopian-picker-surface .calendars-month-header input,.dark .ethiopian-picker-surface .calendars-month a,.dark .ethiopian-picker-surface .calendars-month span,[data-theme=dark] .ethiopian-picker-surface .calendars-month-header,[data-theme=dark] .ethiopian-picker-surface .calendars-month-header select,[data-theme=dark] .ethiopian-picker-surface .calendars-month-header input,[data-theme=dark] .ethiopian-picker-surface .calendars-month a,[data-theme=dark] .ethiopian-picker-surface .calendars-month span,.dark .calendars-popup .calendars-month-header,.dark .calendars-popup .calendars-month-header select,.dark .calendars-popup .calendars-month-header input,.dark .calendars-popup .calendars-month a,.dark .calendars-popup .calendars-month span,[data-theme=dark] .calendars-popup .calendars-month-header,[data-theme=dark] .calendars-popup .calendars-month-header select,[data-theme=dark] .calendars-popup .calendars-month-header input,[data-theme=dark] .calendars-popup .calendars-month a,[data-theme=dark] .calendars-popup .calendars-month span{color:#e5e7eb}.dark .ethiopian-picker-surface .calendars-month th,[data-theme=dark] .ethiopian-picker-surface .calendars-month th,.dark .calendars-popup .calendars-month th,[data-theme=dark] .calendars-popup .calendars-month th{color:#9ca3af}.dark .ethiopian-picker-surface .calendars-month a:hover,[data-theme=dark] .ethiopian-picker-surface .calendars-month a:hover,.dark .calendars-popup .calendars-month a:hover,[data-theme=dark] .calendars-popup .calendars-month a:hover,.dark .ethiopian-picker-surface .calendars-month td .calendars-today,[data-theme=dark] .ethiopian-picker-surface .calendars-month td .calendars-today,.dark .calendars-popup .calendars-month td .calendars-today,[data-theme=dark] .calendars-popup .calendars-month td .calendars-today{background:#14532d;color:#bbf7d0}.dark .ethiopian-picker-surface .calendars-month td .calendars-highlight,[data-theme=dark] .ethiopian-picker-surface .calendars-month td .calendars-highlight,.dark .calendars-popup .calendars-month td .calendars-highlight,[data-theme=dark] .calendars-popup .calendars-month td .calendars-highlight{background:#166534}.dark .ethiopian-picker-surface .calendars-month td .calendars-selected,[data-theme=dark] .ethiopian-picker-surface .calendars-month td .calendars-selected,.dark .calendars-popup .calendars-month td .calendars-selected,[data-theme=dark] .calendars-popup .calendars-month td .calendars-selected{background:#22c55e;color:#052e16}.dark .ethiopian-picker-surface .calendars-month td .calendars-other-month,[data-theme=dark] .ethiopian-picker-surface .calendars-month td .calendars-other-month,.dark .calendars-popup .calendars-month td .calendars-other-month,[data-theme=dark] .calendars-popup .calendars-month td .calendars-other-month{color:#6b7280}.dark .ethiopian-picker-surface .calendars a.calendars-disabled,[data-theme=dark] .ethiopian-picker-surface .calendars a.calendars-disabled,.dark .calendars-popup .calendars a.calendars-disabled,[data-theme=dark] .calendars-popup .calendars a.calendars-disabled{color:#4b5563}.dark .ethiopian-picker-surface .calendars-nav .calendars-cmd-prev,.dark .ethiopian-picker-surface .calendars-nav .calendars-cmd-next,.dark .ethiopian-picker-surface .calendars-nav .calendars-cmd-prevJump,.dark .ethiopian-picker-surface .calendars-nav .calendars-cmd-nextJump,[data-theme=dark] .ethiopian-picker-surface .calendars-nav .calendars-cmd-prev,[data-theme=dark] .ethiopian-picker-surface .calendars-nav .calendars-cmd-next,[data-theme=dark] .ethiopian-picker-surface .calendars-nav .calendars-cmd-prevJump,[data-theme=dark] .ethiopian-picker-surface .calendars-nav .calendars-cmd-nextJump,.dark .calendars-popup .calendars-nav .calendars-cmd-prev,.dark .calendars-popup .calendars-nav .calendars-cmd-next,.dark .calendars-popup .calendars-nav .calendars-cmd-prevJump,.dark .calendars-popup .calendars-nav .calendars-cmd-nextJump,[data-theme=dark] .calendars-popup .calendars-nav .calendars-cmd-prev,[data-theme=dark] .calendars-popup .calendars-nav .calendars-cmd-next,[data-theme=dark] .calendars-popup .calendars-nav .calendars-cmd-prevJump,[data-theme=dark] .calendars-popup .calendars-nav .calendars-cmd-nextJump{background:#15803d38;border-color:#22c55e73;color:#bbf7d0}.dark .ethiopian-picker-surface .calendars-nav .calendars-cmd-prev:hover,.dark .ethiopian-picker-surface .calendars-nav .calendars-cmd-next:hover,.dark .ethiopian-picker-surface .calendars-nav .calendars-cmd-prevJump:hover,.dark .ethiopian-picker-surface .calendars-nav .calendars-cmd-nextJump:hover,[data-theme=dark] .ethiopian-picker-surface .calendars-nav .calendars-cmd-prev:hover,[data-theme=dark] .ethiopian-picker-surface .calendars-nav .calendars-cmd-next:hover,[data-theme=dark] .ethiopian-picker-surface .calendars-nav .calendars-cmd-prevJump:hover,[data-theme=dark] .ethiopian-picker-surface .calendars-nav .calendars-cmd-nextJump:hover,.dark .calendars-popup .calendars-nav .calendars-cmd-prev:hover,.dark .calendars-popup .calendars-nav .calendars-cmd-next:hover,.dark .calendars-popup .calendars-nav .calendars-cmd-prevJump:hover,.dark .calendars-popup .calendars-nav .calendars-cmd-nextJump:hover,[data-theme=dark] .calendars-popup .calendars-nav .calendars-cmd-prev:hover,[data-theme=dark] .calendars-popup .calendars-nav .calendars-cmd-next:hover,[data-theme=dark] .calendars-popup .calendars-nav .calendars-cmd-prevJump:hover,[data-theme=dark] .calendars-popup .calendars-nav .calendars-cmd-nextJump:hover{background:#22c55e;border-color:#22c55e;color:#052e16}.dark .ethiopian-picker-surface .calendars-nav .calendars-cmd-today,[data-theme=dark] .ethiopian-picker-surface .calendars-nav .calendars-cmd-today,.dark .calendars-popup .calendars-nav .calendars-cmd-today,[data-theme=dark] .calendars-popup .calendars-nav .calendars-cmd-today{color:#bbf7d0} diff --git a/apps/edr-freight-web/backoffice/public/_um/assets/SmartOfficeAuditPage-DLN72875.js b/apps/edr-freight-web/backoffice/public/_um/assets/SmartOfficeAuditPage-DLN72875.js new file mode 100644 index 000000000..29575c2bb --- /dev/null +++ b/apps/edr-freight-web/backoffice/public/_um/assets/SmartOfficeAuditPage-DLN72875.js @@ -0,0 +1,35 @@ +import{y as createLucideIcon,u as useTranslation,j as jsxRuntimeExports,aO as Clock,F as FileText,U as User,bn as Settings,b6 as Trash2,z as Upload,r as reactExports,b8 as cn,b9 as buttonVariants,aU as getDefaultExportFromCjs,B as Button$1,aS as X,I as Input,bs as CircleCheckBig,aK as Dialog,aL as DialogContent,aM as DialogHeader,aN as DialogTitle,bo as DialogDescription,bt as FileSpreadsheet,ba as DialogFooter,be as axios,p as api,bu as AUDITLOG_API_URL,t as toast,bf as getRefreshToken,aR as instance}from"./index-Db-xuq0b.js";import{C as Card,d as CardContent,a as CardHeader,b as CardTitle,c as CardDescription}from"./card-BBWyxDss.js";import{T as Table$1,a as TableHeader,b as TableRow,c as TableHead,d as TableBody,e as TableCell}from"./table-D3n3VABd.js";import{B as Badge}from"./badge-D7JvaQeJ.js";import{A as Avatar,c as AvatarImage,a as AvatarFallback}from"./avatar-C2-XEZ4v.js";import{s as startOfDay,a as startOfWeek,f as format,d as differenceInCalendarDays,b as startOfYear,c as startOfISOWeek,g as getISOWeek,e as getWeek,i as isDate}from"./format-DvwV82px.js";import{S as Shield}from"./shield-uC_usZTb.js";import{L as Lock}from"./lock-BCybB-Lq.js";import{E as Eye}from"./eye-Bhud4znU.js";import{S as SquarePen}from"./square-pen-B91TPB19.js";import{D as Download}from"./download-CX6rsOqt.js";import{S as Select,a as SelectTrigger,b as SelectValue,c as SelectContent,d as SelectItem}from"./select-BoQxM42A.js";import{P as Popover,a as PopoverTrigger,b as PopoverContent}from"./popover-X-j_SRnG.js";import{t as toDate,c as constructFrom,g as getTimezoneOffsetInMilliseconds,m as millisecondsInWeek,a as getDefaultOptions,e as enUS}from"./en-US-Cc-9gH5A.js";import{e as endOfMonth,d as differenceInCalendarMonths}from"./endOfMonth-DmxQbzXi.js";import{S as Search}from"./search-CM5F2ZRy.js";import{L as Label}from"./label-CsFy6wpo.js";import{R as RadioGroup,a as RadioGroupItem}from"./radio-group-DqG5gUnT.js";import{C as Checkbox}from"./checkbox-Dd0VhnrO.js";import{R as RefreshCw}from"./refresh-cw-JB7N413f.js";import{E as EyeOff}from"./eye-off-CDMvHElM.js";/** + * @license lucide-react v0.513.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const __iconNode$2=[["path",{d:"M22 12h-2.48a2 2 0 0 0-1.93 1.46l-2.35 8.36a.25.25 0 0 1-.48 0L9.24 2.18a.25.25 0 0 0-.48 0l-2.35 8.36A2 2 0 0 1 4.49 12H2",key:"169zse"}]],Activity=createLucideIcon("activity",__iconNode$2);/** + * @license lucide-react v0.513.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const __iconNode$1=[["path",{d:"M8 2v4",key:"1cmpym"}],["path",{d:"M16 2v4",key:"4m81vk"}],["rect",{width:"18",height:"18",x:"3",y:"4",rx:"2",key:"1hopcy"}],["path",{d:"M3 10h18",key:"8toen8"}]],Calendar$1=createLucideIcon("calendar",__iconNode$1);/** + * @license lucide-react v0.513.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const __iconNode=[["path",{d:"M16 7h6v6",key:"box55l"}],["path",{d:"m22 7-8.5 8.5-5-5L2 17",key:"1t1m79"}]],TrendingUp=createLucideIcon("trending-up",__iconNode);function addDays(r,o){const c=toDate(r);return isNaN(o)?constructFrom(r,NaN):(o&&c.setDate(c.getDate()+o),c)}function addMonths(r,o){const c=toDate(r);if(isNaN(o))return constructFrom(r,NaN);if(!o)return c;const n=c.getDate(),a=constructFrom(r,c.getTime());a.setMonth(c.getMonth()+o+1,0);const s=a.getDate();return n>=s?a:(c.setFullYear(a.getFullYear(),a.getMonth(),n),c)}function addWeeks(r,o){const c=o*7;return addDays(r,c)}function addYears(r,o){return addMonths(r,o*12)}function max(r){let o;return r.forEach(function(c){const n=toDate(c);(o===void 0||o{const n=toDate(c);(!o||o>n||isNaN(+n))&&(o=n)}),o||new Date(NaN)}function isSameDay(r,o){const c=startOfDay(r),n=startOfDay(o);return+c==+n}function differenceInCalendarWeeks(r,o,c){const n=startOfWeek(r,c),a=startOfWeek(o,c),s=+n-getTimezoneOffsetInMilliseconds(n),d=+a-getTimezoneOffsetInMilliseconds(a);return Math.round((s-d)/millisecondsInWeek)}function startOfMonth(r){const o=toDate(r);return o.setDate(1),o.setHours(0,0,0,0),o}function endOfWeek(r,o){var p,m,g,j;const c=getDefaultOptions(),n=(o==null?void 0:o.weekStartsOn)??((m=(p=o==null?void 0:o.locale)==null?void 0:p.options)==null?void 0:m.weekStartsOn)??c.weekStartsOn??((j=(g=c.locale)==null?void 0:g.options)==null?void 0:j.weekStartsOn)??0,a=toDate(r),s=a.getDay(),d=(sn.getTime()}function isBefore(r,o){const c=toDate(r),n=toDate(o);return+c<+n}function isSameMonth(r,o){const c=toDate(r),n=toDate(o);return c.getFullYear()===n.getFullYear()&&c.getMonth()===n.getMonth()}function isSameYear(r,o){const c=toDate(r),n=toDate(o);return c.getFullYear()===n.getFullYear()}function subDays(r,o){return addDays(r,-o)}function setMonth(r,o){const c=toDate(r),n=c.getFullYear(),a=c.getDate(),s=constructFrom(r,0);s.setFullYear(n,o,15),s.setHours(0,0,0,0);const d=getDaysInMonth(s);return c.setMonth(o,Math.min(a,d)),c}function setYear(r,o){const c=toDate(r);return isNaN(+c)?constructFrom(r,NaN):(c.setFullYear(o),c)}const getModuleIcon=r=>{switch(r.toLowerCase()){case"authentication":return jsxRuntimeExports.jsx(Shield,{className:"h-4 w-4"});case"documents":case"files":return jsxRuntimeExports.jsx(FileText,{className:"h-4 w-4"});case"settings":return jsxRuntimeExports.jsx(Settings,{className:"h-4 w-4"});case"users":return jsxRuntimeExports.jsx(User,{className:"h-4 w-4"});default:return jsxRuntimeExports.jsx(FileText,{className:"h-4 w-4"})}},getActionIcon=r=>{switch(r.toLowerCase()){case"upload":return jsxRuntimeExports.jsx(Upload,{className:"h-4 w-4"});case"download":return jsxRuntimeExports.jsx(Download,{className:"h-4 w-4"});case"edit":case"update":return jsxRuntimeExports.jsx(SquarePen,{className:"h-4 w-4"});case"delete":return jsxRuntimeExports.jsx(Trash2,{className:"h-4 w-4"});case"view":return jsxRuntimeExports.jsx(Eye,{className:"h-4 w-4"});case"login":return jsxRuntimeExports.jsx(Lock,{className:"h-4 w-4"});default:return jsxRuntimeExports.jsx(FileText,{className:"h-4 w-4"})}},getStatusColor=r=>{switch(r){case"success":return"bg-primary-100 dark:bg-primary-900/40 text-primary-800 dark:text-primary-300 hover:bg-primary-100 dark:hover:bg-primary-900/40 border-primary-300 dark:border-primary-700";case"failure":return"bg-red-100 dark:bg-red-900/40 text-red-800 dark:text-red-300 hover:bg-red-100 dark:hover:bg-red-900/40 border-red-300 dark:border-red-700";case"warning":return"bg-yellow-100 dark:bg-yellow-900/40 text-yellow-800 dark:text-yellow-300 hover:bg-yellow-100 dark:hover:bg-yellow-900/40 border-yellow-300 dark:border-yellow-700";case"pending":return"bg-primary/15 dark:bg-primary/25 text-primary-800 dark:text-primary-300 hover:bg-primary/15 dark:hover:bg-primary/25 border-primary/40 dark:border-primary/60";default:return"bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 border-gray-300 dark:border-gray-600"}};function ActivityCard({activity:r}){const{t:o}=useTranslation(),c=format(new Date(r.timestamp),"MMM d, yyyy HH:mm:ss");return jsxRuntimeExports.jsx(Card,{className:"hover:shadow-md transition-shadow duration-200 border-gray-200 dark:border-gray-700 dark:bg-gray-800",children:jsxRuntimeExports.jsx(CardContent,{className:"p-4",children:jsxRuntimeExports.jsxs("div",{className:"flex items-start gap-4",children:[jsxRuntimeExports.jsxs("div",{className:"relative",children:[jsxRuntimeExports.jsx("div",{className:"w-10 h-10 rounded-full bg-gray-100 dark:bg-gray-700 flex items-center justify-center",children:getModuleIcon(r.module)}),jsxRuntimeExports.jsx("div",{className:"absolute -bottom-1 -right-1 w-6 h-6 rounded-full bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 flex items-center justify-center",children:getActionIcon(r.action)})]}),jsxRuntimeExports.jsxs("div",{className:"flex-1 min-w-0",children:[jsxRuntimeExports.jsxs("div",{className:"flex items-start justify-between gap-2 mb-2",children:[jsxRuntimeExports.jsxs("div",{className:"flex items-center gap-2",children:[jsxRuntimeExports.jsx("h4",{className:"font-medium text-gray-900 dark:text-gray-100 truncate",children:r.description}),jsxRuntimeExports.jsx(Badge,{variant:"outline",className:`text-xs font-medium ${getStatusColor(r.status)}`,children:o(`auditLogShared.status.${r.status}`)})]}),jsxRuntimeExports.jsxs("div",{className:"flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap",children:[jsxRuntimeExports.jsx(Clock,{className:"h-3 w-3"}),c]})]}),jsxRuntimeExports.jsxs("div",{className:"flex items-center gap-4 text-sm text-gray-600 dark:text-gray-400",children:[jsxRuntimeExports.jsxs("div",{className:"flex items-center gap-2",children:[jsxRuntimeExports.jsxs(Avatar,{className:"h-5 w-5",children:[r.performedBy.avatar&&jsxRuntimeExports.jsx(AvatarImage,{src:r.performedBy.avatar}),jsxRuntimeExports.jsx(AvatarFallback,{className:"text-xs",children:r.performedBy.name.charAt(0).toUpperCase()})]}),jsxRuntimeExports.jsx("span",{className:"font-medium text-gray-900 dark:text-gray-100",children:r.performedBy.name}),jsxRuntimeExports.jsx("span",{className:"text-gray-500 dark:text-gray-500",children:"•"}),jsxRuntimeExports.jsx("span",{children:r.performedBy.email})]}),r.resourceName&&jsxRuntimeExports.jsxs(jsxRuntimeExports.Fragment,{children:[jsxRuntimeExports.jsx("span",{className:"text-gray-500 dark:text-gray-500",children:"•"}),jsxRuntimeExports.jsx("span",{className:"truncate max-w-[200px]",children:r.resourceName})]})]}),(r.details||r.ipAddress||r.location)&&jsxRuntimeExports.jsx("div",{className:"mt-3 pt-3 border-t border-gray-100 dark:border-gray-700",children:jsxRuntimeExports.jsxs("div",{className:"flex flex-wrap gap-4 text-xs text-gray-500 dark:text-gray-400",children:[r.details&&jsxRuntimeExports.jsx("div",{className:"max-w-[300px] truncate",title:r.details,children:r.details}),r.ipAddress&&jsxRuntimeExports.jsxs("div",{className:"flex items-center gap-1",children:[jsxRuntimeExports.jsxs("span",{children:[o("auditLogShared.labels.ip"),":"]}),jsxRuntimeExports.jsx("span",{className:"font-mono",children:r.ipAddress})]}),r.location&&jsxRuntimeExports.jsxs("div",{className:"flex items-center gap-1",children:[jsxRuntimeExports.jsx("span",{children:"📍"}),jsxRuntimeExports.jsx("span",{children:r.location})]})]})})]})]})})})}var __assign=function(){return __assign=Object.assign||function(o){for(var c,n=1,a=arguments.length;n1&&(m||!g),k=o>1&&(g||!m),_=function(){n&&s(n)},T=function(){a&&s(a)};return jsxRuntimeExports.jsx(Navigation,{displayMonth:r.displayMonth,hideNext:j,hidePrevious:k,nextMonth:a,previousMonth:n,onPreviousClick:_,onNextClick:T})}function Caption(r){var o,c=useDayPicker(),n=c.classNames,a=c.disableNavigation,s=c.styles,d=c.captionLayout,p=c.components,m=(o=p==null?void 0:p.CaptionLabel)!==null&&o!==void 0?o:CaptionLabel,g;return a?g=jsxRuntimeExports.jsx(m,{id:r.id,displayMonth:r.displayMonth}):d==="dropdown"?g=jsxRuntimeExports.jsx(CaptionDropdowns,{displayMonth:r.displayMonth,id:r.id}):d==="dropdown-buttons"?g=jsxRuntimeExports.jsxs(jsxRuntimeExports.Fragment,{children:[jsxRuntimeExports.jsx(CaptionDropdowns,{displayMonth:r.displayMonth,displayIndex:r.displayIndex,id:r.id}),jsxRuntimeExports.jsx(CaptionNavigation,{displayMonth:r.displayMonth,displayIndex:r.displayIndex,id:r.id})]}):g=jsxRuntimeExports.jsxs(jsxRuntimeExports.Fragment,{children:[jsxRuntimeExports.jsx(m,{id:r.id,displayMonth:r.displayMonth,displayIndex:r.displayIndex}),jsxRuntimeExports.jsx(CaptionNavigation,{displayMonth:r.displayMonth,id:r.id})]}),jsxRuntimeExports.jsx("div",{className:n.caption,style:s.caption,children:g})}function Footer(r){var o=useDayPicker(),c=o.footer,n=o.styles,a=o.classNames.tfoot;return c?jsxRuntimeExports.jsx("tfoot",{className:a,style:n.tfoot,children:jsxRuntimeExports.jsx("tr",{children:jsxRuntimeExports.jsx("td",{colSpan:8,children:c})})}):jsxRuntimeExports.jsx(jsxRuntimeExports.Fragment,{})}function getWeekdays(r,o,c){for(var n=c?startOfISOWeek(new Date):startOfWeek(new Date,{locale:r,weekStartsOn:o}),a=[],s=0;s<7;s++){var d=addDays(n,s);a.push(d)}return a}function HeadRow(){var r=useDayPicker(),o=r.classNames,c=r.styles,n=r.showWeekNumber,a=r.locale,s=r.weekStartsOn,d=r.ISOWeek,p=r.formatters.formatWeekdayName,m=r.labels.labelWeekday,g=getWeekdays(a,s,d);return jsxRuntimeExports.jsxs("tr",{style:c.head_row,className:o.head_row,children:[n&&jsxRuntimeExports.jsx("td",{style:c.head_cell,className:o.head_cell}),g?.map(function(j,k){return jsxRuntimeExports.jsx("th",{scope:"col",className:o.head_cell,style:c.head_cell,"aria-label":m(j,{locale:a}),children:p(j,{locale:a})},k)})]})}function Head(){var r,o=useDayPicker(),c=o.classNames,n=o.styles,a=o.components,s=(r=a==null?void 0:a.HeadRow)!==null&&r!==void 0?r:HeadRow;return jsxRuntimeExports.jsx("thead",{style:n.head,className:c.head,children:jsxRuntimeExports.jsx(s,{})})}function DayContent(r){var o=useDayPicker(),c=o.locale,n=o.formatters.formatDay;return jsxRuntimeExports.jsx(jsxRuntimeExports.Fragment,{children:n(r.date,{locale:c})})}var SelectMultipleContext=reactExports.createContext(void 0);function SelectMultipleProvider(r){if(!isDayPickerMultiple(r.initialProps)){var o={selected:void 0,modifiers:{disabled:[]}};return jsxRuntimeExports.jsx(SelectMultipleContext.Provider,{value:o,children:r.children})}return jsxRuntimeExports.jsx(SelectMultipleProviderInternal,{initialProps:r.initialProps,children:r.children})}function SelectMultipleProviderInternal(r){var o=r.initialProps,c=r.children,n=o.selected,a=o.min,s=o.max,d=function(g,j,k){var _,T;(_=o.onDayClick)===null||_===void 0||_.call(o,g,j,k);var E=!!(j.selected&&a&&(n==null?void 0:n.length)===a);if(!E){var M=!!(!j.selected&&s&&(n==null?void 0:n.length)===s);if(!M){var P=n?__spreadArray([],n):[];if(j.selected){var A=P.findIndex(function(H){return isSameDay(g,H)});P.splice(A,1)}else P.push(g);(T=o.onSelect)===null||T===void 0||T.call(o,P,g,j,k)}}},p={disabled:[]};n&&p.disabled.push(function(g){var j=s&&n.length>s-1,k=n?.some(function(_){return isSameDay(_,g)});return!!(j&&!k)});var m={selected:n,onDayClick:d,modifiers:p};return jsxRuntimeExports.jsx(SelectMultipleContext.Provider,{value:m,children:c})}function useSelectMultiple(){var r=reactExports.useContext(SelectMultipleContext);if(!r)throw new Error("useSelectMultiple must be used within a SelectMultipleProvider");return r}function addToRange(r,o){var c=o||{},n=c.from,a=c.to;return n&&a?isSameDay(a,r)&&isSameDay(n,r)?void 0:isSameDay(a,r)?{from:a,to:void 0}:isSameDay(n,r)?void 0:isAfter(n,r)?{from:r,to:a}:{from:n,to:r}:a?isAfter(r,a)?{from:a,to:r}:{from:r,to:a}:n?isBefore(r,n)?{from:r,to:n}:{from:n,to:r}:{from:r,to:void 0}}var SelectRangeContext=reactExports.createContext(void 0);function SelectRangeProvider(r){if(!isDayPickerRange(r.initialProps)){var o={selected:void 0,modifiers:{range_start:[],range_end:[],range_middle:[],disabled:[]}};return jsxRuntimeExports.jsx(SelectRangeContext.Provider,{value:o,children:r.children})}return jsxRuntimeExports.jsx(SelectRangeProviderInternal,{initialProps:r.initialProps,children:r.children})}function SelectRangeProviderInternal(r){var o=r.initialProps,c=r.children,n=o.selected,a=n||{},s=a.from,d=a.to,p=o.min,m=o.max,g=function(T,E,M){var P,A;(P=o.onDayClick)===null||P===void 0||P.call(o,T,E,M);var H=addToRange(T,n);(A=o.onSelect)===null||A===void 0||A.call(o,H,T,E,M)},j={range_start:[],range_end:[],range_middle:[],disabled:[]};if(s?(j.range_start=[s],d?(j.range_end=[d],isSameDay(s,d)||(j.range_middle=[{after:s,before:d}])):j.range_end=[s]):d&&(j.range_start=[d],j.range_end=[d]),p&&(s&&!d&&j.disabled.push({after:subDays(s,p-1),before:addDays(s,p-1)}),s&&d&&j.disabled.push({after:s,before:addDays(s,p-1)}),!s&&d&&j.disabled.push({after:subDays(d,p-1),before:addDays(d,p-1)})),m){if(s&&!d&&(j.disabled.push({before:addDays(s,-m+1)}),j.disabled.push({after:addDays(s,m-1)})),s&&d){var k=differenceInCalendarDays(d,s)+1,_=m-k;j.disabled.push({before:subDays(s,_)}),j.disabled.push({after:addDays(d,_)})}!s&&d&&(j.disabled.push({before:addDays(d,-m+1)}),j.disabled.push({after:addDays(d,m-1)}))}return jsxRuntimeExports.jsx(SelectRangeContext.Provider,{value:{selected:n,onDayClick:g,modifiers:j},children:c})}function useSelectRange(){var r=reactExports.useContext(SelectRangeContext);if(!r)throw new Error("useSelectRange must be used within a SelectRangeProvider");return r}function matcherToArray(r){return Array.isArray(r)?__spreadArray([],r):r!==void 0?[r]:[]}function getCustomModifiers(r){var o={};return Object.entries(r).forEach(function(c){var n=c[0],a=c[1];o[n]=matcherToArray(a)}),o}var InternalModifier;(function(r){r.Outside="outside",r.Disabled="disabled",r.Selected="selected",r.Hidden="hidden",r.Today="today",r.RangeStart="range_start",r.RangeEnd="range_end",r.RangeMiddle="range_middle"})(InternalModifier||(InternalModifier={}));var Selected=InternalModifier.Selected,Disabled=InternalModifier.Disabled,Hidden=InternalModifier.Hidden,Today=InternalModifier.Today,RangeEnd=InternalModifier.RangeEnd,RangeMiddle=InternalModifier.RangeMiddle,RangeStart=InternalModifier.RangeStart,Outside=InternalModifier.Outside;function getInternalModifiers(r,o,c){var n,a=(n={},n[Selected]=matcherToArray(r.selected),n[Disabled]=matcherToArray(r.disabled),n[Hidden]=matcherToArray(r.hidden),n[Today]=[r.today],n[RangeEnd]=[],n[RangeMiddle]=[],n[RangeStart]=[],n[Outside]=[],n);return r.fromDate&&a[Disabled].push({before:r.fromDate}),r.toDate&&a[Disabled].push({after:r.toDate}),isDayPickerMultiple(r)?a[Disabled]=a[Disabled].concat(o.modifiers[Disabled]):isDayPickerRange(r)&&(a[Disabled]=a[Disabled].concat(c.modifiers[Disabled]),a[RangeStart]=c.modifiers[RangeStart],a[RangeMiddle]=c.modifiers[RangeMiddle],a[RangeEnd]=c.modifiers[RangeEnd]),a}var ModifiersContext=reactExports.createContext(void 0);function ModifiersProvider(r){var o=useDayPicker(),c=useSelectMultiple(),n=useSelectRange(),a=getInternalModifiers(o,c,n),s=getCustomModifiers(o.modifiers),d=__assign(__assign({},a),s);return jsxRuntimeExports.jsx(ModifiersContext.Provider,{value:d,children:r.children})}function useModifiers(){var r=reactExports.useContext(ModifiersContext);if(!r)throw new Error("useModifiers must be used within a ModifiersProvider");return r}function isDateInterval(r){return!!(r&&typeof r=="object"&&"before"in r&&"after"in r)}function isDateRange(r){return!!(r&&typeof r=="object"&&"from"in r)}function isDateAfterType(r){return!!(r&&typeof r=="object"&&"after"in r)}function isDateBeforeType(r){return!!(r&&typeof r=="object"&&"before"in r)}function isDayOfWeekType(r){return!!(r&&typeof r=="object"&&"dayOfWeek"in r)}function isDateInRange(r,o){var c,n=o.from,a=o.to;if(n&&a){var s=differenceInCalendarDays(a,n)<0;s&&(c=[a,n],n=c[0],a=c[1]);var d=differenceInCalendarDays(r,n)>=0&&differenceInCalendarDays(a,r)>=0;return d}return a?isSameDay(a,r):n?isSameDay(n,r):!1}function isDateType(r){return isDate(r)}function isArrayOfDates(r){return Array.isArray(r)&&r.every(isDate)}function isMatch(r,o){return o?.some(function(c){if(typeof c=="boolean")return c;if(isDateType(c))return isSameDay(r,c);if(isArrayOfDates(c))return c.includes(r);if(isDateRange(c))return isDateInRange(r,c);if(isDayOfWeekType(c))return c.dayOfWeek.includes(r.getDay());if(isDateInterval(c)){var n=differenceInCalendarDays(c.before,r),a=differenceInCalendarDays(c.after,r),s=n>0,d=a<0,p=isAfter(c.before,c.after);return p?d&&s:s||d}return isDateAfterType(c)?differenceInCalendarDays(r,c.after)>0:isDateBeforeType(c)?differenceInCalendarDays(c.before,r)>0:typeof c=="function"?c(r):!1})}function getActiveModifiers(r,o,c){var n=Object.keys(o).reduce(function(s,d){var p=o[d];return isMatch(r,p)&&s.push(d),s},[]),a={};return n.forEach(function(s){return a[s]=!0}),c&&!isSameMonth(r,c)&&(a.outside=!0),a}function getInitialFocusTarget(r,o){for(var c=startOfMonth(r[0]),n=endOfMonth(r[r.length-1]),a,s,d=c;d<=n;){var p=getActiveModifiers(d,o),m=!p.disabled&&!p.hidden;if(!m){d=addDays(d,1);continue}if(p.selected)return d;p.today&&!s&&(s=d),a||(a=d),d=addDays(d,1)}return s||a}var MAX_RETRY=365;function getNextFocus(r,o){var c=o.moveBy,n=o.direction,a=o.context,s=o.modifiers,d=o.retry,p=d===void 0?{count:0,lastFocused:r}:d,m=a.weekStartsOn,g=a.fromDate,j=a.toDate,k=a.locale,_={day:addDays,week:addWeeks,month:addMonths,year:addYears,startOfWeek:function(P){return a.ISOWeek?startOfISOWeek(P):startOfWeek(P,{locale:k,weekStartsOn:m})},endOfWeek:function(P){return a.ISOWeek?endOfISOWeek(P):endOfWeek(P,{locale:k,weekStartsOn:m})}},T=_[c](r,n==="after"?1:-1);n==="before"&&g?T=max([g,T]):n==="after"&&j&&(T=min([j,T]));var E=!0;if(s){var M=getActiveModifiers(T,s);E=!M.disabled&&!M.hidden}return E?T:p.count>MAX_RETRY?p.lastFocused:getNextFocus(T,{moveBy:c,direction:n,context:a,modifiers:s,retry:__assign(__assign({},p),{count:p.count+1})})}var FocusContext=reactExports.createContext(void 0);function FocusProvider(r){var o=useNavigation(),c=useModifiers(),n=reactExports.useState(),a=n[0],s=n[1],d=reactExports.useState(),p=d[0],m=d[1],g=getInitialFocusTarget(o.displayMonths,c),j=a??(p&&o.isDateDisplayed(p))?p:g,k=function(){m(a),s(void 0)},_=function(P){s(P)},T=useDayPicker(),E=function(P,A){if(a){var H=getNextFocus(a,{moveBy:P,direction:A,context:T,modifiers:c});isSameDay(a,H)||(o.goToDate(H,a),_(H))}},M={focusedDay:a,focusTarget:j,blur:k,focus:_,focusDayAfter:function(){return E("day","after")},focusDayBefore:function(){return E("day","before")},focusWeekAfter:function(){return E("week","after")},focusWeekBefore:function(){return E("week","before")},focusMonthBefore:function(){return E("month","before")},focusMonthAfter:function(){return E("month","after")},focusYearBefore:function(){return E("year","before")},focusYearAfter:function(){return E("year","after")},focusStartOfWeek:function(){return E("startOfWeek","before")},focusEndOfWeek:function(){return E("endOfWeek","after")}};return jsxRuntimeExports.jsx(FocusContext.Provider,{value:M,children:r.children})}function useFocusContext(){var r=reactExports.useContext(FocusContext);if(!r)throw new Error("useFocusContext must be used within a FocusProvider");return r}function useActiveModifiers(r,o){var c=useModifiers(),n=getActiveModifiers(r,c,o);return n}var SelectSingleContext=reactExports.createContext(void 0);function SelectSingleProvider(r){if(!isDayPickerSingle(r.initialProps)){var o={selected:void 0};return jsxRuntimeExports.jsx(SelectSingleContext.Provider,{value:o,children:r.children})}return jsxRuntimeExports.jsx(SelectSingleProviderInternal,{initialProps:r.initialProps,children:r.children})}function SelectSingleProviderInternal(r){var o=r.initialProps,c=r.children,n=function(s,d,p){var m,g,j;if((m=o.onDayClick)===null||m===void 0||m.call(o,s,d,p),d.selected&&!o.required){(g=o.onSelect)===null||g===void 0||g.call(o,void 0,s,d,p);return}(j=o.onSelect)===null||j===void 0||j.call(o,s,s,d,p)},a={selected:o.selected,onDayClick:n};return jsxRuntimeExports.jsx(SelectSingleContext.Provider,{value:a,children:c})}function useSelectSingle(){var r=reactExports.useContext(SelectSingleContext);if(!r)throw new Error("useSelectSingle must be used within a SelectSingleProvider");return r}function useDayEventHandlers(r,o){var c=useDayPicker(),n=useSelectSingle(),a=useSelectMultiple(),s=useSelectRange(),d=useFocusContext(),p=d.focusDayAfter,m=d.focusDayBefore,g=d.focusWeekAfter,j=d.focusWeekBefore,k=d.blur,_=d.focus,T=d.focusMonthBefore,E=d.focusMonthAfter,M=d.focusYearBefore,P=d.focusYearAfter,A=d.focusStartOfWeek,H=d.focusEndOfWeek,Y=function(J){var K,Te,rt,nt;isDayPickerSingle(c)?(K=n.onDayClick)===null||K===void 0||K.call(n,r,o,J):isDayPickerMultiple(c)?(Te=a.onDayClick)===null||Te===void 0||Te.call(a,r,o,J):isDayPickerRange(c)?(rt=s.onDayClick)===null||rt===void 0||rt.call(s,r,o,J):(nt=c.onDayClick)===null||nt===void 0||nt.call(c,r,o,J)},se=function(J){var K;_(r),(K=c.onDayFocus)===null||K===void 0||K.call(c,r,o,J)},z=function(J){var K;k(),(K=c.onDayBlur)===null||K===void 0||K.call(c,r,o,J)},te=function(J){var K;(K=c.onDayMouseEnter)===null||K===void 0||K.call(c,r,o,J)},u=function(J){var K;(K=c.onDayMouseLeave)===null||K===void 0||K.call(c,r,o,J)},fe=function(J){var K;(K=c.onDayPointerEnter)===null||K===void 0||K.call(c,r,o,J)},ae=function(J){var K;(K=c.onDayPointerLeave)===null||K===void 0||K.call(c,r,o,J)},Ee=function(J){var K;(K=c.onDayTouchCancel)===null||K===void 0||K.call(c,r,o,J)},De=function(J){var K;(K=c.onDayTouchEnd)===null||K===void 0||K.call(c,r,o,J)},Me=function(J){var K;(K=c.onDayTouchMove)===null||K===void 0||K.call(c,r,o,J)},re=function(J){var K;(K=c.onDayTouchStart)===null||K===void 0||K.call(c,r,o,J)},U=function(J){var K;(K=c.onDayKeyUp)===null||K===void 0||K.call(c,r,o,J)},ye=function(J){var K;switch(J.key){case"ArrowLeft":J.preventDefault(),J.stopPropagation(),c.dir==="rtl"?p():m();break;case"ArrowRight":J.preventDefault(),J.stopPropagation(),c.dir==="rtl"?m():p();break;case"ArrowDown":J.preventDefault(),J.stopPropagation(),g();break;case"ArrowUp":J.preventDefault(),J.stopPropagation(),j();break;case"PageUp":J.preventDefault(),J.stopPropagation(),J.shiftKey?M():T();break;case"PageDown":J.preventDefault(),J.stopPropagation(),J.shiftKey?P():E();break;case"Home":J.preventDefault(),J.stopPropagation(),A();break;case"End":J.preventDefault(),J.stopPropagation(),H();break}(K=c.onDayKeyDown)===null||K===void 0||K.call(c,r,o,J)},ke={onClick:Y,onFocus:se,onBlur:z,onKeyDown:ye,onKeyUp:U,onMouseEnter:te,onMouseLeave:u,onPointerEnter:fe,onPointerLeave:ae,onTouchCancel:Ee,onTouchEnd:De,onTouchMove:Me,onTouchStart:re};return ke}function useSelectedDays(){var r=useDayPicker(),o=useSelectSingle(),c=useSelectMultiple(),n=useSelectRange(),a=isDayPickerSingle(r)?o.selected:isDayPickerMultiple(r)?c.selected:isDayPickerRange(r)?n.selected:void 0;return a}function isInternalModifier(r){return Object.values(InternalModifier).includes(r)}function getDayClassNames(r,o){var c=[r.classNames.day];return Object.keys(o).forEach(function(n){var a=r.modifiersClassNames[n];if(a)c.push(a);else if(isInternalModifier(n)){var s=r.classNames["day_".concat(n)];s&&c.push(s)}}),c}function getDayStyle(r,o){var c=__assign({},r.styles.day);return Object.keys(o).forEach(function(n){var a;c=__assign(__assign({},c),(a=r.modifiersStyles)===null||a===void 0?void 0:a[n])}),c}function useDayRender(r,o,c){var n,a,s,d=useDayPicker(),p=useFocusContext(),m=useActiveModifiers(r,o),g=useDayEventHandlers(r,m),j=useSelectedDays(),k=!!(d.onDayClick||d.mode!=="default");reactExports.useEffect(function(){var te;m.outside||p.focusedDay&&k&&isSameDay(p.focusedDay,r)&&((te=c.current)===null||te===void 0||te.focus())},[p.focusedDay,r,c,k,m.outside]);var _=getDayClassNames(d,m).join(" "),T=getDayStyle(d,m),E=!!(m.outside&&!d.showOutsideDays||m.hidden),M=(s=(a=d.components)===null||a===void 0?void 0:a.DayContent)!==null&&s!==void 0?s:DayContent,P=jsxRuntimeExports.jsx(M,{date:r,displayMonth:o,activeModifiers:m}),A={style:T,className:_,children:P,role:"gridcell"},H=p.focusTarget&&isSameDay(p.focusTarget,r)&&!m.outside,Y=p.focusedDay&&isSameDay(p.focusedDay,r),se=__assign(__assign(__assign({},A),(n={disabled:m.disabled,role:"gridcell"},n["aria-selected"]=m.selected,n.tabIndex=Y||H?0:-1,n)),g),z={isButton:k,isHidden:E,activeModifiers:m,selectedDays:j,buttonProps:se,divProps:A};return z}function Day(r){var o=reactExports.useRef(null),c=useDayRender(r.date,r.displayMonth,o);return c.isHidden?jsxRuntimeExports.jsx("div",{role:"gridcell"}):c.isButton?jsxRuntimeExports.jsx(Button,__assign({name:"day",ref:o},c.buttonProps)):jsxRuntimeExports.jsx("div",__assign({},c.divProps))}function WeekNumber(r){var o=r.number,c=r.dates,n=useDayPicker(),a=n.onWeekNumberClick,s=n.styles,d=n.classNames,p=n.locale,m=n.labels.labelWeekNumber,g=n.formatters.formatWeekNumber,j=g(Number(o),{locale:p});if(!a)return jsxRuntimeExports.jsx("span",{className:d.weeknumber,style:s.weeknumber,children:j});var k=m(Number(o),{locale:p}),_=function(T){a(o,c,T)};return jsxRuntimeExports.jsx(Button,{name:"week-number","aria-label":k,className:d.weeknumber,style:s.weeknumber,onClick:_,children:j})}function Row(r){var o,c,n=useDayPicker(),a=n.styles,s=n.classNames,d=n.showWeekNumber,p=n.components,m=(o=p==null?void 0:p.Day)!==null&&o!==void 0?o:Day,g=(c=p==null?void 0:p.WeekNumber)!==null&&c!==void 0?c:WeekNumber,j;return d&&(j=jsxRuntimeExports.jsx("td",{className:s.cell,style:a.cell,children:jsxRuntimeExports.jsx(g,{number:r.weekNumber,dates:r.dates})})),jsxRuntimeExports.jsxs("tr",{className:s.row,style:a.row,children:[j,r.dates?.map(function(k){return jsxRuntimeExports.jsx("td",{className:s.cell,style:a.cell,role:"presentation",children:jsxRuntimeExports.jsx(m,{displayMonth:r.displayMonth,date:k})},getUnixTime(k))})]})}function daysToMonthWeeks(r,o,c){for(var n=c!=null&&c.ISOWeek?endOfISOWeek(o):endOfWeek(o,c),a=c!=null&&c.ISOWeek?startOfISOWeek(r):startOfWeek(r,c),s=differenceInCalendarDays(n,a),d=[],p=0;p<=s;p++)d.push(addDays(a,p));var m=d.reduce(function(g,j){var k=c!=null&&c.ISOWeek?getISOWeek(j):getWeek(j,c),_=g.find(function(T){return T.weekNumber===k});return _?(_.dates.push(j),g):(g.push({weekNumber:k,dates:[j]}),g)},[]);return m}function getMonthWeeks(r,o){var c=daysToMonthWeeks(startOfMonth(r),endOfMonth(r),o);if(o!=null&&o.useFixedWeeks){var n=getWeeksInMonth(r,o);if(n<6){var a=c[c.length-1],s=a.dates[a.dates.length-1],d=addWeeks(s,6-n),p=daysToMonthWeeks(addWeeks(s,1),d,o);c.push.apply(c,p)}}return c}function Table(r){var o,c,n,a=useDayPicker(),s=a.locale,d=a.classNames,p=a.styles,m=a.hideHead,g=a.fixedWeeks,j=a.components,k=a.weekStartsOn,_=a.firstWeekContainsDate,T=a.ISOWeek,E=getMonthWeeks(r.displayMonth,{useFixedWeeks:!!g,ISOWeek:T,locale:s,weekStartsOn:k,firstWeekContainsDate:_}),M=(o=j==null?void 0:j.Head)!==null&&o!==void 0?o:Head,P=(c=j==null?void 0:j.Row)!==null&&c!==void 0?c:Row,A=(n=j==null?void 0:j.Footer)!==null&&n!==void 0?n:Footer;return jsxRuntimeExports.jsxs("table",{id:r.id,className:d.table,style:p.table,role:"grid","aria-labelledby":r["aria-labelledby"],children:[!m&&jsxRuntimeExports.jsx(M,{}),jsxRuntimeExports.jsx("tbody",{className:d.tbody,style:p.tbody,children:E?.map(function(H){return jsxRuntimeExports.jsx(P,{displayMonth:r.displayMonth,dates:H.dates,weekNumber:H.weekNumber},H.weekNumber)})}),jsxRuntimeExports.jsx(A,{displayMonth:r.displayMonth})]})}function canUseDOM(){return!!(typeof window<"u"&&window.document&&window.document.createElement)}var useIsomorphicLayoutEffect=canUseDOM()?reactExports.useLayoutEffect:reactExports.useEffect,serverHandoffComplete=!1,id=0;function genId(){return"react-day-picker-".concat(++id)}function useId(r){var o,c=r??(serverHandoffComplete?genId():null),n=reactExports.useState(c),a=n[0],s=n[1];return useIsomorphicLayoutEffect(function(){a===null&&s(genId())},[]),reactExports.useEffect(function(){serverHandoffComplete===!1&&(serverHandoffComplete=!0)},[]),(o=r??a)!==null&&o!==void 0?o:void 0}function Month(r){var o,c,n=useDayPicker(),a=n.dir,s=n.classNames,d=n.styles,p=n.components,m=useNavigation().displayMonths,g=useId(n.id?"".concat(n.id,"-").concat(r.displayIndex):void 0),j=n.id?"".concat(n.id,"-grid-").concat(r.displayIndex):void 0,k=[s.month],_=d.month,T=r.displayIndex===0,E=r.displayIndex===m.length-1,M=!T&&!E;a==="rtl"&&(o=[T,E],E=o[0],T=o[1]),T&&(k.push(s.caption_start),_=__assign(__assign({},_),d.caption_start)),E&&(k.push(s.caption_end),_=__assign(__assign({},_),d.caption_end)),M&&(k.push(s.caption_between),_=__assign(__assign({},_),d.caption_between));var P=(c=p==null?void 0:p.Caption)!==null&&c!==void 0?c:Caption;return jsxRuntimeExports.jsxs("div",{className:k.join(" "),style:_,children:[jsxRuntimeExports.jsx(P,{id:g,displayMonth:r.displayMonth,displayIndex:r.displayIndex}),jsxRuntimeExports.jsx(Table,{id:j,"aria-labelledby":g,displayMonth:r.displayMonth})]},r.displayIndex)}function Months(r){var o=useDayPicker(),c=o.classNames,n=o.styles;return jsxRuntimeExports.jsx("div",{className:c.months,style:n.months,children:r.children})}function Root(r){var o,c,n=r.initialProps,a=useDayPicker(),s=useFocusContext(),d=useNavigation(),p=reactExports.useState(!1),m=p[0],g=p[1];reactExports.useEffect(function(){a.initialFocus&&s.focusTarget&&(m||(s.focus(s.focusTarget),g(!0)))},[a.initialFocus,m,s.focus,s.focusTarget,s]);var j=[a.classNames.root,a.className];a.numberOfMonths>1&&j.push(a.classNames.multiple_months),a.showWeekNumber&&j.push(a.classNames.with_weeknumber);var k=__assign(__assign({},a.styles.root),a.style),_=Object.keys(n).filter(function(E){return E.startsWith("data-")}).reduce(function(E,M){var P;return __assign(__assign({},E),(P={},P[M]=n[M],P))},{}),T=(c=(o=n.components)===null||o===void 0?void 0:o.Months)!==null&&c!==void 0?c:Months;return jsxRuntimeExports.jsx("div",__assign({className:j.join(" "),style:k,dir:a.dir,id:a.id,nonce:n.nonce,title:n.title,lang:n.lang},_,{children:jsxRuntimeExports.jsx(T,{children:d.displayMonths?.map(function(E,M){return jsxRuntimeExports.jsx(Month,{displayIndex:M,displayMonth:E},M)})})}))}function RootProvider(r){var o=r.children,c=__rest(r,["children"]);return jsxRuntimeExports.jsx(DayPickerProvider,{initialProps:c,children:jsxRuntimeExports.jsx(NavigationProvider,{children:jsxRuntimeExports.jsx(SelectSingleProvider,{initialProps:c,children:jsxRuntimeExports.jsx(SelectMultipleProvider,{initialProps:c,children:jsxRuntimeExports.jsx(SelectRangeProvider,{initialProps:c,children:jsxRuntimeExports.jsx(ModifiersProvider,{children:jsxRuntimeExports.jsx(FocusProvider,{children:o})})})})})})})}function DayPicker(r){return jsxRuntimeExports.jsx(RootProvider,__assign({},r,{children:jsxRuntimeExports.jsx(Root,{initialProps:r})}))}function Calendar({className:r,classNames:o,showOutsideDays:c=!0,...n}){return jsxRuntimeExports.jsx(DayPicker,{showOutsideDays:c,...n,className:cn("rdp w-full rounded-xl border border-slate-200 bg-white p-3 shadow-[0_10px_30px_-20px_rgba(15,23,42,0.5)] dark:border-slate-700 dark:bg-slate-900",r),classNames:{months:"rdp-months flex flex-col gap-4 sm:flex-row sm:gap-6",month:"rdp-month space-y-4",caption:"rdp-caption relative flex items-center justify-center pt-1",caption_label:"rdp-caption_label text-sm font-semibold tracking-wide text-slate-800 dark:text-slate-100",nav:"rdp-nav flex items-center gap-1",nav_button:cn("rdp-nav_button",buttonVariants({variant:"ghost",size:"icon"}),"h-8 w-8 rounded-lg text-slate-600 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-300 dark:hover:bg-slate-800 dark:hover:text-slate-100"),nav_button_previous:"rdp-nav_button_previous absolute left-0",nav_button_next:"rdp-nav_button_next absolute right-0",table:"rdp-table w-full border-collapse space-y-1",head_row:"rdp-head_row flex w-full justify-between",head_cell:"rdp-head_cell text-slate-500 rounded-md w-9 font-normal text-[0.8rem] dark:text-slate-400 uppercase tracking-wide text-center pt-1 mb-2 whitespace-nowrap",row:"rdp-row flex w-full justify-between mt-1",cell:cn("rdp-cell relative h-9 w-9 p-0 text-center text-sm focus-within:relative focus-within:z-10",n.mode==="range"?"[&:has([aria-selected].day-range-end)]:rounded-r-lg [&:has([aria-selected].day-range-start)]:rounded-l-lg [&:has([aria-selected].day-range-middle)]:bg-primary-50 dark:[&:has([aria-selected].day-range-middle)]:bg-primary-900/30":""),day:cn("rdp-day",buttonVariants({variant:"ghost"}),"h-9 w-9 rounded-lg p-0 font-medium text-slate-700 hover:bg-slate-100 hover:text-slate-900 focus-visible:ring-2 focus-visible:ring-primary-500/30 aria-selected:opacity-100 dark:text-slate-200 dark:hover:bg-slate-800 dark:hover:text-white"),day_selected:"rdp-day_selected bg-primary-600 text-white hover:bg-primary-600 hover:text-white focus:bg-primary-600 focus:text-white dark:bg-primary-500 dark:hover:bg-primary-500",day_today:"rdp-day_today border border-primary-300 bg-primary-50 text-primary-700 dark:border-primary-500/50 dark:bg-primary-900/30 dark:text-primary-300",day_outside:"rdp-day_outside text-slate-400 opacity-60 aria-selected:bg-primary-100 aria-selected:text-primary-700 dark:text-slate-600 dark:aria-selected:bg-primary-900/40 dark:aria-selected:text-primary-300",day_disabled:"rdp-day_disabled text-slate-300 opacity-50 dark:text-slate-600",day_range_middle:"rdp-day_range_middle aria-selected:bg-primary-100 aria-selected:text-primary-800 dark:aria-selected:bg-primary-900/40 dark:aria-selected:text-primary-200",day_range_start:"rdp-day_range_start day-range-start aria-selected:bg-primary-600 aria-selected:text-white",day_range_end:"rdp-day_range_end day-range-end aria-selected:bg-primary-600 aria-selected:text-white",day_hidden:"rdp-day_hidden invisible",...o}})}var jquery$1={exports:{}};/*! + * jQuery JavaScript Library v3.7.1 + * https://jquery.com/ + * + * Copyright OpenJS Foundation and other contributors + * Released under the MIT license + * https://jquery.org/license + * + * Date: 2023-08-28T13:37Z + */var jquery=jquery$1.exports,hasRequiredJquery;function requireJquery(){return hasRequiredJquery||(hasRequiredJquery=1,(function(r){(function(o,c){r.exports=o.document?c(o,!0):function(n){if(!n.document)throw new Error("jQuery requires a window with a document");return c(n)}})(typeof window<"u"?window:jquery,function(o,c){var n=[],a=Object.getPrototypeOf,s=n.slice,d=n.flat?function(e){return n.flat.call(e)}:function(e){return n.concat.apply([],e)},p=n.push,m=n.indexOf,g={},j=g.toString,k=g.hasOwnProperty,_=k.toString,T=_.call(Object),E={},M=function(t){return typeof t=="function"&&typeof t.nodeType!="number"&&typeof t.item!="function"},P=function(t){return t!=null&&t===t.window},A=o.document,H={type:!0,src:!0,nonce:!0,noModule:!0};function Y(e,t,i){i=i||A;var l,f,h=i.createElement("script");if(h.text=e,t)for(l in H)f=t[l]||t.getAttribute&&t.getAttribute(l),f&&h.setAttribute(l,f);i.head.appendChild(h).parentNode.removeChild(h)}function se(e){return e==null?e+"":typeof e=="object"||typeof e=="function"?g[j.call(e)]||"object":typeof e}var z="3.7.1",te=/HTML$/i,u=function(e,t){return new u.fn.init(e,t)};u.fn=u.prototype={jquery:z,constructor:u,length:0,toArray:function(){return s.call(this)},get:function(e){return e==null?s.call(this):e<0?this[e+this.length]:this[e]},pushStack:function(e){var t=u.merge(this.constructor(),e);return t.prevObject=this,t},each:function(e){return u.each(this,e)},map:function(e){return this.pushStack(u?.map(this,function(t,i){return e.call(t,i,t)}))},slice:function(){return this.pushStack(s.apply(this,arguments))},first:function(){return this.eq(0)},last:function(){return this.eq(-1)},even:function(){return this.pushStack(u.grep(this,function(e,t){return(t+1)%2}))},odd:function(){return this.pushStack(u.grep(this,function(e,t){return t%2}))},eq:function(e){var t=this.length,i=+e+(e<0?t:0);return this.pushStack(i>=0&&i0&&t-1 in e}function ae(e,t){return e.nodeName&&e.nodeName.toLowerCase()===t.toLowerCase()}var Ee=n.pop,De=n.sort,Me=n.splice,re="[\\x20\\t\\r\\n\\f]",U=new RegExp("^"+re+"+|((?:^|[^\\\\])(?:\\\\.)*)"+re+"+$","g");u.contains=function(e,t){var i=t&&t.parentNode;return e===i||!!(i&&i.nodeType===1&&(e.contains?e.contains(i):e.compareDocumentPosition&&e.compareDocumentPosition(i)&16))};var ye=/([\0-\x1f\x7f]|^-?\d)|^-$|[^\x80-\uFFFF\w-]/g;function ke(e,t){return t?e==="\0"?"�":e.slice(0,-1)+"\\"+e.charCodeAt(e.length-1).toString(16)+" ":"\\"+e}u.escapeSelector=function(e){return(e+"").replace(ye,ke)};var J=A,K=p;(function(){var e,t,i,l,f,h=K,x,b,v,w,R,I=u.expando,C=0,F=0,ne=qt(),de=qt(),oe=qt(),Se=qt(),Ce=function(y,D){return y===D&&(f=!0),0},Ke="checked|selected|async|autofocus|autoplay|controls|defer|disabled|hidden|ismap|loop|multiple|open|readonly|required|scoped",Qe="(?:\\\\[\\da-fA-F]{1,6}"+re+"?|\\\\[^\\r\\n\\f]|[\\w-]|[^\0-\\x7f])+",ce="\\["+re+"*("+Qe+")(?:"+re+"*([*^$|!~]?=)"+re+`*(?:'((?:\\\\.|[^\\\\'])*)'|"((?:\\\\.|[^\\\\"])*)"|(`+Qe+"))|)"+re+"*\\]",ft=":("+Qe+`)(?:\\((('((?:\\\\.|[^\\\\'])*)'|"((?:\\\\.|[^\\\\"])*)")|((?:\\\\.|[^\\\\()[\\]]|`+ce+")*)|.*)\\)|)",he=new RegExp(re+"+","g"),we=new RegExp("^"+re+"*,"+re+"*"),Tt=new RegExp("^"+re+"*([>+~]|"+re+")"+re+"*"),lr=new RegExp(re+"|>"),Ge=new RegExp(ft),Ot=new RegExp("^"+Qe+"$"),Xe={ID:new RegExp("^#("+Qe+")"),CLASS:new RegExp("^\\.("+Qe+")"),TAG:new RegExp("^("+Qe+"|[*])"),ATTR:new RegExp("^"+ce),PSEUDO:new RegExp("^"+ft),CHILD:new RegExp("^:(only|first|last|nth|nth-last)-(child|of-type)(?:\\("+re+"*(even|odd|(([+-]|)(\\d*)n|)"+re+"*(?:([+-]|)"+re+"*(\\d+)|))"+re+"*\\)|)","i"),bool:new RegExp("^(?:"+Ke+")$","i"),needsContext:new RegExp("^"+re+"*[>+~]|:(even|odd|eq|gt|lt|nth|first|last)(?:\\("+re+"*((?:-\\d)?\\d*)"+re+"*\\)|)(?=[^-]|$)","i")},it=/^(?:input|select|textarea|button)$/i,st=/^h\d$/i,He=/^(?:#([\w-]+)|(\w+)|\.([\w-]+))$/,ur=/[+~]/,et=new RegExp("\\\\[\\da-fA-F]{1,6}"+re+"?|\\\\([^\\r\\n\\f])","g"),tt=function(y,D){var N="0x"+y.slice(1)-65536;return D||(N<0?String.fromCharCode(N+65536):String.fromCharCode(N>>10|55296,N&1023|56320))},In=function(){ot()},An=Ut(function(y){return y.disabled===!0&&ae(y,"fieldset")},{dir:"parentNode",next:"legend"});function Pn(){try{return x.activeElement}catch{}}try{h.apply(n=s.call(J.childNodes),J.childNodes),n[J.childNodes.length].nodeType}catch{h={apply:function(D,N){K.apply(D,s.call(N))},call:function(D){K.apply(D,s.call(arguments,1))}}}function ge(y,D,N,S){var L,W,B,Q,q,le,Z,ie=D&&D.ownerDocument,ue=D?D.nodeType:9;if(N=N||[],typeof y!="string"||!y||ue!==1&&ue!==9&&ue!==11)return N;if(!S&&(ot(D),D=D||x,v)){if(ue!==11&&(q=He.exec(y)))if(L=q[1]){if(ue===9)if(B=D.getElementById(L)){if(B.id===L)return h.call(N,B),N}else return N;else if(ie&&(B=ie.getElementById(L))&&ge.contains(D,B)&&B.id===L)return h.call(N,B),N}else{if(q[2])return h.apply(N,D.getElementsByTagName(y)),N;if((L=q[3])&&D.getElementsByClassName)return h.apply(N,D.getElementsByClassName(L)),N}if(!Se[y+" "]&&(!w||!w.test(y))){if(Z=y,ie=D,ue===1&&(lr.test(y)||Tt.test(y))){for(ie=ur.test(y)&&cr(D.parentNode)||D,(ie!=D||!E.scope)&&((Q=D.getAttribute("id"))?Q=u.escapeSelector(Q):D.setAttribute("id",Q=I)),le=Lt(y),W=le.length;W--;)le[W]=(Q?"#"+Q:":scope")+" "+Jt(le[W]);Z=le.join(",")}try{return h.apply(N,ie.querySelectorAll(Z)),N}catch{Se(y,!0)}finally{Q===I&&D.removeAttribute("id")}}}return Kr(y.replace(U,"$1"),D,N,S)}function qt(){var y=[];function D(N,S){return y.push(N+" ")>t.cacheLength&&delete D[y.shift()],D[N+" "]=S}return D}function Je(y){return y[I]=!0,y}function bt(y){var D=x.createElement("fieldset");try{return!!y(D)}catch{return!1}finally{D.parentNode&&D.parentNode.removeChild(D),D=null}}function Fn(y){return function(D){return ae(D,"input")&&D.type===y}}function Wn(y){return function(D){return(ae(D,"input")||ae(D,"button"))&&D.type===y}}function zr(y){return function(D){return"form"in D?D.parentNode&&D.disabled===!1?"label"in D?"label"in D.parentNode?D.parentNode.disabled===y:D.disabled===y:D.isDisabled===y||D.isDisabled!==!y&&An(D)===y:D.disabled===y:"label"in D?D.disabled===y:!1}}function ht(y){return Je(function(D){return D=+D,Je(function(N,S){for(var L,W=y([],N.length,D),B=W.length;B--;)N[L=W[B]]&&(N[L]=!(S[L]=N[L]))})})}function cr(y){return y&&typeof y.getElementsByTagName<"u"&&y}function ot(y){var D,N=y?y.ownerDocument||y:J;return N==x||N.nodeType!==9||!N.documentElement||(x=N,b=x.documentElement,v=!u.isXMLDoc(x),R=b.matches||b.webkitMatchesSelector||b.msMatchesSelector,b.msMatchesSelector&&J!=x&&(D=x.defaultView)&&D.top!==D&&D.addEventListener("unload",In),E.getById=bt(function(S){return b.appendChild(S).id=u.expando,!x.getElementsByName||!x.getElementsByName(u.expando).length}),E.disconnectedMatch=bt(function(S){return R.call(S,"*")}),E.scope=bt(function(){return x.querySelectorAll(":scope")}),E.cssHas=bt(function(){try{return x.querySelector(":has(*,:jqfake)"),!1}catch{return!0}}),E.getById?(t.filter.ID=function(S){var L=S.replace(et,tt);return function(W){return W.getAttribute("id")===L}},t.find.ID=function(S,L){if(typeof L.getElementById<"u"&&v){var W=L.getElementById(S);return W?[W]:[]}}):(t.filter.ID=function(S){var L=S.replace(et,tt);return function(W){var B=typeof W.getAttributeNode<"u"&&W.getAttributeNode("id");return B&&B.value===L}},t.find.ID=function(S,L){if(typeof L.getElementById<"u"&&v){var W,B,Q,q=L.getElementById(S);if(q){if(W=q.getAttributeNode("id"),W&&W.value===S)return[q];for(Q=L.getElementsByName(S),B=0;q=Q[B++];)if(W=q.getAttributeNode("id"),W&&W.value===S)return[q]}return[]}}),t.find.TAG=function(S,L){return typeof L.getElementsByTagName<"u"?L.getElementsByTagName(S):L.querySelectorAll(S)},t.find.CLASS=function(S,L){if(typeof L.getElementsByClassName<"u"&&v)return L.getElementsByClassName(S)},w=[],bt(function(S){var L;b.appendChild(S).innerHTML="",S.querySelectorAll("[selected]").length||w.push("\\["+re+"*(?:value|"+Ke+")"),S.querySelectorAll("[id~="+I+"-]").length||w.push("~="),S.querySelectorAll("a#"+I+"+*").length||w.push(".#.+[+~]"),S.querySelectorAll(":checked").length||w.push(":checked"),L=x.createElement("input"),L.setAttribute("type","hidden"),S.appendChild(L).setAttribute("name","D"),b.appendChild(S).disabled=!0,S.querySelectorAll(":disabled").length!==2&&w.push(":enabled",":disabled"),L=x.createElement("input"),L.setAttribute("name",""),S.appendChild(L),S.querySelectorAll("[name='']").length||w.push("\\["+re+"*name"+re+"*="+re+`*(?:''|"")`)}),E.cssHas||w.push(":has"),w=w.length&&new RegExp(w.join("|")),Ce=function(S,L){if(S===L)return f=!0,0;var W=!S.compareDocumentPosition-!L.compareDocumentPosition;return W||(W=(S.ownerDocument||S)==(L.ownerDocument||L)?S.compareDocumentPosition(L):1,W&1||!E.sortDetached&&L.compareDocumentPosition(S)===W?S===x||S.ownerDocument==J&&ge.contains(J,S)?-1:L===x||L.ownerDocument==J&&ge.contains(J,L)?1:l?m.call(l,S)-m.call(l,L):0:W&4?-1:1)}),x}ge.matches=function(y,D){return ge(y,null,null,D)},ge.matchesSelector=function(y,D){if(ot(y),v&&!Se[D+" "]&&(!w||!w.test(D)))try{var N=R.call(y,D);if(N||E.disconnectedMatch||y.document&&y.document.nodeType!==11)return N}catch{Se(D,!0)}return ge(D,x,null,[y]).length>0},ge.contains=function(y,D){return(y.ownerDocument||y)!=x&&ot(y),u.contains(y,D)},ge.attr=function(y,D){(y.ownerDocument||y)!=x&&ot(y);var N=t.attrHandle[D.toLowerCase()],S=N&&k.call(t.attrHandle,D.toLowerCase())?N(y,D,!v):void 0;return S!==void 0?S:y.getAttribute(D)},ge.error=function(y){throw new Error("Syntax error, unrecognized expression: "+y)},u.uniqueSort=function(y){var D,N=[],S=0,L=0;if(f=!E.sortStable,l=!E.sortStable&&s.call(y,0),De.call(y,Ce),f){for(;D=y[L++];)D===y[L]&&(S=N.push(L));for(;S--;)Me.call(y,N[S],1)}return l=null,y},u.fn.uniqueSort=function(){return this.pushStack(u.uniqueSort(s.apply(this)))},t=u.expr={cacheLength:50,createPseudo:Je,match:Xe,attrHandle:{},find:{},relative:{">":{dir:"parentNode",first:!0}," ":{dir:"parentNode"},"+":{dir:"previousSibling",first:!0},"~":{dir:"previousSibling"}},preFilter:{ATTR:function(y){return y[1]=y[1].replace(et,tt),y[3]=(y[3]||y[4]||y[5]||"").replace(et,tt),y[2]==="~="&&(y[3]=" "+y[3]+" "),y.slice(0,4)},CHILD:function(y){return y[1]=y[1].toLowerCase(),y[1].slice(0,3)==="nth"?(y[3]||ge.error(y[0]),y[4]=+(y[4]?y[5]+(y[6]||1):2*(y[3]==="even"||y[3]==="odd")),y[5]=+(y[7]+y[8]||y[3]==="odd")):y[3]&&ge.error(y[0]),y},PSEUDO:function(y){var D,N=!y[6]&&y[2];return Xe.CHILD.test(y[0])?null:(y[3]?y[2]=y[4]||y[5]||"":N&&Ge.test(N)&&(D=Lt(N,!0))&&(D=N.indexOf(")",N.length-D)-N.length)&&(y[0]=y[0].slice(0,D),y[2]=N.slice(0,D)),y.slice(0,3))}},filter:{TAG:function(y){var D=y.replace(et,tt).toLowerCase();return y==="*"?function(){return!0}:function(N){return ae(N,D)}},CLASS:function(y){var D=ne[y+" "];return D||(D=new RegExp("(^|"+re+")"+y+"("+re+"|$)"))&&ne(y,function(N){return D.test(typeof N.className=="string"&&N.className||typeof N.getAttribute<"u"&&N.getAttribute("class")||"")})},ATTR:function(y,D,N){return function(S){var L=ge.attr(S,y);return L==null?D==="!=":D?(L+="",D==="="?L===N:D==="!="?L!==N:D==="^="?N&&L.indexOf(N)===0:D==="*="?N&&L.indexOf(N)>-1:D==="$="?N&&L.slice(-N.length)===N:D==="~="?(" "+L.replace(he," ")+" ").indexOf(N)>-1:D==="|="?L===N||L.slice(0,N.length+1)===N+"-":!1):!0}},CHILD:function(y,D,N,S,L){var W=y.slice(0,3)!=="nth",B=y.slice(-4)!=="last",Q=D==="of-type";return S===1&&L===0?(function(q){return!!q.parentNode}):function(q,le,Z){var ie,ue,G,be,Pe,Re=W!==B?"nextSibling":"previousSibling",Ye=q.parentNode,$e=Q&&q.nodeName.toLowerCase(),jt=!Z&&!Q,Le=!1;if(Ye){if(W){for(;Re;){for(G=q;G=G[Re];)if(Q?ae(G,$e):G.nodeType===1)return!1;Pe=Re=y==="only"&&!Pe&&"nextSibling"}return!0}if(Pe=[B?Ye.firstChild:Ye.lastChild],B&&jt){for(ue=Ye[I]||(Ye[I]={}),ie=ue[y]||[],be=ie[0]===C&&ie[1],Le=be&&ie[2],G=be&&Ye.childNodes[be];G=++be&&G&&G[Re]||(Le=be=0)||Pe.pop();)if(G.nodeType===1&&++Le&&G===q){ue[y]=[C,be,Le];break}}else if(jt&&(ue=q[I]||(q[I]={}),ie=ue[y]||[],be=ie[0]===C&&ie[1],Le=be),Le===!1)for(;(G=++be&&G&&G[Re]||(Le=be=0)||Pe.pop())&&!((Q?ae(G,$e):G.nodeType===1)&&++Le&&(jt&&(ue=G[I]||(G[I]={}),ue[y]=[C,Le]),G===q)););return Le-=L,Le===S||Le%S===0&&Le/S>=0}}},PSEUDO:function(y,D){var N,S=t.pseudos[y]||t.setFilters[y.toLowerCase()]||ge.error("unsupported pseudo: "+y);return S[I]?S(D):S.length>1?(N=[y,y,"",D],t.setFilters.hasOwnProperty(y.toLowerCase())?Je(function(L,W){for(var B,Q=S(L,D),q=Q.length;q--;)B=m.call(L,Q[q]),L[B]=!(W[B]=Q[q])}):function(L){return S(L,0,N)}):S}},pseudos:{not:Je(function(y){var D=[],N=[],S=pr(y.replace(U,"$1"));return S[I]?Je(function(L,W,B,Q){for(var q,le=S(L,null,Q,[]),Z=L.length;Z--;)(q=le[Z])&&(L[Z]=!(W[Z]=q))}):function(L,W,B){return D[0]=L,S(D,null,B,N),D[0]=null,!N.pop()}}),has:Je(function(y){return function(D){return ge(y,D).length>0}}),contains:Je(function(y){return y=y.replace(et,tt),function(D){return(D.textContent||u.text(D)).indexOf(y)>-1}}),lang:Je(function(y){return Ot.test(y||"")||ge.error("unsupported lang: "+y),y=y.replace(et,tt).toLowerCase(),function(D){var N;do if(N=v?D.lang:D.getAttribute("xml:lang")||D.getAttribute("lang"))return N=N.toLowerCase(),N===y||N.indexOf(y+"-")===0;while((D=D.parentNode)&&D.nodeType===1);return!1}}),target:function(y){var D=o.location&&o.location.hash;return D&&D.slice(1)===y.id},root:function(y){return y===b},focus:function(y){return y===Pn()&&x.hasFocus()&&!!(y.type||y.href||~y.tabIndex)},enabled:zr(!1),disabled:zr(!0),checked:function(y){return ae(y,"input")&&!!y.checked||ae(y,"option")&&!!y.selected},selected:function(y){return y.parentNode&&y.parentNode.selectedIndex,y.selected===!0},empty:function(y){for(y=y.firstChild;y;y=y.nextSibling)if(y.nodeType<6)return!1;return!0},parent:function(y){return!t.pseudos.empty(y)},header:function(y){return st.test(y.nodeName)},input:function(y){return it.test(y.nodeName)},button:function(y){return ae(y,"input")&&y.type==="button"||ae(y,"button")},text:function(y){var D;return ae(y,"input")&&y.type==="text"&&((D=y.getAttribute("type"))==null||D.toLowerCase()==="text")},first:ht(function(){return[0]}),last:ht(function(y,D){return[D-1]}),eq:ht(function(y,D,N){return[N<0?N+D:N]}),even:ht(function(y,D){for(var N=0;ND?S=D:S=N;--S>=0;)y.push(S);return y}),gt:ht(function(y,D,N){for(var S=N<0?N+D:N;++S1?function(D,N,S){for(var L=y.length;L--;)if(!y[L](D,N,S))return!1;return!0}:y[0]}function Hn(y,D,N){for(var S=0,L=D.length;S-1&&(B[Z]=!(Q[Z]=ue))}}else G=zt(G===Q?G.splice(Re,G.length):G),L?L(null,Q,G,le):h.apply(Q,G)})}function hr(y){for(var D,N,S,L=y.length,W=t.relative[y[0].type],B=W||t.relative[" "],Q=W?1:0,q=Ut(function(ie){return ie===D},B,!0),le=Ut(function(ie){return m.call(D,ie)>-1},B,!0),Z=[function(ie,ue,G){var be=!W&&(G||ue!=i)||((D=ue).nodeType?q(ie,ue,G):le(ie,ue,G));return D=null,be}];Q1&&dr(Z),Q>1&&Jt(y.slice(0,Q-1).concat({value:y[Q-2].type===" "?"*":""})).replace(U,"$1"),N,Q0,S=y.length>0,L=function(W,B,Q,q,le){var Z,ie,ue,G=0,be="0",Pe=W&&[],Re=[],Ye=i,$e=W||S&&t.find.TAG("*",le),jt=C+=Ye==null?1:Math.random()||.1,Le=$e.length;for(le&&(i=B==x||B||le);be!==Le&&(Z=$e[be])!=null;be++){if(S&&Z){for(ie=0,!B&&Z.ownerDocument!=x&&(ot(Z),Q=!v);ue=y[ie++];)if(ue(Z,B||x,Q)){h.call(q,Z);break}le&&(C=jt)}N&&((Z=!ue&&Z)&&G--,W&&Pe.push(Z))}if(G+=be,N&&be!==G){for(ie=0;ue=D[ie++];)ue(Pe,Re,B,Q);if(W){if(G>0)for(;be--;)Pe[be]||Re[be]||(Re[be]=Ee.call(q));Re=zt(Re)}h.apply(q,Re),le&&!W&&Re.length>0&&G+D.length>1&&u.uniqueSort(q)}return le&&(C=jt,i=Ye),Pe};return N?Je(L):L}function pr(y,D){var N,S=[],L=[],W=oe[y+" "];if(!W){for(D||(D=Lt(y)),N=D.length;N--;)W=hr(D[N]),W[I]?S.push(W):L.push(W);W=oe(y,Yn(L,S)),W.selector=y}return W}function Kr(y,D,N,S){var L,W,B,Q,q,le=typeof y=="function"&&y,Z=!S&&Lt(y=le.selector||y);if(N=N||[],Z.length===1){if(W=Z[0]=Z[0].slice(0),W.length>2&&(B=W[0]).type==="ID"&&D.nodeType===9&&v&&t.relative[W[1].type]){if(D=(t.find.ID(B.matches[0].replace(et,tt),D)||[])[0],D)le&&(D=D.parentNode);else return N;y=y.slice(W.shift().value.length)}for(L=Xe.needsContext.test(y)?0:W.length;L--&&(B=W[L],!t.relative[Q=B.type]);)if((q=t.find[Q])&&(S=q(B.matches[0].replace(et,tt),ur.test(W[0].type)&&cr(D.parentNode)||D))){if(W.splice(L,1),y=S.length&&Jt(W),!y)return h.apply(N,S),N;break}}return(le||pr(y,Z))(S,D,!v,N,!D||ur.test(y)&&cr(D.parentNode)||D),N}E.sortStable=I.split("").sort(Ce).join("")===I,ot(),E.sortDetached=bt(function(y){return y.compareDocumentPosition(x.createElement("fieldset"))&1}),u.find=ge,u.expr[":"]=u.expr.pseudos,u.unique=u.uniqueSort,ge.compile=pr,ge.select=Kr,ge.setDocument=ot,ge.tokenize=Lt,ge.escape=u.escapeSelector,ge.getText=u.text,ge.isXML=u.isXMLDoc,ge.selectors=u.expr,ge.support=u.support,ge.uniqueSort=u.uniqueSort})();var Te=function(e,t,i){for(var l=[],f=i!==void 0;(e=e[t])&&e.nodeType!==9;)if(e.nodeType===1){if(f&&u(e).is(i))break;l.push(e)}return l},rt=function(e,t){for(var i=[];e;e=e.nextSibling)e.nodeType===1&&e!==t&&i.push(e);return i},nt=u.expr.match.needsContext,Dt=/^<([a-z][^\/\0>:\x20\t\r\n\f]*)[\x20\t\r\n\f]*\/?>(?:<\/\1>|)$/i;function kt(e,t,i){return M(t)?u.grep(e,function(l,f){return!!t.call(l,f,l)!==i}):t.nodeType?u.grep(e,function(l){return l===t!==i}):typeof t!="string"?u.grep(e,function(l){return m.call(t,l)>-1!==i}):u.filter(t,e,i)}u.filter=function(e,t,i){var l=t[0];return i&&(e=":not("+e+")"),t.length===1&&l.nodeType===1?u.find.matchesSelector(l,e)?[l]:[]:u.find.matches(e,u.grep(t,function(f){return f.nodeType===1}))},u.fn.extend({find:function(e){var t,i,l=this.length,f=this;if(typeof e!="string")return this.pushStack(u(e).filter(function(){for(t=0;t1?u.uniqueSort(i):i},filter:function(e){return this.pushStack(kt(this,e||[],!1))},not:function(e){return this.pushStack(kt(this,e||[],!0))},is:function(e){return!!kt(this,typeof e=="string"&&nt.test(e)?u(e):e||[],!1).length}});var It,Vt=/^(?:\s*(<[\w\W]+>)[^>]*|#([\w-]+))$/,At=u.fn.init=function(e,t,i){var l,f;if(!e)return this;if(i=i||It,typeof e=="string")if(e[0]==="<"&&e[e.length-1]===">"&&e.length>=3?l=[null,e,null]:l=Vt.exec(e),l&&(l[1]||!t))if(l[1]){if(t=t instanceof u?t[0]:t,u.merge(this,u.parseHTML(l[1],t&&t.nodeType?t.ownerDocument||t:A,!0)),Dt.test(l[1])&&u.isPlainObject(t))for(l in t)M(this[l])?this[l](t[l]):this.attr(l,t[l]);return this}else return f=A.getElementById(l[2]),f&&(this[0]=f,this.length=1),this;else return!t||t.jquery?(t||i).find(e):this.constructor(t).find(e);else{if(e.nodeType)return this[0]=e,this.length=1,this;if(M(e))return i.ready!==void 0?i.ready(e):e(u)}return u.makeArray(e,this)};At.prototype=u.fn,It=u(A);var ve=/^(?:parents|prev(?:Until|All))/,wt={children:!0,contents:!0,next:!0,prev:!0};u.fn.extend({has:function(e){var t=u(e,this),i=t.length;return this.filter(function(){for(var l=0;l-1:i.nodeType===1&&u.find.matchesSelector(i,e))){h.push(i);break}}return this.pushStack(h.length>1?u.uniqueSort(h):h)},index:function(e){return e?typeof e=="string"?m.call(u(e),this[0]):m.call(this,e.jquery?e[0]:e):this[0]&&this[0].parentNode?this.first().prevAll().length:-1},add:function(e,t){return this.pushStack(u.uniqueSort(u.merge(this.get(),u(e,t))))},addBack:function(e){return this.add(e==null?this.prevObject:this.prevObject.filter(e))}});function Et(e,t){for(;(e=e[t])&&e.nodeType!==1;);return e}u.each({parent:function(e){var t=e.parentNode;return t&&t.nodeType!==11?t:null},parents:function(e){return Te(e,"parentNode")},parentsUntil:function(e,t,i){return Te(e,"parentNode",i)},next:function(e){return Et(e,"nextSibling")},prev:function(e){return Et(e,"previousSibling")},nextAll:function(e){return Te(e,"nextSibling")},prevAll:function(e){return Te(e,"previousSibling")},nextUntil:function(e,t,i){return Te(e,"nextSibling",i)},prevUntil:function(e,t,i){return Te(e,"previousSibling",i)},siblings:function(e){return rt((e.parentNode||{}).firstChild,e)},children:function(e){return rt(e.firstChild)},contents:function(e){return e.contentDocument!=null&&a(e.contentDocument)?e.contentDocument:(ae(e,"template")&&(e=e.content||e),u.merge([],e.childNodes))}},function(e,t){u.fn[e]=function(i,l){var f=u?.map(this,t,i);return e.slice(-5)!=="Until"&&(l=i),l&&typeof l=="string"&&(f=u.filter(l,f)),this.length>1&&(wt[e]||u.uniqueSort(f),ve.test(e)&&f.reverse()),this.pushStack(f)}});var Ne=/[^\x20\t\r\n\f]+/g;function Ue(e){var t={};return u.each(e.match(Ne)||[],function(i,l){t[l]=!0}),t}u.Callbacks=function(e){e=typeof e=="string"?Ue(e):u.extend({},e);var t,i,l,f,h=[],x=[],b=-1,v=function(){for(f=f||e.once,l=t=!0;x.length;b=-1)for(i=x.shift();++b-1;)h.splice(C,1),C<=b&&b--}),this},has:function(R){return R?u.inArray(R,h)>-1:h.length>0},empty:function(){return h&&(h=[]),this},disable:function(){return f=x=[],h=i="",this},disabled:function(){return!h},lock:function(){return f=x=[],!i&&!t&&(h=i=""),this},locked:function(){return!!f},fireWith:function(R,I){return f||(I=I||[],I=[R,I.slice?I.slice():I],x.push(I),t||v()),this},fire:function(){return w.fireWith(this,arguments),this},fired:function(){return!!l}};return w};function ze(e){return e}function lt(e){throw e}function O(e,t,i,l){var f;try{e&&M(f=e.promise)?f.call(e).done(t).fail(i):e&&M(f=e.then)?f.call(e,t,i):t.apply(void 0,[e].slice(l))}catch(h){i.apply(void 0,[h])}}u.extend({Deferred:function(e){var t=[["notify","progress",u.Callbacks("memory"),u.Callbacks("memory"),2],["resolve","done",u.Callbacks("once memory"),u.Callbacks("once memory"),0,"resolved"],["reject","fail",u.Callbacks("once memory"),u.Callbacks("once memory"),1,"rejected"]],i="pending",l={state:function(){return i},always:function(){return f.done(arguments).fail(arguments),this},catch:function(h){return l.then(null,h)},pipe:function(){var h=arguments;return u.Deferred(function(x){u.each(t,function(b,v){var w=M(h[v[4]])&&h[v[4]];f[v[1]](function(){var R=w&&w.apply(this,arguments);R&&M(R.promise)?R.promise().progress(x.notify).done(x.resolve).fail(x.reject):x[v[0]+"With"](this,w?[R]:arguments)})}),h=null}).promise()},then:function(h,x,b){var v=0;function w(R,I,C,F){return function(){var ne=this,de=arguments,oe=function(){var Ce,Ke;if(!(R=v&&(C!==lt&&(ne=void 0,de=[Ce]),I.rejectWith(ne,de))}};R?Se():(u.Deferred.getErrorHook?Se.error=u.Deferred.getErrorHook():u.Deferred.getStackHook&&(Se.error=u.Deferred.getStackHook()),o.setTimeout(Se))}}return u.Deferred(function(R){t[0][3].add(w(0,R,M(b)?b:ze,R.notifyWith)),t[1][3].add(w(0,R,M(h)?h:ze)),t[2][3].add(w(0,R,M(x)?x:lt))}).promise()},promise:function(h){return h!=null?u.extend(h,l):l}},f={};return u.each(t,function(h,x){var b=x[2],v=x[5];l[x[1]]=b.add,v&&b.add(function(){i=v},t[3-h][2].disable,t[3-h][3].disable,t[0][2].lock,t[0][3].lock),b.add(x[3].fire),f[x[0]]=function(){return f[x[0]+"With"](this===f?void 0:this,arguments),this},f[x[0]+"With"]=b.fireWith}),l.promise(f),e&&e.call(f,f),f},when:function(e){var t=arguments.length,i=t,l=Array(i),f=s.call(arguments),h=u.Deferred(),x=function(b){return function(v){l[b]=this,f[b]=arguments.length>1?s.call(arguments):v,--t||h.resolveWith(l,f)}};if(t<=1&&(O(e,h.done(x(i)).resolve,h.reject,!t),h.state()==="pending"||M(f[i]&&f[i].then)))return h.then();for(;i--;)O(f[i],x(i),h.reject);return h.promise()}});var me=/^(Eval|Internal|Range|Reference|Syntax|Type|URI)Error$/;u.Deferred.exceptionHook=function(e,t){o.console&&o.console.warn&&e&&me.test(e.name)&&o.console.warn("jQuery.Deferred exception: "+e.message,e.stack,t)},u.readyException=function(e){o.setTimeout(function(){throw e})};var pe=u.Deferred();u.fn.ready=function(e){return pe.then(e).catch(function(t){u.readyException(t)}),this},u.extend({isReady:!1,readyWait:1,ready:function(e){(e===!0?--u.readyWait:u.isReady)||(u.isReady=!0,!(e!==!0&&--u.readyWait>0)&&pe.resolveWith(A,[u]))}}),u.ready.then=pe.then;function ee(){A.removeEventListener("DOMContentLoaded",ee),o.removeEventListener("load",ee),u.ready()}A.readyState==="complete"||A.readyState!=="loading"&&!A.documentElement.doScroll?o.setTimeout(u.ready):(A.addEventListener("DOMContentLoaded",ee),o.addEventListener("load",ee));var xe=function(e,t,i,l,f,h,x){var b=0,v=e.length,w=i==null;if(se(i)==="object"){f=!0;for(b in i)xe(e,t,b,i[b],!0,h,x)}else if(l!==void 0&&(f=!0,M(l)||(x=!0),w&&(x?(t.call(e,l),t=null):(w=t,t=function(R,I,C){return w.call(u(R),C)})),t))for(;b1,null,!0)},removeData:function(e){return this.each(function(){_e.remove(this,e)})}}),u.extend({queue:function(e,t,i){var l;if(e)return t=(t||"fx")+"queue",l=V.get(e,t),i&&(!l||Array.isArray(i)?l=V.access(e,t,u.makeArray(i)):l.push(i)),l||[]},dequeue:function(e,t){t=t||"fx";var i=u.queue(e,t),l=i.length,f=i.shift(),h=u._queueHooks(e,t),x=function(){u.dequeue(e,t)};f==="inprogress"&&(f=i.shift(),l--),f&&(t==="fx"&&i.unshift("inprogress"),delete h.stop,f.call(e,x,h)),!l&&h&&h.empty.fire()},_queueHooks:function(e,t){var i=t+"queueHooks";return V.get(e,i)||V.access(e,i,{empty:u.Callbacks("once memory").add(function(){V.remove(e,[t+"queue",i])})})}}),u.fn.extend({queue:function(e,t){var i=2;return typeof e!="string"&&(t=e,e="fx",i--),arguments.length\x20\t\r\n\f]*)/i,br=/^$|^module$|\/(?:java|ecma)script/i;(function(){var e=A.createDocumentFragment(),t=e.appendChild(A.createElement("div")),i=A.createElement("input");i.setAttribute("type","radio"),i.setAttribute("checked","checked"),i.setAttribute("name","t"),t.appendChild(i),E.checkClone=t.cloneNode(!0).cloneNode(!0).lastChild.checked,t.innerHTML="",E.noCloneChecked=!!t.cloneNode(!0).lastChild.defaultValue,t.innerHTML="",E.option=!!t.lastChild})();var We={thead:[1,"","
"],col:[2,"","
"],tr:[2,"","
"],td:[3,"","
"],_default:[0,"",""]};We.tbody=We.tfoot=We.colgroup=We.caption=We.thead,We.th=We.td,E.option||(We.optgroup=We.option=[1,""]);function Ie(e,t){var i;return typeof e.getElementsByTagName<"u"?i=e.getElementsByTagName(t||"*"):typeof e.querySelectorAll<"u"?i=e.querySelectorAll(t||"*"):i=[],t===void 0||t&&ae(e,t)?u.merge([e],i):i}function Kt(e,t){for(var i=0,l=e.length;i-1){f&&f.push(h);continue}if(w=pt(h),x=Ie(I.appendChild(h),"script"),w&&Kt(x),i)for(R=0;h=x[R++];)br.test(h.type||"")&&i.push(h)}return I}var Dr=/^([^.]*)(?:\.(.+)|)/;function xt(){return!0}function gt(){return!1}function Qt(e,t,i,l,f,h){var x,b;if(typeof t=="object"){typeof i!="string"&&(l=l||i,i=void 0);for(b in t)Qt(e,b,i,l,t[b],h);return e}if(l==null&&f==null?(f=i,l=i=void 0):f==null&&(typeof i=="string"?(f=l,l=void 0):(f=l,l=i,i=void 0)),f===!1)f=gt;else if(!f)return e;return h===1&&(x=f,f=function(v){return u().off(v),x.apply(this,arguments)},f.guid=x.guid||(x.guid=u.guid++)),e.each(function(){u.event.add(this,t,f,l,i)})}u.event={global:{},add:function(e,t,i,l,f){var h,x,b,v,w,R,I,C,F,ne,de,oe=V.get(e);if(Be(e))for(i.handler&&(h=i,i=h.handler,f=h.selector),f&&u.find.matchesSelector(ut,f),i.guid||(i.guid=u.guid++),(v=oe.events)||(v=oe.events=Object.create(null)),(x=oe.handle)||(x=oe.handle=function(Se){return typeof u<"u"&&u.event.triggered!==Se.type?u.event.dispatch.apply(e,arguments):void 0}),t=(t||"").match(Ne)||[""],w=t.length;w--;)b=Dr.exec(t[w])||[],F=de=b[1],ne=(b[2]||"").split(".").sort(),F&&(I=u.event.special[F]||{},F=(f?I.delegateType:I.bindType)||F,I=u.event.special[F]||{},R=u.extend({type:F,origType:de,data:l,handler:i,guid:i.guid,selector:f,needsContext:f&&u.expr.match.needsContext.test(f),namespace:ne.join(".")},h),(C=v[F])||(C=v[F]=[],C.delegateCount=0,(!I.setup||I.setup.call(e,l,ne,x)===!1)&&e.addEventListener&&e.addEventListener(F,x)),I.add&&(I.add.call(e,R),R.handler.guid||(R.handler.guid=i.guid)),f?C.splice(C.delegateCount++,0,R):C.push(R),u.event.global[F]=!0)},remove:function(e,t,i,l,f){var h,x,b,v,w,R,I,C,F,ne,de,oe=V.hasData(e)&&V.get(e);if(!(!oe||!(v=oe.events))){for(t=(t||"").match(Ne)||[""],w=t.length;w--;){if(b=Dr.exec(t[w])||[],F=de=b[1],ne=(b[2]||"").split(".").sort(),!F){for(F in v)u.event.remove(e,F+t[w],i,l,!0);continue}for(I=u.event.special[F]||{},F=(l?I.delegateType:I.bindType)||F,C=v[F]||[],b=b[2]&&new RegExp("(^|\\.)"+ne.join("\\.(?:.*\\.|)")+"(\\.|$)"),x=h=C.length;h--;)R=C[h],(f||de===R.origType)&&(!i||i.guid===R.guid)&&(!b||b.test(R.namespace))&&(!l||l===R.selector||l==="**"&&R.selector)&&(C.splice(h,1),R.selector&&C.delegateCount--,I.remove&&I.remove.call(e,R));x&&!C.length&&((!I.teardown||I.teardown.call(e,ne,oe.handle)===!1)&&u.removeEvent(e,F,oe.handle),delete v[F])}u.isEmptyObject(v)&&V.remove(e,"handle events")}},dispatch:function(e){var t,i,l,f,h,x,b=new Array(arguments.length),v=u.event.fix(e),w=(V.get(this,"events")||Object.create(null))[v.type]||[],R=u.event.special[v.type]||{};for(b[0]=v,t=1;t=1)){for(;w!==this;w=w.parentNode||this)if(w.nodeType===1&&!(e.type==="click"&&w.disabled===!0)){for(h=[],x={},i=0;i-1:u.find(f,this,null,[w]).length),x[f]&&h.push(l);h.length&&b.push({elem:w,handlers:h})}}return w=this,v\s*$/g;function kr(e,t){return ae(e,"table")&&ae(t.nodeType!==11?t:t.firstChild,"tr")&&u(e).children("tbody")[0]||e}function nn(e){return e.type=(e.getAttribute("type")!==null)+"/"+e.type,e}function an(e){return(e.type||"").slice(0,5)==="true/"?e.type=e.type.slice(5):e.removeAttribute("type"),e}function wr(e,t){var i,l,f,h,x,b,v;if(t.nodeType===1){if(V.hasData(e)&&(h=V.get(e),v=h.events,v)){V.remove(t,"handle events");for(f in v)for(i=0,l=v[f].length;i1&&typeof F=="string"&&!E.checkClone&&tn.test(F))return e.each(function(de){var oe=e.eq(de);ne&&(t[0]=F.call(this,de,oe.html())),yt(oe,t,i,l)});if(I&&(f=jr(t,e[0].ownerDocument,!1,e,l),h=f.firstChild,f.childNodes.length===1&&(f=h),h||l)){for(x=u?.map(Ie(f,"script"),nn),b=x.length;R0&&Kt(x,!v&&Ie(e,"script")),b},cleanData:function(e){for(var t,i,l,f=u.event.special,h=0;(i=e[h])!==void 0;h++)if(Be(i)){if(t=i[V.expando]){if(t.events)for(l in t.events)f[l]?u.event.remove(i,l):u.removeEvent(i,l,t.handle);i[V.expando]=void 0}i[_e.expando]&&(i[_e.expando]=void 0)}}}),u.fn.extend({detach:function(e){return Er(this,e,!0)},remove:function(e){return Er(this,e)},text:function(e){return xe(this,function(t){return t===void 0?u.text(this):this.empty().each(function(){(this.nodeType===1||this.nodeType===11||this.nodeType===9)&&(this.textContent=t)})},null,e,arguments.length)},append:function(){return yt(this,arguments,function(e){if(this.nodeType===1||this.nodeType===11||this.nodeType===9){var t=kr(this,e);t.appendChild(e)}})},prepend:function(){return yt(this,arguments,function(e){if(this.nodeType===1||this.nodeType===11||this.nodeType===9){var t=kr(this,e);t.insertBefore(e,t.firstChild)}})},before:function(){return yt(this,arguments,function(e){this.parentNode&&this.parentNode.insertBefore(e,this)})},after:function(){return yt(this,arguments,function(e){this.parentNode&&this.parentNode.insertBefore(e,this.nextSibling)})},empty:function(){for(var e,t=0;(e=this[t])!=null;t++)e.nodeType===1&&(u.cleanData(Ie(e,!1)),e.textContent="");return this},clone:function(e,t){return e=e??!1,t=t??e,this?.map(function(){return u.clone(this,e,t)})},html:function(e){return xe(this,function(t){var i=this[0]||{},l=0,f=this.length;if(t===void 0&&i.nodeType===1)return i.innerHTML;if(typeof t=="string"&&!en.test(t)&&!We[(vr.exec(t)||["",""])[1].toLowerCase()]){t=u.htmlPrefilter(t);try{for(;l=0&&(v+=Math.max(0,Math.ceil(e["offset"+t[0].toUpperCase()+t.slice(1)]-h-v-b-.5))||0),v+w}function Or(e,t,i){var l=Ht(e),f=!E.boxSizingReliable()||i,h=f&&u.css(e,"boxSizing",!1,l)==="border-box",x=h,b=St(e,t,l),v="offset"+t[0].toUpperCase()+t.slice(1);if(Gt.test(b)){if(!i)return b;b="auto"}return(!E.boxSizingReliable()&&h||!E.reliableTrDimensions()&&ae(e,"tr")||b==="auto"||!parseFloat(b)&&u.css(e,"display",!1,l)==="inline")&&e.getClientRects().length&&(h=u.css(e,"boxSizing",!1,l)==="border-box",x=v in e,x&&(b=e[v])),b=parseFloat(b)||0,b+Zt(e,t,i||(h?"border":"content"),x,l,b)+"px"}u.extend({cssHooks:{opacity:{get:function(e,t){if(t){var i=St(e,"opacity");return i===""?"1":i}}}},cssNumber:{animationIterationCount:!0,aspectRatio:!0,borderImageSlice:!0,columnCount:!0,flexGrow:!0,flexShrink:!0,fontWeight:!0,gridArea:!0,gridColumn:!0,gridColumnEnd:!0,gridColumnStart:!0,gridRow:!0,gridRowEnd:!0,gridRowStart:!0,lineHeight:!0,opacity:!0,order:!0,orphans:!0,scale:!0,widows:!0,zIndex:!0,zoom:!0,fillOpacity:!0,floodOpacity:!0,stopOpacity:!0,strokeMiterlimit:!0,strokeOpacity:!0},cssProps:{},style:function(e,t,i,l){if(!(!e||e.nodeType===3||e.nodeType===8||!e.style)){var f,h,x,b=je(t),v=Xt.test(t),w=e.style;if(v||(t=$t(b)),x=u.cssHooks[t]||u.cssHooks[b],i!==void 0){if(h=typeof i,h==="string"&&(f=Nt.exec(i))&&f[1]&&(i=gr(e,t,f),h="number"),i==null||i!==i)return;h==="number"&&!v&&(i+=f&&f[3]||(u.cssNumber[b]?"":"px")),!E.clearCloneStyle&&i===""&&t.indexOf("background")===0&&(w[t]="inherit"),(!x||!("set"in x)||(i=x.set(e,i,l))!==void 0)&&(v?w.setProperty(t,i):w[t]=i)}else return x&&"get"in x&&(f=x.get(e,!1,l))!==void 0?f:w[t]}},css:function(e,t,i,l){var f,h,x,b=je(t),v=Xt.test(t);return v||(t=$t(b)),x=u.cssHooks[t]||u.cssHooks[b],x&&"get"in x&&(f=x.get(e,!0,i)),f===void 0&&(f=St(e,t,l)),f==="normal"&&t in Mr&&(f=Mr[t]),i===""||i?(h=parseFloat(f),i===!0||isFinite(h)?h||0:f):f}}),u.each(["height","width"],function(e,t){u.cssHooks[t]={get:function(i,l,f){if(l)return un.test(u.css(i,"display"))&&(!i.getClientRects().length||!i.getBoundingClientRect().width)?Nr(i,dn,function(){return Or(i,t,f)}):Or(i,t,f)},set:function(i,l,f){var h,x=Ht(i),b=!E.scrollboxSize()&&x.position==="absolute",v=b||f,w=v&&u.css(i,"boxSizing",!1,x)==="border-box",R=f?Zt(i,t,f,w,x):0;return w&&b&&(R-=Math.ceil(i["offset"+t[0].toUpperCase()+t.slice(1)]-parseFloat(x[t])-Zt(i,t,"border",!1,x)-.5)),R&&(h=Nt.exec(l))&&(h[3]||"px")!=="px"&&(i.style[t]=l,l=u.css(i,t)),Tr(i,l,R)}}}),u.cssHooks.marginLeft=Cr(E.reliableMarginLeft,function(e,t){if(t)return(parseFloat(St(e,"marginLeft"))||e.getBoundingClientRect().left-Nr(e,{marginLeft:0},function(){return e.getBoundingClientRect().left}))+"px"}),u.each({margin:"",padding:"",border:"Width"},function(e,t){u.cssHooks[e+t]={expand:function(i){for(var l=0,f={},h=typeof i=="string"?i.split(" "):[i];l<4;l++)f[e+Ze[l]+t]=h[l]||h[l-2]||h[0];return f}},e!=="margin"&&(u.cssHooks[e+t].set=Tr)}),u.fn.extend({css:function(e,t){return xe(this,function(i,l,f){var h,x,b={},v=0;if(Array.isArray(l)){for(h=Ht(i),x=l.length;v1)}});function Ae(e,t,i,l,f){return new Ae.prototype.init(e,t,i,l,f)}u.Tween=Ae,Ae.prototype={constructor:Ae,init:function(e,t,i,l,f,h){this.elem=e,this.prop=i,this.easing=f||u.easing._default,this.options=t,this.start=this.now=this.cur(),this.end=l,this.unit=h||(u.cssNumber[i]?"":"px")},cur:function(){var e=Ae.propHooks[this.prop];return e&&e.get?e.get(this):Ae.propHooks._default.get(this)},run:function(e){var t,i=Ae.propHooks[this.prop];return this.options.duration?this.pos=t=u.easing[this.easing](e,this.options.duration*e,0,1,this.options.duration):this.pos=t=e,this.now=(this.end-this.start)*t+this.start,this.options.step&&this.options.step.call(this.elem,this.now,this),i&&i.set?i.set(this):Ae.propHooks._default.set(this),this}},Ae.prototype.init.prototype=Ae.prototype,Ae.propHooks={_default:{get:function(e){var t;return e.elem.nodeType!==1||e.elem[e.prop]!=null&&e.elem.style[e.prop]==null?e.elem[e.prop]:(t=u.css(e.elem,e.prop,""),!t||t==="auto"?0:t)},set:function(e){u.fx.step[e.prop]?u.fx.step[e.prop](e):e.elem.nodeType===1&&(u.cssHooks[e.prop]||e.elem.style[$t(e.prop)]!=null)?u.style(e.elem,e.prop,e.now+e.unit):e.elem[e.prop]=e.now}}},Ae.propHooks.scrollTop=Ae.propHooks.scrollLeft={set:function(e){e.elem.nodeType&&e.elem.parentNode&&(e.elem[e.prop]=e.now)}},u.easing={linear:function(e){return e},swing:function(e){return .5-Math.cos(e*Math.PI)/2},_default:"swing"},u.fx=Ae.prototype.init,u.fx.step={};var vt,Yt,fn=/^(?:toggle|show|hide)$/,hn=/queueHooks$/;function er(){Yt&&(A.hidden===!1&&o.requestAnimationFrame?o.requestAnimationFrame(er):o.setTimeout(er,u.fx.interval),u.fx.tick())}function Lr(){return o.setTimeout(function(){vt=void 0}),vt=Date.now()}function Bt(e,t){var i,l=0,f={height:e};for(t=t?1:0;l<4;l+=2-t)i=Ze[l],f["margin"+i]=f["padding"+i]=e;return t&&(f.opacity=f.width=e),f}function Ir(e,t,i){for(var l,f=(qe.tweeners[t]||[]).concat(qe.tweeners["*"]),h=0,x=f.length;h1)},removeAttr:function(e){return this.each(function(){u.removeAttr(this,e)})}}),u.extend({attr:function(e,t,i){var l,f,h=e.nodeType;if(!(h===3||h===8||h===2)){if(typeof e.getAttribute>"u")return u.prop(e,t,i);if((h!==1||!u.isXMLDoc(e))&&(f=u.attrHooks[t.toLowerCase()]||(u.expr.match.bool.test(t)?Ar:void 0)),i!==void 0){if(i===null){u.removeAttr(e,t);return}return f&&"set"in f&&(l=f.set(e,i,t))!==void 0?l:(e.setAttribute(t,i+""),i)}return f&&"get"in f&&(l=f.get(e,t))!==null?l:(l=u.find.attr(e,t),l??void 0)}},attrHooks:{type:{set:function(e,t){if(!E.radioValue&&t==="radio"&&ae(e,"input")){var i=e.value;return e.setAttribute("type",t),i&&(e.value=i),t}}}},removeAttr:function(e,t){var i,l=0,f=t&&t.match(Ne);if(f&&e.nodeType===1)for(;i=f[l++];)e.removeAttribute(i)}}),Ar={set:function(e,t,i){return t===!1?u.removeAttr(e,i):e.setAttribute(i,i),i}},u.each(u.expr.match.bool.source.match(/\w+/g),function(e,t){var i=_t[t]||u.find.attr;_t[t]=function(l,f,h){var x,b,v=f.toLowerCase();return h||(b=_t[v],_t[v]=x,x=i(l,f,h)!=null?v:null,_t[v]=b),x}});var xn=/^(?:input|select|textarea|button)$/i,gn=/^(?:a|area)$/i;u.fn.extend({prop:function(e,t){return xe(this,u.prop,e,t,arguments.length>1)},removeProp:function(e){return this.each(function(){delete this[u.propFix[e]||e]})}}),u.extend({prop:function(e,t,i){var l,f,h=e.nodeType;if(!(h===3||h===8||h===2))return(h!==1||!u.isXMLDoc(e))&&(t=u.propFix[t]||t,f=u.propHooks[t]),i!==void 0?f&&"set"in f&&(l=f.set(e,i,t))!==void 0?l:e[t]=i:f&&"get"in f&&(l=f.get(e,t))!==null?l:e[t]},propHooks:{tabIndex:{get:function(e){var t=u.find.attr(e,"tabindex");return t?parseInt(t,10):xn.test(e.nodeName)||gn.test(e.nodeName)&&e.href?0:-1}}},propFix:{for:"htmlFor",class:"className"}}),E.optSelected||(u.propHooks.selected={get:function(e){var t=e.parentNode;return t&&t.parentNode&&t.parentNode.selectedIndex,null},set:function(e){var t=e.parentNode;t&&(t.selectedIndex,t.parentNode&&t.parentNode.selectedIndex)}}),u.each(["tabIndex","readOnly","maxLength","cellSpacing","cellPadding","rowSpan","colSpan","useMap","frameBorder","contentEditable"],function(){u.propFix[this.toLowerCase()]=this});function ct(e){var t=e.match(Ne)||[];return t.join(" ")}function dt(e){return e.getAttribute&&e.getAttribute("class")||""}function tr(e){return Array.isArray(e)?e:typeof e=="string"?e.match(Ne)||[]:[]}u.fn.extend({addClass:function(e){var t,i,l,f,h,x;return M(e)?this.each(function(b){u(this).addClass(e.call(this,b,dt(this)))}):(t=tr(e),t.length?this.each(function(){if(l=dt(this),i=this.nodeType===1&&" "+ct(l)+" ",i){for(h=0;h-1;)i=i.replace(" "+f+" "," ");x=ct(i),l!==x&&this.setAttribute("class",x)}}):this):this.attr("class","")},toggleClass:function(e,t){var i,l,f,h,x=typeof e,b=x==="string"||Array.isArray(e);return M(e)?this.each(function(v){u(this).toggleClass(e.call(this,v,dt(this),t),t)}):typeof t=="boolean"&&b?t?this.addClass(e):this.removeClass(e):(i=tr(e),this.each(function(){if(b)for(h=u(this),f=0;f-1)return!0;return!1}});var yn=/\r/g;u.fn.extend({val:function(e){var t,i,l,f=this[0];return arguments.length?(l=M(e),this.each(function(h){var x;this.nodeType===1&&(l?x=e.call(this,h,u(this).val()):x=e,x==null?x="":typeof x=="number"?x+="":Array.isArray(x)&&(x=u?.map(x,function(b){return b==null?"":b+""})),t=u.valHooks[this.type]||u.valHooks[this.nodeName.toLowerCase()],(!t||!("set"in t)||t.set(this,x,"value")===void 0)&&(this.value=x))})):f?(t=u.valHooks[f.type]||u.valHooks[f.nodeName.toLowerCase()],t&&"get"in t&&(i=t.get(f,"value"))!==void 0?i:(i=f.value,typeof i=="string"?i.replace(yn,""):i??"")):void 0}}),u.extend({valHooks:{option:{get:function(e){var t=u.find.attr(e,"value");return t??ct(u.text(e))}},select:{get:function(e){var t,i,l,f=e.options,h=e.selectedIndex,x=e.type==="select-one",b=x?null:[],v=x?h+1:f.length;for(h<0?l=v:l=x?h:0;l-1)&&(i=!0);return i||(e.selectedIndex=-1),h}}}}),u.each(["radio","checkbox"],function(){u.valHooks[this]={set:function(e,t){if(Array.isArray(t))return e.checked=u.inArray(u(e).val(),t)>-1}},E.checkOn||(u.valHooks[this].get=function(e){return e.getAttribute("value")===null?"on":e.value})});var Rt=o.location,Pr={guid:Date.now()},rr=/\?/;u.parseXML=function(e){var t,i;if(!e||typeof e!="string")return null;try{t=new o.DOMParser().parseFromString(e,"text/xml")}catch{}return i=t&&t.getElementsByTagName("parsererror")[0],(!t||i)&&u.error("Invalid XML: "+(i?u?.map(i.childNodes,function(l){return l.textContent}).join(` +`):e)),t};var Fr=/^(?:focusinfocus|focusoutblur)$/,Wr=function(e){e.stopPropagation()};u.extend(u.event,{trigger:function(e,t,i,l){var f,h,x,b,v,w,R,I,C=[i||A],F=k.call(e,"type")?e.type:e,ne=k.call(e,"namespace")?e.namespace.split("."):[];if(h=I=x=i=i||A,!(i.nodeType===3||i.nodeType===8)&&!Fr.test(F+u.event.triggered)&&(F.indexOf(".")>-1&&(ne=F.split("."),F=ne.shift(),ne.sort()),v=F.indexOf(":")<0&&"on"+F,e=e[u.expando]?e:new u.Event(F,typeof e=="object"&&e),e.isTrigger=l?2:3,e.namespace=ne.join("."),e.rnamespace=e.namespace?new RegExp("(^|\\.)"+ne.join("\\.(?:.*\\.|)")+"(\\.|$)"):null,e.result=void 0,e.target||(e.target=i),t=t==null?[e]:u.makeArray(t,[e]),R=u.event.special[F]||{},!(!l&&R.trigger&&R.trigger.apply(i,t)===!1))){if(!l&&!R.noBubble&&!P(i)){for(b=R.delegateType||F,Fr.test(b+F)||(h=h.parentNode);h;h=h.parentNode)C.push(h),x=h;x===(i.ownerDocument||A)&&C.push(x.defaultView||x.parentWindow||o)}for(f=0;(h=C[f++])&&!e.isPropagationStopped();)I=h,e.type=f>1?b:R.bindType||F,w=(V.get(h,"events")||Object.create(null))[e.type]&&V.get(h,"handle"),w&&w.apply(h,t),w=v&&h[v],w&&w.apply&&Be(h)&&(e.result=w.apply(h,t),e.result===!1&&e.preventDefault());return e.type=F,!l&&!e.isDefaultPrevented()&&(!R._default||R._default.apply(C.pop(),t)===!1)&&Be(i)&&v&&M(i[F])&&!P(i)&&(x=i[v],x&&(i[v]=null),u.event.triggered=F,e.isPropagationStopped()&&I.addEventListener(F,Wr),i[F](),e.isPropagationStopped()&&I.removeEventListener(F,Wr),u.event.triggered=void 0,x&&(i[v]=x)),e.result}},simulate:function(e,t,i){var l=u.extend(new u.Event,i,{type:e,isSimulated:!0});u.event.trigger(l,null,t)}}),u.fn.extend({trigger:function(e,t){return this.each(function(){u.event.trigger(e,t,this)})},triggerHandler:function(e,t){var i=this[0];if(i)return u.event.trigger(e,t,i,!0)}});var vn=/\[\]$/,Hr=/\r?\n/g,bn=/^(?:submit|button|image|reset|file)$/i,jn=/^(?:input|select|textarea|keygen)/i;function nr(e,t,i,l){var f;if(Array.isArray(t))u.each(t,function(h,x){i||vn.test(e)?l(e,x):nr(e+"["+(typeof x=="object"&&x!=null?h:"")+"]",x,i,l)});else if(!i&&se(t)==="object")for(f in t)nr(e+"["+f+"]",t[f],i,l);else l(e,t)}u.param=function(e,t){var i,l=[],f=function(h,x){var b=M(x)?x():x;l[l.length]=encodeURIComponent(h)+"="+encodeURIComponent(b??"")};if(e==null)return"";if(Array.isArray(e)||e.jquery&&!u.isPlainObject(e))u.each(e,function(){f(this.name,this.value)});else for(i in e)nr(i,e[i],t,f);return l.join("&")},u.fn.extend({serialize:function(){return u.param(this.serializeArray())},serializeArray:function(){return this?.map(function(){var e=u.prop(this,"elements");return e?u.makeArray(e):this}).filter(function(){var e=this.type;return this.name&&!u(this).is(":disabled")&&jn.test(this.nodeName)&&!bn.test(e)&&(this.checked||!Ct.test(e))})?.map(function(e,t){var i=u(this).val();return i==null?null:Array.isArray(i)?u?.map(i,function(l){return{name:t.name,value:l.replace(Hr,`\r +`)}}):{name:t.name,value:i.replace(Hr,`\r +`)}}).get()}});var Dn=/%20/g,kn=/#.*$/,wn=/([?&])_=[^&]*/,En=/^(.*?):[ \t]*([^\r\n]*)$/mg,Nn=/^(?:about|app|app-storage|.+-extension|file|res|widget):$/,Cn=/^(?:GET|HEAD)$/,Sn=/^\/\//,Yr={},ar={},Br="*/".concat("*"),ir=A.createElement("a");ir.href=Rt.href;function qr(e){return function(t,i){typeof t!="string"&&(i=t,t="*");var l,f=0,h=t.toLowerCase().match(Ne)||[];if(M(i))for(;l=h[f++];)l[0]==="+"?(l=l.slice(1)||"*",(e[l]=e[l]||[]).unshift(i)):(e[l]=e[l]||[]).push(i)}}function Jr(e,t,i,l){var f={},h=e===ar;function x(b){var v;return f[b]=!0,u.each(e[b]||[],function(w,R){var I=R(t,i,l);if(typeof I=="string"&&!h&&!f[I])return t.dataTypes.unshift(I),x(I),!1;if(h)return!(v=I)}),v}return x(t.dataTypes[0])||!f["*"]&&x("*")}function sr(e,t){var i,l,f=u.ajaxSettings.flatOptions||{};for(i in t)t[i]!==void 0&&((f[i]?e:l||(l={}))[i]=t[i]);return l&&u.extend(!0,e,l),e}function _n(e,t,i){for(var l,f,h,x,b=e.contents,v=e.dataTypes;v[0]==="*";)v.shift(),l===void 0&&(l=e.mimeType||t.getResponseHeader("Content-Type"));if(l){for(f in b)if(b[f]&&b[f].test(l)){v.unshift(f);break}}if(v[0]in i)h=v[0];else{for(f in i){if(!v[0]||e.converters[f+" "+v[0]]){h=f;break}x||(x=f)}h=h||x}if(h)return h!==v[0]&&v.unshift(h),i[h]}function Rn(e,t,i,l){var f,h,x,b,v,w={},R=e.dataTypes.slice();if(R[1])for(x in e.converters)w[x.toLowerCase()]=e.converters[x];for(h=R.shift();h;)if(e.responseFields[h]&&(i[e.responseFields[h]]=t),!v&&l&&e.dataFilter&&(t=e.dataFilter(t,e.dataType)),v=h,h=R.shift(),h){if(h==="*")h=v;else if(v!=="*"&&v!==h){if(x=w[v+" "+h]||w["* "+h],!x){for(f in w)if(b=f.split(" "),b[1]===h&&(x=w[v+" "+b[0]]||w["* "+b[0]],x)){x===!0?x=w[f]:w[f]!==!0&&(h=b[0],R.unshift(b[1]));break}}if(x!==!0)if(x&&e.throws)t=x(t);else try{t=x(t)}catch(I){return{state:"parsererror",error:x?I:"No conversion from "+v+" to "+h}}}}return{state:"success",data:t}}u.extend({active:0,lastModified:{},etag:{},ajaxSettings:{url:Rt.href,type:"GET",isLocal:Nn.test(Rt.protocol),global:!0,processData:!0,async:!0,contentType:"application/x-www-form-urlencoded; charset=UTF-8",accepts:{"*":Br,text:"text/plain",html:"text/html",xml:"application/xml, text/xml",json:"application/json, text/javascript"},contents:{xml:/\bxml\b/,html:/\bhtml/,json:/\bjson\b/},responseFields:{xml:"responseXML",text:"responseText",json:"responseJSON"},converters:{"* text":String,"text html":!0,"text json":JSON.parse,"text xml":u.parseXML},flatOptions:{url:!0,context:!0}},ajaxSetup:function(e,t){return t?sr(sr(e,u.ajaxSettings),t):sr(u.ajaxSettings,e)},ajaxPrefilter:qr(Yr),ajaxTransport:qr(ar),ajax:function(e,t){typeof e=="object"&&(t=e,e=void 0),t=t||{};var i,l,f,h,x,b,v,w,R,I,C=u.ajaxSetup({},t),F=C.context||C,ne=C.context&&(F.nodeType||F.jquery)?u(F):u.event,de=u.Deferred(),oe=u.Callbacks("once memory"),Se=C.statusCode||{},Ce={},Ke={},Qe="canceled",ce={readyState:0,getResponseHeader:function(he){var we;if(v){if(!h)for(h={};we=En.exec(f);)h[we[1].toLowerCase()+" "]=(h[we[1].toLowerCase()+" "]||[]).concat(we[2]);we=h[he.toLowerCase()+" "]}return we==null?null:we.join(", ")},getAllResponseHeaders:function(){return v?f:null},setRequestHeader:function(he,we){return v==null&&(he=Ke[he.toLowerCase()]=Ke[he.toLowerCase()]||he,Ce[he]=we),this},overrideMimeType:function(he){return v==null&&(C.mimeType=he),this},statusCode:function(he){var we;if(he)if(v)ce.always(he[ce.status]);else for(we in he)Se[we]=[Se[we],he[we]];return this},abort:function(he){var we=he||Qe;return i&&i.abort(we),ft(0,we),this}};if(de.promise(ce),C.url=((e||C.url||Rt.href)+"").replace(Sn,Rt.protocol+"//"),C.type=t.method||t.type||C.method||C.type,C.dataTypes=(C.dataType||"*").toLowerCase().match(Ne)||[""],C.crossDomain==null){b=A.createElement("a");try{b.href=C.url,b.href=b.href,C.crossDomain=ir.protocol+"//"+ir.host!=b.protocol+"//"+b.host}catch{C.crossDomain=!0}}if(C.data&&C.processData&&typeof C.data!="string"&&(C.data=u.param(C.data,C.traditional)),Jr(Yr,C,t,ce),v)return ce;w=u.event&&C.global,w&&u.active++===0&&u.event.trigger("ajaxStart"),C.type=C.type.toUpperCase(),C.hasContent=!Cn.test(C.type),l=C.url.replace(kn,""),C.hasContent?C.data&&C.processData&&(C.contentType||"").indexOf("application/x-www-form-urlencoded")===0&&(C.data=C.data.replace(Dn,"+")):(I=C.url.slice(l.length),C.data&&(C.processData||typeof C.data=="string")&&(l+=(rr.test(l)?"&":"?")+C.data,delete C.data),C.cache===!1&&(l=l.replace(wn,"$1"),I=(rr.test(l)?"&":"?")+"_="+Pr.guid+++I),C.url=l+I),C.ifModified&&(u.lastModified[l]&&ce.setRequestHeader("If-Modified-Since",u.lastModified[l]),u.etag[l]&&ce.setRequestHeader("If-None-Match",u.etag[l])),(C.data&&C.hasContent&&C.contentType!==!1||t.contentType)&&ce.setRequestHeader("Content-Type",C.contentType),ce.setRequestHeader("Accept",C.dataTypes[0]&&C.accepts[C.dataTypes[0]]?C.accepts[C.dataTypes[0]]+(C.dataTypes[0]!=="*"?", "+Br+"; q=0.01":""):C.accepts["*"]);for(R in C.headers)ce.setRequestHeader(R,C.headers[R]);if(C.beforeSend&&(C.beforeSend.call(F,ce,C)===!1||v))return ce.abort();if(Qe="abort",oe.add(C.complete),ce.done(C.success),ce.fail(C.error),i=Jr(ar,C,t,ce),!i)ft(-1,"No Transport");else{if(ce.readyState=1,w&&ne.trigger("ajaxSend",[ce,C]),v)return ce;C.async&&C.timeout>0&&(x=o.setTimeout(function(){ce.abort("timeout")},C.timeout));try{v=!1,i.send(Ce,ft)}catch(he){if(v)throw he;ft(-1,he)}}function ft(he,we,Tt,lr){var Ge,Ot,Xe,it,st,He=we;v||(v=!0,x&&o.clearTimeout(x),i=void 0,f=lr||"",ce.readyState=he>0?4:0,Ge=he>=200&&he<300||he===304,Tt&&(it=_n(C,ce,Tt)),!Ge&&u.inArray("script",C.dataTypes)>-1&&u.inArray("json",C.dataTypes)<0&&(C.converters["text script"]=function(){}),it=Rn(C,it,ce,Ge),Ge?(C.ifModified&&(st=ce.getResponseHeader("Last-Modified"),st&&(u.lastModified[l]=st),st=ce.getResponseHeader("etag"),st&&(u.etag[l]=st)),he===204||C.type==="HEAD"?He="nocontent":he===304?He="notmodified":(He=it.state,Ot=it.data,Xe=it.error,Ge=!Xe)):(Xe=He,(he||!He)&&(He="error",he<0&&(he=0))),ce.status=he,ce.statusText=(we||He)+"",Ge?de.resolveWith(F,[Ot,He,ce]):de.rejectWith(F,[ce,He,Xe]),ce.statusCode(Se),Se=void 0,w&&ne.trigger(Ge?"ajaxSuccess":"ajaxError",[ce,C,Ge?Ot:Xe]),oe.fireWith(F,[ce,He]),w&&(ne.trigger("ajaxComplete",[ce,C]),--u.active||u.event.trigger("ajaxStop")))}return ce},getJSON:function(e,t,i){return u.get(e,t,i,"json")},getScript:function(e,t){return u.get(e,void 0,t,"script")}}),u.each(["get","post"],function(e,t){u[t]=function(i,l,f,h){return M(l)&&(h=h||f,f=l,l=void 0),u.ajax(u.extend({url:i,type:t,dataType:h,data:l,success:f},u.isPlainObject(i)&&i))}}),u.ajaxPrefilter(function(e){var t;for(t in e.headers)t.toLowerCase()==="content-type"&&(e.contentType=e.headers[t]||"")}),u._evalUrl=function(e,t,i){return u.ajax({url:e,type:"GET",dataType:"script",cache:!0,async:!1,global:!1,converters:{"text script":function(){}},dataFilter:function(l){u.globalEval(l,t,i)}})},u.fn.extend({wrapAll:function(e){var t;return this[0]&&(M(e)&&(e=e.call(this[0])),t=u(e,this[0].ownerDocument).eq(0).clone(!0),this[0].parentNode&&t.insertBefore(this[0]),t?.map(function(){for(var i=this;i.firstElementChild;)i=i.firstElementChild;return i}).append(this)),this},wrapInner:function(e){return M(e)?this.each(function(t){u(this).wrapInner(e.call(this,t))}):this.each(function(){var t=u(this),i=t.contents();i.length?i.wrapAll(e):t.append(e)})},wrap:function(e){var t=M(e);return this.each(function(i){u(this).wrapAll(t?e.call(this,i):e)})},unwrap:function(e){return this.parent(e).not("body").each(function(){u(this).replaceWith(this.childNodes)}),this}}),u.expr.pseudos.hidden=function(e){return!u.expr.pseudos.visible(e)},u.expr.pseudos.visible=function(e){return!!(e.offsetWidth||e.offsetHeight||e.getClientRects().length)},u.ajaxSettings.xhr=function(){try{return new o.XMLHttpRequest}catch{}};var Mn={0:200,1223:204},Mt=u.ajaxSettings.xhr();E.cors=!!Mt&&"withCredentials"in Mt,E.ajax=Mt=!!Mt,u.ajaxTransport(function(e){var t,i;if(E.cors||Mt&&!e.crossDomain)return{send:function(l,f){var h,x=e.xhr();if(x.open(e.type,e.url,e.async,e.username,e.password),e.xhrFields)for(h in e.xhrFields)x[h]=e.xhrFields[h];e.mimeType&&x.overrideMimeType&&x.overrideMimeType(e.mimeType),!e.crossDomain&&!l["X-Requested-With"]&&(l["X-Requested-With"]="XMLHttpRequest");for(h in l)x.setRequestHeader(h,l[h]);t=function(b){return function(){t&&(t=i=x.onload=x.onerror=x.onabort=x.ontimeout=x.onreadystatechange=null,b==="abort"?x.abort():b==="error"?typeof x.status!="number"?f(0,"error"):f(x.status,x.statusText):f(Mn[x.status]||x.status,x.statusText,(x.responseType||"text")!=="text"||typeof x.responseText!="string"?{binary:x.response}:{text:x.responseText},x.getAllResponseHeaders()))}},x.onload=t(),i=x.onerror=x.ontimeout=t("error"),x.onabort!==void 0?x.onabort=i:x.onreadystatechange=function(){x.readyState===4&&o.setTimeout(function(){t&&i()})},t=t("abort");try{x.send(e.hasContent&&e.data||null)}catch(b){if(t)throw b}},abort:function(){t&&t()}}}),u.ajaxPrefilter(function(e){e.crossDomain&&(e.contents.script=!1)}),u.ajaxSetup({accepts:{script:"text/javascript, application/javascript, application/ecmascript, application/x-ecmascript"},contents:{script:/\b(?:java|ecma)script\b/},converters:{"text script":function(e){return u.globalEval(e),e}}}),u.ajaxPrefilter("script",function(e){e.cache===void 0&&(e.cache=!1),e.crossDomain&&(e.type="GET")}),u.ajaxTransport("script",function(e){if(e.crossDomain||e.scriptAttrs){var t,i;return{send:function(l,f){t=u(" + + + +
+ + diff --git a/apps/edr-freight-web/backoffice/public/_um/tinymce/CHANGELOG.md b/apps/edr-freight-web/backoffice/public/_um/tinymce/CHANGELOG.md new file mode 100644 index 000000000..dfa4751f4 --- /dev/null +++ b/apps/edr-freight-web/backoffice/public/_um/tinymce/CHANGELOG.md @@ -0,0 +1,3802 @@ +# Changelog +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html), +and is generated by [Changie](https://github.com/miniscruff/changie). + +## 7.9.3 - 2026-05-19 + +### Security +- Fixed media plugin `data-mce-object` injection leading to stored XSS. #TINY-14357 +- Fixed stored XSS vulnerability through `mce:protected` comments. #TINY-14353 +- Fixed stored XSS vulnerability through `data-mce-` prefixed `src`, `href`, `style` attributes. #TINY-14333 + +## 7.9.2 - 2026-02-11 + +### Deprecated +- The default value of `allow_html_in_comments` will change from `true` to `false` in TinyMCE 8.x. #TINY-11900 + +### Security +- Updated dependencies and parsing logic for enhanced content sanitization. HTML-like content in comments and certain legacy patterns are now sanitized more strictly when `xss_sanitization` is enabled (default). The Introduced `allow_html_in_comments` option provides control over comment node sanitization behavior. + #TINY-11900 +- Introduced `allow_html_in_comments` option (boolean, default: `true`) to control handling of HTML-like syntax in comment nodes. This option will default to `false` in TinyMCE 8.x. #TINY-11900 + +## 7.9.1 - 2025-05-29 + +### Improved +- Update `Notices` file and minified notices. #TINY-12091 + +## 7.9.0 - 2025-05-15 + +### Added +- Added new `disc` style option for unordered lists. #TINY-12015 + +### Improved +- The resize cursor now points in the correct direction for each resize mode. Patch contributed by daniloff200. ##GH-10189 +- If `style_formats` is empty, the button is now disabled. #TINY-12005 +- Inline dialog dropdowns reposition when the dialog is dragged or the window is scrolled. #TINY-11368 +- Bullet list icons were have been updated to better represent the default styles. #TINY-12014 + +### Changed +- The ContextFormSizeInput lock button is now centered instead of aligned to the end. #TINY-11916 +- Changed the default value of `advlist_bullet_styles` option to `default,disc,circle,square`. #TINY-12083 + +### Fixed +- Autolink no longer overrides already existing links when autolinking. #TINY-11836 +- Removed the deprecated CSS media selector `-ms-high-contrast`. #TINY-11876 +- The `mceInsertContent` command no longer deletes the parent block element when an anchor is selected. #TINY-11953 +- Table resizers are now visible when inline editor has a z-index property. #TINY-11981 +- Tabbing inside a `figcaption` element no longer displays two text insertion carets. #TINY-11997 +- Pressing Enter before a floating image no longer duplicates the image. #TINY-11676 +- Editor did not scroll into viewport on receiving focus on Chrome and Safari. #TINY-12017 +- Select UI elements was not properly styled on Chrome version 136. #TINY-12131 + +## 7.8.0 - 2025-04-09 + +### Added +- New subtoolbar support for context toolbars. #TINY-11748 +- New `extended_mathml_attributes` and `extended_mathml_elements` options. #TINY-11756 +- New `onboarding` option. #TINY-11931 + +### Improved +- Focus outline was misaligned with comment card border on saving an edit. #TINY-11329 +- The `editor.selection.scrollIntoView()` method now pads the target scroll area with a small margin, ensuring content doesn't sit at the very edge of the viewport. #TINY-11786 + +### Changed +- Changed promotional text and link. #TINY-11905 + +### Fixed +- Setting editor height to a `pt` or `em` value was ignoring min/max height settings. #TINY-11108 + +## 7.7.2 - 2025-03-19 + +### Fixed +- Error was thrown when pressing tab in the last cell of a non-editable table. #TINY-11797 +- Error was thrown when trying to use the context form API after a component was detached. #TINY-11781 +- Deleting an empty block within an
  • element would move cursor to the end of the
  • . #TINY-11763 +- Deleting an empty block that was between two lists would throw an Error when all three elements were nested inside a list. #TINY-11763 + +## 7.7.1 - 2025-03-05 + +### Fixed +- Skin UI content CSS was truncated when bundling, causing CSS styles to be missing. #TINY-11875 +- Context forms used to disappear if their input was disabled in the `onSetup` API. #TINY-11890 + +## 7.7.0 - 2025-02-20 + +### Added +- `link_attributes_postprocess` option that allows overriding attributes of a link that would be inserted through the link dialog. #TINY-11707 + +### Improved +- Improved visual indication of keyboard focus in annotations that contain an image. #TINY-11596 +- The type now defaults to `info` when `editor.notificationManager.open()` is used without a specified type or with an invalid one. #TINY-11661 + +### Changed +- Updated the `link` plugin behavior to move the cursor outside of the link when inserted or edited via the UI. Patch contributed by Philipp91. #GH-9998 + +### Fixed +- Keyboard navigation for size inputs in context forms. #TINY-11394 +- Keyboard navigation for context form sliders. #TINY-11482 +- The `insertContent` API was not replacing selected non-editable elements correctly. #TINY-11714 +- Context toolbar inputs had incorrect margins. #TINY-11624 +- Iframe aria text no longer suggests opening the help dialog when the help plugin is not enabled. #TINY-11672 +- Preview dialog no longer opens anchor links in a new tab. #TINY-11740 +- The `float` property was not properly removed on the image when converting a image into a captioned image. #TINY-11670 +- Expanding selection to word didn't work inside inline editing host elements. #TINY-11304 +- The `semantics` element in MathML was not properly retained when `annotation` elements were allowed. #TINY-11755 +- It was possible to tab to a toolbar group that had all children disabled. #TINY-11665 +- Keyboard navigation would get stuck on the 'more' toolbar button. #TINY-11762 +- Toolbar groups had both a `title` attribute and a custom tooltip, causing overlapping tooltips #TINY-11768 +- Toolbar text field did not render focus correctly. #TINY-11658 + +## 7.6.1 - 2025-01-22 + +### Fixed +- Text input was prevented in form elements in the contents of the editor. #TINY-11446 +- Opening a notification when the toolbar is positioned at the bottom of the editor threw an error. #TINY-11498 +- Table resize bars were not properly aligned for inline editors inside scrollable containers. #TINY-11215 + +## 7.6.0 - 2024-12-11 + +### Added +- It is now possible to create labeled groups in context toolbars. #TINY-11095 +- New `contextsliderform` and `contextsizeinput` context form types. #TINY-11342 +- New `back` function in `ContextFormApi` to go back to the previous toolbar. #TINY-11344 +- New `QuickbarInsertImage` command that is executed by the `quickimage` button. #TINY-11399 +- New `onSetup` function to the context form API. #TINY-11494 +- New `placeholder` to the context form input field API. #TINY-11459 +- New `disabled` option to restore the previous `readonly` mode behavior, allowing the editor to be displayed in a disabled state. #TINY-11488 + +### Improved +- Base64 data was not properly decoded due to unhandled URL-encoded characters. #TINY-9548 +- The `latin` list style type is now recognized as an alias for the `alpha` list style type. #TINY-11515 + +### Fixed +- Image selection was removed when calling `editor.nodeChanged()` while having focus inside the editor UI. #TINY-11437 +- Tooltip would not show for group toolbar button. #TINY-11391 +- Changing the table row type when a `contenteditable=false` cell was selected would not work as expected. #TINY-11383 +- The `samp` format was being applied as a `block` level format, instead of an `inline` format. #TINY-11390 +- Removed title attribute from dialog tree elements as they already have a tooltip. #TINY-11470 +- Fixed CSS bundling for skin UI content CSS. #TINY-11558 +- Fixed incorrect resource keys for CSS bundling JS files. #TINY-11558 + +## 7.5.0 - 2024-11-06 + +### Added +- Added support for using raw CSS in the list of possible colours, using the `color_map_raw` property. #GH-9788 + +### Improved +- Improved color picker aria support. #TINY-11291 + +### Fixed +- Autocompleter would not activate after applying an inline format like font size in some cases. #TINY-11273 +- The `toolbar-sticky-offset` would still be applied after entering fullscreen mode. #TINY-11137 +- Text and background color toolbar buttons would not be fully greyed out in readonly mode. #TINY-11313 +- Closing a nested modal dialog would lose focus from the editor. #TINY-11153 +- Inability to type '{' character on German keyboard layouts. #TINY-11395 + +## 7.4.1 - 2024-10-10 + +### Fixed +- Invalid HTML elements within SVG elements were not removed. #TINY-11332 + +## 7.4.0 - 2024-10-09 + +### Added +- New `context` property for all ui components. This allows buttons and menu items to be enabled or disabled based on whether their context matches a given predicate; status updates are checked on `init`, `NodeChange`, and `SwitchMode` events. #TINY-11211 +- Tree component now allows the addition of a custom icon. #TINY-11131 +- Added focus function to view button api. #TINY-11122 +- New option `allow_mathml_annotation_encodings` to opt-in to keep math annotations with specific encodings. #TINY-11166 +- Added global `color-active` LESS variable for use in editor skins. #TINY-11266 + +### Improved +- In read-only mode the editor now allows normal cursor movement and block element selection, including video playback. #TINY-11264 +- Pasting a table now places the cursor after the table instead of into the last cell. #TINY-11082 +- Dialog list dropdown menus now close when the browser window resizes. #TINY-11123 + +### Fixed +- Mouse hover on partially visible dialog collection elements no longer scrolls. #TINY-9915 +- Caret would unexpectedly shift to the non-editable table row above when pressing Enter. #TINY-11077 +- Deleting a selection in a list element would sometimes prevent the `input` event from being dispatched. #TINY-11100 +- Placing the cursor after a table with a br after it would misplace added newlines before the table instead of after. #TINY-11110 +- Sidebar could not be toggled until the skin was loaded. #TINY-11155 +- The image dialog lost focus after closing an image upload error alert. #TINY-11159 +- Copying tables to the clipboard did not correctly separate cells and rows for the "text/plain" MIME type. #TINY-10847 +- The editor resize handle was incorrectly rendered when all components were removed from the status bar. #TINY-11257 + +## 7.3.0 - 2024-08-07 + +### Added +- Colorpicker number input fields now show an error tooltip and error icon when invalid text has been entered. #TINY-10799 +- New `format-code` icon. #TINY-11018 + +### Improved +- When a full document was loaded as editor content the head elements were added to the body. #TINY-11053 + +### Fixed +- Unnecessary nbsp entities were inserted when typing at the edges of inline elements. #TINY-10854 +- Fixed JavaScript error when inserting a table using the context menu by adjusting the event order in `renderInsertTableMenuItem`. #TINY-6887 +- Notifications didn't position and resize properly when resizing the editor or toggling views. #TINY-10894 +- The pattern commands would execute even if the command was not enabled. #TINY-10994 +- Split button popups were incorrectly positioned when switching to fullscreen mode if the editor was inside a scrollable container. #TINY-10973 +- Sequential html comments would in some cases generate unwanted elements. #TINY-10955 +- The listbox component had a fixed width and was not a responsive ui element. #TINY-10884 +- Prevent default mousedown on toolbar buttons was causing misplaced focus bugs. #TINY-10638 +- Attempting to use focus commands on an editor where the cursor had last been in certain contentEditable="true" elements would fail. #TINY-11085 +- Colorpicker's hex-based input field showed the wrong validation error message. #TINY-11115 + +## 7.2.1 - 2024-07-03 + +### Fixed +- Text content could move unexpectedly when deleting a paragraph. #TINY-10590 +- Cursor would shift to the start of the editor body when focus was shifted to a noneditable cell of a table. #TINY-10127 +- Long translations of the bottom help text would cause minor graphical issues. #TINY-10961 +- Open Link button was disabled when selection partially covered a link or when multiple links were selected. #TINY-11009 + +## 7.2.0 - 2024-06-19 + +### Added +- Added `options.debug` API that logs the initial raw editor options to console. #TINY-10605 +- Added `referrerpolicy` as a valid attribute for an iframe element. #TINY-10374 +- New `onInit` and `stretched` properties to the `HtmlPanel` dialog component. #TINY-10900 +- Added support for querying the state of the `mceTogglePlainTextPaste` command. #TINY-10938 +- Added `for` option to dialog label components to improve accessibility. The value must be another component on the same dialog. #TINY-10971 + +### Improved +- Dialog slider components now emit an onChange event when using arrow keys. #TINY-10428 +- Accessibility for element path buttons, added tooltip to describe the button and removed incorrect `aria-level` attribute. #TINY-10891 +- Improve merging of inserted inline elements by removing nodes with redundant inheritable styles. #TINY-10869 +- Improved Find & Replace dialog accessibility by changing placeholders to labels. #TINY-10871 + +### Changed +- Replaced tiny branding logo with `Build with TinyMCE` text and logo. #TINY-11001 + +### Fixed +- Deleting in a `div` with preceeding `br` elements would sometimes throw errors. #TINY-10840 +- `autoresize_bottom_margin` was not reliably applied in some situations. #TINY-10793 +- Fixed cases where adding a newline around a br, table or img would not move the cursor to a new line. #TINY-10384 +- Focusing on `contenteditable="true"` element when using `editable_root: false` and inline mode causing selection to be shifted. #TINY-10820 +- Corrected the `role` attribute on listbox dialog components to `combobox` when there are no nested menu items. #TINY-10807 +- HTML entities that were double decoded in `noscript` elements caused an XSS vulnerability. #TINY-11019 +- It was possible to inject XSS HTML that was not matching the regexp when using the `noneditable_regexp` option. #TINY-11022 + +## 7.1.2 - 2024-06-05 + +### Fixed +- CSS color values set to `transparent` were incorrectly converted to '#000000`. #TINY-10916 + +## 7.1.1 - 2024-05-22 + +### Fixed +- Insert/Edit image dialog lost focus after the image upload completed. #TINY-10885 +- Deleting into a list from a paragraph that has an `img` tag could cause extra inline styles to be added. #TINY-10892 +- Resolved an issue where emojis configured with the `emojiimages` database were not loading correctly due to a broken CDN. #TINY-10878 +- Iframes in dialogs were not rendering rounded borders correctly. #TINY-10901 +- Autocompleter possible values are no longer capped at a length of 10. #TINY-10942 + +## 7.1.0 - 2024-05-08 + +### Added +- Parser support for math elements. #TINY-10809 +- New `math-equation` icon. #TINY-10804 + +### Improved +- Included `itemprop`, `itemscope` and `itemtype` as valid HTML5 attributes in the core schema. #TINY-9932 +- Notification accessibility improvements: added tooltips, keyboard navigation and shortcut to focus on notifications. #TINY-6925 +- Removed `aria-pressed` from the `More` button in sliding toolbar mode and replaced it with `aria-expanded`. #TINY-10795 +- The editor UI now renders correctly in Windows High Contrast Mode. #TINY-10781 + +### Fixed +- Backspacing in certain html setups resulted in data moving around unexpectedly. #TINY-10590 +- Dialog title markup changed to use an `h1` element instead of `div`. #TINY-10800 +- Dialog title was not announced in macOS VoiceOver, dialogs now use `aria-label` instead of `aria-labelledby` on macOS. #TINY-10808 +- Theme loader did not respect the suffix when it was loading skin CSS files. #TINY-10602 +- Custom block elements with colon characters would throw errors. #TINY-10813 +- Tab navigation in views didn't work. #TINY-10780 +- Video and audio elements could not be played on Safari. #TINY-10774 +- `ToggleToolbarDrawer` command did not toggle the toolbar in `sliding` mode when `{skipFocus: true}` parameter was passed. #TINY-10726 +- The buttons in the custom view header were clipped on when overflowing. #TINY-10741 +- In the custom view, the scrollbar of the container was not visible if its height was greater than the editor. #TINY-10741 +- Fixed accessibility issue by removing duplicate `role="menu"` attribute from color swatches. #TINY-10806 +- Fullscreen mode now prevents focus from leaving the editor. #TINY-10597 +- Open link context menu action did not work with selection surrounding a link. #TINY-10391 +- Styles were not retained when toggling a list on and off. #TINY-10837 +- Caret and placeholder text were invisible in Windows High Contrast Mode. #TINY-9811 +- Firefox did not announce the iframe title when `iframe_aria_text` was set. #TINY-10718 +- Notification width was not constrained to the width of the editor. #TINY-10886 +- Open link context menu action was not enabled for links on images. #TINY-10391 + +## 7.0.1 - 2024-04-10 + +### Fixed +- Toggle list behavior generated wrong html when the `forced_root_block` option was set to `div`. #TINY-10488 +- Tapping inside a composed text on Firefox Android would not close the autocompleter. #TINY-10715 +- An inline editor toolbar now behaves correctly in horizontally scrolled containers. #TINY-10684 +- Tooltips unintended shrinking and incorrectly positioned when shown in horizontally scrollable container. #TINY-10797 +- The status bar was invisible when the editor's height is short. #TINY-10705 + +## 7.0.0 - 2024-03-20 + +### Added +- New `license_key` option that must be set to `gpl` or a valid license key. #TINY-10681 +- New custom tooltip functionality, tooltip will be shown when hovering with a mouse or with keyboard focus. #TINY-9275 +- New `sandbox_iframes_exclusions` option that holds a list of URL host names to be excluded from iframe sandboxing when `sandbox_iframes` is set to `true`. #TINY-10350 +- Added 'getAllEmojis' api function to the emoticons plugin. #TINY-10572 +- Element preset support for the `valid_children` option and Schema.addValidChildren API. #TINY-9979 +- A new `trigger` property for block text pattern configurations, allowing pattern activation with either Space or Enter keys. #TINY-10324 +- onFocus callback for CustomEditor dialog component. #TINY-10596 +- icons for the import from Word, export to Word and export to PDF premium plugins. #TINY-10612 +- `data` is now a valid element in the Schema. #TINY-10611 +- More advanced schema config for custom elements. #TINY-9980 +- Custom tooltip for autocompleter, now visible on both mouse hover and keyboard focus, except single column cases. #TINY-9638 + +### Improved +- Included keyboard shortcut in custom tooltip for `ToolbarButton` and `ToolbarToggleButton`. #TINY-10487 +- Improved showing which element has focus for keyboard navigation. #TINY-9176 +- Custom tooltips will now show for items in `collection` which is rendered inside a dialog, on mouse hover and keyboard focus. #TINY-9637 +- Autocompleter will now work with IMEs. #TINY-10637 +- Make table ghost element better reflect height changes when resizing. #TINY-10658 + +### Changed +- TinyMCE is now licensed GPL Version 2 or later. #TINY-10578 +- `convert_unsafe_embeds` editor option is now defaulted to `true`. #TINY-10351 +- `sandbox_iframes` editor option is now defaulted to `true`. #TINY-10350 +- The DOMUtils.isEmpty API function has been modified to consider nodes containing only comments as empty. #TINY-10459 +- The `highlight_on_focus` option now defaults to true, adding a focus outline to every editor. #TINY-10574 +- Delay before the tooltip to show up, from 800ms to 300ms. #TINY-10475 +- Now `tox-view__pane` has `position: relative` instead of `static`. #TINY-10561 +- Update outbound link for statusbar Tiny logo #TINY-10494 +- Remove the height field from the `table` plugin cell dialog. The `table` plugin row dialog now controls the row height by setting the height on the `tr` element, not the `td` elements. #TINY-10617 +- Change table height resizing handling to remove heights from `td`/`th` elements and only apply to `tr` elements. #TINY-10589 +- Removed incorrect `aria-placeholder` attribute from editor body when `placeholder` option is set. #TINY-10452 +- The `tooltip` property for dialog's footer `togglebutton` is now optional. #TINY-10672 +- Changed the `media_url_resolver` option to use promises. #TINY-9154 +- `Styles` bespoke toolbar button fallback changed to `Formats` if `Paragraph` is not configured in `style_formats` option. #TINY-10603 +- Updated deprecation/removed console message. #TINY-10694 + +### Removed +- Deprecated `force_hex_color` option, with the default now being all colors are forced to hex format as lower case. #TINY-10436 +- Deprecated `remove_trailing_brs` option from DomParser. #TINY-10454 +- `title` attribute on buttons with visible label. #TINY-10453 +- `InsertOrderedList` and `InsertUnorderedList` commands from core, these now only exist in the `lists` plugin. #TINY-10644 +- `closeButton` from the notification API, close buttons in notifications are now required. #TINY-10646 +- The autocompleter `ch` configuration property has been removed. Use the `trigger` property instead. #TINY-8929 +- Deprecated `template` plugin. #TINY-10654 + +### Fixed +- When deleting the last row in a table, the cursor would jump to the first cell (top left), instead of moving to the next adjacent cell in some cases. #TINY-6309 +- Heading formatting would be partially applied to the content within the `summary` element when the caret was positioned between words. #TINY-10312 +- Moving focus to the outside of the editor after having clicked a menu would not fire a `blur` event as expected. #TINY-10310 +- Autocomplete would sometimes cause corrupt data when starting during text composition. #TINY-10317 +- Inline mode with persisted toolbar would show regardless of the skin being loaded, causing css issues. #TINY-10482 +- Table classes couldn't be removed via setting an empty value in `table_class_list`. Also fixed being forced to pick the first class option. #TINY-6653 +- Directly right clicking on a ol's li in FireFox didn't enable the button `List Properties...` in the context menu. #TINY-10490 +- The `link_default_target` option wasn't considered when inserting a link via `quicklink` toolbar. #TINY-10439 +- When inline editor toolbar wrapped to multiple lines the top wasn't always calculated correctly. #TINY-10580 +- Removed manually dispatching dragend event on drop in Firefox. #TINY-10389 +- Slovenian help dialog content had a dot in the wrong place. #TINY-10601 +- Pressing Backspace at the start of an empty `summary` element within a `details` element nested in a list item no longer removes the `summary` element. #TINY-10303 +- The toolbar width was miscalculated for the inline editor positioned inside a scrollable container. #TINY-10581 +- Fixed incorrect object processor for `event_root` option. #TINY-10433 +- Adding newline after using `selection.setContent` to insert a block element would throw an unhandled exception. #TINY-10560 +- Floating toolbar buttons in inline editor incorrectly wrapped into multiple rows on window resizing or zooming. #TINY-10570 +- When setting table border width and `table_style_by_css` is true, only the border attribute is set to 0 and border-width styling is no longer used. #TINY-10308 +- Clicking to the left or right of a non-editable div in Firefox would show two cursors. #TINY-10314 + +## 6.8.3 - 2024-02-08 + +### Changed +- Update outbound TinyMCE website links. #TINY-10491 + +### Fixed +- The floating toolbar would not be fully visible when the editor was placed inside a scrollable container. #TINY-10335 +- ShadowDOM skin was not loaded properly when used with js bundling feature. #TINY-10451 + +## 6.8.2 - 2023-12-11 + +### Fixed +- Bespoke select toolbar buttons including `fontfamily`, `fontsize`, `blocks`, and `styles` incorrectly used plural words in their accessible names. #TINY-10426 +- The `align` bespoke select toolbar button had an accessible name that was misleading and grammatically incorrect in certain cases. #TINY-10435 +- Accessible names of bespoke select toolbar buttons including `align`, `fontfamily`, `fontsize`, `blocks`, and `styles` were incorrectly translated. #TINY-10426 #TINY-10435 +- Clicking inside table cells with heavily nested content could cause the browser to hang. #TINY-10380 +- Toggling a list that contains an LI element having another list as its first child would remove the remaining content within that LI element. #TINY-10414 + +## 6.8.1 - 2023-11-29 + +### Improved +- Colorpicker now includes the Brightness/Saturation selector and hue slider in the keyboard navigable items. #TINY-9287 + +### Fixed +- Translation syntax for announcement text in the table grid was incorrectly formatted. #TINY-10141 +- The functions `schema.isWrapper` and `schema.isInline` did not exclude node names that started with `#` which should not be considered as elements. #TINY-10385 + +## 6.8.0 - 2023-11-22 + +### Added +- CSS files are now also generated as separate JS files to improve bundling of all resources. #TINY-10352 +- Added new `StylesheetLoader.loadRawCss` API that can be used to load CSS into a style element. #TINY-10352 +- Added new `StylesheetLoader.unloadRawCss` API that can be used to unload CSS that was loaded into a style element. #TINY-10352 +- Added `force_hex_color` editor option. Option `'always'` converts all RGB & RGBA colours to hex, `'rgb_only'` will only convert RGB and *not* RGBA colours to hex, `'off'` won't convert any colours to hex. #TINY-9819 +- Added `default_font_stack` editor option that makes it possible to define what is considered a system font stack. #TINY-10290 +- New `sandbox_iframes` option that controls whether iframe elements will be added a `sandbox=""` attribute to mitigate malicious intent. #TINY-10348 +- New `convert_unsafe_embeds` option that controls whether `` and `` elements will be converted to more restrictive alternatives, namely `` for image MIME types, `