44 Commits

Author SHA1 Message Date
Sven laptop 3c35999a84 feat: add employee API integration and request lab component
Build and Push Docker Images / detect-changes (push) Successful in 13s
Build and Push Docker Images / build-auth-api (push) Successful in 1m19s
Build and Push Docker Images / build-employee-api (push) Successful in 1m1s
Build and Push Docker Images / build-transit-api (push) Successful in 59s
Build and Push Docker Images / build-error-log-api (push) Successful in 57s
Build and Push Docker Images / build-admin-ui (push) Successful in 1m14s
Build and Push Docker Images / build-monitoring-ui (push) Successful in 1m12s
Build and Push Docker Images / deploy-test-node (push) Successful in 3s
- Introduced EMPLOYEE_API endpoint in api.js for employee service.
- Added RequestLab component for testing API routes with a user-friendly interface.
- Updated monitoring_ui documentation to reflect changes in features and authentication.
- Removed unnecessary login functionality from monitoring_ui to maintain unauthenticated access.
- Enhanced styles for new components and improved overall UI consistency.
2026-10-05 20:03:53 +02:00
Sven laptop 30f4ca6701 Fix error logging
Build and Push Docker Images / detect-changes (push) Successful in 23s
Build and Push Docker Images / build-employee-api (push) Successful in 2m20s
Build and Push Docker Images / build-auth-api (push) Successful in 3m27s
Build and Push Docker Images / build-transit-api (push) Successful in 3m4s
Build and Push Docker Images / build-error-log-api (push) Successful in 2m38s
Build and Push Docker Images / build-admin-ui (push) Successful in 3m48s
Build and Push Docker Images / build-monitoring-ui (push) Successful in 3m45s
Build and Push Docker Images / deploy-test-node (push) Successful in 9s
2026-09-25 00:02:14 +02:00
Sven laptop 46fb9b9d6b Added docviewer for docks on the monitoring UI
Build and Push Docker Images / detect-changes (push) Successful in 29s
Build and Push Docker Images / build-auth-api (push) Successful in 2m1s
Build and Push Docker Images / build-employee-api (push) Successful in 2m1s
Build and Push Docker Images / build-transit-api (push) Successful in 2m9s
Build and Push Docker Images / build-error-log-api (push) Successful in 1m57s
Build and Push Docker Images / build-admin-ui (push) Successful in 2m50s
Build and Push Docker Images / build-monitoring-ui (push) Successful in 2m39s
Build and Push Docker Images / deploy-test-node (push) Successful in 6s
2026-09-24 20:24:53 +02:00
Sven laptop e169e37b5e Refactored all api endpoints to /bus/xxxxx
Build and Push Docker Images / detect-changes (push) Successful in 20s
Build and Push Docker Images / build-auth-api (push) Successful in 2m2s
Build and Push Docker Images / build-employee-api (push) Successful in 2m2s
Build and Push Docker Images / build-transit-api (push) Successful in 2m8s
Build and Push Docker Images / build-error-log-api (push) Successful in 1m58s
Build and Push Docker Images / build-monitoring-ui (push) Has been cancelled
Build and Push Docker Images / build-admin-ui (push) Has been cancelled
Build and Push Docker Images / deploy-test-node (push) Has been cancelled
2026-09-24 20:20:43 +02:00
Sven laptop 7d55580d87 Refactor transit_api into a repo/routes module split
Split the 5,441-line index.js into an 86-line bootstrap, thin domain
route modules, shared lib/ helpers, and a repo/ layer that isolates the
DEV_MOCK branches (repo/*.mock.js). Repo methods return {statusCode, body};
routes are one-line wrappers. Pin search_path to "transit.bus" and
document the /bus/ prefix with legacy root aliases (Deprecation header).

Parity verified: 57/57 byte-identical on the stub differential harness
plus 24/24 live read-only responses vs the original against real
PostgreSQL. The only intentional behavior change: mock reorder/delete
bus-line endpoints no longer require a live DB (previously 500 in
DEV_MOCK=1 without PG). DEV_MOCK gate and node --check clean.
2026-09-24 20:18:32 +02:00
Sven laptop 2ae1720383 Redesign admin UI as transit operations command center with sidebar navigation, design system, and dashboard 2026-09-24 16:54:37 +02:00
Sven laptop b766725b3b Remove .env.example 2026-09-23 20:12:21 +02:00
Sven laptop 2707bff6fd Add INTERNAL_API_KEY authentication for error_log_api; update all services to send key 2026-09-23 20:09:38 +02:00
Sven laptop b36975b680 Add comprehensive local logging to all microservices; log all errors locally and forward to error_log_api 2026-09-23 20:00:30 +02:00
Sven laptop 50634a3ed7 Remove Deprecated bus terminal
Build and Push Docker Images / detect-changes (push) Successful in 10s
Build and Push Docker Images / build-auth-api (push) Successful in 58s
Build and Push Docker Images / build-employee-api (push) Successful in 57s
Build and Push Docker Images / build-transit-api (push) Successful in 57s
Build and Push Docker Images / build-error-log-api (push) Successful in 59s
Build and Push Docker Images / build-admin-ui (push) Successful in 1m14s
Build and Push Docker Images / build-monitoring-ui (push) Successful in 1m11s
Build and Push Docker Images / deploy-test-node (push) Successful in 3s
2026-09-23 19:31:22 +02:00
Sven laptop 18c330854e help
Build and Push Docker Images / detect-changes (push) Successful in 10s
Build and Push Docker Images / build-auth-api (push) Successful in 1m14s
Build and Push Docker Images / build-employee-api (push) Successful in 59s
Build and Push Docker Images / build-transit-api (push) Successful in 1m0s
Build and Push Docker Images / build-error-log-api (push) Successful in 59s
Build and Push Docker Images / build-admin-ui (push) Successful in 1m15s
Build and Push Docker Images / build-monitoring-ui (push) Successful in 1m10s
Build and Push Docker Images / deploy-test-node (push) Successful in 4s
2026-09-23 18:44:36 +02:00
Sven laptop 0da6d07283 Add local logging to all microservices; auto-create error_log table on startup
Build and Push Docker Images / detect-changes (push) Successful in 9s
Build and Push Docker Images / build-auth-api (push) Successful in 1m3s
Build and Push Docker Images / build-employee-api (push) Successful in 59s
Build and Push Docker Images / build-transit-api (push) Successful in 56s
Build and Push Docker Images / build-error-log-api (push) Successful in 1m4s
Build and Push Docker Images / build-admin-ui (push) Successful in 1m20s
Build and Push Docker Images / build-monitoring-ui (push) Successful in 1m12s
Build and Push Docker Images / deploy-test-node (push) Successful in 3s
2026-09-23 18:29:46 +02:00
Sven laptop 04bb0e3347 Add error reporting middleware to error_log_api; fix boolean isAdmin in auth_api 2026-09-23 18:21:27 +02:00
Sven laptop a5924086f4 Fixed payload check
Build and Push Docker Images / detect-changes (push) Successful in 10s
Build and Push Docker Images / build-auth-api (push) Successful in 58s
Build and Push Docker Images / build-employee-api (push) Successful in 58s
Build and Push Docker Images / build-transit-api (push) Successful in 58s
Build and Push Docker Images / build-error-log-api (push) Successful in 1m9s
Build and Push Docker Images / build-admin-ui (push) Successful in 1m15s
Build and Push Docker Images / build-monitoring-ui (push) Successful in 1m12s
Build and Push Docker Images / deploy-test-node (push) Successful in 3s
2026-09-23 17:31:23 +02:00
Sven laptop 89e4f90fc3 Fix monitoring UI 2026-09-23 17:23:04 +02:00
Sven laptop 39b0b07a77 updated docker build to always deplyo 2026-09-23 17:22:14 +02:00
Sven laptop 48c710bfbf Fix admin check on monitoring UI
Build and Push Docker Images / detect-changes (push) Successful in 12s
Build and Push Docker Images / build-auth-api (push) Has been skipped
Build and Push Docker Images / build-employee-api (push) Has been skipped
Build and Push Docker Images / build-transit-api (push) Has been skipped
Build and Push Docker Images / build-error-log-api (push) Has been skipped
Build and Push Docker Images / build-admin-ui (push) Has been skipped
Build and Push Docker Images / build-monitoring-ui (push) Successful in 1m15s
Build and Push Docker Images / deploy-test-node (push) Successful in 3s
2026-09-23 17:12:41 +02:00
Sven laptop 1c055d84e8 Merge branch 'driver_ui' into dev
Build and Push Docker Images / build-auth-api (push) Has been skipped
Build and Push Docker Images / build-employee-api (push) Has been skipped
Build and Push Docker Images / detect-changes (push) Successful in 10s
Build and Push Docker Images / build-transit-api (push) Has been skipped
Build and Push Docker Images / build-error-log-api (push) Has been skipped
Build and Push Docker Images / build-admin-ui (push) Has been skipped
Build and Push Docker Images / build-monitoring-ui (push) Successful in 1m30s
Build and Push Docker Images / deploy-test-node (push) Successful in 3s
2026-09-23 10:00:20 +02:00
Sven laptop d7100e6f2a Add docs tab with local READMEs and login form to monitoring UI
Build and Push Docker Images / detect-changes (pull_request) Successful in 13s
Build and Push Docker Images / build-transit-api (pull_request) Has been skipped
Build and Push Docker Images / build-monitoring-ui (pull_request) Has been skipped
Build and Push Docker Images / deploy-test-node (pull_request) Has been skipped
Build and Push Docker Images / build-employee-api (pull_request) Has been skipped
Build and Push Docker Images / build-auth-api (pull_request) Has been skipped
Build and Push Docker Images / build-error-log-api (pull_request) Has been skipped
Build and Push Docker Images / build-admin-ui (pull_request) Has been skipped
Build and Push Docker Images / detect-changes (push) Successful in 9s
Build and Push Docker Images / build-auth-api (push) Successful in 1m0s
Build and Push Docker Images / build-employee-api (push) Successful in 59s
Build and Push Docker Images / build-transit-api (push) Successful in 57s
Build and Push Docker Images / build-error-log-api (push) Successful in 1m4s
Build and Push Docker Images / build-admin-ui (push) Successful in 1m17s
Build and Push Docker Images / build-monitoring-ui (push) Successful in 1m14s
Build and Push Docker Images / deploy-test-node (push) Successful in 3s
2026-09-22 00:14:01 +02:00
Sven laptop 6e0b7e4793 Empty commit for docker-compose.yml
Build and Push Docker Images / detect-changes (push) Successful in 9s
Build and Push Docker Images / build-auth-api (push) Has been skipped
Build and Push Docker Images / build-employee-api (push) Has been skipped
Build and Push Docker Images / build-transit-api (push) Has been skipped
Build and Push Docker Images / build-error-log-api (push) Has been skipped
Build and Push Docker Images / build-admin-ui (push) Has been skipped
Build and Push Docker Images / build-monitoring-ui (push) Successful in 1m11s
Build and Push Docker Images / deploy-test-node (push) Successful in 4s
2026-09-22 00:01:30 +02:00
Sven laptop da5c6b2839 added docs tab and improved authentication on dash
Build and Push Docker Images / detect-changes (push) Successful in 9s
Build and Push Docker Images / build-transit-api (push) Has been skipped
Build and Push Docker Images / build-auth-api (push) Has been skipped
Build and Push Docker Images / build-employee-api (push) Has been skipped
Build and Push Docker Images / build-error-log-api (push) Has been skipped
Build and Push Docker Images / build-admin-ui (push) Has been skipped
Build and Push Docker Images / build-monitoring-ui (push) Successful in 1m11s
Build and Push Docker Images / deploy-test-node (push) Has been skipped
2026-09-21 23:53:54 +02:00
Sven laptop 38782bca6d fixed 304 errors and removed admin ui from dash
Build and Push Docker Images / detect-changes (push) Successful in 10s
Build and Push Docker Images / build-auth-api (push) Has been skipped
Build and Push Docker Images / build-employee-api (push) Has been skipped
Build and Push Docker Images / build-transit-api (push) Has been skipped
Build and Push Docker Images / build-error-log-api (push) Has been skipped
Build and Push Docker Images / build-admin-ui (push) Has been skipped
Build and Push Docker Images / build-monitoring-ui (push) Successful in 1m15s
Build and Push Docker Images / deploy-test-node (push) Has been skipped
2026-09-21 23:36:07 +02:00
Sven laptop a17125bdee fixed monitor api polling
Build and Push Docker Images / detect-changes (push) Successful in 10s
Build and Push Docker Images / build-auth-api (push) Successful in 1m6s
Build and Push Docker Images / build-employee-api (push) Successful in 1m2s
Build and Push Docker Images / build-transit-api (push) Successful in 58s
Build and Push Docker Images / build-error-log-api (push) Successful in 1m10s
Build and Push Docker Images / build-admin-ui (push) Successful in 1m15s
Build and Push Docker Images / build-monitoring-ui (push) Successful in 1m16s
Build and Push Docker Images / deploy-test-node (push) Successful in 4s
2026-09-21 19:59:53 +02:00
Sven laptop 217dfd3abc Update readme's, branches and actions
Build and Push Docker Images / detect-changes (push) Successful in 9s
Build and Push Docker Images / build-auth-api (push) Successful in 1m5s
Build and Push Docker Images / build-employee-api (push) Successful in 1m1s
Build and Push Docker Images / build-transit-api (push) Successful in 57s
Build and Push Docker Images / build-error-log-api (push) Successful in 1m5s
Build and Push Docker Images / build-admin-ui (push) Successful in 1m16s
Build and Push Docker Images / build-monitoring-ui (push) Successful in 1m13s
Build and Push Docker Images / deploy-test-node (push) Successful in 5s
2026-09-21 00:15:05 +02:00
Sven laptop 635a130f0b ──────
Build and Push Docker Images / detect-changes (push) Successful in 11s
Build and Push Docker Images / build-auth-api (push) Successful in 1m0s
Build and Push Docker Images / build-employee-api (push) Successful in 1m2s
Build and Push Docker Images / build-transit-api (push) Successful in 1m1s
Build and Push Docker Images / build-error-log-api (push) Successful in 1m5s
Build and Push Docker Images / build-admin-ui (push) Successful in 1m13s
Build and Push Docker Images / build-monitoring-ui (push) Successful in 1m15s
Build and Push Docker Images / deploy-test-node (push) Successful in 3s
### Summary of Changes
  1. Documentation Consolidation:
      • Relocated the docs/ folder and all repository .md files (README.md, DEPLOYMENT.md, DOCKER_README.md,
      DOMAINS_README.md, GETTING_STARTED.md, CI_CD_PORTAINER_GUIDE.md, ADMIN_DEPLOYMENT_SUMMARY.md,
      SECURITY_PRIVATE_FILES.md, handoff.md, azure-project-plan.md, plus service-specific READMEs) into .
      • Updated document links in README.md.
  2. AI Agent Project Map:
      • Created PROJECT_MAP.md in the project root containing complete architecture diagrams, service specs, ports, DB
      schemas, environment variables, and developer workflows.
      • Added PROJECT_MAP.md to .gitignore.
  3. Monitoring UI & Infrastructure Integration:
      • Added monitoring_ui service to docker-compose.yml (port 5175).
      • Configured Gitea workflow docker-build.yml for monitoring_ui builds.
      • Added CORS middleware headers in index.js.
      • Added nginx.conf copy step in Dockerfile.

  ──────
  ### Recommended Commit Message

    docs: consolidate project documentation into monitoring_ui and add agent project map

    - Move all markdown documentation files and docs/ folder into monitoring_ui/docs/
    - Update documentation hub index (monitoring_ui/docs/README.md) with relative paths
    - Generate comprehensive AI agent reference map (PROJECT_MAP.md) in repository root
    - Add PROJECT_MAP.md and monitoring_ui build output/env overrides to .gitignore
    - Integrate monitoring_ui service into docker-compose.yml and Gitea CI workflow
    - Configure CORS handling in employee_api for monitoring dashboard access
    ──────
  ### Alternative Short Commit Message

    refactor(docs): move docs to monitoring_ui and create AI agent PROJECT_MAP.md
2026-09-20 23:33:55 +02:00
Sven laptop f935bbeb8c updated transit api
Build and Push Docker Images / detect-changes (push) Successful in 10s
Build and Push Docker Images / build-auth-api (push) Successful in 1m0s
Build and Push Docker Images / build-employee-api (push) Successful in 1m3s
Build and Push Docker Images / build-transit-api (push) Successful in 59s
Build and Push Docker Images / build-error-log-api (push) Successful in 1m9s
Build and Push Docker Images / build-admin-ui (push) Successful in 1m20s
Build and Push Docker Images / deploy-test-node (push) Successful in 4s
2026-09-18 20:08:10 +02:00
Sven laptop 1154d1d658 chore: standardize service domains and error logging
Build and Push Docker Images / detect-changes (push) Successful in 15s
Build and Push Docker Images / build-auth-api (push) Successful in 1m2s
Build and Push Docker Images / build-employee-api (push) Successful in 1m2s
Build and Push Docker Images / build-transit-api (push) Successful in 1m2s
Build and Push Docker Images / build-error-log-api (push) Successful in 1m10s
Build and Push Docker Images / build-admin-ui (push) Successful in 1m22s
Build and Push Docker Images / deploy-test-node (push) Successful in 4s
2026-09-18 19:26:05 +02:00
Sven laptop 3f0a02aa04 feat: add weekly schedule shifts and bulk departures
Build and Push Docker Images / detect-changes (push) Successful in 11s
Build and Push Docker Images / build-auth-api (push) Successful in 1m1s
Build and Push Docker Images / build-employee-api (push) Successful in 59s
Build and Push Docker Images / build-transit-api (push) Successful in 56s
Build and Push Docker Images / build-error-log-api (push) Successful in 1m11s
Build and Push Docker Images / build-admin-ui (push) Successful in 1m37s
Build and Push Docker Images / deploy-test-node (push) Successful in 5s
2026-09-18 18:00:51 +02:00
Sven laptop ebcc485f51 updated ui to use multiple screens 2026-09-18 17:15:00 +02:00
Sven laptop c09b3f3f66 created driver_ui with login and viewing assigned routes 2026-09-18 00:03:58 +02:00
Sven laptop 9d1a37e248 Update readme's, branches and actions
Build and Push Docker Images / detect-changes (push) Successful in 10s
Build and Push Docker Images / build-auth-api (push) Successful in 58s
Build and Push Docker Images / build-employee-api (push) Successful in 58s
Build and Push Docker Images / build-transit-api (push) Successful in 59s
Build and Push Docker Images / build-error-log-api (push) Successful in 1m8s
Build and Push Docker Images / build-admin-ui (push) Successful in 1m14s
Build and Push Docker Images / deploy-test-node (push) Successful in 5s
2026-09-17 12:04:44 +02:00
Sven laptop fdbdb98b21 Deploy registry images through Portainer
Build and Push Docker Images / build-and-push (./admin_ui, admin_ui) (push) Successful in 1m18s
Build and Push Docker Images / build-and-push (./auth_api, auth_api) (push) Successful in 59s
Build and Push Docker Images / build-and-push (./employee_api, employee_api) (push) Successful in 1m0s
Build and Push Docker Images / build-and-push (./error_log_api, error_log_api) (push) Successful in 1m18s
Build and Push Docker Images / build-and-push (./transit_api, transit_api) (push) Successful in 1m1s
Build and Push Docker Images / deploy-test-node (push) Successful in 4s
2026-09-16 19:01:53 +02:00
Sven laptop 0d37750855 Updated docker deploy workflow
Build and Push Docker Images / build-and-push (./admin_ui, admin_ui) (push) Successful in 1m17s
Build and Push Docker Images / build-and-push (./auth_api, auth_api) (push) Successful in 59s
Build and Push Docker Images / build-and-push (./error_log_api, error_log_api) (push) Has been cancelled
Build and Push Docker Images / build-and-push (./transit_api, transit_api) (push) Has been cancelled
Build and Push Docker Images / deploy-test-node (push) Has been cancelled
Build and Push Docker Images / build-and-push (./employee_api, employee_api) (push) Has been cancelled
2026-09-16 18:59:09 +02:00
Sven laptop e95b10abbb Update to gitea actions
Build and Push Docker Images / build-and-push (./admin_ui, admin_ui) (push) Successful in 1m23s
Build and Push Docker Images / build-and-push (./auth_api, auth_api) (push) Successful in 1m2s
Build and Push Docker Images / build-and-push (./employee_api, employee_api) (push) Successful in 1m6s
Build and Push Docker Images / build-and-push (./error_log_api, error_log_api) (push) Successful in 1m13s
Build and Push Docker Images / build-and-push (./transit_api, transit_api) (push) Successful in 1m1s
2026-09-16 18:35:43 +02:00
Sven laptop 9a56b40f50 refactor: update ADMIN_USER_CODES configuration; move to auth_api and clarify usage in documentation
Build and Push Docker Images / build-auth-api (push) Has been cancelled
Build and Push Docker Images / build-employee-api (push) Failing after 2m52s
Build and Push Docker Images / build-admin-ui (push) Failing after 24s
Build and Push Docker Images / build-transit-api (push) Failing after 26s
Build and Push Docker Images / build-error-log-api (push) Failing after 27s
2026-05-18 00:33:43 +02:00
Sven laptop 0e19aa0521 feat: add centralized error logging system
- Implemented Error Log API to capture and store error logs from various services.
- Updated admin_ui to include Error Log Viewer component for inspecting logs.
- Enhanced error handling in auth_api, employee_api, and transit_api to report errors to the Error Log API.
- Modified existing services to utilize the new error logging functionality.
- Updated Docker Compose configuration to include the Error Log API service.
- Added error logging service in bus_terminal for Flutter application.
2026-05-17 23:58:23 +02:00
Sven laptop 73de0d713e Enhance navigation and error handling; implement global navigator and messenger keys, update schedule progress retrieval, and improve error messaging in various services 2026-05-17 22:29:41 +02:00
Sven laptop 0e10757c49 Enhance FixedScheduleEditor; implement driver assignment dialog and conflict checking for bulk schedules 2026-05-14 01:30:50 +02:00
Sven laptop ad48e79db2 Enhance FixedScheduleEditor; add time manipulation functions, support for batch wrapping, and improve preset management 2026-05-14 01:04:50 +02:00
Sven laptop b04dfba4c7 feat: add bulk schedule creation endpoint and UI for managing fixed schedules
- Implemented a new POST endpoint for bulk schedule creation in transit_api/index.js.
- Added FixedScheduleEditor component for creating and editing single and batch schedules in admin_ui.
- Introduced TimetableDisplay component for displaying daily schedules with filter options.
- Enhanced error handling and validation for schedule entries.
- Added functionality for saving and loading schedule presets in local storage.
2026-05-13 19:54:55 +02:00
Sven laptop 6e62a9cac1 Enhance schedule management; implement recurring schedules retrieval, update TimetableViewer to display summary, and improve schedule loading logic 2026-05-13 18:15:08 +02:00
Sven laptop 1e41c05214 Enhance schedule management; add recurring schedules screen, implement schedule editing functionality, and improve schedule loading and filtering in ScheduleMaker 2026-05-13 17:41:25 +02:00
Sven laptop 86ffce07b0 Enhance schedule stop progress; add scheduled_passed_date column and update unique constraints 2026-05-12 12:05:34 +02:00
Sven laptop 8caf189c4c Enhance schedule progress tracking; add support for occurrence dates in schedule stop progress and retrieval 2026-05-12 11:54:14 +02:00
282 changed files with 22487 additions and 14566 deletions
+536
View File
@@ -0,0 +1,536 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Command Center | XTransit</title>
<link rel="stylesheet" href="./theme.css" />
</head>
<body>
<div class="preview-root">
<header class="preview-header">
<div class="preview-header__brand">
<span class="preview-header__logo">XT</span>
<span>XTransit Operations</span>
</div>
<div class="preview-header__actions">
<button
class="preview-btn preview-btn--ghost"
aria-label="Search services"
>
<svg class="preview-icon" viewBox="0 0 24 24" aria-hidden="true">
<circle cx="11" cy="11" r="6.5" stroke-width="1.8"></circle>
<path
d="m16 16 4.5 4.5"
stroke-width="1.8"
stroke-linecap="round"
></path>
</svg>
<span>Search</span>
</button>
<button
class="preview-btn preview-btn--ghost"
aria-label="View notifications"
>
<svg class="preview-icon" viewBox="0 0 24 24" aria-hidden="true">
<path
d="M18 9a6 6 0 0 0-12 0c0 7-3 7-3 9h18c0-2-3-2-3-9Z"
stroke-width="1.8"
stroke-linejoin="round"
></path>
<path
d="M10 21h4"
stroke-width="1.8"
stroke-linecap="round"
></path>
</svg>
<span class="preview-badge preview-badge--warning">2</span>
</button>
<span class="preview-avatar" aria-label="Signed in as Ava Morgan"
>AM</span
>
</div>
</header>
<nav class="preview-nav" aria-label="Primary navigation">
<a class="preview-nav__link preview-nav__link--active" href="#">
<svg class="preview-icon" viewBox="0 0 24 24" aria-hidden="true">
<rect
x="3.5"
y="3.5"
width="7"
height="7"
rx="1"
stroke-width="1.8"
></rect>
<rect
x="13.5"
y="3.5"
width="7"
height="7"
rx="1"
stroke-width="1.8"
></rect>
<rect
x="3.5"
y="13.5"
width="7"
height="7"
rx="1"
stroke-width="1.8"
></rect>
<rect
x="13.5"
y="13.5"
width="7"
height="7"
rx="1"
stroke-width="1.8"
></rect>
</svg>
<span>Command Center</span>
</a>
<a class="preview-nav__link" href="./service-catalog.html">
<svg class="preview-icon" viewBox="0 0 24 24" aria-hidden="true">
<path
d="M4 6.5h16M4 12h16M4 17.5h16"
stroke-width="1.8"
stroke-linecap="round"
></path>
<circle
cx="7"
cy="6.5"
r="1.4"
fill="currentColor"
stroke="none"
></circle>
<circle
cx="12"
cy="12"
r="1.4"
fill="currentColor"
stroke="none"
></circle>
<circle
cx="17"
cy="17.5"
r="1.4"
fill="currentColor"
stroke="none"
></circle>
</svg>
<span>Service Catalog</span>
</a>
<a class="preview-nav__link" href="./request-lab.html">
<svg class="preview-icon" viewBox="0 0 24 24" aria-hidden="true">
<path
d="M5 4.5h14v15H5z"
stroke-width="1.8"
stroke-linejoin="round"
></path>
<path
d="M8 8h8M8 12h8M8 16h5"
stroke-width="1.8"
stroke-linecap="round"
></path>
</svg>
<span>Request Lab</span>
</a>
<a class="preview-nav__link" href="./incident-log.html">
<svg class="preview-icon" viewBox="0 0 24 24" aria-hidden="true">
<path
d="M12 3.5 20 7v5c0 4.2-3.2 7.4-8 8.8C7.2 19.4 4 16.2 4 12V7l8-3.5Z"
stroke-width="1.8"
stroke-linejoin="round"
></path>
<path
d="M12 8v4M12 15.5v.1"
stroke-width="1.8"
stroke-linecap="round"
></path>
</svg>
<span>Incident Log</span>
</a>
</nav>
<div class="preview-shell">
<aside class="preview-sidebar" aria-label="Command center sections">
<div class="preview-sidebar__section">Monitor</div>
<a
class="preview-sidebar__item preview-sidebar__item--active"
href="#"
>
<svg class="preview-icon" viewBox="0 0 24 24" aria-hidden="true">
<path
d="M4 17.5h16M6.5 14V9M12 14V5M17.5 14v-3"
stroke-width="1.8"
stroke-linecap="round"
></path>
<path
d="M4 19.5h16"
stroke-width="1.8"
stroke-linecap="round"
></path>
</svg>
<span>Overview</span>
</a>
<a class="preview-sidebar__item" href="./service-catalog.html">
<svg class="preview-icon" viewBox="0 0 24 24" aria-hidden="true">
<rect
x="4"
y="4"
width="6"
height="6"
rx="1"
stroke-width="1.8"
></rect>
<rect
x="14"
y="4"
width="6"
height="6"
rx="1"
stroke-width="1.8"
></rect>
<rect
x="4"
y="14"
width="6"
height="6"
rx="1"
stroke-width="1.8"
></rect>
<rect
x="14"
y="14"
width="6"
height="6"
rx="1"
stroke-width="1.8"
></rect>
</svg>
<span>Services</span>
</a>
<a class="preview-sidebar__item" href="./incident-log.html">
<svg class="preview-icon" viewBox="0 0 24 24" aria-hidden="true">
<path
d="M5 4.5h14v15H5z"
stroke-width="1.8"
stroke-linejoin="round"
></path>
<path
d="M8 8h8M8 12h6M8 16h4"
stroke-width="1.8"
stroke-linecap="round"
></path>
</svg>
<span>Activity</span>
</a>
<div class="preview-sidebar__section">Workspace</div>
<a class="preview-sidebar__item" href="./request-lab.html">
<svg class="preview-icon" viewBox="0 0 24 24" aria-hidden="true">
<path
d="m8 8-4 4 4 4M16 8l4 4-4 4M14 5l-4 14"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
></path>
</svg>
<span>Request Lab</span>
</a>
<a class="preview-sidebar__item" href="./incident-log.html">
<svg class="preview-icon" viewBox="0 0 24 24" aria-hidden="true">
<path
d="M12 4.5v8l5 3"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
></path>
<circle cx="12" cy="12" r="8.5" stroke-width="1.8"></circle>
</svg>
<span>Incident Log</span>
</a>
</aside>
<main class="preview-main">
<section class="preview-hero" aria-labelledby="page-title">
<div class="preview-hero__mesh"></div>
<div class="preview-hero__eyebrow">Operations / Live overview</div>
<h1 class="preview-hero__title" id="page-title">Command Center</h1>
<p class="preview-hero__subtitle">
Current health, polling freshness, and important service signals
across the XTransit platform.
</p>
<div class="preview-hero__actions">
<button class="preview-btn preview-btn--primary">
<svg
class="preview-icon"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
d="M12 4v8l4 2"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
></path>
<path
d="M19.5 12a7.5 7.5 0 1 1-2.2-5.3"
stroke-width="1.8"
stroke-linecap="round"
></path>
<path
d="M17 4h3v3"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
></path>
</svg>
<span>Run health check</span>
</button>
<span class="preview-badge preview-badge--success"
><span class="preview-badge__dot"></span>Polling every 60
seconds</span
>
</div>
</section>
<section class="preview-kpi-row" aria-label="Platform health summary">
<article class="preview-kpi">
<svg
class="preview-icon preview-icon--lg"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
d="M4 19.5h16M6.5 16V8M12 16V4.5M17.5 16v-5"
stroke-width="1.8"
stroke-linecap="round"
></path>
</svg>
<span class="preview-kpi__label">Monitored services</span>
<strong class="preview-kpi__value">4</strong>
<span class="preview-kpi__delta">Across APIs and admin UI</span>
</article>
<article class="preview-kpi">
<svg
class="preview-icon preview-icon--lg"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
d="m5 12 4.5 4.5L19 7"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
></path>
<circle cx="12" cy="12" r="9" stroke-width="1.8"></circle>
</svg>
<span class="preview-kpi__label">Operational</span>
<strong class="preview-kpi__value">3</strong>
<span class="preview-kpi__delta preview-kpi__delta--up"
>75% of monitored services</span
>
</article>
<article class="preview-kpi">
<svg
class="preview-icon preview-icon--lg"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
d="M12 4.5 20 19H4L12 4.5Z"
stroke-width="1.8"
stroke-linejoin="round"
></path>
<path
d="M12 10v4M12 16.5v.1"
stroke-width="1.8"
stroke-linecap="round"
></path>
</svg>
<span class="preview-kpi__label">Degraded</span>
<strong class="preview-kpi__value">1</strong>
<span class="preview-kpi__delta"
>Error Log API needs attention</span
>
</article>
<article class="preview-kpi">
<svg
class="preview-icon preview-icon--lg"
viewBox="0 0 24 24"
aria-hidden="true"
>
<circle cx="12" cy="12" r="8.5" stroke-width="1.8"></circle>
<path
d="M12 7v5l3.5 2"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
></path>
</svg>
<span class="preview-kpi__label">Last poll</span>
<strong class="preview-kpi__value">18 sec ago</strong>
<span class="preview-kpi__delta">Next poll in 42 seconds</span>
</article>
</section>
<section class="preview-two-column" aria-label="Service signals">
<div>
<div class="preview-section-title">
<h2 class="preview-section-title__h">
<svg
class="preview-icon"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
d="M4 12h4l2-7 4 14 2-7h4"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
></path>
</svg>
Service health
</h2>
<span class="preview-section-title__hint"
>Live response checks</span
>
</div>
<div class="preview-table-wrap">
<table class="preview-table">
<thead>
<tr>
<th>Service</th>
<th>Latency</th>
<th>Status</th>
</tr>
</thead>
<tbody>
<tr>
<td>
<strong>Transit API</strong><br /><span
class="preview-section-title__hint"
>/health</span
>
</td>
<td>184 ms</td>
<td>
<span class="preview-badge preview-badge--success"
><span class="preview-badge__dot"></span
>Operational</span
>
</td>
</tr>
<tr>
<td>
<strong>Employee API</strong><br /><span
class="preview-section-title__hint"
>/health</span
>
</td>
<td>96 ms</td>
<td>
<span class="preview-badge preview-badge--success"
><span class="preview-badge__dot"></span
>Operational</span
>
</td>
</tr>
<tr>
<td>
<strong>Error Log API</strong><br /><span
class="preview-section-title__hint"
>/health</span
>
</td>
<td>121 ms</td>
<td>
<span class="preview-badge preview-badge--warning"
><span class="preview-badge__dot"></span
>Degraded</span
>
</td>
</tr>
<tr>
<td>
<strong>Admin UI</strong><br /><span
class="preview-section-title__hint"
>http://localhost:5173</span
>
</td>
<td>42 ms</td>
<td>
<span class="preview-badge preview-badge--success"
><span class="preview-badge__dot"></span
>Operational</span
>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div>
<div class="preview-section-title">
<h2 class="preview-section-title__h">
<svg
class="preview-icon"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
d="M4.5 5.5h15v13h-15z"
stroke-width="1.8"
stroke-linejoin="round"
></path>
<path
d="M8 9h8M8 13h5"
stroke-width="1.8"
stroke-linecap="round"
></path>
</svg>
Recent poll activity
</h2>
<span class="preview-section-title__hint">Updating</span>
</div>
<div
class="preview-card"
aria-label="Recent poll activity loading"
>
<div class="preview-skeleton preview-skeleton--title"></div>
<div class="preview-skeleton preview-skeleton--line"></div>
<div class="preview-skeleton preview-skeleton--line"></div>
<div class="preview-skeleton preview-skeleton--line"></div>
<div class="preview-skeleton preview-skeleton--block"></div>
</div>
</div>
</section>
<div class="preview-action-bar">
<span class="preview-section-title__hint"
>Signed in as Ava Morgan · Operator access</span
>
<button class="preview-btn preview-btn--secondary">
<svg class="preview-icon" viewBox="0 0 24 24" aria-hidden="true">
<path
d="M4 12a8 8 0 1 0 2.3-5.7"
stroke-width="1.8"
stroke-linecap="round"
></path>
<path
d="M4 5v5h5"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
></path>
</svg>
<span>Refresh overview</span>
</button>
</div>
</main>
</div>
</div>
</body>
</html>
+479
View File
@@ -0,0 +1,479 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Incident Log | Transit Operations</title>
<link rel="stylesheet" href="./theme.css" />
</head>
<body>
<div class="preview-root">
<header class="preview-header">
<div class="preview-header__brand">
<span class="preview-header__logo">TO</span>
<span>Transit Operations</span>
</div>
<div class="preview-header__actions">
<label class="preview-form__input" aria-label="Search console">
<svg
class="preview-icon preview-icon--sm"
viewBox="0 0 24 24"
aria-hidden="true"
>
<circle cx="11" cy="11" r="6.5" stroke-width="1.8"></circle>
<path
d="m16 16 5 5"
stroke-width="1.8"
stroke-linecap="round"
></path>
</svg>
<input
type="search"
placeholder="Search console"
aria-label="Search console"
style="
border: 0;
background: transparent;
outline: 0;
font: inherit;
width: 135px;
color: inherit;
"
/>
</label>
<a
class="preview-header__action"
href="#notifications"
aria-label="Notifications"
>
<svg class="preview-icon" viewBox="0 0 24 24" aria-hidden="true">
<path
d="M18 9a6 6 0 0 0-12 0c0 7-3 7-3 9h18c0-2-3-2-3-9ZM10 21h4"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
></path>
</svg>
</a>
<span class="preview-avatar" aria-label="Signed in as Maya Chen"
>MC</span
>
</div>
</header>
<nav class="preview-nav" aria-label="Primary navigation">
<a class="preview-nav__link" href="./command-center.html"
><svg
class="preview-icon preview-icon--sm"
viewBox="0 0 24 24"
aria-hidden="true"
>
<rect x="4" y="4" width="6" height="6" stroke-width="1.8"></rect>
<rect x="14" y="4" width="6" height="6" stroke-width="1.8"></rect>
<rect x="4" y="14" width="6" height="6" stroke-width="1.8"></rect>
<rect
x="14"
y="14"
width="6"
height="6"
stroke-width="1.8"
></rect></svg
>Command center</a
>
<a class="preview-nav__link" href="./service-catalog.html"
><svg
class="preview-icon preview-icon--sm"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
d="M4 20V5l8-2 8 2v15M4 20h16M8 8h2m4 0h2m-8 4h2m4 0h2m-8 4h2m4 0h2"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
></path></svg
>Service catalog</a
>
<a class="preview-nav__link" href="./request-lab.html"
><svg
class="preview-icon preview-icon--sm"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
d="m8 8-4 4 4 4M16 8l4 4-4 4M14 4l-4 16"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
></path></svg
>Request lab</a
>
<a
class="preview-nav__link preview-nav__link--active"
href="#"
aria-current="page"
><svg
class="preview-icon preview-icon--sm"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
d="M5 4h14v16H5zM8 8h8M8 12h8M8 16h5"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
></path></svg
>Incident log</a
>
</nav>
<div class="preview-shell">
<aside class="preview-sidebar" aria-label="Incident log navigation">
<div class="preview-sidebar__section">Monitor</div>
<a class="preview-sidebar__item" href="./command-center.html"
><svg
class="preview-icon preview-icon--sm"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
d="M4 13h4l2-7 4 12 2-5h4"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
></path></svg
>Command center</a
>
<a
class="preview-sidebar__item preview-sidebar__item--active"
href="#"
aria-current="page"
><svg
class="preview-icon preview-icon--sm"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
d="M5 4h14v16H5zM8 8h8M8 12h8M8 16h5"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
></path></svg
>Incident log</a
>
<div class="preview-sidebar__section">Services</div>
<a class="preview-sidebar__item" href="./service-catalog.html"
><svg
class="preview-icon preview-icon--sm"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
d="M4 20V5l8-2 8 2v15M4 20h16M8 8h2m4 0h2m-8 4h2m4 0h2"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
></path></svg
>Service catalog</a
>
<a class="preview-sidebar__item" href="./request-lab.html"
><svg
class="preview-icon preview-icon--sm"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
d="m8 8-4 4 4 4M16 8l4 4-4 4M14 4l-4 16"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
></path></svg
>Request lab</a
>
</aside>
<main class="preview-main">
<div class="preview-section-title">
<div>
<div class="preview-card__meta">MONITORING / INCIDENTS</div>
<h1
class="preview-hero__title"
style="color: var(--color-text); margin-top: 4px"
>
Incident log
</h1>
</div>
<a
class="preview-btn preview-btn--primary"
href="#refresh-incidents"
><svg
class="preview-icon preview-icon--sm"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
d="M20 11a8 8 0 1 0 1 4M20 5v6h-6"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
></path></svg
>Refresh incidents</a
>
</div>
<div
class="preview-action-bar"
style="justify-content: flex-start; padding: 0; border-top: 0"
>
<label class="preview-form__input"
><span class="preview-card__meta">Service</span
><select aria-label="Filter by service">
<option>All services</option>
<option>Auth API</option>
<option>Employee API</option>
<option>Transit API</option>
<option>Error Log API</option>
<option>Admin UI</option>
</select></label
>
<label class="preview-form__input"
><span class="preview-card__meta">Severity</span
><select aria-label="Filter by severity">
<option>All severities</option>
<option>Critical</option>
<option>Error</option>
<option>Warning</option>
<option>Info</option>
</select></label
>
<label class="preview-form__input"
><span class="preview-card__meta">Window</span
><select aria-label="Filter by time window">
<option>Last 24 hours</option>
<option>Last 7 days</option>
<option>Last 30 days</option>
</select></label
>
<span class="preview-card__meta"
>3 incidents in the selected window</span
>
</div>
<div class="preview-tabs" role="tablist" aria-label="Incident views">
<a
class="preview-tabs__tab preview-tabs__tab--active"
href="#all-incidents"
role="tab"
aria-selected="true"
>All incidents</a
>
<a
class="preview-tabs__tab"
href="#delivery-status"
role="tab"
aria-selected="false"
>Delivery status</a
>
</div>
<section id="all-incidents" aria-labelledby="all-incidents-heading">
<div class="preview-section-title">
<h2 class="preview-section-title__h" id="all-incidents-heading">
<svg
class="preview-icon"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
d="M5 4h14v16H5zM8 8h8M8 12h8M8 16h5"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
></path>
</svg>
Recent activity
</h2>
<span class="preview-section-title__hint"
>Normalized from five connected services</span
>
</div>
<div class="preview-table-wrap">
<table class="preview-table">
<thead>
<tr>
<th>Time</th>
<th>Service</th>
<th>Code</th>
<th>Message</th>
<th>Severity</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>09:42:18</strong></td>
<td>Error Log API</td>
<td><code>BAD_REQUEST</code></td>
<td>Missing route identifier</td>
<td>
<span class="preview-badge preview-badge--warning"
><span class="preview-badge__dot"></span>Warning</span
>
</td>
</tr>
<tr>
<td><strong>09:37:04</strong></td>
<td>Transit API</td>
<td><code>FORBIDDEN</code></td>
<td>Admin role required</td>
<td>
<span class="preview-badge"
><span class="preview-badge__dot"></span>Info</span
>
</td>
</tr>
<tr>
<td><strong>09:18:51</strong></td>
<td>Employee API</td>
<td><code>INTERNAL_ERROR</code></td>
<td>Employee lookup timed out</td>
<td>
<span class="preview-badge preview-badge--danger"
><span class="preview-badge__dot"></span>Critical</span
>
</td>
</tr>
</tbody>
</table>
</div>
</section>
<section class="preview-card-list" aria-labelledby="severity-heading">
<div class="preview-section-title">
<h2 class="preview-section-title__h" id="severity-heading">
<svg
class="preview-icon"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
d="M12 3 3 20h18L12 3ZM12 9v5m0 3h.01"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
></path>
</svg>
Severity summary
</h2>
<span class="preview-section-title__hint">Current window</span>
</div>
<div class="preview-card-grid">
<article class="preview-card">
<div class="preview-card__title">
<svg
class="preview-icon preview-icon--sm"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
d="M12 3 3 20h18L12 3Zm0 6v5m0 3h.01"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
></path>
</svg>
Warning
</div>
<strong class="preview-kpi__value">1</strong>
<p class="preview-card__body">
Requires review before the next dispatch cycle.
</p>
</article>
<article class="preview-card">
<div class="preview-card__title">
<svg
class="preview-icon preview-icon--sm"
viewBox="0 0 24 24"
aria-hidden="true"
>
<circle cx="12" cy="12" r="8.5" stroke-width="1.8"></circle>
<path
d="M12 8v4l2.5 2"
stroke-width="1.8"
stroke-linecap="round"
></path>
</svg>
Info
</div>
<strong class="preview-kpi__value">1</strong>
<p class="preview-card__body">
Access policy event recorded from Transit API.
</p>
</article>
<article class="preview-card">
<div class="preview-card__title">
<svg
class="preview-icon preview-icon--sm"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
d="M12 3 3 20h18L12 3Zm0 6v5m0 3h.01"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
></path>
</svg>
Critical
</div>
<strong class="preview-kpi__value">1</strong>
<p class="preview-card__body">
Employee lookup timeout needs immediate attention.
</p>
</article>
</div>
</section>
<section
id="delivery-status"
class="preview-error"
aria-labelledby="delivery-heading"
>
<svg
class="preview-icon preview-icon--lg preview-error__icon"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
d="M12 3 3 20h18L12 3Zm0 6v5m0 3h.01"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
></path>
</svg>
<div class="preview-error__body">
<strong class="preview-error__title" id="delivery-heading"
>Error log API delayed</strong
><span class="preview-error__text"
>Last successful pull was 3 minutes ago</span
>
</div>
<a class="preview-btn preview-btn--secondary" href="#retry-delivery"
><svg
class="preview-icon preview-icon--sm"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
d="M20 11a8 8 0 1 0 1 4M20 5v6h-6"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
></path></svg
>Retry</a
>
</section>
</main>
</div>
</div>
</body>
</html>
+30
View File
@@ -0,0 +1,30 @@
{
"generatedAt": "2026-09-20T00:00:00.000Z",
"previewStatus": "generating",
"pages": [
{
"slug": "command-center",
"title": "Command Center",
"route": "/",
"status": "pending"
},
{
"slug": "service-catalog",
"title": "Service Catalog",
"route": "/services",
"status": "pending"
},
{
"slug": "request-lab",
"title": "Request Lab",
"route": "/requests",
"status": "pending"
},
{
"slug": "incident-log",
"title": "Incident Log",
"route": "/incidents",
"status": "pending"
}
]
}
+495
View File
@@ -0,0 +1,495 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Request Lab | XTransit Operations</title>
<link rel="stylesheet" href="./theme.css" />
</head>
<body>
<div class="preview-root">
<header class="preview-header">
<div class="preview-header__brand">
<span class="preview-header__logo" aria-hidden="true">XT</span>
<span>XTransit Operations</span>
</div>
<div class="preview-header__actions">
<label class="preview-header__search" aria-label="Search operations">
<svg class="preview-icon" viewBox="0 0 24 24" aria-hidden="true">
<circle cx="11" cy="11" r="6.5" stroke-width="1.8"></circle>
<path
d="m16 16 4.2 4.2"
stroke-width="1.8"
stroke-linecap="round"
></path>
</svg>
<input
class="preview-form__input"
type="search"
placeholder="Search operations"
value=""
/>
</label>
<a class="preview-header__action" href="#" aria-label="Notifications">
<svg class="preview-icon" viewBox="0 0 24 24" aria-hidden="true">
<path
d="M18 9a6 6 0 0 0-12 0c0 7-3 7-3 9h18c0-2-3-2-3-9M10 21h4"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
></path>
</svg>
</a>
<span class="preview-avatar" aria-label="Signed in as Maya Chen"
>MC</span
>
</div>
</header>
<nav class="preview-nav" aria-label="Primary navigation">
<a class="preview-nav__link" href="./command-center.html"
><svg
class="preview-icon preview-icon--sm"
viewBox="0 0 24 24"
aria-hidden="true"
>
<rect
x="4"
y="4"
width="6"
height="6"
rx="1"
stroke-width="1.8"
></rect>
<rect
x="14"
y="4"
width="6"
height="6"
rx="1"
stroke-width="1.8"
></rect>
<rect
x="4"
y="14"
width="6"
height="6"
rx="1"
stroke-width="1.8"
></rect>
<rect
x="14"
y="14"
width="6"
height="6"
rx="1"
stroke-width="1.8"
></rect></svg
>Command Center</a
>
<a class="preview-nav__link" href="./service-catalog.html"
><svg
class="preview-icon preview-icon--sm"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
d="M5 5h14v14H5zM8 9h8M8 13h5"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
></path></svg
>Service Catalog</a
>
<a
class="preview-nav__link preview-nav__link--active"
href="#"
aria-current="page"
><svg
class="preview-icon preview-icon--sm"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
d="M4 6h16M4 12h10M4 18h16"
stroke-width="1.8"
stroke-linecap="round"
></path>
<circle cx="17" cy="12" r="2" stroke-width="1.8"></circle></svg
>Request Lab</a
>
<a class="preview-nav__link" href="./incident-log.html"
><svg
class="preview-icon preview-icon--sm"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
d="M12 4 21 20H3L12 4Z"
stroke-width="1.8"
stroke-linejoin="round"
></path>
<path
d="M12 9v5M12 17h.01"
stroke-width="1.8"
stroke-linecap="round"
></path></svg
>Incident Log</a
>
</nav>
<div class="preview-shell">
<aside class="preview-sidebar" aria-label="Operations sections">
<div class="preview-sidebar__section">Workspace</div>
<a class="preview-sidebar__item" href="./command-center.html"
><svg
class="preview-icon preview-icon--sm"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
d="M4 13h6V4H4v9ZM14 20h6v-9h-6v9ZM14 8h6V4h-6v4ZM4 20h6v-3H4v3Z"
stroke-width="1.8"
stroke-linejoin="round"
></path></svg
>Command Center</a
>
<a class="preview-sidebar__item" href="./service-catalog.html"
><svg
class="preview-icon preview-icon--sm"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
d="M7 4h10v4H7zM4 10h16v10H4zM8 14h8M8 17h5"
stroke-width="1.8"
stroke-linejoin="round"
stroke-linecap="round"
></path></svg
>Service Catalog</a
>
<a
class="preview-sidebar__item preview-sidebar__item--active"
href="#"
aria-current="page"
><svg
class="preview-icon preview-icon--sm"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
d="M5 5h14v14H5zM8 9h8M8 13h5M8 17h8"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
></path></svg
>Request Lab</a
>
<a class="preview-sidebar__item" href="./incident-log.html"
><svg
class="preview-icon preview-icon--sm"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
d="M12 4 21 20H3L12 4Z"
stroke-width="1.8"
stroke-linejoin="round"
></path>
<path
d="M12 9v5M12 17h.01"
stroke-width="1.8"
stroke-linecap="round"
></path></svg
>Incident Log</a
>
<div class="preview-sidebar__section">Tools</div>
<a class="preview-sidebar__item" href="#"
><svg
class="preview-icon preview-icon--sm"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
d="M12 3v4M12 17v4M3 12h4M17 12h4M5.6 5.6l2.8 2.8M15.6 15.6l2.8 2.8M18.4 5.6l-2.8 2.8M8.4 15.6l-2.8 2.8"
stroke-width="1.8"
stroke-linecap="round"
></path>
<circle cx="12" cy="12" r="3.5" stroke-width="1.8"></circle></svg
>Environment</a
>
</aside>
<main class="preview-main">
<div class="preview-section-title">
<div>
<div class="preview-section-title__h">
<svg
class="preview-icon"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
d="M4 5h16v14H4zM8 9h8M8 13h5"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
></path>
</svg>
Request Lab
</div>
<div class="preview-section-title__hint">
Run a bounded request against an approved upstream route and
inspect timing.
</div>
</div>
<span class="preview-badge"
><span class="preview-badge__dot"></span>Authenticated
session</span
>
</div>
<div class="preview-split">
<form class="preview-form" aria-label="Request builder">
<div class="preview-section-title">
<div class="preview-section-title__h">
<svg
class="preview-icon"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
d="M5 6h14M5 12h9M5 18h14"
stroke-width="1.8"
stroke-linecap="round"
></path>
<circle cx="17" cy="12" r="2" stroke-width="1.8"></circle>
</svg>
Request builder
</div>
<span class="preview-section-title__hint"
>Allowlisted routes only</span
>
</div>
<div class="preview-form__field">
<label class="preview-form__label" for="preset">Preset</label>
<select class="preview-form__select" id="preset">
<option>
Transit API GET /schedules/overview Schedule summary
</option>
<option>Transit API GET /admin/drivers Driver roster</option>
<option>Employee API GET /health Service health</option>
</select>
</div>
<div class="preview-two-column">
<div class="preview-form__field">
<label class="preview-form__label" for="service"
>Service</label
>
<select class="preview-form__select" id="service">
<option>Transit API</option>
<option>Employee API</option>
<option>Auth API</option>
</select>
</div>
<div class="preview-form__field">
<label class="preview-form__label" for="method">Method</label>
<select class="preview-form__select" id="method">
<option>GET</option>
<option>POST</option>
</select>
</div>
</div>
<div class="preview-form__field">
<label class="preview-form__label" for="path">Path</label>
<input
class="preview-form__input"
id="path"
type="text"
value="/schedules/overview"
/>
</div>
<div class="preview-two-column">
<div class="preview-form__field">
<label class="preview-form__label" for="timeout"
>Timeout</label
>
<input
class="preview-form__input"
id="timeout"
type="number"
value="5000"
/>
<span class="preview-form__hint">Milliseconds</span>
</div>
<div class="preview-form__field">
<label class="preview-form__label" for="query"
>Query fields</label
>
<input
class="preview-form__input"
id="query"
type="text"
value="date=2026-09-20"
/>
</div>
</div>
<div class="preview-form__field">
<label class="preview-form__label" for="body">Body</label>
<textarea
class="preview-form__textarea"
id="body"
placeholder="No request body for this GET route"
></textarea>
<span class="preview-form__hint"
>Safe limit: 10 requests per minute, 5-second timeout.</span
>
</div>
<section class="preview-error" aria-label="Request response">
<svg
class="preview-icon preview-icon--lg preview-error__icon"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
d="M12 4 21 20H3L12 4Z"
stroke-width="1.8"
stroke-linejoin="round"
></path>
<path
d="M12 9v5M12 17h.01"
stroke-width="1.8"
stroke-linecap="round"
></path>
</svg>
<div class="preview-error__body">
<div class="preview-error__title">Request blocked</div>
<div class="preview-error__text">
Admin role required for /admin/drivers
</div>
<button
class="preview-btn preview-btn--secondary"
type="button"
>
Retry request
</button>
</div>
</section>
</form>
<section class="preview-table-wrap" aria-labelledby="history-title">
<div
class="preview-section-title"
style="padding: 20px 20px 12px"
>
<div class="preview-section-title__h" id="history-title">
<svg
class="preview-icon"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
d="M4 5h16v15H4zM8 9h8M8 13h5M8 17h8"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
></path>
</svg>
Request history
</div>
<span class="preview-section-title__hint"
>Today, 09:00–12:00 UTC</span
>
</div>
<table class="preview-table">
<thead>
<tr>
<th>Request</th>
<th>Status</th>
<th>Duration</th>
<th>Time</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>GET</strong> /schedules/overview</td>
<td>
<span class="preview-badge preview-badge--success"
>200 OK</span
>
</td>
<td>214 ms</td>
<td>11:42:18</td>
</tr>
<tr>
<td><strong>GET</strong> /admin/drivers</td>
<td>
<span class="preview-badge preview-badge--danger"
>403</span
>
</td>
<td>88 ms</td>
<td>11:36:02</td>
</tr>
<tr>
<td><strong>GET</strong> /health</td>
<td>
<span class="preview-badge preview-badge--success"
>200 OK</span
>
</td>
<td>42 ms</td>
<td>11:20:44</td>
</tr>
<tr>
<td><strong>GET</strong> /schedules/overview</td>
<td>
<span class="preview-badge preview-badge--success"
>200 OK</span
>
</td>
<td>197 ms</td>
<td>10:58:09</td>
</tr>
<tr>
<td><strong>GET</strong> /admin/drivers</td>
<td>
<span class="preview-badge preview-badge--danger"
>403</span
>
</td>
<td>91 ms</td>
<td>10:41:27</td>
</tr>
</tbody>
</table>
</section>
</div>
<div class="preview-action-bar">
<span class="preview-form__hint"
>Requests are logged to the operations audit trail.</span
>
<button class="preview-btn preview-btn--secondary" type="button">
Clear
</button>
<button class="preview-btn preview-btn--primary" type="submit">
<svg
class="preview-icon preview-icon--sm"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
d="m5 12 5 5L20 7"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
></path></svg
>Run request
</button>
</div>
</main>
</div>
</div>
</body>
</html>
+608
View File
@@ -0,0 +1,608 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Service Catalog | Transit Operations</title>
<link rel="stylesheet" href="./theme.css" />
</head>
<body>
<div class="preview-root">
<header class="preview-header">
<div class="preview-header__brand">
<div class="preview-header__logo" aria-hidden="true">TO</div>
<span>Transit Operations</span>
</div>
<div class="preview-header__actions">
<label
class="preview-form__input"
style="
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
min-width: 220px;
"
aria-label="Search services"
>
<svg
class="preview-icon preview-icon--sm"
viewBox="0 0 24 24"
aria-hidden="true"
>
<circle cx="11" cy="11" r="6.5" stroke-width="1.8"></circle>
<path
d="m16 16 4.5 4.5"
stroke-width="1.8"
stroke-linecap="round"
></path>
</svg>
<input
type="search"
placeholder="Search services"
style="
border: 0;
outline: 0;
background: transparent;
color: inherit;
width: 100%;
font: inherit;
"
/>
</label>
<a class="preview-header__action" href="#" aria-label="Notifications">
<svg class="preview-icon" viewBox="0 0 24 24" aria-hidden="true">
<path
d="M18 9a6 6 0 0 0-12 0c0 7-3 7-3 9h18c0-2-3-2-3-9M10 21h4"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
></path>
</svg>
</a>
<div class="preview-avatar" aria-label="Signed in as Olivia Moore">
OM
</div>
</div>
</header>
<nav class="preview-nav" aria-label="Primary navigation">
<a class="preview-nav__link" href="#"
><svg
class="preview-icon preview-icon--sm"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
d="M4 13h6V4H4v9Zm10 7h6v-9h-6v9ZM4 20h6v-3H4v3Zm10-12h6V4h-6v4Z"
stroke-width="1.7"
stroke-linejoin="round"
></path></svg
>Command Center</a
>
<a
class="preview-nav__link preview-nav__link--active"
href="./service-catalog.html"
aria-current="page"
><svg
class="preview-icon preview-icon--sm"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
d="M5 5h14v14H5zM8 9h8M8 13h5M8 17h3"
stroke-width="1.7"
stroke-linecap="round"
stroke-linejoin="round"
></path></svg
>Service Catalog</a
>
<a class="preview-nav__link" href="./request-lab.html"
><svg
class="preview-icon preview-icon--sm"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
d="M5 4h14v16H5zM8 8h8M8 12h8M8 16h5"
stroke-width="1.7"
stroke-linecap="round"
stroke-linejoin="round"
></path></svg
>Request Lab</a
>
<a class="preview-nav__link" href="./incident-log.html"
><svg
class="preview-icon preview-icon--sm"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
d="m12 4 8 16H4L12 4Zm0 5v5M12 17v.5"
stroke-width="1.7"
stroke-linecap="round"
stroke-linejoin="round"
></path></svg
>Incident Log</a
>
</nav>
<div class="preview-shell">
<aside class="preview-sidebar" aria-label="Service catalog navigation">
<div class="preview-sidebar__section">Monitor</div>
<a class="preview-sidebar__item" href="#"
><svg
class="preview-icon preview-icon--sm"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
d="M4 13h6V4H4v9Zm10 7h6v-9h-6v9ZM4 20h6v-3H4v3Zm10-12h6V4h-6v4Z"
stroke-width="1.7"
stroke-linejoin="round"
></path></svg
>Command Center</a
>
<a
class="preview-sidebar__item preview-sidebar__item--active"
href="./service-catalog.html"
><svg
class="preview-icon preview-icon--sm"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
d="M5 5h14v14H5zM8 9h8M8 13h5M8 17h3"
stroke-width="1.7"
stroke-linecap="round"
stroke-linejoin="round"
></path></svg
>Service Catalog</a
>
<a class="preview-sidebar__item" href="./request-lab.html"
><svg
class="preview-icon preview-icon--sm"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
d="M5 4h14v16H5zM8 8h8M8 12h8M8 16h5"
stroke-width="1.7"
stroke-linecap="round"
stroke-linejoin="round"
></path></svg
>Request Lab</a
>
<a class="preview-sidebar__item" href="./incident-log.html"
><svg
class="preview-icon preview-icon--sm"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
d="m12 4 8 16H4L12 4Zm0 5v5M12 17v.5"
stroke-width="1.7"
stroke-linecap="round"
stroke-linejoin="round"
></path></svg
>Incident Log</a
>
<div class="preview-sidebar__section">Workspace</div>
<a class="preview-sidebar__item" href="#"
><svg
class="preview-icon preview-icon--sm"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
d="M4 6h16M4 12h16M4 18h16"
stroke-width="1.7"
stroke-linecap="round"
></path></svg
>Route mappings</a
>
<a class="preview-sidebar__item" href="#"
><svg
class="preview-icon preview-icon--sm"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
d="M12 3 4 7v5c0 4.5 3.4 7.6 8 9 4.6-1.4 8-4.5 8-9V7l-8-4Zm0 5v4m0 4v.5"
stroke-width="1.7"
stroke-linecap="round"
stroke-linejoin="round"
></path></svg
>Access policies</a
>
</aside>
<main class="preview-main">
<div class="preview-section-title">
<div>
<div
class="preview-section-title__h"
style="display: flex; align-items: center; gap: 8px"
>
<svg
class="preview-icon"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
d="M6 4h12v16H6zM9 8h6M9 12h6M9 16h3"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
></path></svg
>Service Catalog
</div>
<p class="preview-card__body">
Inspect upstream capabilities and representative route coverage
across the transit platform.
</p>
</div>
<span class="preview-badge preview-badge--success"
><span class="preview-badge__dot"></span>5 services
monitored</span
>
</div>
<div class="preview-tabs" role="tablist" aria-label="Catalog views">
<a
class="preview-tabs__tab preview-tabs__tab--active"
href="#catalog"
role="tab"
aria-selected="true"
>Catalog</a
>
<a
class="preview-tabs__tab"
href="#unmapped"
role="tab"
aria-selected="false"
>Unmapped routes <span class="preview-badge">0</span></a
>
</div>
<section id="catalog" aria-label="Service catalog">
<div class="preview-section-title" style="margin-bottom: 12px">
<h2
class="preview-section-title__h"
style="display: flex; align-items: center; gap: 8px"
>
<svg
class="preview-icon"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
d="M4 6h16v12H4zM8 10h8M8 14h5"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
></path></svg
>Monitored services
</h2>
<span class="preview-section-title__hint"
>Last inventory sync 09:42 UTC</span
>
</div>
<div class="preview-card-list">
<article class="preview-card">
<div class="preview-section-title">
<h3
class="preview-card__title"
style="display: flex; align-items: center; gap: 8px"
>
<svg
class="preview-icon"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
d="M5 5h14v14H5zM8 9h8M8 13h5M8 17h3"
stroke-width="1.7"
stroke-linecap="round"
stroke-linejoin="round"
></path></svg
>Auth API
</h3>
<span class="preview-badge preview-badge--success"
>Healthy</span
>
</div>
<p class="preview-card__body">
Identity and session access for operations staff.
</p>
<p class="preview-card__meta">4 endpoints · 2 route mappings</p>
</article>
<article class="preview-card">
<div class="preview-section-title">
<h3
class="preview-card__title"
style="display: flex; align-items: center; gap: 8px"
>
<svg
class="preview-icon"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
d="M12 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8Zm-7 8a7 7 0 0 1 14 0"
stroke-width="1.7"
stroke-linecap="round"
></path></svg
>Employee API
</h3>
<span class="preview-badge preview-badge--success"
>Healthy</span
>
</div>
<p class="preview-card__body">
Staff records, roles, and workforce administration.
</p>
<p class="preview-card__meta">7 endpoints · 1 route mapping</p>
</article>
<article class="preview-card">
<div class="preview-section-title">
<h3
class="preview-card__title"
style="display: flex; align-items: center; gap: 8px"
>
<svg
class="preview-icon"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
d="M4 17h16M5 17V8h14v9M8 8V5h8v3M8 20h8"
stroke-width="1.7"
stroke-linecap="round"
stroke-linejoin="round"
></path></svg
>Transit API
</h3>
<span class="preview-badge preview-badge--success"
>Healthy</span
>
</div>
<p class="preview-card__body">
Routes, stops, fleet data, and daily schedules.
</p>
<p class="preview-card__meta">
18 endpoints · 2 route mappings
</p>
</article>
<article class="preview-card">
<div class="preview-section-title">
<h3
class="preview-card__title"
style="display: flex; align-items: center; gap: 8px"
>
<svg
class="preview-icon"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
d="M5 4h14v16H5zM8 8h8M8 12h5M8 16h3"
stroke-width="1.7"
stroke-linecap="round"
stroke-linejoin="round"
></path></svg
>Error Log API
</h3>
<span class="preview-badge preview-badge--warning"
>Degraded</span
>
</div>
<p class="preview-card__body">
Centralized application errors and diagnostic events.
</p>
<p class="preview-card__meta">3 endpoints · 1 route mapping</p>
</article>
<article class="preview-card">
<div class="preview-section-title">
<h3
class="preview-card__title"
style="display: flex; align-items: center; gap: 8px"
>
<svg
class="preview-icon"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
d="M4 5h16v14H4zM8 9h8M8 13h5M8 17h3"
stroke-width="1.7"
stroke-linecap="round"
stroke-linejoin="round"
></path></svg
>Admin UI
</h3>
<span class="preview-badge preview-badge--success"
>Healthy</span
>
</div>
<p class="preview-card__body">
Signed-in administration workspace for transit teams.
</p>
<p class="preview-card__meta">12 screens · 0 route mappings</p>
</article>
</div>
</section>
<section aria-labelledby="coverage-title">
<div class="preview-section-title" style="margin-bottom: 12px">
<h2
id="coverage-title"
class="preview-section-title__h"
style="display: flex; align-items: center; gap: 8px"
>
<svg
class="preview-icon"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
d="M5 5h14v14H5zM8 9h8M8 13h8M8 17h4"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
></path></svg
>Representative route coverage
</h2>
<span class="preview-section-title__hint"
>4 mapped capabilities</span
>
</div>
<div class="preview-table-wrap">
<table class="preview-table">
<thead>
<tr>
<th>Service</th>
<th>Capability</th>
<th>Route</th>
<th>Access</th>
<th>Status</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>Transit API</strong></td>
<td>Daily schedules</td>
<td><code>/schedule/daily</code></td>
<td><span class="preview-badge">Authenticated</span></td>
<td>
<span class="preview-badge preview-badge--success"
>Mapped</span
>
</td>
</tr>
<tr>
<td><strong>Transit API</strong></td>
<td>Route detail</td>
<td><code>/routes/:routeId</code></td>
<td><span class="preview-badge">Authenticated</span></td>
<td>
<span class="preview-badge preview-badge--success"
>Mapped</span
>
</td>
</tr>
<tr>
<td><strong>Employee API</strong></td>
<td>Create employee</td>
<td><code>/createemployee</code></td>
<td>
<span class="preview-badge preview-badge--warning"
>Admin</span
>
</td>
<td>
<span class="preview-badge preview-badge--success"
>Mapped</span
>
</td>
</tr>
<tr>
<td><strong>Error Log API</strong></td>
<td>Read error log</td>
<td><code>/error_log</code></td>
<td>
<span class="preview-badge preview-badge--warning"
>Admin</span
>
</td>
<td>
<span class="preview-badge preview-badge--success"
>Mapped</span
>
</td>
</tr>
</tbody>
</table>
</div>
</section>
<section
id="unmapped"
class="preview-empty"
aria-label="Unmapped routes empty state"
>
<div class="preview-empty__icon">
<svg
class="preview-icon preview-icon--lg"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
d="M4 5h11v14H4zM8 9h3M8 13h3M17 15l4 4M15 13a4 4 0 1 0 0-8 4 4 0 0 0 0 8Z"
stroke-width="1.7"
stroke-linecap="round"
stroke-linejoin="round"
></path>
</svg>
</div>
<div class="preview-empty__title">No unmapped routes</div>
<div class="preview-empty__body">
All discovered upstream capabilities are currently represented in
the catalog.
</div>
<a class="preview-btn preview-btn--primary" href="#"
><svg
class="preview-icon preview-icon--sm"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
d="M12 5v14M5 12h14"
stroke-width="2"
stroke-linecap="round"
></path></svg
>Add route mapping</a
>
</section>
<div class="preview-action-bar">
<span class="preview-section-title__hint"
>Catalog source: route inventory and service manifests</span
>
<a class="preview-btn preview-btn--secondary" href="#"
><svg
class="preview-icon preview-icon--sm"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
d="M12 4v11m0 0 4-4m-4 4-4-4M5 19h14"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
></path></svg
>Export catalog</a
>
<a class="preview-btn preview-btn--primary" href="#"
><svg
class="preview-icon preview-icon--sm"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
d="M12 5v14M5 12h14"
stroke-width="2"
stroke-linecap="round"
></path></svg
>Add route mapping</a
>
</div>
</main>
</div>
<footer class="preview-footer">
<span>Transit Operations Console</span
><span>Environment: Production · Read-only catalog</span>
</footer>
</div>
</body>
</html>
+592
View File
@@ -0,0 +1,592 @@
:root {
--color-primary: #155eef;
--color-on-primary: #ffffff;
--color-accent: #f59e0b;
--color-on-accent: #172033;
--color-surface: #f6f8fb;
--color-surface-raised: #ffffff;
--color-surface-sunken: #eef2f7;
--color-text: #172033;
--color-muted: #667085;
--color-border: #d9e0ea;
--color-success: #16803c;
--color-warning: #b54708;
--color-danger: #d92d20;
--font-body: "Segoe UI Variable", "Segoe UI", sans-serif;
--font-heading: var(--font-body);
--text-xs: 11px;
--text-sm: 13px;
--text-base: 14px;
--text-lg: 17px;
--text-xl: 22px;
--text-2xl: 30px;
--text-3xl: 38px;
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 12px;
--radius-pill: 9999px;
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 20px;
--space-6: 28px;
--space-7: 40px;
--space-8: 56px;
--shadow-sm: 0 1px 2px rgba(23, 32, 51, 0.06);
--shadow-md: 0 5px 16px rgba(23, 32, 51, 0.1);
--shadow-lg: 0 16px 32px rgba(23, 32, 51, 0.14);
}
*,
*::before,
*::after {
box-sizing: border-box;
}
html,
body {
margin: 0;
padding: 0;
}
body {
background: var(--color-surface);
color: var(--color-text);
font-family: var(--font-body);
font-size: var(--text-base);
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
h1,
h2,
h3 {
font-family: var(--font-heading);
line-height: 1.2;
margin: 0;
}
a {
color: var(--color-primary);
text-decoration: none;
}
.preview-root {
display: flex;
flex-direction: column;
min-height: 100vh;
}
.preview-shell {
display: flex;
flex: 1;
min-height: 0;
}
.preview-main {
flex: 1;
padding: var(--space-6);
display: flex;
flex-direction: column;
gap: var(--space-5);
min-width: 0;
}
.preview-split,
.preview-two-column {
display: grid;
gap: var(--space-5);
}
.preview-split {
grid-template-columns: 1fr 2fr;
}
.preview-two-column {
grid-template-columns: 1fr 1fr;
}
.preview-header {
position: sticky;
top: 0;
z-index: 5;
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-4);
padding: var(--space-3) var(--space-6);
background: var(--color-surface-raised);
border-bottom: 1px solid var(--color-border);
box-shadow: var(--shadow-sm);
}
.preview-header__brand,
.preview-header__actions {
display: flex;
align-items: center;
gap: var(--space-3);
}
.preview-header__brand {
font-weight: 700;
}
.preview-header__logo {
width: 30px;
height: 30px;
border-radius: var(--radius-sm);
background: var(--color-primary);
color: var(--color-on-primary);
display: grid;
place-items: center;
font-size: var(--text-sm);
}
.preview-header__action {
color: var(--color-muted);
display: inline-flex;
}
.preview-nav {
display: flex;
gap: var(--space-1);
padding: var(--space-2) var(--space-6);
background: var(--color-surface-raised);
border-bottom: 1px solid var(--color-border);
}
.preview-nav__link,
.preview-sidebar__item {
display: inline-flex;
align-items: center;
gap: var(--space-2);
color: var(--color-muted);
border-radius: var(--radius-sm);
}
.preview-nav__link {
padding: var(--space-2) var(--space-3);
font-size: var(--text-sm);
}
.preview-nav__link--active,
.preview-sidebar__item--active {
color: var(--color-primary);
background: color-mix(in srgb, var(--color-primary) 10%, transparent);
font-weight: 600;
}
.preview-sidebar {
width: 220px;
flex-shrink: 0;
padding: var(--space-4) var(--space-3);
background: var(--color-surface-sunken);
border-right: 1px solid var(--color-border);
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.preview-sidebar__section {
padding: var(--space-3) var(--space-2) var(--space-1);
color: var(--color-muted);
font-size: var(--text-xs);
font-weight: 700;
letter-spacing: 0.07em;
text-transform: uppercase;
}
.preview-sidebar__item {
padding: var(--space-2) var(--space-3);
font-size: var(--text-sm);
}
.preview-hero {
position: relative;
overflow: hidden;
padding: var(--space-6);
border-radius: var(--radius-md);
background: linear-gradient(135deg, #155eef, #174ea6);
color: var(--color-on-primary);
display: flex;
flex-direction: column;
gap: var(--space-3);
box-shadow: var(--shadow-md);
}
.preview-hero__mesh {
position: absolute;
inset: 0;
opacity: 0.35;
background:
radial-gradient(60% 80% at 100% 0%, #7aa7ff, transparent 70%),
radial-gradient(60% 80% at 0% 100%, #0e3b8e, transparent 70%);
}
.preview-hero > *:not(.preview-hero__mesh) {
position: relative;
z-index: 1;
}
.preview-hero__eyebrow {
text-transform: uppercase;
letter-spacing: 0.08em;
font-size: var(--text-xs);
font-weight: 700;
opacity: 0.85;
}
.preview-hero__title {
font-size: var(--text-2xl);
font-weight: 700;
max-width: 28ch;
}
.preview-hero__subtitle {
max-width: 62ch;
opacity: 0.9;
}
.preview-hero__actions,
.preview-action-bar {
display: flex;
align-items: center;
gap: var(--space-2);
flex-wrap: wrap;
}
.preview-kpi-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: var(--space-3);
}
.preview-kpi,
.preview-card,
.preview-form,
.preview-empty,
.preview-table-wrap {
background: var(--color-surface-raised);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-sm);
}
.preview-kpi {
padding: var(--space-4);
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.preview-kpi__label {
color: var(--color-muted);
font-size: var(--text-xs);
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.preview-kpi__value {
font-size: var(--text-xl);
font-weight: 700;
}
.preview-kpi__delta {
color: var(--color-muted);
font-size: var(--text-xs);
}
.preview-kpi__delta--up {
color: var(--color-success);
font-weight: 600;
}
.preview-section-title {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: var(--space-3);
}
.preview-section-title__h {
font-size: var(--text-lg);
font-weight: 700;
}
.preview-section-title__hint {
color: var(--color-muted);
font-size: var(--text-xs);
}
.preview-card-list {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.preview-card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: var(--space-4);
}
.preview-card {
padding: var(--space-4);
display: flex;
flex-direction: column;
gap: var(--space-2);
transition:
transform 160ms ease,
box-shadow 160ms ease;
}
.preview-card:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-md);
}
.preview-card__title {
font-weight: 600;
}
.preview-card__body,
.preview-card__meta {
margin: 0;
color: var(--color-muted);
font-size: var(--text-sm);
}
.preview-card__meta {
font-size: var(--text-xs);
}
.preview-badge {
display: inline-flex;
align-items: center;
gap: var(--space-1);
padding: 2px var(--space-2);
border-radius: var(--radius-pill);
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
color: var(--color-primary);
font-size: var(--text-xs);
font-weight: 600;
}
.preview-badge--success {
background: #e7f6ec;
color: var(--color-success);
}
.preview-badge--warning {
background: #fff4e5;
color: var(--color-warning);
}
.preview-badge--danger {
background: #feeceb;
color: var(--color-danger);
}
.preview-badge__dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
}
.preview-form {
padding: var(--space-5);
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.preview-form__field {
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.preview-form__label {
font-size: var(--text-sm);
font-weight: 600;
}
.preview-form__hint {
color: var(--color-muted);
font-size: var(--text-xs);
}
.preview-form__input,
.preview-form__textarea,
.preview-form__select {
padding: var(--space-2) var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-surface);
color: var(--color-text);
font: inherit;
}
.preview-form__textarea {
min-height: 96px;
resize: vertical;
}
.preview-form__actions {
display: flex;
justify-content: flex-end;
gap: var(--space-2);
}
.preview-btn {
display: inline-flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-4);
border: 1px solid transparent;
border-radius: var(--radius-sm);
font: inherit;
font-size: var(--text-sm);
font-weight: 600;
}
.preview-btn--primary {
background: var(--color-primary);
color: var(--color-on-primary);
box-shadow: var(--shadow-sm);
}
.preview-btn--secondary {
background: var(--color-surface);
color: var(--color-text);
border-color: var(--color-border);
}
.preview-btn--ghost {
background: transparent;
color: var(--color-text);
}
.preview-table-wrap {
overflow: hidden;
}
.preview-table {
width: 100%;
border-collapse: collapse;
}
.preview-table th,
.preview-table td {
text-align: left;
padding: var(--space-3);
border-bottom: 1px solid var(--color-border);
font-size: var(--text-sm);
}
.preview-table th {
background: var(--color-surface-sunken);
color: var(--color-muted);
font-size: var(--text-xs);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.preview-table tr:last-child td {
border-bottom: 0;
}
.preview-action-bar {
justify-content: flex-end;
padding-top: var(--space-3);
border-top: 1px solid var(--color-border);
}
.preview-tabs {
display: flex;
gap: var(--space-1);
border-bottom: 1px solid var(--color-border);
}
.preview-tabs__tab {
padding: var(--space-2) var(--space-4);
color: var(--color-muted);
font-size: var(--text-sm);
}
.preview-tabs__tab--active {
color: var(--color-primary);
border-bottom: 2px solid var(--color-primary);
font-weight: 600;
}
.preview-empty {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-3);
padding: var(--space-7) var(--space-4);
text-align: center;
}
.preview-empty__icon {
width: 58px;
height: 58px;
display: grid;
place-items: center;
border-radius: var(--radius-pill);
background: #e6eeff;
color: var(--color-primary);
}
.preview-empty__title {
font-weight: 700;
}
.preview-empty__body {
max-width: 40ch;
color: var(--color-muted);
font-size: var(--text-sm);
}
.preview-error {
display: flex;
gap: var(--space-3);
padding: var(--space-4);
border: 1px solid #f4b8b4;
border-radius: var(--radius-md);
background: #fff4f3;
}
.preview-error__icon {
color: var(--color-danger);
}
.preview-error__body {
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.preview-error__title {
font-size: var(--text-sm);
font-weight: 700;
}
.preview-error__text {
color: var(--color-muted);
font-size: var(--text-sm);
}
.preview-footer {
display: flex;
justify-content: space-between;
padding: var(--space-3) var(--space-6);
color: var(--color-muted);
font-size: var(--text-xs);
border-top: 1px solid var(--color-border);
}
.preview-icon {
width: 18px;
height: 18px;
flex-shrink: 0;
display: inline-block;
vertical-align: middle;
stroke: currentColor;
fill: none;
}
.preview-icon--sm {
width: 14px;
height: 14px;
}
.preview-icon--lg {
width: 24px;
height: 24px;
}
.preview-avatar {
width: 34px;
height: 34px;
border-radius: 50%;
display: grid;
place-items: center;
color: var(--color-primary);
background: #e6eeff;
font-size: var(--text-xs);
font-weight: 700;
}
.preview-skeleton {
background: linear-gradient(90deg, #e8edf4 25%, #f7f9fc 37%, #e8edf4 63%);
background-size: 400% 100%;
border-radius: var(--radius-sm);
animation: preview-shimmer 1.4s ease infinite;
}
.preview-skeleton--line {
height: 12px;
margin-bottom: var(--space-2);
}
.preview-skeleton--title {
height: 18px;
width: 40%;
margin-bottom: var(--space-3);
}
.preview-skeleton--block {
height: 96px;
}
@keyframes preview-shimmer {
0% {
background-position: 100% 0;
}
100% {
background-position: 0 0;
}
}
@media (max-width: 760px) {
.preview-sidebar {
display: none;
}
.preview-main {
padding: var(--space-4);
}
.preview-nav {
overflow-x: auto;
padding: var(--space-2) var(--space-4);
}
.preview-split,
.preview-two-column {
grid-template-columns: 1fr;
}
.preview-header {
padding: var(--space-3) var(--space-4);
}
.preview-table-wrap {
overflow-x: auto;
}
}
@media (prefers-reduced-motion: reduce) {
.preview-card,
.preview-skeleton {
transition: none;
animation: none;
}
}
+247
View File
@@ -0,0 +1,247 @@
{
"schemaVersion": "2",
"generatedAt": "2026-09-20T00:00:00.000Z",
"mode": "NEW",
"summary": "A new React + Vite monitoring dashboard for the existing XTransit APIs and admin UI. It will aggregate health, service capabilities, error activity, and a guarded API request tester without adding a new datastore.",
"workspaceSignals": {
"detectedFiles": [
"README.md",
"docker-compose.yml",
"auth_api/index.js",
"auth_api/package.json",
"employee_api/index.js",
"employee_api/package.json",
"transit_api/index.js",
"transit_api/package.json",
"error_log_api/index.js",
"error_log_api/package.json",
"admin_ui/package.json",
"admin_ui/src/api.js",
"admin_ui/src/App.jsx",
"DOMAINS_README.md",
"admin_ui/README.md",
"admin_ui/SCHEDULE_API.md",
"transit_api/README.md"
],
"decision": "NEW",
"decisionReason": "The repository contains the existing services and admin UI, but no monitoring dashboard; the requested dashboard is a new project folder within this workspace."
},
"services": [
{
"id": "transit-monitor-dashboard",
"label": "Transit Monitor Dashboard",
"role": "frontend",
"root": "./monitoring_dashboard"
},
{
"id": "monitoring-proxy-api",
"label": "Monitoring Proxy API",
"role": "backend",
"root": "./monitoring_dashboard/server"
}
],
"questions": [
{
"id": "transit-monitor-dashboard:language",
"category": "service",
"question": "Which programming language for Transit Monitor Dashboard?",
"header": "Language",
"answer": "TypeScript",
"status": "confirmed",
"rationale": "The requested framework is explicitly React + Vite, but the language was not specified; TypeScript is recommended for the dashboard's endpoint catalog and response-shape handling.",
"options": [
{
"label": "TypeScript",
"description": "Type-safe React application code"
},
{
"label": "JavaScript",
"description": "Plain JavaScript matching the existing admin UI"
}
],
"recommendedChoice": "TypeScript",
"multiSelect": false,
"allowFreeformInput": false,
"serviceId": "transit-monitor-dashboard"
},
{
"id": "transit-monitor-dashboard:framework",
"category": "service",
"question": "Which frontend framework for Transit Monitor Dashboard?",
"header": "Framework",
"answer": "React + Vite",
"status": "confirmed",
"rationale": "React + Vite is explicitly requested.",
"options": [
{
"label": "React + Vite",
"description": "React SPA with Vite, as requested"
},
{
"label": "Vue + Vite",
"description": "Vue SPA with Vite"
},
{
"label": "Angular",
"description": "Angular application"
},
{
"label": "Svelte",
"description": "Svelte application with Vite"
}
],
"recommendedChoice": "React + Vite",
"multiSelect": false,
"allowFreeformInput": true,
"serviceId": "transit-monitor-dashboard"
},
{
"id": "transit-monitor-dashboard:features",
"category": "service",
"question": "Describe the features or API routes for Transit Monitor Dashboard.",
"header": "Features",
"answer": "Service health overview for auth_api, employee_api, transit_api, error_log_api, and admin_ui; service capability catalog; error and bad-request views; guarded live request tester; configurable environments; polling controls; response timing and status history.",
"status": "confirmed",
"rationale": "Distilled from the requested monitoring, service-data, error, request-testing, and health-check workflows.",
"recommendedChoice": "Service health overview for auth_api, employee_api, transit_api, error_log_api, and admin_ui; service capability catalog; error and bad-request views; guarded live request tester; configurable environments; polling controls; response timing and status history.",
"multiSelect": false,
"serviceId": "transit-monitor-dashboard"
},
{
"id": "monitoring-proxy-api:language",
"category": "service",
"question": "Which programming language for Monitoring Proxy API?",
"header": "Language",
"answer": "TypeScript",
"status": "confirmed",
"rationale": "The workspace already uses Node.js services and the proxy needs shared typed endpoint metadata and response normalization; TypeScript is recommended, but the user did not explicitly choose the proxy language.",
"options": [
{
"label": "TypeScript",
"description": "Node.js + TypeScript proxy service"
},
{
"label": "Python",
"description": "Python proxy service"
},
{
"label": "C# (.NET)",
"description": ".NET proxy service"
}
],
"recommendedChoice": "TypeScript",
"multiSelect": false,
"allowFreeformInput": false,
"serviceId": "monitoring-proxy-api"
},
{
"id": "monitoring-proxy-api:framework",
"category": "service",
"question": "Which backend framework for Monitoring Proxy API?",
"header": "Framework",
"answer": "Express",
"status": "confirmed",
"rationale": "Express matches the existing API services and is sufficient for an allowlisted proxy, health aggregation, and request execution endpoints.",
"options": [
{
"label": "Express",
"description": "Small Node.js HTTP proxy consistent with the existing APIs"
},
{
"label": "Fastify",
"description": "Typed, low-overhead Node.js HTTP service"
},
{
"label": "Azure Functions",
"description": "Serverless HTTP functions"
}
],
"recommendedChoice": "Express",
"multiSelect": false,
"allowFreeformInput": true,
"serviceId": "monitoring-proxy-api"
},
{
"id": "monitoring-proxy-api:features",
"category": "service",
"question": "Describe the features or API routes for Monitoring Proxy API.",
"header": "Features",
"answer": "Environment-configured upstream base URLs; aggregated health checks including admin_ui; allowlisted endpoint catalog; authenticated request execution with safe method/body/timeout limits; error-log read proxy; redacted structured results; CORS and rate limiting.",
"status": "confirmed",
"rationale": "The proxy is planned to centralize cross-origin access and protect administrative/tester capabilities while preserving the existing APIs unchanged.",
"recommendedChoice": "Environment-configured upstream base URLs; aggregated health checks including admin_ui; allowlisted endpoint catalog; authenticated request execution with safe method/body/timeout limits; error-log read proxy; redacted structured results; CORS and rate limiting.",
"multiSelect": false,
"serviceId": "monitoring-proxy-api"
},
{
"id": "dataStores",
"category": "data",
"question": "Which data stores does your app need?",
"header": "Data Stores",
"answer": [
"No datastore required"
],
"status": "confirmed",
"rationale": "The dashboard monitors and queries the existing APIs and error-log database; it needs only bounded in-memory polling state and does not introduce persistence, file storage, queues, or caches.",
"options": [
{
"label": "No datastore required",
"description": "The dashboard reads existing APIs and does not persist new data",
"exclusive": true
},
{
"label": "Blob Storage",
"description": "Store files and images"
},
{
"label": "Queue Storage",
"description": "Async message queue"
},
{
"label": "PostgreSQL",
"description": "Relational database"
},
{
"label": "CosmosDB",
"description": "NoSQL document database"
},
{
"label": "Redis",
"description": "In-memory cache"
},
{
"label": "Azure SQL",
"description": "Managed SQL Server"
}
],
"recommendedChoice": [
"No datastore required"
],
"multiSelect": true,
"allowFreeformInput": false
},
{
"id": "auth",
"category": "auth",
"question": "Does this app need user login?",
"header": "API Login",
"answer": "Yes",
"status": "confirmed",
"rationale": "Reading centralized errors and exercising authenticated transit/admin endpoints must be restricted to authorized dashboard users.",
"options": [
{
"label": "Yes",
"description": "Add a user sign-in flow and authenticated API access"
},
{
"label": "No",
"description": "No user sign-in or authenticated API access"
}
],
"recommendedChoice": "Yes",
"multiSelect": false,
"allowFreeformInput": false
}
],
"executionMode": "guided"
}
+318
View File
@@ -0,0 +1,318 @@
name: Build and Push Docker Images
on:
push:
branches:
- dev
- main
- develop
- auth-api
- employee-api
- transit-api
- error-log-api
- admin-ui
- monitoring-ui
pull_request:
branches:
- dev
- main
- develop
env:
REGISTRY: gitea.testinstance.nl
IMAGE_NAMESPACE: sven/the-transit-project
jobs:
detect-changes:
runs-on: ubuntu-latest
outputs:
auth_api: ${{ steps.filter.outputs.auth_api }}
employee_api: ${{ steps.filter.outputs.employee_api }}
transit_api: ${{ steps.filter.outputs.transit_api }}
error_log_api: ${{ steps.filter.outputs.error_log_api }}
admin_ui: ${{ steps.filter.outputs.admin_ui }}
monitoring_ui: ${{ steps.filter.outputs.monitoring_ui }}
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Detect changed paths
id: filter
uses: dorny/paths-filter@v3
with:
filters: |
auth_api:
- 'auth_api/**'
- 'docker-compose.yml'
- '.gitea/workflows/docker-build.yml'
employee_api:
- 'employee_api/**'
- 'docker-compose.yml'
- '.gitea/workflows/docker-build.yml'
transit_api:
- 'transit_api/**'
- 'docker-compose.yml'
- '.gitea/workflows/docker-build.yml'
error_log_api:
- 'error_log_api/**'
- 'docker-compose.yml'
- '.gitea/workflows/docker-build.yml'
admin_ui:
- 'admin_ui/**'
- 'docker-compose.yml'
- '.gitea/workflows/docker-build.yml'
monitoring_ui:
- 'monitoring_ui/**'
- 'docker-compose.yml'
- '.gitea/workflows/docker-build.yml'
build-auth-api:
needs: detect-changes
if: ${{ (gitea.ref_name == 'auth-api' || gitea.ref_name == 'dev' || gitea.ref_name == 'main' || gitea.ref_name == 'develop') && needs.detect-changes.outputs.auth_api == 'true' }}
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Gitea Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ secrets.REGISTRY_USERNAME }}
password: ${{ secrets.REGISTRY_PASSWORD }}
- name: Set image tags
id: tags
shell: bash
run: |
image_base="${{ env.REGISTRY }}/${{ env.IMAGE_NAMESPACE }}/auth_api"
echo "tags<<EOF" >> "$GITHUB_OUTPUT"
echo "${image_base}:${{ gitea.ref_name }}" >> "$GITHUB_OUTPUT"
echo "${image_base}:${{ gitea.sha }}" >> "$GITHUB_OUTPUT"
if [[ "${{ gitea.ref_name }}" == "dev" || "${{ gitea.ref_name }}" == "main" || "${{ gitea.ref_name }}" == "develop" ]]; then
echo "${image_base}:latest" >> "$GITHUB_OUTPUT"
fi
echo "EOF" >> "$GITHUB_OUTPUT"
- name: Build and push auth_api
uses: docker/build-push-action@v6
with:
context: ./auth_api
push: ${{ gitea.event_name == 'push' }}
tags: ${{ steps.tags.outputs.tags }}
build-employee-api:
needs: detect-changes
if: ${{ (gitea.ref_name == 'employee-api' || gitea.ref_name == 'dev' || gitea.ref_name == 'main' || gitea.ref_name == 'develop') && needs.detect-changes.outputs.employee_api == 'true' }}
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Gitea Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ secrets.REGISTRY_USERNAME }}
password: ${{ secrets.REGISTRY_PASSWORD }}
- name: Set image tags
id: tags
shell: bash
run: |
image_base="${{ env.REGISTRY }}/${{ env.IMAGE_NAMESPACE }}/employee_api"
echo "tags<<EOF" >> "$GITHUB_OUTPUT"
echo "${image_base}:${{ gitea.ref_name }}" >> "$GITHUB_OUTPUT"
echo "${image_base}:${{ gitea.sha }}" >> "$GITHUB_OUTPUT"
if [[ "${{ gitea.ref_name }}" == "dev" || "${{ gitea.ref_name }}" == "main" || "${{ gitea.ref_name }}" == "develop" ]]; then
echo "${image_base}:latest" >> "$GITHUB_OUTPUT"
fi
echo "EOF" >> "$GITHUB_OUTPUT"
- name: Build and push employee_api
uses: docker/build-push-action@v6
with:
context: ./employee_api
push: ${{ gitea.event_name == 'push' }}
tags: ${{ steps.tags.outputs.tags }}
build-transit-api:
needs: detect-changes
if: ${{ (gitea.ref_name == 'transit-api' || gitea.ref_name == 'dev' || gitea.ref_name == 'main' || gitea.ref_name == 'develop') && needs.detect-changes.outputs.transit_api == 'true' }}
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Gitea Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ secrets.REGISTRY_USERNAME }}
password: ${{ secrets.REGISTRY_PASSWORD }}
- name: Set image tags
id: tags
shell: bash
run: |
image_base="${{ env.REGISTRY }}/${{ env.IMAGE_NAMESPACE }}/transit_api"
echo "tags<<EOF" >> "$GITHUB_OUTPUT"
echo "${image_base}:${{ gitea.ref_name }}" >> "$GITHUB_OUTPUT"
echo "${image_base}:${{ gitea.sha }}" >> "$GITHUB_OUTPUT"
if [[ "${{ gitea.ref_name }}" == "dev" || "${{ gitea.ref_name }}" == "main" || "${{ gitea.ref_name }}" == "develop" ]]; then
echo "${image_base}:latest" >> "$GITHUB_OUTPUT"
fi
echo "EOF" >> "$GITHUB_OUTPUT"
- name: Build and push transit_api
uses: docker/build-push-action@v6
with:
context: ./transit_api
push: ${{ gitea.event_name == 'push' }}
tags: ${{ steps.tags.outputs.tags }}
build-error-log-api:
needs: detect-changes
if: ${{ (gitea.ref_name == 'error-log-api' || gitea.ref_name == 'dev' || gitea.ref_name == 'main' || gitea.ref_name == 'develop') && needs.detect-changes.outputs.error_log_api == 'true' }}
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Gitea Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ secrets.REGISTRY_USERNAME }}
password: ${{ secrets.REGISTRY_PASSWORD }}
- name: Set image tags
id: tags
shell: bash
run: |
image_base="${{ env.REGISTRY }}/${{ env.IMAGE_NAMESPACE }}/error_log_api"
echo "tags<<EOF" >> "$GITHUB_OUTPUT"
echo "${image_base}:${{ gitea.ref_name }}" >> "$GITHUB_OUTPUT"
echo "${image_base}:${{ gitea.sha }}" >> "$GITHUB_OUTPUT"
if [[ "${{ gitea.ref_name }}" == "dev" || "${{ gitea.ref_name }}" == "main" || "${{ gitea.ref_name }}" == "develop" ]]; then
echo "${image_base}:latest" >> "$GITHUB_OUTPUT"
fi
echo "EOF" >> "$GITHUB_OUTPUT"
- name: Build and push error_log_api
uses: docker/build-push-action@v6
with:
context: ./error_log_api
push: ${{ gitea.event_name == 'push' }}
tags: ${{ steps.tags.outputs.tags }}
build-admin-ui:
needs: detect-changes
if: ${{ (gitea.ref_name == 'admin-ui' || gitea.ref_name == 'dev' || gitea.ref_name == 'main' || gitea.ref_name == 'develop') && needs.detect-changes.outputs.admin_ui == 'true' }}
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Gitea Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ secrets.REGISTRY_USERNAME }}
password: ${{ secrets.REGISTRY_PASSWORD }}
- name: Set image tags
id: tags
shell: bash
run: |
image_base="${{ env.REGISTRY }}/${{ env.IMAGE_NAMESPACE }}/admin_ui"
echo "tags<<EOF" >> "$GITHUB_OUTPUT"
echo "${image_base}:${{ gitea.ref_name }}" >> "$GITHUB_OUTPUT"
echo "${image_base}:${{ gitea.sha }}" >> "$GITHUB_OUTPUT"
if [[ "${{ gitea.ref_name }}" == "dev" || "${{ gitea.ref_name }}" == "main" || "${{ gitea.ref_name }}" == "develop" ]]; then
echo "${image_base}:latest" >> "$GITHUB_OUTPUT"
fi
echo "EOF" >> "$GITHUB_OUTPUT"
- name: Build and push admin_ui
uses: docker/build-push-action@v6
with:
context: ./admin_ui
push: ${{ gitea.event_name == 'push' }}
tags: ${{ steps.tags.outputs.tags }}
build-monitoring-ui:
needs: detect-changes
if: ${{ (gitea.ref_name == 'monitoring-ui' || gitea.ref_name == 'dev' || gitea.ref_name == 'main' || gitea.ref_name == 'develop') && needs.detect-changes.outputs.monitoring_ui == 'true' }}
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Gitea Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ secrets.REGISTRY_USERNAME }}
password: ${{ secrets.REGISTRY_PASSWORD }}
- name: Set image tags
id: tags
shell: bash
run: |
image_base="${{ env.REGISTRY }}/${{ env.IMAGE_NAMESPACE }}/monitoring_ui"
echo "tags<<EOF" >> "$GITHUB_OUTPUT"
echo "${image_base}:${{ gitea.ref_name }}" >> "$GITHUB_OUTPUT"
echo "${image_base}:${{ gitea.sha }}" >> "$GITHUB_OUTPUT"
if [[ "${{ gitea.ref_name }}" == "dev" || "${{ gitea.ref_name }}" == "main" || "${{ gitea.ref_name }}" == "develop" ]]; then
echo "${image_base}:latest" >> "$GITHUB_OUTPUT"
fi
echo "EOF" >> "$GITHUB_OUTPUT"
- name: Build and push monitoring_ui
uses: docker/build-push-action@v6
with:
context: ./monitoring_ui
push: ${{ gitea.event_name == 'push' }}
tags: ${{ steps.tags.outputs.tags }}
deploy-test-node:
needs:
[
detect-changes,
build-auth-api,
build-employee-api,
build-transit-api,
build-error-log-api,
build-admin-ui,
build-monitoring-ui,
]
if: ${{ always() && !contains(needs.*.result, 'failure') && !contains(needs.*.result, 'cancelled') && gitea.event_name == 'push' && (gitea.ref_name == 'dev' || gitea.ref_name == 'main' || gitea.ref_name == 'develop' || gitea.ref_name == 'auth-api' || gitea.ref_name == 'employee-api' || gitea.ref_name == 'transit-api' || gitea.ref_name == 'error-log-api' || gitea.ref_name == 'admin-ui' || gitea.ref_name == 'monitoring-ui') }}
runs-on: ubuntu-latest
steps:
- name: Redeploy Portainer stack
shell: bash
run: curl --fail --silent --show-error --retry 3 --max-time 30 -X POST "$PORTAINER_WEBHOOK_URL"
env:
PORTAINER_WEBHOOK_URL: ${{ secrets.PORTAINER_WEBHOOK_URL }}
-196
View File
@@ -1,196 +0,0 @@
name: Build and Push Docker Images
on:
push:
branches:
- main
- develop
paths:
- 'auth_api/**'
- 'employee_api/**'
- 'transit_api/**'
- 'admin_ui/**'
- '.github/workflows/docker-build.yml'
pull_request:
branches:
- main
- develop
env:
# GitHub Container Registry
REGISTRY: ghcr.io
# Dockerhub alternative (uncomment to use instead)
# REGISTRY: docker.io
# REGISTRY_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
jobs:
build-auth-api:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# For DockerHub, use instead:
# username: ${{ secrets.DOCKERHUB_USERNAME }}
# password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ github.repository }}/auth_api
tags: |
type=ref,event=branch
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha,prefix={{branch}}-
type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push
uses: docker/build-push-action@v5
with:
context: ./auth_api
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
build-employee-api:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ github.repository }}/employee_api
tags: |
type=ref,event=branch
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha,prefix={{branch}}-
type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push
uses: docker/build-push-action@v5
with:
context: ./employee_api
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
build-transit-api:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ github.repository }}/transit_api
tags: |
type=ref,event=branch
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha,prefix={{branch}}-
type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push
uses: docker/build-push-action@v5
with:
context: ./transit_api
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
build-admin-ui:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ github.repository }}/admin_ui
tags: |
type=ref,event=branch
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha,prefix={{branch}}-
type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push
uses: docker/build-push-action@v5
with:
context: ./admin_ui
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
+73 -16
View File
@@ -1,21 +1,63 @@
# Environment and secrets
.env
/*.env
transit_api/.env
.env.*
!.env.example
**/.env
**/.env.*
!**/.env.example
# Local build / node_modules
# Node.js
node_modules/
admin_ui/node_modules/
admin_ui/dist/
monitoring_ui/node_modules/
monitoring_ui/dist/
coverage/
dist/
build/
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Sensitive docs to keep private
ADMIN_DEPLOYMENT_SUMMARY.md
CI_CD_PORTAINER_GUIDE.md
DEPLOYMENT.md
GETTING_STARTED.md
# Flutter / Dart
**/.dart_tool/
**/.pub-cache/
**/.pub/
**/build/
**/.flutter-plugins
**/.flutter-plugins-dependencies
# Database and local artifacts
*.sqlite
*.sqlite3
*.db
dump.sql
# Certificates and keys
*.pem
*.key
*.crt
*.p12
# OS/editor files
.DS_Store
.vscode/
.idea/
# Sensitive project docs & AI Agent Map
PROJECT_MAP.md
monitoring_ui/docs/ADMIN_DEPLOYMENT_SUMMARY.md
monitoring_ui/docs/CI_CD_PORTAINER_GUIDE.md
monitoring_ui/docs/DEPLOYMENT.md
monitoring_ui/docs/GETTING_STARTED.md
monitoring_ui/docs/handoff.md
INTERNAL_*.md
SETUP_*.md
DEPLOY_*.md
# Custom instruction files (private)
# Private instructions and tooling files
.instructions.md
.prompt.md
.agent.md
@@ -23,15 +65,30 @@ SKILL.md
copilot-instructions.md
AGENTS.md
# Private scripts
# Local/private scripts
scripts/private/
scripts/*.local.*
scripts/.env
# Private timetable note
transit_api/LOCAL_BUS_TIMETABLE_README.md
# Monitoring UI local configuration
monitoring_ui/.env.local
monitoring_ui/.env.*.local
# Runtime files
*.log
*.sqlite3
.DS_Store
# Project-specific private notes
monitoring_ui/docs/transit_api/LOCAL_BUS_TIMETABLE_README.md
# Deprecated app backups and legacy artifacts
deprecated/build/
deprecated/.dart_tool/
deprecated/.pub-cache/
deprecated/.idea/
deprecated/.vscode/
# Runtime logs
auth_api.log
transit_api.log
error_log_api.log
# Temporary files
tmp/
temp/
-315
View File
@@ -1,315 +0,0 @@
# GitHub Actions → Docker Registry → Portainer Deployment
Automatisch Docker images bouwen via GitHub en deployen op Portainer.
## 📋 Setup Stappen
### 1. GitHub Repository voorbereiding
```bash
# Push je code naar GitHub (met .github/workflows/docker-build.yml)
git add .
git commit -m "Add Docker build workflow"
git push origin main
```
**Controleer:** https://github.com/YOUR_USERNAME/YOUR_REPO/actions
### 2. GitHub Container Registry inschakelen
✅ Automatisch als je private repo hebt. Voor public repos:
1. Ga naar GitHub repo → **Settings → Packages and registries**
2. Of gewoon eerste push doen → workflow doet dit automatisch
### 3. GitHub Token configureren (optioneel)
Standaard gebruikt GitHub Actions je `GITHUB_TOKEN` (automatisch).
Voor meer controle, maak Personal Access Token (PAT):
1. GitHub → **Settings → Developer settings → Personal access tokens**
2. New token → Scopes: `write:packages`, `read:packages`
3. Token kopieren
### 4. Docker Login op Portainer Server
```bash
# Op je remote server waar Portainer draait
docker login ghcr.io
# Username: je GitHub username
# Password: GITHUB_TOKEN of PAT token
```
---
## 🚀 Image Bouwen & Pushen
### Automatisch (via GitHub Actions)
1. **Je code pushen naar main/develop:**
```bash
git push origin main
```
2. **GitHub Actions bouwt automatisch:**
- ✅ Checkout code
- ✅ Build Docker images
- ✅ Push naar ghcr.io
- ✅ Tags: `latest`, branch name, git SHA
3. **Bekijk workflow:**
- GitHub → **Actions tab** → Zie voortgang
- Logs klikken voor debug info
### Images checken in registry
```bash
# List available images/tags
docker pull ghcr.io/YOUR_USERNAME/YOUR_REPO/auth_api:latest --dry-run
# of via GitHub CLI:
gh api user/packages --jq '.[] | select(.package_type=="container")'
```
---
## 🐳 Portainer Deployment
### Methode 1: Via Docker Compose (aanbevolen)
**Op je Portainer server:**
1. **Clone repo of kopie bestand:**
```bash
git clone https://github.com/YOUR_USERNAME/YOUR_REPO.git
cd YOUR_REPO
```
2. **Login bij registry:**
```bash
docker login ghcr.io
# Credentials: GitHub username + token
```
3. **Start via docker-compose:**
```bash
docker-compose -f docker-compose.portainer.yml up -d
```
### Methode 2: Via Portainer UI
1. **Portainer Web Dashboard → Stacks**
2. **"Add Stack" → "Compose"**
3. **Paste inhoud van `docker-compose.portainer.yml`**
4. **Voeg environment vars toe:**
- `POSTGRES_PASSWORD=...`
- `JWT_SECRET=...`
5. **Deploy**
### Methode 3: Private Registry in Portainer
1. **Portainer → Registries → Add Registry**
- **Provider:** GitHub Container Registry
- **URL:** `ghcr.io`
- **Username:** je GitHub username
- **Password:** GITHUB_TOKEN
2. **In docker-compose, images gebruiken:**
```yaml
image: ghcr.io/YOUR_USERNAME/YOUR_REPO/auth_api:latest
```
---
## 🔄 Workflow Overzicht
```
┌─────────────────┐
│ Push to GitHub │
└────────┬────────┘
│
▼
┌──────────────────────────────┐
│ GitHub Actions Workflow: │
│ - Checkout │
│ - Build images │
│ - Push to ghcr.io │
└────────┬─────────────────────┘
│
▼
┌──────────────────────────────┐
│ ghcr.io Registry │
│ - auth_api:latest │
│ - employee_api:latest │
│ - transit_api:latest │
└────────┬─────────────────────┘
│
▼
┌──────────────────────────────┐
│ Portainer (Remote Server) │
│ docker-compose pull & up │
└──────────────────────────────┘
```
---
## 📝 Image Tags Automatisering
GitHub Actions genereert automatisch tags:
| Event | Tag |
|-------|-----|
| Push naar main | `latest`, `main-{SHA}` |
| Push naar develop | `develop-{SHA}` |
| Release/Tag | `v1.0.0`, `1.0`, `1` |
**Voorbeeld:**
```
ghcr.io/sven/transit_project/auth_api:latest
ghcr.io/sven/transit_project/auth_api:main-a1b2c3d
ghcr.io/sven/transit_project/auth_api:v1.0.0
```
---
## ⚙️ Configuratie
### docker-compose.portainer.yml aanpassen
1. **Vervang dit:**
```
ghcr.io/YOUR_USERNAME/YOUR_REPO/auth_api:latest
```
2. **Met jouw gegevens:**
```
ghcr.io/sven/transit_project/auth_api:latest
```
3. **Voor private image, pull policy toevoegen:**
```yaml
auth_api:
image: ghcr.io/sven/transit_project/auth_api:latest
pull_policy: always
```
### Environment variabelen
**Maak .env file op Portainer server:**
```bash
POSTGRES_DB=transit_db
POSTGRES_USER=transit_user
POSTGRES_PASSWORD=super_secure_password
JWT_SECRET=your_secret_key_here
```
**Dan starten:**
```bash
docker-compose --env-file .env -f docker-compose.portainer.yml up -d
```
---
## 🔐 Veiligheid
### GitHub Token limieten
- Standaard `GITHUB_TOKEN` werkt goed
- Verloopt na 60 minuten (genoeg voor build)
### Private Registry
Als je Portainer server geen internet heeft:
- Use **Portainer Registry Mirror**
- Of **Private Registry** (Harbor, Nexus)
### Credentials in Portainer
**Nooit credentials in docker-compose zetten!**
✅ Juist: Environment file
```bash
docker-compose --env-file .env up -d
```
❌ Fout:
```yaml
environment:
POSTGRES_PASSWORD: password123 # DON'T DO THIS
```
---
## 🐛 Troubleshooting
### "Image not found"
```bash
# Check of image in registry staat
docker pull ghcr.io/USERNAME/REPO/auth_api:latest
# Kijk in GitHub Actions logs
# Check je IMAGE naam in workflow
```
### "Unauthorized: authentication required"
```bash
# Login opnieuw
docker login ghcr.io
# Check token permissies (scope: write:packages)
```
### "Connection refused" naar database
```bash
# Check Docker network
docker network ls
docker network inspect YOUR_NETWORK
# Check database running
docker ps | grep postgres
```
### Logs bekijken
```bash
# Via docker
docker logs auth_api
# Via Portainer: Containers → Select → Logs
```
---
## 📚 Nuttige Commando's
```bash
# Check beschikbare images/tags
curl -s -H "Authorization: Bearer TOKEN" \
https://ghcr.io/v2/USERNAME/REPO/auth_api/tags/list | jq
# Manual image build (als je workflow debug)
docker build -t ghcr.io/USERNAME/REPO/auth_api:test ./auth_api
docker push ghcr.io/USERNAME/REPO/auth_api:test
# Pull latest images
docker-compose -f docker-compose.portainer.yml pull
# Recreate containers (na pull)
docker-compose -f docker-compose.portainer.yml up -d --force-recreate
```
---
## 🎯 Samenvatting
| Stap | Wat | Waar |
|------|-----|------|
| 1 | Code pushen | GitHub |
| 2 | Images bouwen | GitHub Actions |
| 3 | Images pushen | ghcr.io registry |
| 4 | Images pullen | Portainer server |
| 5 | Containers starten | Docker Compose |
**Voordelen:**
- ✅ Volledig geautomatiseerd
- ✅ Versiebeheer via git tags
- ✅ Makkelijk rollback
- ✅ Geen lokale builds nodig
- ✅ CI/CD best practices
-240
View File
@@ -1,240 +0,0 @@
# Docker Setup voor Transit Project APIs
Dit project bevat Docker-configuratie voor alle APIs, Admin Control Center en PostgreSQL database.
## Bestanden
- **auth_api/Dockerfile** - Image voor Authentication API (port 5000)
- **employee_api/Dockerfile** - Image voor Employee API (port 4000)
- **transit_api/Dockerfile** - Image voor Transit API (port 5001)
- **admin_ui/Dockerfile** - Image voor Admin Control Center (port 5174)
- **docker-compose.yml** - Orchestratie van alle services
- **.dockerignore** - Bestanden die niet in Docker images opgenomen worden
- **.env.example** - Template voor environment variabelen
- **DEPLOYMENT.md** - Gedetailleerde deployment guide
- **scripts/deploy.sh** - Deployment helper script
- **scripts/build-and-push.sh** - Build en push script voor registry
## Vereisten
- Docker (>=20.10)
- Docker Compose (>=2.0)
## Installatie
### 1. Environment variabelen configureren
```bash
cp .env.example .env
# Bewerk .env met je eigen waarden
```
### 2. Services starten
```bash
# Start alle services (APIs + Admin UI)
docker-compose up -d
# Of build en start tegelijk (als je code hebt aangepast)
docker-compose up -d --build
# Quick start met script
./scripts/deploy.sh start
```
### 3. Logs bekijken
```bash
# Alle logs
docker-compose logs -f
# Logs van specifieke service
docker-compose logs -f auth_api
docker-compose logs -f transit_api
docker-compose logs -f admin_ui
# Via script
./scripts/deploy.sh logs admin_ui
```
## Beschikbare Services
| Service | Port | Beschrijving |
|---------|------|--------------|
| admin_ui | 5174 | Admin Control Center (React + Nginx) |
| auth_api | 5000 | Authentication endpoints |
| employee_api | 4000 | Employee management endpoints |
| transit_api | 5001 | Transit management endpoints |
## Commando's
### Services beheren (Quick way)
```bash
# Via helper script
./scripts/deploy.sh start # Start alle services
./scripts/deploy.sh stop # Stop alle services
./scripts/deploy.sh restart # Herstart alle services
./scripts/deploy.sh status # Show status
./scripts/deploy.sh logs # Bekijk logs admin_ui
./scripts/deploy.sh logs transit_api # Bekijk logs specifieke service
```
### Services beheren (Docker compose)
```bash
# Start services
docker-compose up -d
# Stop services
docker-compose down
# Herstart services
docker-compose restart
# Verwijder containers en volumes
docker-compose down -v
```
### Individuele services
```bash
# Herstart specifieke service
docker-compose restart auth_api
docker-compose restart admin_ui
# Build specifieke image
docker-compose build auth_api
# Logs van specifieke service
docker-compose logs -f transit_api
```
### Admin UI
```bash
# Bekijk Admin UI
http://localhost:5174
# Logs
docker-compose logs -f admin_ui
# Shell in container
docker-compose exec admin_ui sh
```
## Troubleshooting
### Port al in gebruik
```bash
# Check welke container de port gebruikt
docker ps
# Verander port in docker-compose.yml (e.g., "5000:5000" naar "5010:5000")
```
### Database connectie fout
```bash
# Check database status
docker-compose logs db
# Herstart database
docker-compose restart db
```
### API start probleem
```bash
# Check logs
docker-compose logs auth_api
# Controleer environment variabelen in .env
# Controleer database connection string
```
### Admin UI niet bereikbaar
```bash
# Check Admin UI container
docker-compose logs admin_ui
# Zorg ervoor dat poort 5174 niet in gebruik is
lsof -i :5174
```
## Admin Control Center
De Admin UI is een modern React + Vite applicatie die alle admin functies biedt.
### Features
- **Authenticatie**: JWT-based login met dev token helper
- **Stops Management**: Create en beheer transit stops met geo-coördinaten
- **Bus Lines**: Define bus routes met start/end stops en reistijd
- **Fleet Management**: Monitor fleet vehicles
- **Stop Linking**: Assign stops aan bus lines met volgorde en ETA
### Toegang
```
URL: http://localhost:5174
```
### Deployment Configuratie
Environment variabelen in docker-compose.yml:
- `VITE_AUTH_API`: URL naar auth_api (standaard: http://auth_api:5000)
- `VITE_TRANSIT_API`: URL naar transit_api (standaard: http://transit_api:5001)
Voor externe toegang, update de URLs naar je deployment domain.
### Build en Deployment
```bash
# Build Admin UI image
docker-compose build admin_ui
# Push naar registry
./scripts/build-and-push.sh ghcr.io/your-org/xtransit latest
# Deploy met updated image
docker-compose up -d
```
## Development
### Wijzigingen doorgevoerd in code
```bash
# Rebuild container
docker-compose build auth_api
# Herstart service
docker-compose restart auth_api
```
### Code reloading (hot reload)
Voor hot reload kunt u volumes toevoegen aan docker-compose.yml:
```yaml
volumes:
- ./auth_api:/app
```
Dit kan performance beïnvloeden. Alleen gebruiken tijdens development.
## Productie Tips
1. **Veiligheid**: Wijzig alle wachtwoorden en JWT_SECRET in .env
2. **Database**: Gebruik een managed database service in productie
3. **Logging**: Configureer centralized logging (ELK stack, Splunk, etc.)
4. **Backups**: Stel automatische backups in voor de database
5. **Health Checks**: Monitoreer de health endpoints van de APIs
6. **SSL/TLS**: Gebruik reverse proxy (nginx) voor HTTPS
## Netwerking
De services kunnen elkaar bereiken via:
- auth_api: `http://auth_api:5000`
- employee_api: `http://employee_api:4000`
- transit_api: `http://transit_api:5001`
- database: `db:5432`
-157
View File
@@ -1,157 +0,0 @@
# Quick Start Guide - XTransit Admin Control Center
## 🚀 5-Minute Setup
### Step 1: Initial Setup
```bash
# From the project root
./scripts/init.sh
```
This will:
- Check Docker and Docker Compose installation
- Create `.env` file from template
- Build all Docker images
- Prompt you to configure database credentials
### Step 2: Configure Database Connection
Edit `.env` with your database details:
```env
POSTGRES_HOST=your-db-host.com
POSTGRES_USER_AUTH=transitAUTH
POSTGRES_PASSWORD_AUTH=your_password
POSTGRES_USER_TRANSIT=transitAPI
POSTGRES_PASSWORD_TRANSIT=your_password
JWT_SECRET=your-secure-secret
ADMIN_USER_CODES=42
```
### Step 3: Start Services
```bash
./scripts/deploy.sh start
```
### Step 4: Access Admin UI
```
http://localhost:5174
```
Log in with your real user code and password.
## 📱 Admin UI Features
### Stops Management
- Create new transit stops
- Set geolocation (latitude, longitude)
- View all stops
- Link stops to bus lines
### Bus Lines
- Create new bus routes
- Define start and end stops
- Set estimated travel time
- Assign stops to lines with order
### Fleet
- View fleet vehicles (read-only)
- See vehicle details
## 🐳 Docker Services
| Port | Service | URL |
|------|---------|-----|
| 5174 | Admin UI | http://localhost:5174 |
| 5000 | Auth API | http://localhost:5000 |
| 5001 | Transit API | http://localhost:5001 |
| 4000 | Employee API | http://localhost:4000 |
## 🔧 Common Commands
```bash
# View service status
./scripts/deploy.sh status
# Restart all services
./scripts/deploy.sh restart
# View logs
./scripts/deploy.sh logs admin_ui
./scripts/deploy.sh logs transit_api
# Stop services
./scripts/deploy.sh stop
# Clean up containers and volumes
./scripts/deploy.sh clean
```
### Authentication
Use the real login flow with user code and password.
### Production
- Keep `DEV_BYPASS=0` and `DEV_MOCK=0`
- Configure proper admin user codes in `ADMIN_USER_CODES`
- Ensure JWTs include the `userCode` claim
## 📝 Configuration Files
- **docker-compose.yml** - All service definitions
- **.env** - Database and API credentials
- **DEPLOYMENT.md** - Comprehensive deployment guide
- **DOCKER_README.md** - Docker-specific documentation
## ⚠️ Troubleshooting
### "Address already in use"
```bash
# Find and stop the process using the port
lsof -i :5174 # Find process on port 5174
kill -9 <PID> # Kill the process
```
### "Cannot connect to database"
```bash
# Check database credentials in .env
# Verify database is accessible from your machine
# Ensure database user exists and has proper permissions
```
### "Admin UI not loading"
```bash
# Check container is running
docker-compose ps admin_ui
# View logs
docker-compose logs -f admin_ui
# Restart service
docker-compose restart admin_ui
```
### "Permission denied" errors
The transit API user needs proper database permissions:
```sql
-- Grant permissions to transitAPI user
GRANT CONNECT ON DATABASE "XTransit" TO transitAPI;
GRANT USAGE ON SCHEMA public TO transitAPI;
GRANT SELECT, INSERT, UPDATE ON all tables IN SCHEMA public TO transitAPI;
```
## 📚 For More Information
- **DEPLOYMENT.md** - Full deployment guide for production
- **DOCKER_README.md** - Docker and container information
- **admin_ui/README.md** - Admin UI specific details
## 🎯 Next Steps
1. ✅ Run initial setup script
2. ✅ Configure database credentials
3. ✅ Start services
4. ✅ Access Admin UI at http://localhost:5174
5. ✅ Create test stops and bus lines
6. ✅ Test the admin interface
For production deployment, see **DEPLOYMENT.md** for security and configuration best practices.
+165 -107
View File
@@ -1,20 +1,37 @@
# Transit Project
Dit repository bevat een suite van drie Node.js microservices (APIs) en bijbehorende tooling:
This repository contains a suite of Node.js microservices, a modern React administration console, an operational monitoring dashboard, and a legacy client retained for compatibility reference.
- `auth_api` — Authenticatie en token-uitgifte (standaard port 5000)
- `employee_api` — Employee onboarding / management (standaard port 4000)
- `transit_api` — Transit/schedule endpoints (standaard port 5001)
- `auth_api` — Authentication and token issuance (default port 5000)
- `employee_api` — Employee onboarding and management (default port 4000)
- `transit_api` — Routes, schedules, fleet, GPS, and transit administration (default port 5001)
- `error_log_api` — Centralized error logging and viewer (default port 5003)
Er is ook een GitHub Actions workflow aanwezig die Docker images bouwt en pusht naar GitHub Container Registry (GHCR).
The error API is available at `https://error.xtransit.testinstance.nl`.
Belangrijk: deze README bevat geen wachtwoorden of andere gevoelige gegevens. Sla secrets altijd buiten versiebeheer op (environment variables, secret managers, Portainer secrets, etc.).
- `admin_ui` — React + Vite administration interface
The admin panel is available at `https://admin.xtransit.testinstance.nl`.
- `monitoring_ui` — React + Vite service monitoring dashboard
The monitoring dashboard is available at host port 5175 (`https://monitor.xtransit.testinstance.nl` or local port 5173).
- `windows_xp_driver_ui/` — Native Win32 driver client targeting Windows XP-era systems
- `deprecated/` — Legacy Flutter bus terminal, retained for reference while moving the client to a Windows XP-era build path
The repository also includes CI workflows that build Docker images and push them to GitHub Container Registry (GHCR).
See [monitoring_ui/docs/DOMAINS_README.md](monitoring_ui/docs/DOMAINS_README.md) for the central list of all public domains and their functions.
The consolidated documentation hub is available in [monitoring_ui/docs/README.md](monitoring_ui/docs/README.md).
Important: this README does not contain any passwords or other sensitive information. Always store secrets outside of version control (environment variables, secret managers, Portainer secrets, etc.).
---
## Security & Private Files
**⚠️ Important:** This repository excludes private documentation and setup instructions from Git and Docker builds. See [SECURITY_PRIVATE_FILES.md](SECURITY_PRIVATE_FILES.md) for details on:
**⚠️ Important:** This repository excludes private documentation and setup instructions from Git and Docker builds. See [monitoring_ui/docs/SECURITY_PRIVATE_FILES.md](monitoring_ui/docs/SECURITY_PRIVATE_FILES.md) for details on:
- What files are kept private and why
- How `.gitignore` and `.dockerignore` protect sensitive information
@@ -22,6 +39,7 @@ Belangrijk: deze README bevat geen wachtwoorden of andere gevoelige gegevens. Sl
- Guidelines for adding new private files
**Private files are excluded** (not in git/docker):
- `GETTING_STARTED.md` - Internal setup guide
- `ADMIN_DEPLOYMENT_SUMMARY.md` - Deployment status
- `CI_CD_PORTAINER_GUIDE.md` - Infrastructure docs
@@ -30,74 +48,84 @@ Belangrijk: deze README bevat geen wachtwoorden of andere gevoelige gegevens. Sl
---
## Overzicht van inhoud
## Table of Contents / Directory Overview
- `auth_api/` — source voor auth service
- `employee_api/` — source voor employee service
- `transit_api/` — source voor transit service
- `.github/workflows/docker-build.yml` — CI: bouw en push images naar GHCR
- `docker-compose.yml` — compose file die (in deze workspace-versie) images trekt van `ghcr.io/...` en verwacht dat je een externe PostgreSQL hebt draaien
- `.env.example` — voorbeeld environment variabelen
- `DOCKER_README.md` & `CI_CD_PORTAINER_GUIDE.md` — extra docs voor Docker/CI
- `auth_api/` — Source code for the authentication service
- `employee_api/` — Source code for the employee service
- `transit_api/` — Source code for the transit service
- `error_log_api/` — Source code for centralized error collection
- `admin_ui/` — Source code for React admin panel
- `monitoring_ui/` — Source code for React monitoring dashboard and documentation hub
- `windows_xp_driver_ui/` — Native driver interface
- `deprecated/` — Legacy client reference
- `.github/workflows/docker-build.yml` — CI: build and push images to GHCR
- `docker-compose.yml` — Compose file that pulls images from GHCR or builds locally and connects to an external PostgreSQL database
- `.env.example` — Example environment variables
- `monitoring_ui/docs/` — Consolidated documentation hub
---
## Vereisten
## Prerequisites
- Git
- Docker (20.10+)
- Docker Compose (v2+ of `docker compose`)
- (Optioneel) een remote omgeving met Portainer
- (Optioneel) Een GitHub account met rechten om images naar GHCR te publiceren
- Docker Compose (v2+ or `docker compose`)
- (Optional) A remote environment running Portainer
- (Optional) A GitHub account with permissions to publish images to GHCR
---
## Veiligheid — belangrijke reminders
## Security — Important Reminders
- Plaats geen secrets in git. Gebruik een `.env`-bestand lokaal (dat je in `.gitignore` houdt) of een secret manager.
- JWT secrets, database wachtwoorden en andere credentials moeten via environment variables of Portainer secrets geleverd worden.
- Never commit secrets into git. Use a local `.env` file (kept in `.gitignore`) or a secret manager.
- JWT secrets, database passwords, and other credentials must be supplied via environment variables or Portainer secrets.
---
## Environment variabelen (voorbeeld)
## Environment Variables (Example)
Je `docker-compose.yml` verwacht environment variabelen die de database-verbinding en JWT secret definiëren.
Maak een local `.env` bestand in de projectroot (NIET commiten). Gebruik deze variabelen als leidraad:
Your `docker-compose.yml` expects environment variables defining the database connection and JWT secret.
Create a local `.env` file in the project root (DO NOT commit). Use these variables as a guide:
```
# Database (externe Postgres)
```env
# Database (external Postgres)
POSTGRES_HOST=your.db.host.or.ip
POSTGRES_PORT=5432
POSTGRES_DB=transit_db
# Per-service DB-credentials (optioneel: kun ook één DB-user gebruiken per DB)
# Per-service DB credentials (optional: you can also use a single DB user per DB)
POSTGRES_USER_AUTH=auth_user
POSTGRES_PASSWORD_AUTH=strong_password_auth
POSTGRES_USER_EMPLOYEE=employee_user
POSTGRES_PASSWORD_EMPLOYEE=strong_password_employee
POSTGRES_USER_TRANSIT=transit_user
POSTGRES_PASSWORD_TRANSIT=strong_password_transit
POSTGRES_USER_ERROR_LOG=error_logger
POSTGRES_PASSWORD_ERROR_LOG=strong_password_error_log
# JWT secret voor services die JWT gebruiken
# JWT secret for services using JWT
JWT_SECRET=super_secret_jwt_key_here
# Optional: central error log endpoint for clients/services
ERROR_LOG_API_URL=https://error.xtransit.testinstance.nl/error_log
```
Opmerking: de exacte variabelen worden gelezen door `docker-compose.yml`. Pas de namen/waarden aan naar jouw omgeving.
Note: The exact variables are read by `docker-compose.yml`. Adjust names and values according to your environment.
---
## Lokale ontwikkeling (zonder Docker)
## Local Development (Without Docker)
1. Zorg dat je een PostgreSQL instance hebt draaien (lokal of remote).
2. Kopieer per API een `.env` of export de benodigde vars.
1. Ensure you have a running PostgreSQL instance (local or remote).
2. Copy a `.env` file for each API or export the required environment variables.
Voorbeeld: run de `auth_api` lokaal
Example: running `auth_api` locally:
```bash
cd auth_api
# installeer dependencies
# Install dependencies
npm install
# export env vars (voorbeeld):
# Export env vars (example):
export DB_HOST=your.db.host
export DB_PORT=5432
export DB_NAME=transit_db
@@ -108,93 +136,97 @@ export JWT_SECRET=...
npm start
```
Herhaal voor `employee_api` en `transit_api` met de juiste env keys/poorten.
Repeat for `employee_api`, `transit_api`, and `error_log_api` with the appropriate env keys and ports.
Tip: tijdens development kun je één Postgres instance gebruiken en schema/rollen configureren.
Tip: During development, you can use a single Postgres instance and configure schemas and roles.
---
## Docker / Portainer — deployment (met externe Postgres)
## Docker / Portainer — Deployment (With External Postgres)
Deze repository bevat `docker-compose.yml` die images vanuit GHCR trekt. De compose is aangepast om een externe Postgres te gebruiken.
This repository includes a `docker-compose.yml` file that pulls images from GHCR. The compose setup is configured to use an external Postgres instance.
Voordat je start:
- Zorg dat je `.env` met database credentials en `JWT_SECRET` klaar staat op de server.
- Zorg dat de host waar je compose draait netwerk-connectiviteit heeft naar de Postgres host.
Before starting:
Stappen om te deployen (server/Portainer host):
- Ensure your `.env` file with database credentials and `JWT_SECRET` is prepared on the server.
- Ensure the host running compose has network connectivity to the Postgres host.
Deployment steps (server / Portainer host):
```bash
# 1. Zet .env op server (NIET in git)
# 1. Place .env on the server (NOT in git)
scp .env user@server:/path/to/project/.env
# of maak lokaal op de server
# or create it directly on the server
# 2. Login bij GHCR (om private images te kunnen pullen)
# Username = je GitHub-gebruikersnaam
# Password = PAT met 'write:packages'/'read:packages' scopes of je GITHUB_TOKEN
# 2. Login to GHCR (to pull private images)
# Username = your GitHub username
# Password = PAT with 'write:packages'/'read:packages' scopes or your GITHUB_TOKEN
docker login ghcr.io
# 3. Start stack
docker-compose --env-file .env up -d
# of met compose V2:
# or with compose V2:
docker compose --env-file .env up -d
```
Als je Portainer gebruikt kun je `docker-compose.yml` ook als "Stack" in de Portainer UI plakken en deployment via de UI uitvoeren. Voeg de environment variabelen in Portainer toe of verwijs naar een server-side `.env`.
If using Portainer, you can also paste `docker-compose.yml` as a "Stack" in the Portainer UI and deploy via the UI. Add environment variables in Portainer or reference a server-side `.env`.
Opmerking over `depends_on` in `docker-compose.yml`: omdat je een externe DB gebruikt, verwijst de compose soms nog naar een non-existent `db` service. Je kunt die `depends_on` regels veilig verwijderen of negeren, zolang je database beschikbaar is voordat de services de verbinding proberen te maken.
Note on `depends_on` in `docker-compose.yml`: because you are using an external DB, compose configs sometimes reference a non-existent `db` service. You can safely remove or ignore those `depends_on` rules, as long as your database is available before the services attempt to connect.
---
## GitHub Actions & Container Registry (CI)
Het project bevat een workflow in `.github/workflows/docker-build.yml` die bij push naar `main`/`develop` de volgende stappen uitvoert:
The project includes a CI workflow in `.github/workflows/docker-build.yml` (and `.gitea/workflows/docker-build.yml`) that executes the following steps on push to `main`/`dev`:
- Checkout
- Build Docker images per API (met BuildKit/Buildx)
- Login naar de Container Registry (GHCR) en push images
- Genereert tags (`latest`, branch/sha, semver als aanwezig)
- Checkout repository
- Build Docker images for each API (using BuildKit / Buildx)
- Login to the Container Registry (GHCR) and push images
- Generate tags (`latest`, branch/sha, semver if present)
Om GHCR te gebruiken op je server:
1. Maak een Personal Access Token (PAT) met `read:packages` (en `write:packages` indien je zelf ook wilt pushen).
2. Login op de server: `docker login ghcr.io` (gebruik PAT als wachtwoord).
To use GHCR on your server:
1. Create a Personal Access Token (PAT) with `read:packages` (and `write:packages` if you also wish to push).
2. Login on the server: `docker login ghcr.io` (use PAT as password).
Example image name:
Image naam voorbeeld:
```
ghcr.io/<github-username>/<repo>/auth_api:latest
```
---
## Health checks, endpoints en debugging
## Health Checks, Endpoints, and Debugging
Enkele nuttige endpoints (voorbeeld):
Useful endpoints (example):
- `GET /health` — (beschikbaar in `employee_api`) controleert reachability
- `POST /login` — (`auth_api`) authenticatie
- `GET /schedule/daily?date=YYYY-MM-DD` — (`transit_api`) vereist auth
- `GET /health` — (available in `employee_api`, `transit_api`, `error_log_api`) checks reachability
- `POST /login` — (`auth_api`) authentication
- `GET /schedule/daily?date=YYYY-MM-DD` — (`transit_api`) requires authentication
Logs bekijken:
Viewing logs:
```bash
# Logs van service
# Logs from a service
docker-compose logs -f auth_api
# Of via container id
# Or via container ID
docker logs -f auth_api
```
Problemen met DB-verbinding:
- Controleer netwerkconnectiviteit naar `POSTGRES_HOST`
- Controleer users/permissions in Postgres
- Controleer dat de juiste schema (zoals `workers`) bestaat als de queries die verwachten
Troubleshooting DB connection:
- Verify network connectivity to `POSTGRES_HOST`
- Check users and permissions in Postgres
- Ensure the expected schema (such as `workers` or `transit`) exists if queries expect it
---
## Veelvoorkomende taken & commando's
## Common Tasks & Commands
```bash
# Build & run (pull images van GHCR)
# Build & run (pull images from GHCR)
docker-compose --env-file .env up -d
# Pull latest images
@@ -212,44 +244,70 @@ docker login ghcr.io
---
## Migraties & Database schema
## Migrations & Database Schema
Database schema en migraties zitten niet in deze repository. Zorg dat je:
- de juiste schema's (`workers`, `routes`, etc.) aanmaakt
- gebruikers en rechten configureert voor de verschillende services
Database schemas and migrations are maintained separately. Ensure you:
Als je nog geen database hebt, kun je lokaal een Postgres container gebruiken (development):
- Create the necessary schemas (`workers`, `transit`, `data`, etc.)
- Configure users and privileges for the respective services
### Error Logging Schema
For centralized error logging, create schema `data` and table `data.errors`:
```sql
CREATE SCHEMA IF NOT EXISTS data;
CREATE TABLE IF NOT EXISTS data.errors (
id BIGSERIAL PRIMARY KEY,
"date" TIMESTAMPTZ NOT NULL DEFAULT NOW(),
"service" TEXT NOT NULL,
partofservice TEXT,
error TEXT NOT NULL
);
```
Use a dedicated user with minimal privileges, e.g. `error_logger`:
```sql
CREATE ROLE error_logger LOGIN PASSWORD 'strong_password_error_log';
GRANT USAGE ON SCHEMA data TO error_logger;
GRANT INSERT, SELECT ON data.errors TO error_logger;
```
If you do not have a database yet, you can run a local PostgreSQL container for development:
```bash
docker run --name transit-db -e POSTGRES_DB=transit_db -e POSTGRES_USER=transit_user -e POSTGRES_PASSWORD=pass -p 5432:5432 -d postgres:15-alpine
```
Maar voor je production/Portainer setup volg je je bestaande beheersbeleid en gebruik je een managed DB of beveiligde host.
For production / Portainer setups, follow your infrastructure policy and use a managed database or secured host.
---
## Deployment tips
## Deployment Tips
- Gebruik verschillende database users per service met minimale rechten
- Gebruik secrets in Portainer of Docker swarm/kubernetes secrets in productie
- Gebruik een reverse proxy (nginx, Traefik) voor TLS/HTTPS
- Stel log aggregatie en monitoring in (Prometheus/Grafana, ELK, etc.)
- Use distinct database users per service with minimal privileges
- Use secrets in Portainer, Docker swarm, or Kubernetes in production
- Use a reverse proxy (Nginx, Traefik) for TLS/HTTPS
- Set up log aggregation and monitoring (Prometheus/Grafana, ELK, etc.)
---
## Contributie
## Contributing
- Maak feature branches en open pull requests
- Push naar `develop` of `main` afhankelijk van je branching policy
- CI zal automatisch images bouwen en pushen voor `main`/`develop`
- Create feature branches and open pull requests
- Push to `dev` or `main` depending on your branching policy
- CI will automatically build and push images for `main`/`dev`
---
## Waar te vinden
## Where to Find Everything
- API code: `auth_api/`, `employee_api/`, `transit_api/`
- CI workflow: `.github/workflows/docker-build.yml`
- Extra docs: `DOCKER_README.md`, `CI_CD_PORTAINER_GUIDE.md`
- API code: `auth_api/`, `employee_api/`, `transit_api/`, `error_log_api/`
- Frontend UI: `admin_ui/`, `monitoring_ui/`
- Documentation hub: `monitoring_ui/docs/`
- CI workflows: `.github/workflows/docker-build.yml`, `.gitea/workflows/docker-build.yml`
---
@@ -284,7 +342,6 @@ admin_ui/
│ └── styles.css # Responsive UI styling
├── docker-entrypoint.sh # Runtime env config
└── Dockerfile # Multi-stage build
```
### Building the Admin UI
@@ -310,25 +367,28 @@ docker-compose up admin_ui
The Transit API exposes public (unauthenticated) schedule endpoints:
**Endpoints:**
- `GET /schedules?date=YYYY-MM-DD&lineId=ID` — Get schedules for a date/line
- `GET /schedules/:scheduleId` — Get schedule details
- `GET /schedules/:scheduleId/stops` — Get stops on a schedule's line
**Example:**
```bash
curl http://localhost:5001/schedules?date=2026-05-06
curl https://transit.xtransit.testinstance.nl/schedules?date=2026-05-06
```
**TimetableViewer Component:**
```jsx
import TimetableViewer from './components/TimetableViewer'
<TimetableViewer apiBaseUrl="http://api.transit.local:5001" />
```jsx
import TimetableViewer from "./components/TimetableViewer";
<TimetableViewer apiBaseUrl="http://api.transit.local:5001" />;
```
### Schedule Maker Documentation
See [admin_ui/SCHEDULE_API.md](admin_ui/SCHEDULE_API.md) for detailed API documentation, usage examples, and UI features.
See [monitoring_ui/docs/admin_ui/SCHEDULE_API.md](monitoring_ui/docs/admin_ui/SCHEDULE_API.md) for detailed API documentation, usage examples, and UI features.
---
@@ -340,10 +400,11 @@ Admin dashboard uses simple JWT-based authentication:
2. Receive JWT token from `auth_api`
3. Use token for admin operations (`/admin/*` endpoints)
Admin authorization is controlled by `ADMIN_USER_CODES` environment variable:
Admin authorization is controlled by `ADMIN_USER_CODES` environment variable configured in the `auth_api` service:
```bash
ADMIN_USER_CODES=42,101,205 # Comma-separated admin user codes
# Set admin user codes for the auth service (comma-separated)
ADMIN_USER_CODES=42,101,205
```
---
@@ -355,13 +416,10 @@ ADMIN_USER_CODES=42,101,205 # Comma-separated admin user codes
Use `DEV_MOCK=1` to work without a database (in-memory mock storage):
```bash
ADMIN_USER_CODES=42 DEV_MOCK=1 JWT_SECRET=devsecret npm --prefix ./transit_api start
# For local development set admin codes on the auth_api and start services
ADMIN_USER_CODES=42 DEV_MOCK=1 JWT_SECRET=devsecret npm --prefix ./auth_api start
```
### Production Mode
Set up real PostgreSQL database and provide credentials via environment variables.
---
Als je wilt, kan ik deze README nog aanpassen met specifieke voorbeelden van database schema's of exacte environment-variabele namen voor lokaal draaien. Zeg welke details je wil toevoegen (bijv. example SQL voor `workers` schema).
+1
View File
@@ -7,6 +7,7 @@ RUN npm run build
FROM nginx:stable-alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY docker-entrypoint.sh /docker-entrypoint.sh
RUN chmod +x /docker-entrypoint.sh
EXPOSE 80
+3 -3
View File
@@ -1,9 +1,9 @@
version: '3.8'
version: "3.8"
services:
admin-ui:
build: .
ports:
- "5174:80"
environment:
VITE_AUTH_API: ${VITE_AUTH_API:-http://localhost:5000}
VITE_TRANSIT_API: ${VITE_TRANSIT_API:-http://localhost:5001}
VITE_AUTH_API: ${VITE_AUTH_API:-https://auth.xtransit.testinstance.nl}
VITE_TRANSIT_API: ${VITE_TRANSIT_API:-https://transit.xtransit.testinstance.nl}
+3 -2
View File
@@ -3,8 +3,9 @@ set -e
cat > /usr/share/nginx/html/env-config.js <<EOF
window.__ENV__ = {
AUTH_API: "${AUTH_API:-http://localhost:5000}",
TRANSIT_API: "${TRANSIT_API:-http://localhost:5001}"
AUTH_API: "${AUTH_API:-https://auth.xtransit.testinstance.nl}",
TRANSIT_API: "${TRANSIT_API:-https://transit.xtransit.testinstance.nl}",
ERROR_LOG_API: "${ERROR_LOG_API:-https://error.xtransit.testinstance.nl}"
}
EOF
+9 -2
View File
@@ -1,9 +1,16 @@
<!doctype html>
<html>
<html lang="nl">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Transit Admin</title>
<meta name="theme-color" content="#0e1526" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;550;600;700;800&display=swap"
rel="stylesheet"
/>
<title>Transit Beheercentrum</title>
</head>
<body>
<div id="root"></div>
+14
View File
@@ -0,0 +1,14 @@
server {
listen 80;
root /usr/share/nginx/html;
location = /health {
default_type application/json;
add_header Cache-Control "no-store";
return 200 '{"status":"ok","service":"admin_ui"}';
}
location / {
try_files $uri $uri/ /index.html;
}
}
+90 -22
View File
@@ -1,14 +1,37 @@
import React, { useState, useEffect } from 'react'
import React, { useEffect, useState } from 'react'
import Login from './components/Login'
import Overview from './components/Overview'
import StopsManager from './components/StopsManager'
import LinkStop from './components/LinkStop'
import BusLinesManager from './components/BusLinesManager'
import FleetManager from './components/FleetManager'
import ScheduleMaker from './components/ScheduleMaker'
import ErrorLogViewer from './components/ErrorLogViewer'
import RequestLab from './components/RequestLab'
import { IconAlert, IconBus, IconCalendar, IconGrid, IconLink, IconLogout, IconMapPin, IconRoute, IconTerminal } from './components/icons'
const SECTIONS = [
{ id: 'overview', label: 'Overzicht', eyebrow: 'Operations', title: 'Dagelijks overzicht', desc: 'Vloot, netwerk en dienstrooster in één oogopslag.', icon: IconGrid },
{ id: 'stops', label: 'Haltes', eyebrow: 'Netwerkbeheer', title: 'Haltes', desc: 'Maak herbruikbare haltes aan en bewerk ze.', icon: IconMapPin },
{ id: 'lines', label: 'Buslijnen', eyebrow: 'Netwerkbeheer', title: 'Buslijnen', desc: 'Definieer lijnen tussen start- en eindhalte.', icon: IconRoute },
{ id: 'routes', label: 'Lijnroutes', eyebrow: 'Netwerkbeheer', title: 'Stops per lijn', desc: 'Koppel haltes aan lijnen en bepaal de volgorde.', icon: IconLink },
{ id: 'fleet', label: 'Vloot', eyebrow: 'Vlootbeheer', title: 'Vloot', desc: 'Beheer de voertuigen die deel uitmaken van de vloot.', icon: IconBus },
{ id: 'schedules', label: 'Diensten', eyebrow: 'Planning', title: 'Dienstenplanning', desc: 'Plan wekelijkse schema’s en vaste diensten.', icon: IconCalendar },
{ id: 'logs', label: 'Foutmeldingen', eyebrow: 'Systeem', title: 'Centrale foutmeldingen', desc: 'Bekijk fouten die door alle diensten zijn gemeld.', icon: IconAlert },
{ id: 'lab', label: 'API-lab', eyebrow: 'Systeem', title: 'API-testomgeving', desc: 'Voer goedgekeurde verzoeken uit en inspecteer live antwoorden.', icon: IconTerminal },
]
const NAV_GROUPS = [
{ label: 'Algemeen', items: ['overview'] },
{ label: 'Netwerkbeheer', items: ['stops', 'lines', 'routes', 'fleet'] },
{ label: 'Planning', items: ['schedules'] },
{ label: 'Systeem', items: ['logs', 'lab'] },
]
export default function App() {
const [token, setToken] = useState(localStorage.getItem('token'))
const [user, setUser] = useState(JSON.parse(localStorage.getItem('user') || 'null'))
const [section, setSection] = useState('overview')
useEffect(() => {
if (token) localStorage.setItem('token', token)
@@ -20,33 +43,78 @@ export default function App() {
else localStorage.removeItem('user')
}, [user])
if (!token) return <Login onLogin={(t,u) => { setToken(t); setUser(u); }} />
if (!token) return <Login onLogin={(t, u) => { setToken(t); setUser(u); setSection('overview') }} />
const active = SECTIONS.find((s) => s.id === section) || SECTIONS[0]
const initials = (user?.name || user?.userCode || 'U').toString().slice(0, 1).toUpperCase()
return (
<div className="app">
<header className="topbar">
<div>
<p className="eyebrow">Transit operations</p>
<h1>Admin control center</h1>
<div className="app-shell">
<aside className="sidebar">
<div className="brand">
<div className="brand-mark">🚌</div>
<div className="brand-text">
<div className="brand-name">Transit Ops</div>
<div className="brand-sub">Beheercentrum</div>
</div>
<div className="userbox">
<strong>{user?.userCode ? `User ${user.userCode}` : 'User'}</strong>
<button onClick={() => { setToken(null); setUser(null); }}>Logout</button>
</div>
<nav>
{NAV_GROUPS.map((group) => (
<div key={group.label}>
<div className="nav-label">{group.label}</div>
{group.items.map((id) => {
const item = SECTIONS.find((s) => s.id === id)
const Icon = item.icon
return (
<button
key={id}
className={`nav-item ${section === id ? 'active' : ''}`}
onClick={() => setSection(id)}
>
<Icon size={18} />
<span>{item.label}</span>
</button>
)
})}
</div>
))}
</nav>
<div className="sidebar-footer">
<div className="user-chip">
<div className="user-avatar">{initials}</div>
<div className="user-meta">
<div className="user-name">{user?.name || user?.userCode || 'Gebruiker'}</div>
<div className="user-role">{user?.isAdmin ? 'Beheerder' : 'Medewerker'}</div>
</div>
<button className="logout-btn" title="Uitloggen" onClick={() => { setToken(null); setUser(null) }}>
<IconLogout size={17} />
</button>
</div>
</div>
</aside>
<div className="main">
<header className="topbar">
<div className="topbar-title">
<p className="eyebrow">{active.eyebrow}</p>
<h1>{active.title}</h1>
<p>{active.desc}</p>
</div>
</header>
<div className="hero">
<div>
<h2>Fleet, lijnen en stops</h2>
<p>Beheer haltes, buslijnen, gekoppelde stops en voertuigen vanuit één overzicht.</p>
</div>
</div>
<main>
<StopsManager token={token} user={user} />
<BusLinesManager token={token} user={user} />
<FleetManager token={token} user={user} />
<LinkStop token={token} user={user} />
<ScheduleMaker token={token} user={user} />
<main className="content">
{section === 'overview' && <Overview token={token} user={user} />}
{section === 'stops' && <StopsManager token={token} user={user} />}
{section === 'lines' && <BusLinesManager token={token} user={user} />}
{section === 'routes' && <LinkStop token={token} user={user} />}
{section === 'fleet' && <FleetManager token={token} user={user} />}
{section === 'schedules' && <ScheduleMaker token={token} user={user} />}
{section === 'logs' && <ErrorLogViewer token={token} />}
{section === 'lab' && <RequestLab token={token} />}
</main>
</div>
</div>
)
}
+95 -24
View File
@@ -1,46 +1,117 @@
import axios from 'axios'
import axios from "axios";
const RUNTIME = typeof window !== 'undefined' && window.__ENV__ ? window.__ENV__ : {}
const RUNTIME =
typeof window !== "undefined" && window.__ENV__ ? window.__ENV__ : {};
const normalizeApiBaseUrl = (value, fallbackPath) => {
const rawValue = value || ''
if (!rawValue) return fallbackPath
if (/^https?:\/\//i.test(rawValue) || rawValue.startsWith('//') || rawValue.startsWith('/')) {
return rawValue
const rawValue = value || "";
if (!rawValue) return fallbackPath;
if (
/^https?:\/\//i.test(rawValue) ||
rawValue.startsWith("//") ||
rawValue.startsWith("/")
) {
return rawValue;
}
return `${window.location.protocol}//${rawValue}`
return `${window.location.protocol}//${rawValue}`;
};
const AUTH_API = normalizeApiBaseUrl(
RUNTIME.AUTH_API || import.meta.env.VITE_AUTH_API,
"https://auth.xtransit.testinstance.nl",
);
const TRANSIT_API = normalizeApiBaseUrl(
RUNTIME.TRANSIT_API || import.meta.env.VITE_TRANSIT_API,
"https://transit.xtransit.testinstance.nl",
);
const EMPLOYEE_API = normalizeApiBaseUrl(
RUNTIME.EMPLOYEE_API || import.meta.env.VITE_EMPLOYEE_API,
"https://employees.xtransit.testinstance.nl",
);
const ERROR_LOG_API = normalizeApiBaseUrl(
RUNTIME.ERROR_LOG_API || import.meta.env.VITE_ERROR_LOG_API,
"https://error.xtransit.testinstance.nl",
);
export const API_BASE = {
auth: AUTH_API,
employee: EMPLOYEE_API,
transit: TRANSIT_API,
errors: ERROR_LOG_API,
};
const reportClientError = async ({ service, partofservice, error }) => {
try {
await fetch(`${ERROR_LOG_API}/error_log`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
service,
partofservice,
error: typeof error === "string" ? error : JSON.stringify(error),
}),
});
} catch (e) {
// ignore logging failures
}
};
const AUTH_API = normalizeApiBaseUrl(RUNTIME.AUTH_API || import.meta.env.VITE_AUTH_API, 'http://localhost:5000')
const TRANSIT_API = normalizeApiBaseUrl(RUNTIME.TRANSIT_API || import.meta.env.VITE_TRANSIT_API, 'http://localhost:5001')
export const authClient = axios.create({ baseURL: AUTH_API });
export const authClient = axios.create({ baseURL: AUTH_API })
authClient.interceptors.response.use(
(response) => response,
(error) => {
void reportClientError({
service: "admin_ui",
partofservice: "authClient",
error:
error?.response?.data?.error || error?.message || "authClient_error",
});
return Promise.reject(error);
},
);
export const transitClient = (token) => {
const client = axios.create({
baseURL: TRANSIT_API,
headers: { Authorization: `Bearer ${token}` }
})
baseURL: `${TRANSIT_API}/bus`,
headers: { Authorization: `Bearer ${token}` },
});
// Auto-logout on unauthorized responses to avoid delayed failures
client.interceptors.response.use(
response => response,
error => {
(response) => response,
(error) => {
try {
const status = error?.response?.status
const status = error?.response?.status;
if (status === 401 || status === 403) {
localStorage.removeItem('token')
localStorage.removeItem('user')
localStorage.removeItem("token");
localStorage.removeItem("user");
// reload to let App show the Login screen
if (typeof window !== 'undefined') window.location.reload()
if (typeof window !== "undefined") window.location.reload();
}
void reportClientError({
service: "admin_ui",
partofservice: "transitClient",
error:
error?.response?.data?.error ||
error?.message ||
"transitClient_error",
});
} catch (e) {
// ignore
}
return Promise.reject(error)
}
)
return Promise.reject(error);
},
);
return client
}
return client;
};
export const errorLogClient = (token) =>
axios.create({
baseURL: ERROR_LOG_API,
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
export { reportClientError };
+86 -134
View File
@@ -1,5 +1,6 @@
import React, { useEffect, useState } from 'react'
import { transitClient } from '../api'
import Modal from './Modal'
export default function BusLinesManager({ token, user }) {
const client = transitClient(token)
@@ -11,16 +12,19 @@ export default function BusLinesManager({ token, user }) {
const [estimatedDurationMinutes, setEstimatedDurationMinutes] = useState('')
const [description, setDescription] = useState('')
const [message, setMessage] = useState(null)
const [isAdmin] = useState(Boolean(user?.userCode))
const [messageType, setMessageType] = useState('')
const [isAdmin] = useState(Boolean(user?.isAdmin))
const [deleteConfirm, setDeleteConfirm] = useState(null)
const [editingLine, setEditingLine] = useState(null)
const notify = (text, type = '') => { setMessage(text); setMessageType(type) }
const loadBusLines = async () => {
try {
const res = await client.get('/admin/bus-lines')
setBusLines(res.data.busLines || [])
} catch (err) {
setMessage('Error: ' + (err.response?.data?.error || err.message))
notify('Error: ' + (err.response?.data?.error || err.message), 'error')
}
}
@@ -29,7 +33,7 @@ export default function BusLinesManager({ token, user }) {
const res = await client.get('/admin/stops')
setStops(res.data.stops || [])
} catch (err) {
setMessage('Error: ' + (err.response?.data?.error || err.message))
notify('Error: ' + (err.response?.data?.error || err.message), 'error')
}
}
@@ -38,43 +42,56 @@ export default function BusLinesManager({ token, user }) {
loadStops()
}, [])
const stopNameById = (id) => stops.find(s => String(s.id) === String(id))?.name
const stopIdByName = (name) => (stops.find(s => s.name === name) || {}).id
const createBusLine = async (e) => {
e.preventDefault()
setMessage(null)
notify(null)
try {
// Map selected stop ids to names (server currently stores names)
const startStopName = stops.find(s => String(s.id) === String(startStop))?.name || startStop
const endStopName = stops.find(s => String(s.id) === String(endStop))?.name || endStop
const res = await client.post('/admin/bus-lines', {
lineNumber: Number(lineNumber),
startStop: startStopName,
endStop: endStopName,
startStop: stopNameById(startStop) || startStop,
endStop: stopNameById(endStop) || endStop,
estimatedDurationMinutes: Number(estimatedDurationMinutes),
description,
})
setLineNumber('')
setStartStop('')
setEndStop('')
setEstimatedDurationMinutes('')
setDescription('')
setMessage('Bus line created: #' + (res.data.busLine?.id || 'ok'))
setLineNumber(''); setStartStop(''); setEndStop(''); setEstimatedDurationMinutes(''); setDescription('')
notify('Buslijn aangemaakt: #' + (res.data.busLine?.id || 'ok'))
await loadBusLines()
} catch (err) {
setMessage('Error: ' + (err.response?.data?.error || err.message))
notify('Error: ' + (err.response?.data?.error || err.message), 'error')
}
}
const saveBusLine = async (e) => {
e.preventDefault()
notify(null)
try {
await client.patch(`/admin/bus-lines/${editingLine.id}`, {
lineNumber: Number(editingLine.lineNumber),
startStop: stopNameById(editingLine.startStop) || editingLine.startStop,
endStop: stopNameById(editingLine.endStop) || editingLine.endStop,
estimatedDurationMinutes: Number(editingLine.estimatedDurationMinutes),
description: editingLine.description,
})
notify('Buslijn bijgewerkt')
setEditingLine(null)
await loadBusLines()
} catch (err) {
notify('Error: ' + (err.response?.data?.error || err.message), 'error')
}
}
const deleteBusLine = async (lineId, lineNumber) => {
setMessage(null)
notify(null)
try {
await client.delete(`/admin/bus-lines/${lineId}`)
setMessage(`Buslijn ${lineNumber} verwijderd`)
notify(`Buslijn ${lineNumber} verwijderd`)
setDeleteConfirm(null)
await loadBusLines()
} catch (err) {
const errMsg = err.response?.data?.error || err.message
setMessage('Error: ' + errMsg)
notify('Error: ' + (err.response?.data?.error || err.message), 'error')
}
}
@@ -83,42 +100,31 @@ export default function BusLinesManager({ token, user }) {
<div className="section-header">
<div>
<h2>Buslijnen</h2>
<p>Maak en bewerk buslijnen.</p>
<p>Definieer lijnen tussen start- en eindhalte.</p>
</div>
<button className="secondary" onClick={loadBusLines}>Refresh</button>
<button className="secondary" onClick={loadBusLines}>Ververs</button>
</div>
<div className="list-grid">
{busLines.map(line => (
<article className="mini-card" key={line.id}>
<strong>#{line.id} Lijn {line.lineNumber}</strong>
<div>{line.startStop} → {line.endStop}</div>
<div>{line.estimatedDurationMinutes} min</div>
{line.description ? <div className="muted">{line.description}</div> : null}
<strong><span className="badge badge-primary">Lijn {line.lineNumber}</span></strong>
<div className="meta-line">#{line.id}</div>
<div className="meta-line">{line.startStop} → {line.endStop}</div>
<div className="meta-line"><span className="badge badge-accent">{line.estimatedDurationMinutes} min</span></div>
{line.description ? <div className="muted" style={{ marginTop: 4 }}>{line.description}</div> : null}
{isAdmin && (
<>
<button
className="danger"
onClick={() => setDeleteConfirm(line)}
style={{ marginTop: '0.5rem', width: '100%' }}
>
Delete
</button>
<button
className="secondary"
onClick={() => setEditingLine({
<div className="card-actions" style={{ gridTemplateColumns: '1fr 1fr' }}>
<button className="secondary" onClick={() => setEditingLine({
id: line.id,
lineNumber: line.lineNumber,
startStop: (stops.find(s => s.name === line.startStop) || {}).id || line.startStop,
endStop: (stops.find(s => s.name === line.endStop) || {}).id || line.endStop,
startStop: stopIdByName(line.startStop) || line.startStop,
endStop: stopIdByName(line.endStop) || line.endStop,
estimatedDurationMinutes: line.estimatedDurationMinutes,
description: line.description || '',
})}
style={{ marginTop: '0.5rem', width: '100%' }}
>
Edit
</button>
</>
})}>Bewerken</button>
<button className="danger" onClick={() => setDeleteConfirm(line)}>Verwijderen</button>
</div>
)}
</article>
))}
@@ -127,120 +133,66 @@ export default function BusLinesManager({ token, user }) {
{isAdmin ? (
<form onSubmit={createBusLine} className="stack-form">
<div className="form-row">
<input placeholder="Line number" value={lineNumber} onChange={e => setLineNumber(e.target.value)} />
<input placeholder="Lijnnummer" value={lineNumber} onChange={e => setLineNumber(e.target.value)} />
<select value={startStop} onChange={e => setStartStop(e.target.value)}>
<option value="">Start stop</option>
{stops.map(stop => (
<option key={stop.id} value={stop.id}>#{stop.id} {stop.name}</option>
))}
<option value="">Start halte</option>
{stops.map(stop => <option key={stop.id} value={stop.id}>#{stop.id} {stop.name}</option>)}
</select>
<select value={endStop} onChange={e => setEndStop(e.target.value)}>
<option value="">End stop</option>
{stops.map(stop => (
<option key={stop.id} value={stop.id}>#{stop.id} {stop.name}</option>
))}
<option value="">Eind halte</option>
{stops.map(stop => <option key={stop.id} value={stop.id}>#{stop.id} {stop.name}</option>)}
</select>
</div>
<div className="form-row">
<input placeholder="Duration in minutes" value={estimatedDurationMinutes} onChange={e => setEstimatedDurationMinutes(e.target.value)} />
<input placeholder="Description" value={description} onChange={e => setDescription(e.target.value)} />
<button type="submit">Create bus line</button>
<input placeholder="Reistijd in minuten" value={estimatedDurationMinutes} onChange={e => setEstimatedDurationMinutes(e.target.value)} />
<input placeholder="Omschrijving (optioneel)" value={description} onChange={e => setDescription(e.target.value)} />
<button type="submit" className="no-grow">Buslijn aanmaken</button>
</div>
</form>
) : <div>You must be admin to create bus lines.</div>}
) : <div className="muted">Alleen beheerders kunnen buslijnen aanmaken.</div>}
{message && <div className="message">{message}</div>}
{message && <div className={`message ${messageType}`}>{message}</div>}
{editingLine && (
<div style={overlay}>
<div style={modal}>
<h3>Edit Bus Line</h3>
<form onSubmit={async (e) => {
e.preventDefault()
setMessage(null)
try {
const startStopName = stops.find(s => String(s.id) === String(editingLine.startStop))?.name || editingLine.startStop
const endStopName = stops.find(s => String(s.id) === String(editingLine.endStop))?.name || editingLine.endStop
await client.patch(`/admin/bus-lines/${editingLine.id}`, {
lineNumber: Number(editingLine.lineNumber),
startStop: startStopName,
endStop: endStopName,
estimatedDurationMinutes: Number(editingLine.estimatedDurationMinutes),
description: editingLine.description,
})
setMessage('Bus line updated')
setEditingLine(null)
await loadBusLines()
} catch (err) {
setMessage('Error: ' + (err.response?.data?.error || err.message))
}
}} className="stack-form">
<Modal title="Buslijn bewerken" onClose={() => setEditingLine(null)}
footer={
<>
<button className="secondary" onClick={() => setEditingLine(null)}>Annuleren</button>
<button onClick={saveBusLine}>Opslaan</button>
</>
}>
<div className="form-row">
<input value={editingLine.lineNumber} onChange={e => setEditingLine({ ...editingLine, lineNumber: e.target.value })} />
</div>
<div className="form-row">
<select value={editingLine.startStop} onChange={e => setEditingLine({ ...editingLine, startStop: e.target.value })}>
<option value="">Start stop</option>
{stops.map(stop => (
<option key={stop.id} value={stop.id}>#{stop.id} {stop.name}</option>
))}
<option value="">Start halte</option>
{stops.map(stop => <option key={stop.id} value={stop.id}>#{stop.id} {stop.name}</option>)}
</select>
<select value={editingLine.endStop} onChange={e => setEditingLine({ ...editingLine, endStop: e.target.value })}>
<option value="">End stop</option>
{stops.map(stop => (
<option key={stop.id} value={stop.id}>#{stop.id} {stop.name}</option>
))}
<option value="">Eind halte</option>
{stops.map(stop => <option key={stop.id} value={stop.id}>#{stop.id} {stop.name}</option>)}
</select>
</div>
<div className="form-row">
<input value={editingLine.estimatedDurationMinutes} onChange={e => setEditingLine({ ...editingLine, estimatedDurationMinutes: e.target.value })} />
<input value={editingLine.description} onChange={e => setEditingLine({ ...editingLine, description: e.target.value })} />
</div>
<div className="form-row" style={{ marginTop: '1rem' }}>
<button className="secondary" type="button" onClick={() => setEditingLine(null)}>Cancel</button>
<button type="submit">Save</button>
</div>
</form>
</div>
</div>
</Modal>
)}
{deleteConfirm && (
<div style={overlay}>
<div style={modal}>
<h3>Delete Bus Line?</h3>
<p><strong>Lijn {deleteConfirm.lineNumber}</strong> ({deleteConfirm.startStop} → {deleteConfirm.endStop})</p>
<p style={{ color: '#999', fontSize: '0.9rem' }}>This will also delete all stops on this line. Cannot be undone.</p>
<div className="form-row" style={{ marginTop: '1rem' }}>
<button className="secondary" onClick={() => setDeleteConfirm(null)}>Cancel</button>
<button className="danger" onClick={() => deleteBusLine(deleteConfirm.id, deleteConfirm.lineNumber)}>
Delete
</button>
</div>
</div>
</div>
<Modal title="Buslijn verwijderen?" onClose={() => setDeleteConfirm(null)}
footer={
<>
<button className="secondary" onClick={() => setDeleteConfirm(null)}>Annuleren</button>
<button className="danger" onClick={() => deleteBusLine(deleteConfirm.id, deleteConfirm.lineNumber)}>Verwijderen</button>
</>
}>
<p>Weet je zeker dat je <strong>Lijn {deleteConfirm.lineNumber}</strong> ({deleteConfirm.startStop} → {deleteConfirm.endStop}) wilt verwijderen?</p>
<p className="faint">Hiermee worden ook alle gekoppelde stops op deze lijn verwijderd. Dit kan niet worden ongedaan gemaakt.</p>
</Modal>
)}
</section>
)
}
const overlay = {
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0,0,0,0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1000,
}
const modal = {
backgroundColor: '#fff',
padding: '2rem',
borderRadius: '8px',
maxWidth: '400px',
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
}
+109
View File
@@ -0,0 +1,109 @@
import React, { useEffect, useMemo, useState } from "react";
import { errorLogClient } from "../api";
import { IconAlert } from "./icons";
const formatDate = (value) => {
if (!value) return "—";
try {
return new Date(value).toLocaleString();
} catch (_) {
return String(value);
}
};
const serviceTone = (service = "") => {
if (service.includes("auth")) return "badge-primary";
if (service.includes("employee")) return "badge-success";
if (service.includes("transit")) return "badge-accent";
if (service.includes("error")) return "badge-warning";
return "badge-muted";
};
export default function ErrorLogViewer({ token }) {
const client = useMemo(() => errorLogClient(token), [token]);
const [logs, setLogs] = useState([]);
const [service, setService] = useState("");
const [partofservice, setPartofservice] = useState("");
const [loading, setLoading] = useState(false);
const [message, setMessage] = useState(null);
const loadLogs = async () => {
setLoading(true);
setMessage(null);
try {
const params = { limit: 100 };
if (service) params.service = service;
if (partofservice) params.partofservice = partofservice;
const res = await client.get("/error_log", { params });
setLogs(res.data.errors || []);
} catch (err) {
setMessage(err.response?.data?.error || err.message || "Kon fouten niet laden");
} finally {
setLoading(false);
}
};
useEffect(() => {
loadLogs();
}, [service, partofservice]);
return (
<section className="card">
<div className="section-header">
<div>
<h2>Error log</h2>
<p>Laatste foutmeldingen uit de centrale logging service.</p>
</div>
<button className="secondary" onClick={loadLogs} disabled={loading}>
{loading ? "Laden…" : "Ververs"}
</button>
</div>
<div className="form-row">
<input placeholder="Filter op service" value={service} onChange={(e) => setService(e.target.value)} />
<input placeholder="Filter op locatie" value={partofservice} onChange={(e) => setPartofservice(e.target.value)} />
{(service || partofservice) && (
<button type="button" className="ghost no-grow" onClick={() => { setService(""); setPartofservice("") }}>
Wissen
</button>
)}
</div>
{message && <div className="message error">{message}</div>}
{logs.length > 0 ? (
<div className="table-wrap">
<table className="data-table">
<thead>
<tr>
<th>Datum</th>
<th>Service</th>
<th>Locatie</th>
<th>Fout</th>
</tr>
</thead>
<tbody>
{logs.map((log) => (
<tr key={log.id}>
<td style={{ whiteSpace: 'nowrap' }}>{formatDate(log.date)}</td>
<td>
<span className={`badge ${serviceTone(log.service)}`}>{log.service}</span>
</td>
<td className="truncate-cell">{log.partofservice || "—"}</td>
<td>
<span className="error-cell" title={log.error}>{log.error}</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div style={{ textAlign: 'center', padding: '36px 0', color: 'var(--text-faint)' }}>
<IconAlert size={28} />
<p style={{ margin: '10px 0 0' }}>Nog geen foutmeldingen gevonden.</p>
</div>
)}
</section>
);
}
File diff suppressed because it is too large Load Diff
+78 -94
View File
@@ -1,5 +1,7 @@
import React, { useEffect, useState } from 'react'
import { transitClient } from '../api'
import Modal from './Modal'
import { IconBus } from './icons'
export default function FleetManager({ token, user }) {
const client = transitClient(token)
@@ -8,67 +10,74 @@ export default function FleetManager({ token, user }) {
const [seatCapacity, setSeatCapacity] = useState('')
const [licensePlate, setLicensePlate] = useState('')
const [message, setMessage] = useState(null)
const [messageType, setMessageType] = useState('')
const [canCreateBus, setCanCreateBus] = useState(true)
const [isAdmin] = useState(Boolean(user?.userCode))
const [isAdmin] = useState(Boolean(user?.isAdmin))
const [editingBus, setEditingBus] = useState(null)
const [deleteConfirm, setDeleteConfirm] = useState(null)
const notify = (text, type = '') => { setMessage(text); setMessageType(type) }
const loadBuses = async () => {
try {
const res = await client.get('/admin/fleet/buses')
setBuses(res.data.buses || [])
} catch (err) {
setMessage('Error: ' + (err.response?.data?.error || err.message))
notify('Error: ' + (err.response?.data?.error || err.message), 'error')
}
}
const overlay = {
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0,0,0,0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1000,
}
const modal = {
backgroundColor: '#fff',
padding: '2rem',
borderRadius: '8px',
maxWidth: '400px',
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
}
useEffect(() => {
loadBuses()
}, [])
const createBus = async (e) => {
e.preventDefault()
setMessage(null)
notify(null)
try {
const res = await client.post('/admin/fleet/buses', {
name,
seatCapacity: Number(seatCapacity),
licensePlate,
name, seatCapacity: Number(seatCapacity), licensePlate,
})
setName('')
setSeatCapacity('')
setLicensePlate('')
setMessage('Bus created: #' + (res.data.bus?.id || 'ok'))
setName(''); setSeatCapacity(''); setLicensePlate('')
notify('Bus aangemaakt: #' + (res.data.bus?.id || 'ok'))
await loadBuses()
} catch (err) {
const details = err.response?.data?.details || err.response?.data?.error || err.message
if (String(details).includes('permission denied') || err.response?.status === 403) {
setCanCreateBus(false)
setMessage('Fleet create is read-only on this database user.')
notify('Vloot is read-only voor deze database-gebruiker.', 'error')
return
}
setMessage('Error: ' + details)
notify('Error: ' + details, 'error')
}
}
const saveBus = async (e) => {
e.preventDefault()
notify(null)
try {
await client.patch(`/admin/fleet/buses/${editingBus.id}`, {
name: editingBus.name,
seatCapacity: Number(editingBus.seatCapacity),
licensePlate: editingBus.licensePlate,
})
notify('Bus bijgewerkt')
setEditingBus(null)
await loadBuses()
} catch (err) {
notify('Error: ' + (err.response?.data?.error || err.message), 'error')
}
}
const deleteBus = async () => {
notify(null)
try {
await client.delete(`/admin/fleet/buses/${deleteConfirm.id}`)
notify('Bus verwijderd')
setDeleteConfirm(null)
await loadBuses()
} catch (err) {
notify('Error: ' + (err.response?.data?.error || err.message), 'error')
}
}
@@ -76,23 +85,24 @@ export default function FleetManager({ token, user }) {
<section className="card">
<div className="section-header">
<div>
<h2>Fleet</h2>
<p>Beheer voertuigen voor de vloot.</p>
<h2>Vloot</h2>
<p>Beheer voertuigen die deel uitmaken van de vloot.</p>
</div>
<button className="secondary" onClick={loadBuses}>Refresh</button>
<button className="secondary" onClick={loadBuses}>Ververs</button>
</div>
<div className="list-grid">
{buses.map(bus => (
<article className="mini-card" key={bus.id}>
<strong>#{bus.id} {bus.name}</strong>
<div>Seats: {bus.seatCapacity}</div>
<div>Plate: {bus.licensePlate}</div>
<strong><IconBus size={16} style={{ verticalAlign: '-2px', color: 'var(--primary)' }} /> {bus.name}</strong>
<div className="meta-line">#{bus.id}</div>
<div className="meta-line"><span className="badge badge-success">{bus.seatCapacity} zitplaatsen</span></div>
<div className="meta-line" style={{ fontFamily: 'var(--font-mono)' }}>{bus.licensePlate}</div>
{isAdmin && (
<>
<button className="secondary" style={{ marginTop: '0.5rem', width: '100%' }} onClick={() => setEditingBus(bus)}>Edit</button>
<button className="danger" style={{ marginTop: '0.5rem', width: '100%' }} onClick={() => setDeleteConfirm(bus)}>Delete</button>
</>
<div className="card-actions">
<button className="secondary" onClick={() => setEditingBus(bus)}>Bewerken</button>
<button className="danger" onClick={() => setDeleteConfirm(bus)}>Verwijderen</button>
</div>
)}
</article>
))}
@@ -101,72 +111,46 @@ export default function FleetManager({ token, user }) {
{isAdmin && canCreateBus ? (
<form onSubmit={createBus} className="stack-form">
<div className="form-row">
<input placeholder="Bus name" value={name} onChange={e => setName(e.target.value)} />
<input placeholder="Seat capacity" value={seatCapacity} onChange={e => setSeatCapacity(e.target.value)} />
<input placeholder="License plate" value={licensePlate} onChange={e => setLicensePlate(e.target.value)} />
<button type="submit">Create bus</button>
<input placeholder="Busnaam" value={name} onChange={e => setName(e.target.value)} />
<input placeholder="Zitplaatsen" value={seatCapacity} onChange={e => setSeatCapacity(e.target.value)} />
<input placeholder="Kenteken" value={licensePlate} onChange={e => setLicensePlate(e.target.value)} />
<button type="submit" className="no-grow">Bus toevoegen</button>
</div>
</form>
) : (
<div className="muted">Fleet create is read-only on this database user.</div>
<div className="muted">Vloot is read-only voor deze database-gebruiker.</div>
)}
{message && <div className="message">{message}</div>}
{message && <div className={`message ${messageType}`}>{message}</div>}
{editingBus && (
<div style={overlay}>
<div style={modal}>
<h3>Edit Bus</h3>
<form onSubmit={async (e) => {
e.preventDefault()
setMessage(null)
try {
await client.patch(`/admin/fleet/buses/${editingBus.id}`, {
name: editingBus.name,
seatCapacity: Number(editingBus.seatCapacity),
licensePlate: editingBus.licensePlate,
})
setMessage('Bus updated')
setEditingBus(null)
await loadBuses()
} catch (err) {
setMessage('Error: ' + (err.response?.data?.error || err.message))
}
}} className="stack-form">
<Modal title="Bus bewerken" onClose={() => setEditingBus(null)}
footer={
<>
<button className="secondary" onClick={() => setEditingBus(null)}>Annuleren</button>
<button onClick={saveBus}>Opslaan</button>
</>
}>
<div className="form-row">
<input value={editingBus.name} onChange={e => setEditingBus({ ...editingBus, name: e.target.value })} />
</div>
<div className="form-row">
<input value={editingBus.seatCapacity} onChange={e => setEditingBus({ ...editingBus, seatCapacity: e.target.value })} />
<input value={editingBus.licensePlate} onChange={e => setEditingBus({ ...editingBus, licensePlate: e.target.value })} />
</div>
<div className="form-row" style={{ marginTop: '1rem' }}>
<button className="secondary" type="button" onClick={() => setEditingBus(null)}>Cancel</button>
<button type="submit">Save</button>
</div>
</form>
</div>
</div>
</Modal>
)}
{deleteConfirm && (
<div style={overlay}>
<div style={modal}>
<h3>Delete Bus?</h3>
<p><strong>{deleteConfirm.name}</strong></p>
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '1rem' }}>
<button className="secondary" onClick={() => setDeleteConfirm(null)}>Cancel</button>
<button className="danger" onClick={async () => {
try {
await client.delete(`/admin/fleet/buses/${deleteConfirm.id}`)
setMessage('Bus deleted')
setDeleteConfirm(null)
await loadBuses()
} catch (err) {
setMessage('Error: ' + (err.response?.data?.error || err.message))
}
}}>Delete</button>
</div>
</div>
</div>
<Modal title="Bus verwijderen?" onClose={() => setDeleteConfirm(null)}
footer={
<>
<button className="secondary" onClick={() => setDeleteConfirm(null)}>Annuleren</button>
<button className="danger" onClick={deleteBus}>Verwijderen</button>
</>
}>
<p>Weet je zeker dat je <strong>{deleteConfirm.name}</strong> ({deleteConfirm.licensePlate}) wilt verwijderen?</p>
</Modal>
)}
</section>
)
+73 -112
View File
@@ -1,5 +1,6 @@
import React, { useEffect, useMemo, useState } from 'react'
import { transitClient } from '../api'
import Modal from './Modal'
export default function LinkStop({ token, user }) {
const client = transitClient(token)
@@ -9,31 +10,33 @@ export default function LinkStop({ token, user }) {
const [stopId, setStopId] = useState('')
const [eta, setEta] = useState('')
const [message, setMessage] = useState(null)
const [messageType, setMessageType] = useState('')
const [stops, setStops] = useState([])
const [draggedItem, setDraggedItem] = useState(null)
const [deleteConfirm, setDeleteConfirm] = useState(null)
const isAdmin = Boolean(user?.userCode)
const isAdmin = Boolean(user?.isAdmin)
const nextOrder = useMemo(() => stops.length + 1, [stops])
const notify = (text, type = '') => { setMessage(text); setMessageType(type) }
const loadBusLines = async () => {
try {
const res = await client.get('/admin/bus-lines')
setBusLines(res.data.busLines || [])
} catch (err) {
setMessage('Error: ' + (err.response?.data?.error || err.message))
notify('Error: ' + (err.response?.data?.error || err.message), 'error')
}
}
const loadStopsForLine = async (lineId = busLineId) => {
if (!lineId) return
setMessage(null)
notify(null)
try {
const res = await client.get(`/bus-lines/${lineId}/stops`)
setStops(res.data.stops || [])
} catch (err) {
setMessage('Error: ' + (err.response?.data?.error || err.message))
notify('Error: ' + (err.response?.data?.error || err.message), 'error')
}
}
@@ -42,62 +45,48 @@ export default function LinkStop({ token, user }) {
const res = await client.get('/admin/stops')
setAvailableStops(res.data.stops || [])
} catch (err) {
setMessage('Error: ' + (err.response?.data?.error || err.message))
notify('Error: ' + (err.response?.data?.error || err.message), 'error')
}
}
const refreshBoard = async () => {
await Promise.all([
loadBusLines(),
loadAvailableStops(),
loadStopsForLine(),
])
await Promise.all([loadBusLines(), loadAvailableStops(), loadStopsForLine()])
}
useEffect(() => {
loadBusLines()
loadAvailableStops()
}, [])
useEffect(() => {
loadStopsForLine()
}, [busLineId])
useEffect(() => { loadBusLines(); loadAvailableStops() }, [])
useEffect(() => { loadStopsForLine() }, [busLineId])
const link = async (e) => {
e.preventDefault()
setMessage(null)
notify(null)
try {
const res = await client.post(`/admin/bus-lines/${busLineId}/stops`, {
stopId: parseInt(stopId, 10),
stopOrder: nextOrder,
estimatedArrivalMinutes: eta ? parseInt(eta, 10) : null,
})
setMessage('Linked: ' + res.data.routeStop.id)
notify('Gekoppeld: ' + res.data.routeStop.id)
setStopId('')
await loadStopsForLine()
} catch (err) {
if (err.response?.status === 403) setMessage('Forbidden: not admin')
else setMessage('Error: ' + (err.response?.data?.error || err.message))
if (err.response?.status === 403) notify('Forbidden: not admin', 'error')
else notify('Error: ' + (err.response?.data?.error || err.message), 'error')
}
}
const appendDraggedStop = async (stopToLink) => {
if (!busLineId) {
setMessage('Select a bus line first')
return
}
setMessage(null)
if (!busLineId) { notify('Kies eerst een buslijn', 'error'); return }
notify(null)
try {
const res = await client.post(`/admin/bus-lines/${busLineId}/stops`, {
stopId: Number(stopToLink.id),
stopOrder: nextOrder,
estimatedArrivalMinutes: eta ? parseInt(eta, 10) : null,
})
setMessage(`Linked: ${stopToLink.name} as stop #${res.data.routeStop.stopOrder}`)
notify(`Gekoppeld: ${stopToLink.name} als stop #${res.data.routeStop.stopOrder}`)
await loadStopsForLine()
} catch (err) {
setMessage('Error: ' + (err.response?.data?.error || err.message))
notify('Error: ' + (err.response?.data?.error || err.message), 'error')
}
}
@@ -119,12 +108,12 @@ export default function LinkStop({ token, user }) {
nextStops.splice(toIndex, 0, moved)
const normalized = nextStops.map((stop, index) => ({ ...stop, order: index + 1 }))
setStops(normalized)
setMessage('Saving new route order...')
notify('Nieuwe volgorde opslaan…')
try {
await persistOrder(normalized)
setMessage('Route order updated')
notify('Routenvolgorde bijgewerkt')
} catch (err) {
setMessage('Error: ' + (err.response?.data?.error || err.message))
notify('Error: ' + (err.response?.data?.error || err.message), 'error')
await loadStopsForLine()
}
}
@@ -139,23 +128,22 @@ export default function LinkStop({ token, user }) {
setStops(normalized)
try {
await persistOrder(normalized)
setMessage('Route order updated')
notify('Routenvolgorde bijgewerkt')
} catch (err) {
setMessage('Error: ' + (err.response?.data?.error || err.message))
notify('Error: ' + (err.response?.data?.error || err.message), 'error')
await loadStopsForLine()
}
}
const deleteRouteStop = async (routeStopId, stopName) => {
setMessage(null)
notify(null)
try {
await client.delete(`/admin/bus-lines/${busLineId}/stops/${routeStopId}`)
setMessage(`Halte "${stopName}" verwijderd uit de lijn`)
notify(`Halte "${stopName}" verwijderd uit de lijn`)
setDeleteConfirm(null)
await loadStopsForLine()
} catch (err) {
const errMsg = err.response?.data?.error || err.message
setMessage('Error: ' + errMsg)
notify('Error: ' + (err.response?.data?.error || err.message), 'error')
}
}
@@ -164,28 +152,27 @@ export default function LinkStop({ token, user }) {
<div className="section-header">
<div>
<h2>Stops per lijn</h2>
<p>Koppel stops aan lijnen en bekijk de volgorde.</p>
<p>Koppel haltes aan een lijn en bepaal de rijvolgorde.</p>
</div>
<button className="secondary" onClick={refreshBoard}>Refresh</button>
<button className="secondary" onClick={refreshBoard}>Ververs</button>
</div>
<div className="form-row">
<select
value={busLineId}
onChange={e => setBusLineId(e.target.value)}
>
<option value="">Choose line</option>
<select value={busLineId} onChange={e => setBusLineId(e.target.value)}>
<option value="">Kies lijn</option>
{busLines.map(line => (
<option key={line.id} value={line.id}>
#{line.id} lijn {line.lineNumber} - {line.startStop} → {line.endStop}
#{line.id} Lijn {line.lineNumber} — {line.startStop} → {line.endStop}
</option>
))}
</select>
<button type="button" onClick={() => loadStopsForLine()} disabled={!busLineId}>Load Stops</button>
<button type="button" className="secondary no-grow" onClick={() => loadStopsForLine()} disabled={!busLineId}>
Laad stops
</button>
</div>
<div className="muted" style={{ marginBottom: 10 }}>
Drag a stop onto the route board to append it to the line. Use the arrows to nudge the order.
Sleep een halte op het routepane om hem toe te voegen. Gebruik de pijltjes om de volgorde aan te passen.
</div>
<div
@@ -199,6 +186,11 @@ export default function LinkStop({ token, user }) {
setDraggedItem(null)
}}
>
{stops.length === 0 && (
<p className="muted" style={{ textAlign: 'center', padding: '12px 0' }}>
Nog geen haltes gekoppeld — sleep een halte hierheen.
</p>
)}
{stops.map((s, index) => (
<article
className={`route-stop-item ${draggedItem?.type === 'route' && Number(draggedItem.id) === Number(s.id) ? 'dragging' : ''}`}
@@ -208,9 +200,7 @@ export default function LinkStop({ token, user }) {
onDragEnd={() => setDraggedItem(null)}
onDragOver={(event) => event.preventDefault()}
onDrop={() => {
if (draggedItem?.type === 'route') {
reorderStops(draggedItem.id, s.id)
}
if (draggedItem?.type === 'route') reorderStops(draggedItem.id, s.id)
if (draggedItem?.type === 'available') {
const stop = availableStops.find(item => Number(item.id) === Number(draggedItem.id))
if (stop) appendDraggedStop(stop)
@@ -219,25 +209,26 @@ export default function LinkStop({ token, user }) {
}}
>
<div className="route-stop-handle" aria-hidden="true">⋮⋮</div>
<div className="route-order-badge">{s.order}</div>
<div className="route-stop-content">
<strong>{s.order}. {s.name}</strong>
<strong>{s.name}</strong>
<div className="muted">{s.latitude}, {s.longitude}</div>
{s.estimatedArrivalMinutes !== null && s.estimatedArrivalMinutes !== undefined ? (
<div className="muted">ETA {s.estimatedArrivalMinutes} min</div>
) : null}
</div>
<div className="route-stop-actions">
<button type="button" className="secondary" onClick={() => moveStop(index, -1)} disabled={index === 0}>↑</button>
<button type="button" className="secondary" onClick={() => moveStop(index, 1)} disabled={index === stops.length - 1}>↓</button>
<button type="button" className="secondary sm" onClick={() => moveStop(index, -1)} disabled={index === 0}>↑</button>
<button type="button" className="secondary sm" onClick={() => moveStop(index, 1)} disabled={index === stops.length - 1}>↓</button>
{isAdmin && (
<button type="button" className="danger" onClick={() => setDeleteConfirm({ id: s.id, name: s.name })}>✕</button>
<button type="button" className="danger sm" onClick={() => setDeleteConfirm({ id: s.id, name: s.name })}>✕</button>
)}
</div>
</article>
))}
</div>
<h3>Beschikbare haltes</h3>
<h3 style={{ marginBottom: 0 }}>Beschikbare haltes</h3>
<div className="available-stops-grid">
{availableStops.map(stop => (
<article
@@ -246,82 +237,52 @@ export default function LinkStop({ token, user }) {
draggable
onDragStart={() => setDraggedItem({ type: 'available', id: stop.id })}
onDragEnd={() => setDraggedItem(null)}
onClick={() => {
if (busLineId) {
setStopId(String(stop.id))
appendDraggedStop(stop)
}
}}
onClick={() => { if (busLineId) { setStopId(String(stop.id)); appendDraggedStop(stop) } }}
role="button"
tabIndex={0}
>
<strong>#{stop.id} {stop.name}</strong>
<div className="muted">{stop.latitude}, {stop.longitude}</div>
<div className="muted">Sleep of klik om toe te voegen</div>
<div className="faint">Sleep of klik om toe te voegen</div>
</article>
))}
</div>
<h3>Link a Stop to Line</h3>
<div className="stack-form">
<h3 style={{ marginTop: 0 }}>Halte koppelen aan lijn</h3>
{isAdmin ? (
<form onSubmit={link} className="stack-form">
<form onSubmit={link} className="stack-form" style={{ borderTop: 'none', paddingTop: 0 }}>
<div className="form-row">
<select value={stopId} onChange={e => setStopId(e.target.value)}>
<option value="">Select stop</option>
{availableStops.map(stop => (
<option key={stop.id} value={stop.id}>
#{stop.id} {stop.name}
</option>
))}
<option value="">Selecteer halte</option>
{availableStops.map(stop => <option key={stop.id} value={stop.id}>#{stop.id} {stop.name}</option>)}
</select>
<input value={`Next order: ${nextOrder}`} readOnly />
<input placeholder="ETA minutes (optional)" value={eta} onChange={e=>setEta(e.target.value)} />
<input value={`Volgende: ${nextOrder}`} readOnly className="no-grow" />
<input placeholder="ETA minuten (optioneel)" value={eta} onChange={e => setEta(e.target.value)} />
</div>
<div className="form-row">
<button type="submit">Link Stop</button>
<button type="submit" className="no-grow">Halte koppelen</button>
</div>
</form>
) : (
<div>You must be admin to link stops.</div>
<div className="muted">Alleen beheerders kunnen haltes koppelen.</div>
)}
{message && <div className="message">{message}</div>}
</div>
{message && <div className={`message ${messageType}`}>{message}</div>}
{deleteConfirm && (
<div style={overlay}>
<div style={modal}>
<h3>Remove Stop from Line?</h3>
<p><strong>{deleteConfirm.name}</strong></p>
<p style={{ color: '#999', fontSize: '0.9rem' }}>This will remove the stop from the route and re-order the remaining stops.</p>
<div className="form-row" style={{ marginTop: '1rem' }}>
<button className="secondary" onClick={() => setDeleteConfirm(null)}>Cancel</button>
<button className="danger" onClick={() => deleteRouteStop(deleteConfirm.id, deleteConfirm.name)}>
Remove
</button>
</div>
</div>
</div>
<Modal title="Halte verwijderen uit lijn?" onClose={() => setDeleteConfirm(null)}
footer={
<>
<button className="secondary" onClick={() => setDeleteConfirm(null)}>Annuleren</button>
<button className="danger" onClick={() => deleteRouteStop(deleteConfirm.id, deleteConfirm.name)}>Verwijderen</button>
</>
}>
<p>Weet je zeker dat je <strong>{deleteConfirm.name}</strong> uit deze lijn wilt verwijderen?</p>
<p className="faint">De halte wordt uit de route gehaald en de resterende stops worden opnieuw geordend.</p>
</Modal>
)}
</section>
)
}
const overlay = {
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0,0,0,0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1000,
}
const modal = {
backgroundColor: '#fff',
padding: '2rem',
borderRadius: '8px',
maxWidth: '400px',
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
}
+32 -9
View File
@@ -1,5 +1,6 @@
import React, { useState } from 'react'
import { authClient } from '../api'
import { IconBusFront } from './icons'
export default function Login({ onLogin }) {
const [userCode, setUserCode] = useState('')
@@ -14,29 +15,51 @@ export default function Login({ onLogin }) {
try {
const res = await authClient.post('/login', { userCode, password })
const token = res.data.token
// fetch /me to get payload
const me = await authClient.get('/me', { headers: { Authorization: `Bearer ${token}` } })
onLogin(token, me.data.user)
} catch (err) {
setError(err.response?.data?.error || 'login_failed')
} finally { setLoading(false) }
} finally {
setLoading(false)
}
}
return (
<div className="login">
<h2>Admin Login</h2>
<div className="auth-page">
<div className="auth-card">
<div className="auth-brand">
<div className="brand-mark"><IconBusFront size={26} /></div>
<div>
<h1>Transit Beheer</h1>
<p>Operationeel dashboard voor netwerkbeheer</p>
</div>
</div>
<form onSubmit={submit}>
<label>
User Code
<input value={userCode} onChange={e => setUserCode(e.target.value)} />
Gebruikercode
<input
value={userCode}
onChange={(e) => setUserCode(e.target.value)}
placeholder="Bijv. 1234"
autoFocus
/>
</label>
<label>
Password
<input type="password" value={password} onChange={e => setPassword(e.target.value)} />
Wachtwoord
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
/>
</label>
<button type="submit" disabled={loading}>{loading ? 'Please wait...' : 'Login'}</button>
{error && <div className="error">{error}</div>}
<button type="submit" className="auth-submit" disabled={loading}>
{loading ? 'Bezig met inloggen…' : 'Inloggen'}
</button>
</form>
<div className="auth-footer">Alleen beheerders hebben toegang</div>
</div>
</div>
)
}
+16
View File
@@ -0,0 +1,16 @@
import React from 'react'
export default function Modal({ title, onClose, children, footer, wide }) {
return (
<div className="modal-overlay" onClick={onClose}>
<div className={`modal ${wide ? 'modal-wide' : ''}`} onClick={(e) => e.stopPropagation()} role="dialog" aria-modal="true">
<header className="modal-header">
<h3>{title}</h3>
<button type="button" className="icon-btn" onClick={onClose} aria-label="Sluiten">✕</button>
</header>
<div className="modal-body">{children}</div>
{footer && <footer className="modal-footer">{footer}</footer>}
</div>
</div>
)
}
+79
View File
@@ -0,0 +1,79 @@
import React, { useEffect, useState } from 'react'
import { errorLogClient, transitClient } from '../api'
import TimetableDisplay from './TimetableDisplay'
import { IconAlert, IconBusFront, IconCalendar, IconMapPin, IconRoute } from './icons'
export default function Overview({ token }) {
const client = transitClient(token)
const [stats, setStats] = useState({ stops: null, lines: null, buses: null, drivers: null, logs: null })
const [drivers, setDrivers] = useState([])
const [overviewDate, setOverviewDate] = useState(new Date().toISOString().split('T')[0])
const [driverFilter, setDriverFilter] = useState('')
useEffect(() => {
const load = async () => {
try {
const [stops, lines, buses, shifts] = await Promise.all([
client.get('/admin/stops'),
client.get('/admin/bus-lines'),
client.get('/admin/fleet/buses'),
client.get('/admin/shift-drivers'),
])
let logs = null
try {
const res = await errorLogClient(token).get('/error_log', { params: { limit: 1 } })
logs = res.data.errors?.length ?? null
} catch (_) {
logs = null
}
setStats({
stops: stops.data.stops?.length ?? null,
lines: lines.data.busLines?.length ?? null,
buses: buses.data.buses?.length ?? null,
drivers: shifts.data.drivers?.length ?? null,
logs,
})
setDrivers(shifts.data.drivers || [])
} catch (_) {
// stats stay null; individual sections handle real errors
}
}
load()
}, [])
const statCards = [
{ label: 'Haltes', value: stats.stops, icon: IconMapPin, tone: 'primary' },
{ label: 'Buslijnen', value: stats.lines, icon: IconRoute, tone: 'accent' },
{ label: 'Voertuigen', value: stats.buses, icon: IconBusFront, tone: 'success' },
{ label: 'Chauffeurs', value: stats.drivers, icon: IconCalendar, tone: 'warning' },
{ label: 'Foutmeldingen', value: stats.logs, icon: IconAlert, tone: 'danger' },
]
return (
<>
<section className="stats-grid">
{statCards.map((card) => {
const Icon = card.icon
return (
<article className="stat-card" key={card.label}>
<div className={`stat-icon ${card.tone}`}><Icon size={22} /></div>
<div>
<div className="stat-value">{card.value === null ? '…' : card.value}</div>
<div className="stat-label">{card.label}</div>
</div>
</article>
)
})}
</section>
<TimetableDisplay
client={client}
overviewDate={overviewDate}
setOverviewDate={setOverviewDate}
selectedDriverFilter={driverFilter}
setSelectedDriverFilter={setDriverFilter}
drivers={drivers}
/>
</>
)
}
+164
View File
@@ -0,0 +1,164 @@
import React, { useState } from 'react'
import { API_BASE } from '../api'
import { IconTerminal } from './icons'
const SERVICE_OPTIONS = [
{ key: 'auth', label: 'Auth API' },
{ key: 'employee', label: 'Employee API' },
{ key: 'transit', label: 'Transit API' },
{ key: 'errors', label: 'Error Log API' },
]
const ROUTES = [
{ service: 'transit', method: 'GET', path: '/health', label: 'Transit health' },
{ service: 'transit', method: 'GET', path: '/bus/admin/stops', label: 'Haltes (admin)' },
{ service: 'transit', method: 'GET', path: '/bus/schedules', label: 'Dienstrooster-overzicht' },
{ service: 'transit', method: 'GET', path: '/bus/schedule/daily?date=2026-09-25', label: 'Dagelijkse dienstregeling' },
{ service: 'transit', method: 'GET', path: '/bus/public/schedules/1/progress', label: 'Openbare ritvoortgang' },
{ service: 'auth', method: 'GET', path: '/me', label: 'Huidige sessie' },
{ service: 'auth', method: 'POST', path: '/login', label: 'Login (userCode + wachtwoord)' },
{ service: 'employee', method: 'GET', path: '/health', label: 'Employee health' },
{ service: 'errors', method: 'GET', path: '/error_log?limit=50', label: 'Recente foutmeldingen' },
]
const pretty = (value) => {
try {
return JSON.stringify(value, null, 2)
} catch {
return String(value)
}
}
export default function RequestLab({ token }) {
const [route, setRoute] = useState(ROUTES[0])
const [body, setBody] = useState('')
const [result, setResult] = useState(null)
const [busy, setBusy] = useState(false)
const routesForService = ROUTES.filter((r) => r.service === route.service)
const pickService = (next) => {
const first = ROUTES.find((r) => r.service === next)
if (first) setRoute(first)
}
const run = async () => {
if (route.method !== 'GET' && !window.confirm('Deze aanvraag kan gegevens wijzigen. Doorgaan?')) return
setBusy(true)
setResult(null)
const started = performance.now()
try {
const response = await fetch(`${API_BASE[route.service]}${route.path}`, {
method: route.method,
headers: {
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(body ? { 'Content-Type': 'application/json' } : {}),
},
body: body || undefined,
})
const text = await response.text()
let parsed
try {
parsed = JSON.parse(text)
} catch {
parsed = text
}
setResult({
ok: response.ok,
status: response.status,
latency: Math.round(performance.now() - started),
body: parsed,
label: route.label,
})
} catch (err) {
setResult({
ok: false,
status: 0,
latency: Math.round(performance.now() - started),
body: { error: err.message },
label: route.label,
})
} finally {
setBusy(false)
}
}
return (
<div className="stack">
<section className="card">
<div className="section-header">
<div>
<h2>API-testomgeving</h2>
<p>Voer een van de goedgekeurde verzoeken uit om het live antwoord te bekijken.</p>
</div>
</div>
<div className="form-row">
<select value={route.service} onChange={(e) => pickService(e.target.value)} className="no-grow">
{SERVICE_OPTIONS.map((s) => (
<option key={s.key} value={s.key}>{s.label}</option>
))}
</select>
<select
value={`${route.method} ${route.path}`}
onChange={(e) => {
const found = routesForService.find((r) => `${r.method} ${r.path}` === e.target.value)
if (found) setRoute(found)
}}
className="flex-1"
>
{routesForService.map((r) => (
<option key={`${r.method} ${r.path}`} value={`${r.method} ${r.path}`}>
{r.method} {r.path} — {r.label}
</option>
))}
</select>
</div>
<div className="form-row">
<textarea
className="lab-body flex-1"
placeholder="Optionele request body (JSON)"
value={body}
onChange={(e) => setBody(e.target.value)}
/>
</div>
<div className="form-row">
<button className="no-grow" onClick={run} disabled={busy}>
{busy ? 'Uitvoeren…' : 'Verzoek uitvoeren'}
</button>
<span className="muted minw-0">
Verzoeken gaan rechtstreeks van deze browser naar de geconfigureerde service. Schrijfverzoeken vereisen een bevestiging.
</span>
</div>
</section>
<section className="card">
<div className="section-header">
<div>
<h2>Antwoord</h2>
<p>
{result
? `${result.label} · ${result.status || 'Netwerk'} · ${result.latency} ms`
: 'Nog geen verzoek uitgevoerd.'}
</p>
</div>
{result && (
<span className={`badge ${result.ok ? 'badge-success' : 'badge-danger'}`}>
{result.ok ? 'Succes' : 'Mislukt'}
</span>
)}
</div>
{result ? (
<pre className="lab-response">{pretty(result.body)}</pre>
) : (
<div className="lab-placeholder">
<IconTerminal size={28} />
<p>Kies een goedgekeurde route en voer deze uit om het live antwoord te inspecteren.</p>
</div>
)}
</section>
</div>
)
}
+70 -566
View File
@@ -1,606 +1,110 @@
import React, { useEffect, useState } from 'react'
import { transitClient } from '../api'
const WEEKDAYS = ['Ma', 'Di', 'Wo', 'Do', 'Vr', 'Za', 'Zo']
const WEEKDAY_VALUES = [1, 2, 3, 4, 5, 6, 0] // 0=Sun, 1=Mon...
import React, { useEffect, useState } from "react";
import { transitClient } from "../api";
import WeeklyScheduleEditor from "./WeeklyScheduleEditor";
import FixedScheduleEditor from "./FixedScheduleEditor";
export default function ScheduleMaker({ token, user }) {
const client = transitClient(token)
const [schedules, setSchedules] = useState([])
const [overviewSchedules, setOverviewSchedules] = useState([])
const [busLines, setBusLines] = useState([])
const [buses, setBuses] = useState([])
const [drivers, setDrivers] = useState([])
const [selectedDriverFilter, setSelectedDriverFilter] = useState('')
const [message, setMessage] = useState(null)
const [isAdmin] = useState(Boolean(user?.userCode))
const [deleteConfirm, setDeleteConfirm] = useState(null)
const [expandedSchedule, setExpandedSchedule] = useState(null)
const [routeStopsByLine, setRouteStopsByLine] = useState({})
const client = transitClient(token);
const [tab, setTab] = useState("weekly");
const [weeklySchedules, setWeeklySchedules] = useState([]);
const [templates, setTemplates] = useState([]);
const [busLines, setBusLines] = useState([]);
const [buses, setBuses] = useState([]);
const [drivers, setDrivers] = useState([]);
const [message, setMessage] = useState(null);
// Form state
const [selectedLine, setSelectedLine] = useState('')
const [selectedBus, setSelectedBus] = useState('')
const [selectedDate, setSelectedDate] = useState(new Date().toISOString().split('T')[0])
const [overviewDate, setOverviewDate] = useState(new Date().toISOString().split('T')[0])
const [startTime, setStartTime] = useState('08:00')
const [driverId, setDriverId] = useState('')
const [selectedWeekdays, setSelectedWeekdays] = useState([])
const loadSchedules = async () => {
const loadWeekly = async () => {
try {
const res = await client.get('/admin/schedules')
setSchedules(res.data.schedules || [])
const res = await client.get("/admin/weekly-schedules");
setWeeklySchedules(res.data.schedules || []);
} catch (err) {
setMessage('Error loading schedules: ' + (err.response?.data?.error || err.message))
}
setMessage("Kon wekelijkse diensten niet laden.");
}
};
const loadOverviewSchedules = async () => {
const loadTemplates = async () => {
try {
const params = new URLSearchParams({ date: overviewDate })
if (selectedDriverFilter) {
params.set('driverId', selectedDriverFilter)
}
const res = await client.get(`/schedules/overview?${params.toString()}`)
setOverviewSchedules(res.data.schedules || [])
const res = await client.get("/admin/schedules");
setTemplates(res.data.schedules || []);
} catch (err) {
setMessage('Error loading timetable: ' + (err.response?.data?.error || err.message))
}
setTemplates([]);
}
};
const loadBusLines = async () => {
try {
const res = await client.get('/admin/bus-lines')
setBusLines(res.data.busLines || [])
const res = await client.get("/admin/bus-lines");
setBusLines(res.data.busLines || []);
} catch (err) {
setMessage('Error loading bus lines: ' + (err.response?.data?.error || err.message))
}
setMessage("Kon buslijnen niet laden.");
}
};
const loadBuses = async () => {
try {
const res = await client.get('/admin/fleet/buses')
setBuses(res.data.buses || [])
const res = await client.get("/admin/fleet/buses");
setBuses(res.data.buses || []);
} catch (err) {
setMessage('Error loading buses: ' + (err.response?.data?.error || err.message))
}
setBuses([]);
}
};
const loadDrivers = async () => {
try {
const res = await client.get('/admin/drivers')
setDrivers(res.data.drivers || [])
const res = await client.get("/admin/shift-drivers");
setDrivers(res.data.drivers || []);
} catch (err) {
setMessage('Error loading drivers: ' + (err.response?.data?.error || err.message))
}
setMessage("Kon chauffeurs niet laden.");
}
};
useEffect(() => {
loadSchedules()
loadBusLines()
loadBuses()
loadDrivers()
}, [])
loadWeekly();
loadTemplates();
loadBusLines();
loadBuses();
loadDrivers();
}, []);
useEffect(() => {
loadOverviewSchedules()
}, [overviewDate, selectedDriverFilter])
const createSchedule = async (e) => {
e.preventDefault()
setMessage(null)
if (!selectedLine || !selectedBus || !startTime) {
setMessage('Lijn, voertuig en start-tijd zijn verplicht')
return
}
try {
const startDateTime = `${selectedDate}T${startTime}:00`
const normalizedWeekdays = [...selectedWeekdays].sort((a, b) => a - b)
const res = await client.post('/admin/schedules', {
busLineId: Number(selectedLine),
busId: Number(selectedBus),
driverId: driverId ? Number(driverId) : null,
startTime: startDateTime,
weekdays: normalizedWeekdays,
})
const dayLabels = normalizedWeekdays.length > 0
? normalizedWeekdays.map(d => WEEKDAYS[WEEKDAY_VALUES.indexOf(d)]).join(', ')
: 'eenmalig'
setMessage(`Dienst aangemaakt: #${res.data.schedule.id} (${dayLabels})`)
setSelectedLine('')
setSelectedBus('')
setStartTime('08:00')
setDriverId('')
setSelectedWeekdays([])
await loadSchedules()
await loadOverviewSchedules()
} catch (err) {
const errMsg = err.response?.data?.error || err.message
setMessage('Error: ' + errMsg)
}
}
const deleteSchedule = async (scheduleId, lineNumber, busName) => {
setMessage(null)
try {
await client.delete(`/admin/schedules/${scheduleId}`)
setMessage(`Dienst verwijderd: Lijn ${lineNumber}, ${busName}`)
setDeleteConfirm(null)
await loadSchedules()
await loadOverviewSchedules()
} catch (err) {
const errMsg = err.response?.data?.error || err.message
setMessage('Error: ' + errMsg)
}
}
const toggleWeekday = (dayValue) => {
setSelectedWeekdays(prev =>
prev.includes(dayValue)
? prev.filter(d => d !== dayValue)
: [...prev, dayValue]
)
}
const getWeekdayLabel = (weekdayValues) => {
if (!weekdayValues || weekdayValues.length === 0) return 'Eenmalig'
if (weekdayValues.length === 7) return 'Dagelijks'
const labels = weekdayValues
.slice()
.sort((a, b) => a - b)
.map(w => WEEKDAYS[WEEKDAY_VALUES.indexOf(w)])
return labels.join(', ')
}
const formatTime = (timeString) => {
const date = new Date(timeString)
return date.toLocaleTimeString('nl-NL', { hour: '2-digit', minute: '2-digit' })
}
// Fetch route stops for a given line and cache them in state
const loadRouteStops = async (busLineId) => {
if (!busLineId) return
try {
const res = await client.get(`/admin/bus-lines/${busLineId}/stops`)
const routeStops = res.data.routeStops || []
setRouteStopsByLine(prev => ({ ...prev, [busLineId]: routeStops }))
} catch (err) {
// ignore silently, message already used elsewhere
}
}
const getCandidateWindow = () => {
const line = busLines.find(l => String(l.id) === String(selectedLine))
if (!line || !selectedDate || !startTime) return null
const start = new Date(`${selectedDate}T${startTime}:00`)
const end = new Date(start)
end.setMinutes(end.getMinutes() + (line.estimatedDurationMinutes || 60))
return { start, end }
}
const hasOverlap = (candidate, existing) => {
if (!candidate || !existing?.startTime || !existing?.endTime) return false
const cStart = candidate.start.getTime()
const cEnd = candidate.end.getTime()
const eStart = new Date(existing.startTime).getTime()
const eEnd = new Date(existing.endTime).getTime()
const timeOverlap = cStart < eEnd && cEnd > eStart
if (!timeOverlap) return false
const cWeekdays = selectedWeekdays || []
const eWeekdays = Array.isArray(existing.weekdays) ? existing.weekdays : []
if (cWeekdays.length === 0 && eWeekdays.length === 0) {
return candidate.start.toDateString() === new Date(existing.startTime).toDateString()
}
if (cWeekdays.length > 0 && eWeekdays.length > 0) {
return cWeekdays.some(d => eWeekdays.includes(d))
}
if (cWeekdays.length > 0 && eWeekdays.length === 0) {
return cWeekdays.includes(new Date(existing.startTime).getDay())
}
return eWeekdays.includes(candidate.start.getDay())
}
const candidateWindow = getCandidateWindow()
const busyDriverIds = new Set(
schedules
.filter(s => s.driverId && hasOverlap(candidateWindow, s))
.map(s => Number(s.driverId))
)
const busyBusIds = new Set(
schedules
.filter(s => s.busId && hasOverlap(candidateWindow, s))
.map(s => Number(s.busId))
)
const scheduleHasDriverConflict = (schedule) => {
if (!schedule?.driverId) return false
return schedules.some(other =>
other.id !== schedule.id &&
Number(other.driverId) === Number(schedule.driverId) &&
hasOverlap({ start: new Date(schedule.startTime), end: new Date(schedule.endTime) }, other)
)
}
const scheduleHasBusConflict = (schedule) => {
if (!schedule?.busId) return false
return schedules.some(other =>
other.id !== schedule.id &&
Number(other.busId) === Number(schedule.busId) &&
hasOverlap({ start: new Date(schedule.startTime), end: new Date(schedule.endTime) }, other)
)
}
// Group schedules by date and time using the API-backed overview
const timeSlots = generateTimeSlots()
const schedulesBySlot = groupSchedulesByTimeSlot(overviewSchedules, timeSlots)
const handleScheduleChange = () => {
loadWeekly();
loadTemplates();
};
return (
<section className="card">
<div className="section-header">
<div>
<h2>Diensten Inplannen</h2>
<p>Maak diensten aan met automatische eindtijd, vertrektijden per halte en herhaald rooster.</p>
</div>
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
<button className="secondary" onClick={loadSchedules}>Refresh</button>
<select
value={selectedDriverFilter}
onChange={e => setSelectedDriverFilter(e.target.value)}
style={{ padding: '6px', borderRadius: '6px', border: '1px solid #ccc' }}
>
<option value="">Alle chauffeurs</option>
{drivers.map(d => (
<option key={d.id} value={d.id}>{d.name}</option>
))}
</select>
</div>
</div>
{isAdmin && (
<form onSubmit={createSchedule} className="stack-form">
<div className="form-row">
<select value={selectedLine} onChange={e => setSelectedLine(e.target.value)}>
<option value="">Kies lijn</option>
{busLines.map(line => (
<option key={line.id} value={line.id}>
Lijn {line.lineNumber} ({line.startStop} → {line.endStop})
</option>
))}
</select>
<select value={selectedBus} onChange={e => setSelectedBus(e.target.value)}>
<option value="">Kies voertuig</option>
{buses.map(bus => (
<option key={bus.id} value={bus.id} disabled={busyBusIds.has(Number(bus.id))}>
{bus.name} ({bus.licensePlate}) {busyBusIds.has(Number(bus.id)) ? '- in gebruik' : ''}
</option>
))}
</select>
</div>
<div className="form-row">
<select value={driverId} onChange={e => setDriverId(e.target.value)}>
<option value="">Bestuurder (optioneel)</option>
{drivers.map(driver => (
<option key={driver.id} value={driver.id} disabled={busyDriverIds.has(Number(driver.id))}>
{driver.name} {busyDriverIds.has(Number(driver.id)) ? '- in gebruik' : ''}
</option>
))}
</select>
<input
type="date"
value={selectedDate}
onChange={e => setSelectedDate(e.target.value)}
/>
<input
type="time"
value={startTime}
onChange={e => setStartTime(e.target.value)}
placeholder="Start time"
/>
</div>
<div style={{ marginBottom: '1rem' }}>
<label style={{ display: 'block', marginBottom: '0.5rem', fontWeight: 500 }}>
Herhaal op weekdagen (optioneel):
</label>
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
{WEEKDAYS.map((day, idx) => (
<button
key={day}
type="button"
onClick={() => toggleWeekday(WEEKDAY_VALUES[idx])}
style={{
padding: '0.5rem 1rem',
backgroundColor: selectedWeekdays.includes(WEEKDAY_VALUES[idx]) ? '#1D4ED8' : '#e0e0e0',
color: selectedWeekdays.includes(WEEKDAY_VALUES[idx]) ? '#fff' : '#000',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '0.9rem',
}}
>
{day}
<div className="stack">
<div className="segmented">
<button type="button" className={tab === "weekly" ? "active" : ""} onClick={() => setTab("weekly")}>
Wekelijks schema
</button>
))}
</div>
</div>
<div className="form-row">
<button type="submit">Dienst Aanmaken</button>
</div>
</form>
)}
<h3 style={{ marginTop: '2rem' }}>Timetable</h3>
<div className="form-row" style={{ marginBottom: '1rem' }}>
<input
type="date"
value={overviewDate}
onChange={e => setOverviewDate(e.target.value)}
/>
<button type="button" onClick={() => setOverviewDate(new Date().toISOString().split('T')[0])}>
Vandaag
<button type="button" className={tab === "fixed" ? "active" : ""} onClick={() => setTab("fixed")}>
Vaste diensten
</button>
</div>
<div className="timetable-container">
<table className="timetable">
<thead>
<tr>
<th>Tijdvak</th>
<th>Lijnen en Voertuigen</th>
</tr>
</thead>
<tbody>
{timeSlots.map((slot, idx) => (
<tr key={idx}>
<td className="time-cell">{slot}</td>
<td className="schedule-cell">
<div className="schedule-items">
{schedulesBySlot[slot]?.map(schedule => (
<article
key={schedule.id}
className="schedule-card"
style={{ position: 'relative' }}
>
<div className="schedule-header">
<strong>Lijn {schedule.lineNumber}</strong>
<span className="schedule-time">
{formatTime(schedule.startTime)} - {formatTime(schedule.endTime)}
</span>
</div>
<div className="schedule-details">
<div>{schedule.busName}</div>
<div className="schedule-plate">{schedule.licensePlate}</div>
{schedule.driverName && (
<div style={{ fontSize: '0.85rem', color: '#666', marginTop: '0.25rem' }}>
{schedule.driverName}
</div>
)}
{scheduleHasDriverConflict(schedule) && (
<div style={{ fontSize: '0.75rem', color: '#b45309', marginTop: '0.25rem', fontWeight: 600 }}>
Chauffeur in gebruik
</div>
)}
{scheduleHasBusConflict(schedule) && (
<div style={{ fontSize: '0.75rem', color: '#b45309', marginTop: '0.25rem', fontWeight: 600 }}>
Voertuig in gebruik
</div>
)}
<div style={{ fontSize: '0.85rem', color: '#666', marginTop: '0.25rem' }}>
{schedule.recurrenceLabel || getWeekdayLabel(schedule.weekdays)}
</div>
</div>
{/* Expandable stops list */}
<button
type="button"
onClick={() => setExpandedSchedule(expandedSchedule === schedule.id ? null : schedule.id)}
style={{
marginTop: '0.5rem',
width: '100%',
padding: '0.4rem',
fontSize: '0.85rem',
backgroundColor: '#f0f0f0',
border: '1px solid #ddd',
borderRadius: '3px',
cursor: 'pointer',
}}
>
{expandedSchedule === schedule.id ? '▼' : '▶'} Haltes ({schedule.departureTimes?.length || 0})
</button>
{expandedSchedule === schedule.id && (
<div style={{ marginTop: '0.5rem', paddingTop: '0.5rem', borderTop: '1px solid #eee' }}>
{(() => {
const lineId = schedule.busLineId
const cached = routeStopsByLine[lineId]
if (!cached) {
// Load and show loading text
loadRouteStops(lineId)
return <p style={{ fontSize: '0.85rem', color: '#999' }}>Laden haltes...</p>
}
if (cached.length === 0) {
return <p style={{ fontSize: '0.85rem', color: '#999' }}>Geen haltes gekoppeld</p>
}
// Render editable list of stops with ETA inputs
return (
<table style={{ width: '100%', fontSize: '0.8rem' }}>
<tbody>
{cached.map(rs => {
const departureDate = new Date(schedule.startTime)
departureDate.setMinutes(departureDate.getMinutes() + (rs.estimatedArrivalMinutes || 0))
return (
<tr key={rs.id}>
<td style={{ paddingRight: '0.5rem' }}><strong>{rs.stopOrder}.</strong></td>
<td style={{ flex: 1 }}>{rs.name}</td>
<td style={{ textAlign: 'right', color: '#666' }}>{formatTime(departureDate.toISOString())}</td>
<td style={{ paddingLeft: '0.5rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<input
type="range"
min="0"
max="240"
value={Number(rs.estimatedArrivalMinutes ?? 0)}
onChange={e => {
const val = Number(e.target.value)
setRouteStopsByLine(prev => ({
...prev,
[lineId]: prev[lineId].map(item => item.id === rs.id ? { ...item, estimatedArrivalMinutes: val } : item)
}))
}}
style={{ width: '8rem' }}
{tab === "weekly" ? (
<WeeklyScheduleEditor
client={client}
busLines={busLines}
drivers={drivers}
schedules={weeklySchedules}
onChange={handleScheduleChange}
/>
<input
type="number"
value={Number(rs.estimatedArrivalMinutes ?? 0)}
onChange={e => {
const val = e.target.value === '' ? 0 : Number(e.target.value)
setRouteStopsByLine(prev => ({
...prev,
[lineId]: prev[lineId].map(item => item.id === rs.id ? { ...item, estimatedArrivalMinutes: val } : item)
}))
}}
style={{ width: '4.5rem' }}
) : (
<FixedScheduleEditor
client={client}
user={user}
scheduleTemplates={templates}
busLines={busLines}
buses={buses}
drivers={drivers}
message={message}
setMessage={setMessage}
onScheduleChange={handleScheduleChange}
/>
</div>
<button type="button" onClick={async () => {
const newVal = Number((routeStopsByLine[lineId].find(i => i.id === rs.id)?.estimatedArrivalMinutes) || 0)
try {
await client.post(`/admin/bus-lines/${lineId}/stops`, {
stopId: rs.stopId,
stopOrder: rs.stopOrder,
estimatedArrivalMinutes: newVal,
})
setMessage('ETA updated')
await loadRouteStops(lineId)
await loadSchedules()
await loadOverviewSchedules()
} catch (err) {
setMessage('Error: ' + (err.response?.data?.error || err.message))
}
}} style={{ marginLeft: '0.5rem' }}>Save</button>
</td>
</tr>
)
})}
</tbody>
</table>
)
})()}
</div>
)}
{isAdmin && (
<button
className="danger"
style={{ marginTop: '0.5rem', width: '100%', fontSize: '0.85rem', padding: '0.4rem' }}
onClick={() => setDeleteConfirm(schedule)}
>
Delete
</button>
)}
</article>
))}
{tab === "weekly" && message && <div className="message">{message}</div>}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{message && <div className="message">{message}</div>}
{deleteConfirm && (
<div style={overlay}>
<div style={modal}>
<h3>Delete Service?</h3>
<p>
<strong>Lijn {deleteConfirm.lineNumber}</strong> - {deleteConfirm.busName} ({deleteConfirm.licensePlate})
</p>
<p style={{ color: '#999', fontSize: '0.9rem' }}>
{new Date(deleteConfirm.startTime).toLocaleString()}
</p>
<div className="form-row" style={{ marginTop: '1rem' }}>
<button className="secondary" onClick={() => setDeleteConfirm(null)}>Cancel</button>
<button
className="danger"
onClick={() =>
deleteSchedule(deleteConfirm.id, deleteConfirm.lineNumber, deleteConfirm.busName)
}
>
Delete
</button>
</div>
</div>
</div>
)}
</section>
)
}
function generateTimeSlots() {
const slots = []
for (let hour = 0; hour < 24; hour++) {
slots.push(`${String(hour).padStart(2, '0')}:00-${String((hour + 1) % 24).padStart(2, '0')}:00`)
}
return slots
}
function groupSchedulesByTimeSlot(schedules, slots) {
const grouped = {}
slots.forEach(slot => {
grouped[slot] = []
})
schedules.forEach(schedule => {
const startHour = new Date(schedule.startTime).getHours()
const slot = `${String(startHour).padStart(2, '0')}:00-${String((startHour + 1) % 24).padStart(2, '0')}:00`
if (grouped[slot]) {
grouped[slot].push(schedule)
}
})
return grouped
}
function formatTime(timeString) {
const date = new Date(timeString)
return date.toLocaleTimeString('nl-NL', { hour: '2-digit', minute: '2-digit' })
}
const overlay = {
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0,0,0,0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1000,
}
const modal = {
backgroundColor: '#fff',
padding: '2rem',
borderRadius: '8px',
maxWidth: '400px',
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
);
}
+65 -100
View File
@@ -1,5 +1,6 @@
import React, { useEffect, useState } from 'react'
import { transitClient } from '../api'
import Modal from './Modal'
export default function StopsManager({ token, user }) {
const client = transitClient(token)
@@ -8,17 +9,19 @@ export default function StopsManager({ token, user }) {
const [lat, setLat] = useState('')
const [lon, setLon] = useState('')
const [message, setMessage] = useState(null)
const [isAdmin] = useState(Boolean(user?.userCode))
const [messageType, setMessageType] = useState('')
const [isAdmin] = useState(Boolean(user?.isAdmin))
const [deleteConfirm, setDeleteConfirm] = useState(null)
const [editingStop, setEditingStop] = useState(null)
const notify = (text, type = '') => { setMessage(text); setMessageType(type) }
const loadStops = async () => {
try {
const res = await client.get('/admin/stops')
setStops(res.data.stops || [])
} catch (err) {
setMessage('Error: ' + (err.response?.data?.error || err.message))
notify('Error: ' + (err.response?.data?.error || err.message), 'error')
}
}
@@ -28,38 +31,50 @@ export default function StopsManager({ token, user }) {
const createStop = async (e) => {
e.preventDefault()
setMessage(null)
notify(null)
try {
const res = await client.post('/admin/stops', {
name,
latitude: parseFloat(lat),
longitude: parseFloat(lon)
})
setMessage('Stop created: #' + res.data.stop.id)
notify('Halte aangemaakt: #' + res.data.stop.id)
setName(''); setLat(''); setLon('')
await loadStops()
} catch (err) {
const errMsg = err.response?.data?.error || err.message
if (err.response?.status === 401) {
setMessage('Unauthorized: invalid token')
} else if (err.response?.status === 403) {
setMessage('Forbidden: you are not an admin')
} else {
setMessage('Error: ' + errMsg)
}
if (err.response?.status === 401) notify('Unauthorized: invalid token', 'error')
else if (err.response?.status === 403) notify('Forbidden: you are not an admin', 'error')
else notify('Error: ' + errMsg, 'error')
}
}
const deleteStop = async (stopId, stopName) => {
setMessage(null)
notify(null)
try {
await client.delete(`/admin/stops/${stopId}`)
setMessage(`Halte "${stopName}" verwijderd`)
notify(`Halte "${stopName}" verwijderd`)
setDeleteConfirm(null)
await loadStops()
} catch (err) {
const errMsg = err.response?.data?.error || err.message
setMessage('Error: ' + errMsg)
notify('Error: ' + (err.response?.data?.error || err.message), 'error')
}
}
const saveStop = async (e) => {
e.preventDefault()
notify(null)
try {
await client.patch(`/admin/stops/${editingStop.id}`, {
name: editingStop.name,
latitude: parseFloat(editingStop.latitude),
longitude: parseFloat(editingStop.longitude),
})
notify('Halte bijgewerkt')
setEditingStop(null)
await loadStops()
} catch (err) {
notify('Error: ' + (err.response?.data?.error || err.message), 'error')
}
}
@@ -67,122 +82,72 @@ export default function StopsManager({ token, user }) {
<section className="card">
<div className="section-header">
<div>
<h2>Stops</h2>
<p>Maak herbruikbare haltes aan.</p>
<h2>Haltes</h2>
<p>Maak herbruikbare haltes aan en bewerk hun coördinaten.</p>
</div>
<button className="secondary" onClick={loadStops}>Refresh</button>
<button className="secondary" onClick={loadStops}>Ververs</button>
</div>
<div className="list-grid">
{stops.map(stop => (
<article className="mini-card" key={stop.id}>
<strong>#{stop.id} {stop.name}</strong>
<div>{stop.latitude}, {stop.longitude}</div>
<strong>Halte #{stop.id} · {stop.name}</strong>
<div className="meta-line"><span className="badge badge-primary">#</span> {stop.latitude}, {stop.longitude}</div>
{isAdmin && (
<button
className="danger"
onClick={() => setDeleteConfirm(stop)}
style={{ marginTop: '0.5rem', width: '100%' }}
>
Delete
</button>
)}
{isAdmin && (
<button
className="secondary"
onClick={() => setEditingStop(stop)}
style={{ marginTop: '0.5rem', width: '100%' }}
>
Edit
</button>
<div className="card-actions">
<button className="secondary" onClick={() => setEditingStop(stop)}>Bewerken</button>
<button className="danger" onClick={() => setDeleteConfirm(stop)}>Verwijderen</button>
</div>
)}
</article>
))}
</div>
{isAdmin ? (
<form onSubmit={createStop} className="stack-form">
<div className="form-row">
<input placeholder="Name" value={name} onChange={e=>setName(e.target.value)} />
<input placeholder="Naam halte" value={name} onChange={e => setName(e.target.value)} />
<input placeholder="Latitude" value={lat} onChange={e => setLat(e.target.value)} />
<input placeholder="Longitude" value={lon} onChange={e => setLon(e.target.value)} />
<button type="submit">Create Stop</button>
<button type="submit" className="no-grow">Halte aanmaken</button>
</div>
</form>
) : (
<div>You must be admin to create stops.</div>
<div className="muted">Alleen beheerders kunnen haltes aanmaken.</div>
)}
{message && <div className="message">{message}</div>}
{message && <div className={`message ${messageType}`}>{message}</div>}
{deleteConfirm && (
<div style={overlay}>
<div style={modal}>
<h3>Delete Stop?</h3>
<p><strong>{deleteConfirm.name}</strong></p>
<p style={{ color: '#999', fontSize: '0.9rem' }}>This cannot be undone.</p>
<div className="form-row" style={{ marginTop: '1rem' }}>
<button className="secondary" onClick={() => setDeleteConfirm(null)}>Cancel</button>
<button className="danger" onClick={() => deleteStop(deleteConfirm.id, deleteConfirm.name)}>
Delete
</button>
</div>
</div>
</div>
<Modal title="Halte verwijderen?" onClose={() => setDeleteConfirm(null)}
footer={
<>
<button className="secondary" onClick={() => setDeleteConfirm(null)}>Annuleren</button>
<button className="danger" onClick={() => deleteStop(deleteConfirm.id, deleteConfirm.name)}>Verwijderen</button>
</>
}>
<p>Weet je zeker dat je <strong>{deleteConfirm.name}</strong> wilt verwijderen?</p>
<p className="faint">Dit kan niet ongedaan worden gemaakt.</p>
</Modal>
)}
{editingStop && (
<div style={overlay}>
<div style={modal}>
<h3>Edit Stop</h3>
<form onSubmit={async (e) => {
e.preventDefault()
setMessage(null)
try {
const res = await client.patch(`/admin/stops/${editingStop.id}`, {
name: editingStop.name,
latitude: parseFloat(editingStop.latitude),
longitude: parseFloat(editingStop.longitude),
})
setMessage('Stop updated')
setEditingStop(null)
await loadStops()
} catch (err) {
setMessage('Error: ' + (err.response?.data?.error || err.message))
}
}} className="stack-form">
<Modal title="Halte bewerken" onClose={() => setEditingStop(null)}
footer={
<>
<button className="secondary" onClick={() => setEditingStop(null)}>Annuleren</button>
<button onClick={saveStop}>Opslaan</button>
</>
}>
<div className="form-row">
<input value={editingStop.name} onChange={e => setEditingStop({ ...editingStop, name: e.target.value })} />
</div>
<div className="form-row">
<input value={editingStop.latitude} onChange={e => setEditingStop({ ...editingStop, latitude: e.target.value })} />
<input value={editingStop.longitude} onChange={e => setEditingStop({ ...editingStop, longitude: e.target.value })} />
</div>
<div className="form-row" style={{ marginTop: '1rem' }}>
<button className="secondary" type="button" onClick={() => setEditingStop(null)}>Cancel</button>
<button type="submit">Save</button>
</div>
</form>
</div>
</div>
</Modal>
)}
</section>
)
}
const overlay = {
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0,0,0,0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1000,
}
const modal = {
backgroundColor: '#fff',
padding: '2rem',
borderRadius: '8px',
maxWidth: '400px',
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
}
@@ -0,0 +1,209 @@
import React, { useState, useEffect } from 'react'
const TimetableDisplay = ({ client, overviewDate, setOverviewDate, selectedDriverFilter, setSelectedDriverFilter, drivers }) => {
const [overviewSchedules, setOverviewSchedules] = useState([])
const [message, setMessage] = useState(null)
const [expandedSchedule, setExpandedSchedule] = useState(null)
const [routeStopsByLine, setRouteStopsByLine] = useState({})
const loadOverviewSchedules = async () => {
try {
const params = new URLSearchParams({ date: overviewDate })
if (selectedDriverFilter) {
params.set('driverId', selectedDriverFilter)
}
const res = await client.get(`/schedules/overview?${params.toString()}`)
setOverviewSchedules(res.data.schedules || [])
} catch (err) {
setMessage('Error loading timetable: ' + (err.response?.data?.error || err.message))
}
}
useEffect(() => {
loadOverviewSchedules()
}, [overviewDate, selectedDriverFilter])
const formatTime = (timeString) => {
const date = new Date(timeString)
return date.toLocaleTimeString('nl-NL', { hour: '2-digit', minute: '2-digit' })
}
const loadRouteStops = async (busLineId) => {
if (!busLineId) return
try {
const res = await client.get(`/admin/bus-lines/${busLineId}/stops`)
const routeStops = res.data.routeStops || []
setRouteStopsByLine(prev => ({ ...prev, [busLineId]: routeStops }))
} catch (err) {
// ignore silently
}
}
const getWeekdayLabel = (weekdayValues) => {
const WEEKDAYS = ['Ma', 'Di', 'Wo', 'Do', 'Vr', 'Za', 'Zo']
const WEEKDAY_VALUES = [1, 2, 3, 4, 5, 6, 0]
if (!weekdayValues || weekdayValues.length === 0) return 'Eenmalig'
if (weekdayValues.length === 7) return 'Dagelijks'
const labels = weekdayValues
.slice()
.sort((a, b) => a - b)
.map(w => WEEKDAYS[WEEKDAY_VALUES.indexOf(w)])
return labels.join(', ')
}
const timeSlots = (() => {
const slots = []
for (let hour = 0; hour < 24; hour++) {
slots.push(`${String(hour).padStart(2, '0')}:00-${String((hour + 1) % 24).padStart(2, '0')}:00`)
}
return slots
})()
const schedulesBySlot = (() => {
const grouped = {}
timeSlots.forEach(slot => {
grouped[slot] = []
})
overviewSchedules.forEach(schedule => {
const startHour = new Date(schedule.startTime).getHours()
const slot = `${String(startHour).padStart(2, '0')}:00-${String((startHour + 1) % 24).padStart(2, '0')}:00`
if (grouped[slot]) {
grouped[slot].push(schedule)
}
})
return grouped
})()
return (
<section className="card" style={{ marginTop: '2rem' }}>
<div className="section-header">
<div>
<h3>Dagelijks Overzicht</h3>
<p>Alle geplande diensten voor de geselecteerde dag</p>
</div>
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', flexWrap: 'wrap' }}>
<input
type="date"
value={overviewDate}
onChange={e => setOverviewDate(e.target.value)}
className="no-grow"
style={{ minWidth: 150 }}
/>
<button type="button" className="secondary no-grow" onClick={() => setOverviewDate(new Date().toISOString().split('T')[0])}>
Vandaag
</button>
<select
value={selectedDriverFilter}
onChange={e => setSelectedDriverFilter(e.target.value)}
className="no-grow"
style={{ minWidth: 170 }}
>
<option value="">Alle chauffeurs</option>
{drivers.map(d => (
<option key={d.id} value={d.id}>{d.name}</option>
))}
</select>
</div>
</div>
<div className="timetable-container">
<table className="timetable">
<thead>
<tr>
<th>Tijdvak</th>
<th>Lijnen en Voertuigen</th>
</tr>
</thead>
<tbody>
{timeSlots.map((slot, idx) => (
<tr key={idx}>
<td className="time-cell">{slot}</td>
<td className="schedule-cell">
<div className="schedule-items">
{schedulesBySlot[slot]?.map(schedule => (
<article
key={schedule.id}
className="schedule-card"
style={{ position: 'relative' }}
>
<div className="schedule-header">
<strong>Lijn {schedule.lineNumber}</strong>
<span className="schedule-time">
{formatTime(schedule.startTime)} - {formatTime(schedule.endTime)}
</span>
</div>
<div className="schedule-details">
<div>{schedule.busName}</div>
<div className="schedule-plate">{schedule.licensePlate}</div>
{schedule.driverName && (
<div style={{ fontSize: '0.85rem', color: '#666', marginTop: '0.25rem' }}>
{schedule.driverName}
</div>
)}
<div style={{ fontSize: '0.85rem', color: '#666', marginTop: '0.25rem' }}>
{schedule.recurrenceLabel || getWeekdayLabel(schedule.weekdays)}
</div>
</div>
{/* Expandable stops list */}
<button
type="button"
className="schedule-expand"
onClick={() => setExpandedSchedule(expandedSchedule === schedule.id ? null : schedule.id)}
>
{expandedSchedule === schedule.id ? '▼' : '▶'} Haltes ({schedule.departureTimes?.length || 0})
</button>
{expandedSchedule === schedule.id && (
<div style={{ marginTop: '0.5rem', paddingTop: '0.5rem', borderTop: '1px solid var(--border)' }}>
{(() => {
const lineId = schedule.busLineId
const cached = routeStopsByLine[lineId]
if (!cached) {
loadRouteStops(lineId)
return <p className="faint">Laden haltes…</p>
}
if (cached.length === 0) {
return <p className="faint">Geen haltes gekoppeld</p>
}
return (
<table className="stop-table">
<tbody>
{cached.map(rs => {
const departureDate = new Date(schedule.startTime)
departureDate.setMinutes(departureDate.getMinutes() + (rs.estimatedArrivalMinutes || 0))
return (
<tr key={rs.id}>
<td style={{ width: 24 }}><strong>{rs.stopOrder}.</strong></td>
<td>{rs.name}</td>
<td style={{ textAlign: 'right', color: 'var(--text-muted)', whiteSpace: 'nowrap' }}>{formatTime(departureDate.toISOString())}</td>
</tr>
)
})}
</tbody>
</table>
)
})()}
</div>
)}
</article>
))}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{message && <div className="message">{message}</div>}
</section>
)
}
export default TimetableDisplay
+199 -74
View File
@@ -1,70 +1,85 @@
import React, { useEffect, useState } from 'react'
import React, { useEffect, useState } from "react";
export default function TimetableViewer({ apiBaseUrl = 'http://localhost:5001' }) {
const [schedules, setSchedules] = useState([])
const [selectedDate, setSelectedDate] = useState(new Date().toISOString().split('T')[0])
const [selectedLineId, setSelectedLineId] = useState('')
const [lines, setLines] = useState([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
export default function TimetableViewer({
apiBaseUrl = "https://transit.xtransit.testinstance.nl",
}) {
const [schedules, setSchedules] = useState([]);
const [summary, setSummary] = useState(null);
const [selectedDate, setSelectedDate] = useState(
new Date().toISOString().split("T")[0],
);
const [selectedLineId, setSelectedLineId] = useState("");
const [lines, setLines] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
// Fetch available bus lines
useEffect(() => {
const fetchLines = async () => {
try {
const res = await fetch(`${apiBaseUrl}/admin/bus-lines`)
const data = await res.json()
setLines(data.busLines || [])
const res = await fetch(`${apiBaseUrl}/bus/admin/bus-lines`);
const data = await res.json();
setLines(data.busLines || []);
} catch (err) {
console.error('Failed to load lines:', err)
console.error("Failed to load lines:", err);
}
}
fetchLines()
}, [apiBaseUrl])
};
fetchLines();
}, [apiBaseUrl]);
// Fetch schedules based on date and line filter
useEffect(() => {
const fetchSchedules = async () => {
setLoading(true)
setError(null)
setLoading(true);
setError(null);
try {
let url = `${apiBaseUrl}/schedules?date=${selectedDate}`
let url = `${apiBaseUrl}/bus/schedules/overview?date=${selectedDate}`;
if (selectedLineId) {
url += `&lineId=${selectedLineId}`
url += `&lineId=${selectedLineId}`;
}
const res = await fetch(url)
const data = await res.json()
setSchedules(data.schedules || [])
const res = await fetch(url);
const data = await res.json();
setSchedules(data.schedules || []);
setSummary(data.summary || null);
} catch (err) {
setError('Failed to load schedules: ' + err.message)
setError("Failed to load schedules: " + err.message);
} finally {
setLoading(false)
setLoading(false);
}
}
fetchSchedules()
}, [selectedDate, selectedLineId, apiBaseUrl])
};
fetchSchedules();
}, [selectedDate, selectedLineId, apiBaseUrl]);
const timeSlots = generateTimeSlots()
const schedulesBySlot = groupSchedulesByTimeSlot(schedules, timeSlots)
const timeSlots = generateTimeSlots();
const schedulesBySlot = groupSchedulesByTimeSlot(schedules, timeSlots);
return (
<div style={{ padding: '20px', maxWidth: '1200px', margin: '0 auto' }}>
<div style={{ padding: "20px", maxWidth: "1200px", margin: "0 auto" }}>
<h1>Public Transit Timetable</h1>
<div style={{ marginBottom: '20px', display: 'flex', gap: '10px' }}>
<div style={{ marginBottom: "20px", display: "flex", gap: "10px" }}>
<input
type="date"
value={selectedDate}
onChange={e => setSelectedDate(e.target.value)}
style={{ padding: '8px', borderRadius: '6px', border: '1px solid #ccc' }}
onChange={(e) => setSelectedDate(e.target.value)}
style={{
padding: "8px",
borderRadius: "6px",
border: "1px solid #ccc",
}}
/>
<select
value={selectedLineId}
onChange={e => setSelectedLineId(e.target.value)}
style={{ padding: '8px', borderRadius: '6px', border: '1px solid #ccc', minWidth: '200px' }}
onChange={(e) => setSelectedLineId(e.target.value)}
style={{
padding: "8px",
borderRadius: "6px",
border: "1px solid #ccc",
minWidth: "200px",
}}
>
<option value="">All lines</option>
{lines.map(line => (
{lines.map((line) => (
<option key={line.id} value={line.id}>
Line {line.lineNumber} ({line.startStop} → {line.endStop})
</option>
@@ -72,82 +87,192 @@ export default function TimetableViewer({ apiBaseUrl = 'http://localhost:5001' }
</select>
</div>
{loading && <p>Loading schedules...</p>}
{error && <p style={{ color: 'red' }}>{error}</p>}
{summary && (
<div
style={{
marginBottom: "16px",
display: "flex",
gap: "12px",
flexWrap: "wrap",
}}
>
<span
style={{
padding: "6px 10px",
borderRadius: "999px",
backgroundColor: "#eef2ff",
color: "#3730a3",
}}
>
Total: {summary.total}
</span>
<span
style={{
padding: "6px 10px",
borderRadius: "999px",
backgroundColor: "#ecfeff",
color: "#155e75",
}}
>
Vast: {summary.recurring}
</span>
<span
style={{
padding: "6px 10px",
borderRadius: "999px",
backgroundColor: "#f8fafc",
color: "#334155",
}}
>
Eenmalig: {summary.oneTime}
</span>
</div>
)}
<table style={{ width: '100%', borderCollapse: 'collapse', marginTop: '20px' }}>
<thead style={{ backgroundColor: '#f1f5f9' }}>
{loading && <p>Loading schedules...</p>}
{error && <p style={{ color: "red" }}>{error}</p>}
<table
style={{ width: "100%", borderCollapse: "collapse", marginTop: "20px" }}
>
<thead style={{ backgroundColor: "#f1f5f9" }}>
<tr>
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '2px solid #ddd' }}>Time</th>
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '2px solid #ddd' }}>Line</th>
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '2px solid #ddd' }}>Vehicle</th>
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '2px solid #ddd' }}>Status</th>
<th
style={{
padding: "12px",
textAlign: "left",
borderBottom: "2px solid #ddd",
}}
>
Time
</th>
<th
style={{
padding: "12px",
textAlign: "left",
borderBottom: "2px solid #ddd",
}}
>
Line
</th>
<th
style={{
padding: "12px",
textAlign: "left",
borderBottom: "2px solid #ddd",
}}
>
Vehicle
</th>
<th
style={{
padding: "12px",
textAlign: "left",
borderBottom: "2px solid #ddd",
}}
>
Day status
</th>
<th
style={{
padding: "12px",
textAlign: "left",
borderBottom: "2px solid #ddd",
}}
>
Delay
</th>
</tr>
</thead>
<tbody>
{timeSlots.map((slot, idx) => {
const slotSchedules = schedulesBySlot[slot] || []
if (slotSchedules.length === 0) return null
const slotSchedules = schedulesBySlot[slot] || [];
if (slotSchedules.length === 0) return null;
return slotSchedules.map((schedule, sidx) => (
<tr key={`${idx}-${sidx}`} style={{ borderBottom: '1px solid #eee' }}>
<td style={{ padding: '12px' }}>
{sidx === 0 ? slot : ''}
</td>
<td style={{ padding: '12px', fontWeight: 'bold', color: '#4f46e5' }}>
<tr
key={`${idx}-${sidx}`}
style={{ borderBottom: "1px solid #eee" }}
>
<td style={{ padding: "12px" }}>{sidx === 0 ? slot : ""}</td>
<td
style={{
padding: "12px",
fontWeight: "bold",
color: "#4f46e5",
}}
>
Line {schedule.lineNumber}
</td>
<td style={{ padding: '12px' }}>
<td style={{ padding: "12px" }}>
{schedule.busName} ({schedule.licensePlate})
</td>
<td style={{ padding: '12px' }}>
<td style={{ padding: "12px" }}>
<span
style={{
padding: '4px 8px',
borderRadius: '4px',
backgroundColor: schedule.status === 'active' ? '#dcfce7' : '#f3f4f6',
color: schedule.status === 'active' ? '#166534' : '#374151',
fontSize: '0.9rem',
padding: "4px 8px",
borderRadius: "4px",
backgroundColor:
schedule.status === "in_progress"
? "#dbeafe"
: schedule.status === "completed"
? "#dcfce7"
: "#f3f4f6",
color:
schedule.status === "in_progress"
? "#1d4ed8"
: schedule.status === "completed"
? "#166534"
: "#374151",
fontSize: "0.9rem",
}}
>
{schedule.status}
</span>
</td>
<td style={{ padding: "12px" }}>
{typeof schedule.currentDelayMinutes === "number" &&
schedule.currentDelayMinutes !== null
? `${schedule.currentDelayMinutes > 0 ? "+" : ""}${schedule.currentDelayMinutes} min`
: "—"}
</td>
</tr>
))
));
})}
</tbody>
</table>
{schedules.length === 0 && !loading && (
<p style={{ textAlign: 'center', color: '#999', marginTop: '40px' }}>
<p style={{ textAlign: "center", color: "#999", marginTop: "40px" }}>
No schedules found for the selected date and line.
</p>
)}
</div>
)
);
}
function generateTimeSlots() {
const slots = []
const slots = [];
for (let hour = 0; hour < 24; hour++) {
slots.push(`${String(hour).padStart(2, '0')}:00-${String((hour + 1) % 24).padStart(2, '0')}:00`)
slots.push(
`${String(hour).padStart(2, "0")}:00-${String((hour + 1) % 24).padStart(2, "0")}:00`,
);
}
return slots
return slots;
}
function groupSchedulesByTimeSlot(schedules, slots) {
const grouped = {}
slots.forEach(slot => {
grouped[slot] = []
})
const grouped = {};
slots.forEach((slot) => {
grouped[slot] = [];
});
schedules.forEach(schedule => {
const startHour = new Date(schedule.startTime).getHours()
const slot = `${String(startHour).padStart(2, '0')}:00-${String((startHour + 1) % 24).padStart(2, '0')}:00`
schedules.forEach((schedule) => {
const startHour = new Date(schedule.startTime).getHours();
const slot = `${String(startHour).padStart(2, "0")}:00-${String((startHour + 1) % 24).padStart(2, "0")}:00`;
if (grouped[slot]) {
grouped[slot].push(schedule)
grouped[slot].push(schedule);
}
})
});
return grouped
return grouped;
}
@@ -0,0 +1,214 @@
import React, { useMemo, useState } from "react";
const DAY_GROUPS = [
{ value: "weekdays", label: "Weekdagen", days: [1, 2, 3, 4, 5] },
{ value: "saturday", label: "Zaterdag", days: [6] },
{ value: "sunday", label: "Zondag", days: [0] },
];
const dayLabel = (day) =>
["Zondag", "Maandag", "Dinsdag", "Woensdag", "Donderdag", "Vrijdag", "Zaterdag"][day];
export default function WeeklyScheduleEditor({
client,
busLines,
drivers,
schedules,
onChange,
}) {
const [lineId, setLineId] = useState("");
const [category, setCategory] = useState("weekdays");
const [startTimes, setStartTimes] = useState(["08:00"]);
const [newStartTime, setNewStartTime] = useState("");
const [driverId, setDriverId] = useState("");
const [message, setMessage] = useState(null);
const [messageType, setMessageType] = useState("");
const [saving, setSaving] = useState(false);
const notify = (text, type = "") => { setMessage(text); setMessageType(type) };
const groupedSchedules = useMemo(() => {
return schedules.reduce((groups, schedule) => {
const key =
schedule.dayOfWeek >= 1 && schedule.dayOfWeek <= 5
? "weekdays"
: schedule.dayOfWeek === 6
? "saturday"
: "sunday";
groups[key] = [...(groups[key] || []), schedule];
return groups;
}, {});
}, [schedules]);
const createSchedules = async (event) => {
event.preventDefault();
notify(null);
if (!lineId || startTimes.length === 0) {
notify("Kies een lijn en voeg minstens één vertrektijd toe.", "error");
return;
}
const group = DAY_GROUPS.find((item) => item.value === category);
setSaving(true);
try {
const response = await client.post("/admin/weekly-schedules/bulk", {
busLineId: Number(lineId),
dayOfWeeks: group.days,
startTimes,
driverId: driverId ? Number(driverId) : null,
});
notify(`${response.data.createdCount} vertrekken aangemaakt voor ${group.label}.`);
setStartTimes(["08:00"]);
setNewStartTime("");
onChange();
} catch (error) {
notify(error.response?.data?.error || "Kon het schema niet opslaan.", "error");
} finally {
setSaving(false);
}
};
const addStartTime = () => {
if (!newStartTime || startTimes.includes(newStartTime)) return;
setStartTimes([...startTimes, newStartTime].sort());
setNewStartTime("");
};
const removeStartTime = (time) => {
setStartTimes(startTimes.filter((item) => item !== time));
};
const assignDriver = async (scheduleId, value) => {
try {
await client.put(`/admin/weekly-schedules/${scheduleId}/shift`, {
driverId: value ? Number(value) : null,
});
notify("Chauffeurstoewijzing bijgewerkt.");
onChange();
} catch (error) {
notify(error.response?.data?.error || "Kon de toewijzing niet bijwerken.", "error");
}
};
const removeSchedule = async (scheduleId) => {
if (!window.confirm("Dit wekelijkse schema verwijderen?")) return;
try {
await client.delete(`/admin/weekly-schedules/${scheduleId}`);
notify("Wekelijks schema verwijderd.");
onChange();
} catch (error) {
notify(error.response?.data?.error || "Kon het schema niet verwijderen.", "error");
}
};
return (
<section className="card">
<div className="section-header">
<div>
<h3>Wekelijkse schema's</h3>
<p>
Voeg meerdere dagelijkse vertrekken toe voor weekdagen, zaterdag of zondag en wijs
daarna een chauffeur toe.
</p>
</div>
</div>
<form onSubmit={createSchedules} className="stack-form">
<div className="form-row">
<select value={lineId} onChange={(event) => setLineId(event.target.value)}>
<option value="">Kies buslijn</option>
{busLines.map((line) => (
<option key={line.id} value={line.id}>
Lijn {line.lineNumber} ({line.startStop} → {line.endStop})
</option>
))}
</select>
<select value={category} onChange={(event) => setCategory(event.target.value)}>
{DAY_GROUPS.map((group) => (
<option key={group.value} value={group.value}>{group.label}</option>
))}
</select>
</div>
<div className="form-row">
<input
type="time"
value={newStartTime}
onChange={(event) => setNewStartTime(event.target.value)}
aria-label="Nieuwe vertrektijd"
className="no-grow"
style={{ minWidth: 130 }}
/>
<button type="button" className="secondary no-grow" onClick={addStartTime}>
Vertrektijd toevoegen
</button>
<div className="flex-1 minw-0" style={{ display: "flex", gap: "8px", flexWrap: "wrap" }}>
{startTimes.map((time) => (
<span key={time} className="pill-chip">
{time}
<button type="button" onClick={() => removeStartTime(time)} aria-label={`Verwijder ${time}`}>×</button>
</span>
))}
</div>
</div>
<div className="form-row">
<select value={driverId} onChange={(event) => setDriverId(event.target.value)}>
<option value="">Niet toegewezen</option>
{drivers.map((driver) => (
<option key={driver.id} value={driver.id}>{driver.name}</option>
))}
</select>
<button type="submit" className="no-grow" disabled={saving}>
{saving ? "Opslaan…" : `Aanmaken (${startTimes.length} vertrekken)`}
</button>
</div>
</form>
{message && <div className={`message ${messageType}`}>{message}</div>}
<div className="weekly-panels">
{DAY_GROUPS.map((group) => (
<section key={group.value} className="weekly-panel">
<h4>{group.label}</h4>
{(groupedSchedules[group.value] || []).length === 0 ? (
<p className="muted">Nog geen schema geconfigureerd.</p>
) : (
<div style={{ display: "grid", gap: "10px" }}>
{groupedSchedules[group.value].map((schedule) => (
<article key={schedule.id} className="schedule-card">
<div className="schedule-header">
<strong>Lijn {schedule.lineNumber}</strong>
<span className="schedule-time">
{String(schedule.startTime).slice(0, 5)} – {String(schedule.endTime).slice(0, 5)}
</span>
</div>
<div className="schedule-details">
<div>{dayLabel(schedule.dayOfWeek)}: {schedule.startStop} → {schedule.endStop}</div>
</div>
<select
value={schedule.driverId || ""}
onChange={(event) => assignDriver(schedule.id, event.target.value)}
style={{ width: "100%", marginTop: "10px" }}
>
<option value="">Niet toegewezen</option>
{drivers.map((driver) => (
<option key={driver.id} value={driver.id}>{driver.name}</option>
))}
</select>
<button
type="button"
className="danger block"
style={{ marginTop: "8px" }}
onClick={() => removeSchedule(schedule.id)}
>
Verwijderen
</button>
</article>
))}
</div>
)}
</section>
))}
</div>
</section>
);
}
+104
View File
@@ -0,0 +1,104 @@
import React from 'react'
const Svg = ({ size = 18, children, ...props }) => (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
{...props}
>
{children}
</svg>
)
export const IconGrid = (p) => (
<Svg {...p}>
<rect x="3" y="3" width="7" height="7" rx="1.5" />
<rect x="14" y="3" width="7" height="7" rx="1.5" />
<rect x="3" y="14" width="7" height="7" rx="1.5" />
<rect x="14" y="14" width="7" height="7" rx="1.5" />
</Svg>
)
export const IconMapPin = (p) => (
<Svg {...p}>
<path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 1 1 16 0Z" />
<circle cx="12" cy="10" r="3" />
</Svg>
)
export const IconRoute = (p) => (
<Svg {...p}>
<circle cx="6" cy="19" r="3" />
<circle cx="18" cy="5" r="3" />
<path d="M9 19h3a4 4 0 0 0 0-8H9a4 4 0 0 1 0-8h6" />
</Svg>
)
export const IconLink = (p) => (
<Svg {...p}>
<path d="M10 13a5 5 0 0 0 7.5.5l3-3a5 5 0 0 0-7-7l-1.7 1.7" />
<path d="M14 11a5 5 0 0 0-7.5-.5l-3 3a5 5 0 0 0 7 7l1.7-1.7" />
</Svg>
)
export const IconBus = (p) => (
<Svg {...p}>
<path d="M4 6a4 4 0 0 1 4-4h8a4 4 0 0 1 4 4v12a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6Z" />
<path d="M4 10h16" />
<path d="M8 20v2M16 20v2" />
<circle cx="8" cy="14" r="1" />
<circle cx="16" cy="14" r="1" />
</Svg>
)
export const IconCalendar = (p) => (
<Svg {...p}>
<rect x="3" y="4" width="18" height="18" rx="2" />
<path d="M16 2v4M8 2v4M3 10h18" />
</Svg>
)
export const IconAlert = (p) => (
<Svg {...p}>
<path d="M10.3 4.2 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 4.2a2 2 0 0 0-3.4 0Z" />
<path d="M12 9v4M12 17h.01" />
</Svg>
)
export const IconLogout = (p) => (
<Svg {...p}>
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
<path d="M16 17l5-5-5-5M21 12H9" />
</Svg>
)
export const IconBusFront = (p) => (
<Svg {...p}>
<path d="M4 8V7a4 4 0 0 1 4-4h8a4 4 0 0 1 4 4v1" />
<path d="M4 8h16v6H4zM8 17v3M16 17v3" />
<rect x="4" y="14" width="16" height="3" rx="1" />
<circle cx="8" cy="11" r="0.6" />
<circle cx="16" cy="11" r="0.6" />
</Svg>
)
export const IconClock = (p) => (
<Svg {...p}>
<circle cx="12" cy="12" r="9" />
<path d="M12 7v5l3 2" />
</Svg>
)
export const IconTerminal = (p) => (
<Svg {...p}>
<rect x="3" y="4" width="18" height="16" rx="2" />
<path d="m7 9 3 3-3 3M13 15h4" />
</Svg>
)
+1087 -173
View File
File diff suppressed because it is too large Load Diff
+19
View File
@@ -15,8 +15,27 @@ pool.query('SELECT 1')
.catch(err => {
console.error('PostgreSQL connection FAILED');
console.error(err);
const url = process.env.ERROR_LOG_API_URL || 'https://error.xtransit.testinstance.nl/error_log';
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 5000);
fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-internal-api-key': process.env.INTERNAL_API_KEY || '',
},
body: JSON.stringify({
service: 'auth_api',
partofservice: 'startup: db connection',
error: err.stack || err.message || String(err),
}),
signal: controller.signal,
}).catch(() => {})
.finally(() => {
clearTimeout(timer);
process.exit(1);
});
});
module.exports = {
query: (text, params) => pool.query(text, params),
+144 -23
View File
@@ -1,57 +1,167 @@
const express = require('express');
const bcrypt = require('bcryptjs');
const jwt = require('jsonwebtoken');
const db = require('./db');
require('dotenv').config();
const express = require("express");
const bcrypt = require("bcryptjs");
const jwt = require("jsonwebtoken");
const db = require("./db");
require("dotenv").config();
function log(level, message, meta = {}) {
const timestamp = new Date().toISOString();
console.log(JSON.stringify({ timestamp, level, service: "auth_api", message, ...meta }));
}
const app = express();
app.use(express.json());
// Simple CORS middleware for browser-based admin UI testing.
app.use((req, res, next) => {
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET,POST,OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
if (req.method === 'OPTIONS') {
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Access-Control-Allow-Methods", "GET,POST,OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
if (req.method === "OPTIONS") {
return res.sendStatus(204);
}
next();
});
const ERROR_LOG_API_URL =
process.env.ERROR_LOG_API_URL ||
"https://error.xtransit.testinstance.nl/error_log";
function friendlyErrorMessage(statusCode) {
if (statusCode === 400) return "Controleer je invoer en probeer opnieuw.";
if (statusCode === 401) return "Je sessie is verlopen. Log opnieuw in.";
if (statusCode === 403) return "Je hebt hier geen toegang toe.";
if (statusCode === 404) return "Niet gevonden.";
return "Er ging iets mis. Probeer het later opnieuw.";
}
async function reportError({ partofservice, error }) {
try {
const response = await fetch(ERROR_LOG_API_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-internal-api-key": process.env.INTERNAL_API_KEY || "",
},
body: JSON.stringify({
service: "auth_api",
partofservice,
error,
}),
});
if (!response.ok) {
log("warn", "Error log forward failed", { status: response.status, partofservice });
}
} catch (err) {
log("warn", "Error log forward failed", { error: err.message, partofservice });
}
}
function reportUnhandled(context, err) {
const message = err?.message || String(err);
const stack = err?.stack || message;
log("error", context, { error: message, stack });
void reportError({ partofservice: context, error: stack });
}
process.on("uncaughtException", (err) => reportUnhandled("uncaughtException", err));
process.on("unhandledRejection", (reason) => reportUnhandled("unhandledRejection", reason));
app.use((req, res, next) => {
const originalJson = res.json.bind(res);
res.json = (body) => {
if (res.statusCode >= 400 && body && typeof body === "object") {
const rawError =
[body.error, body.message, body.details]
.filter((v) => v !== undefined && v !== null && v !== "")
.join(" - ") || `HTTP ${res.statusCode}`;
log("warn", `HTTP ${res.statusCode}`, { path: req.originalUrl, method: req.method, error: rawError });
void reportError({
partofservice: `${req.method} ${req.originalUrl}`,
error: rawError,
});
// /login errors are client-actionable: pass the specific message through
// instead of masking it with the generic session-expired text.
if (req.method === "POST" && req.path === "/login") {
return originalJson(body);
}
return originalJson({ error: friendlyErrorMessage(res.statusCode) });
}
return originalJson(body);
};
next();
});
const JWT_SECRET = process.env.JWT_SECRET;
const ADMIN_USER_CODES = new Set(
String(process.env.ADMIN_USER_CODES || "")
.split(",")
.map((c) => Number(c.trim()))
.filter(Number.isFinite),
);
app.post('/login', async (req, res) => {
app.post("/login", async (req, res) => {
try {
const { userCode, password } = req.body;
if (!userCode || !password) return res.status(400).json({ error: 'userCode and password required' });
if (!userCode || !password)
return res.status(400).json({ error: "Vul gebruikerscode en wachtwoord in." });
const result = await db.query('SELECT id, usercode, password FROM workers.users WHERE usercode = $1', [userCode]);
const result = await db.query(
"SELECT id, usercode, password FROM workers.users WHERE usercode = $1",
[userCode],
);
const user = result.rows[0];
if (!user) return res.status(401).json({ error: 'invalid_credentials' });
if (!user) {
log("warn", "Login attempt for non-existent user", { userCode });
return res.status(401).json({ error: "Ongeldige gebruikerscode of wachtwoord." });
}
const ok = await bcrypt.compare(password, user.password);
if (!ok) return res.status(401).json({ error: 'invalid_credentials' });
if (!ok) {
log("warn", "Invalid password for user", { userCode });
return res.status(401).json({ error: "Ongeldige gebruikerscode of wachtwoord." });
}
const payload = { userId: user.id, userCode: user.usercode };
const token = jwt.sign(payload, JWT_SECRET, { expiresIn: '1h' });
const isAdmin = ADMIN_USER_CODES.has(Number(user.usercode));
const payload = {
userId: user.id,
userCode: user.usercode,
isAdmin: Boolean(isAdmin),
};
const token = jwt.sign(payload, JWT_SECRET, { expiresIn: "1h" });
res.json({ token, user: { id: user.id, userCode: user.userCode, job: user.job } });
log("info", "User logged in", { userCode: user.usercode, isAdmin });
res.json({
token,
user: {
id: user.id,
userCode: user.usercode,
job: user.job,
isAdmin: Boolean(isAdmin),
},
});
} catch (err) {
console.error(err);
res.status(500).json({ error: 'internal_error' });
log("error", "Login error", { error: err.message, stack: err.stack });
reportError({ partofservice: `${req.method} ${req.originalUrl}`, error: err.stack || err.message });
res.status(500).json({ error: friendlyErrorMessage(500) });
}
});
app.get('/me', async (req, res) => {
app.get("/me", async (req, res) => {
const auth = req.headers.authorization;
if (!auth || !auth.startsWith('Bearer ')) return res.status(401).json({ error: 'missing_token' });
if (!auth || !auth.startsWith("Bearer ")) {
log("warn", "Missing or invalid Authorization header", { path: req.originalUrl });
return res.status(401).json({ error: "missing_token" });
}
const token = auth.slice(7);
try {
const payload = jwt.verify(token, JWT_SECRET);
res.json({ user: payload });
} catch (err) {
res.status(401).json({ error: 'invalid_token' });
log("error", "JWT verification failed", { error: err.message, stack: err.stack });
reportError({ partofservice: `${req.method} ${req.originalUrl}`, error: err.stack || err.message });
res.status(401).json({ error: friendlyErrorMessage(401) });
}
});
@@ -63,4 +173,15 @@ app.get('/me', async (req, res) => {
const PORT = process.env.PORT || 5000;
app.listen(PORT, () => console.log(`Auth API listening on ${PORT}`));
app.get("/health", async (_req, res) => {
try {
await db.query("SELECT 1");
res.json({ status: "ok", service: "auth_api", database: "ok" });
} catch (err) {
log("error", "Health check DB query failed", { error: err.message, stack: err.stack });
reportError({ partofservice: "GET /health", error: err.stack || err.message });
res.status(503).json({ status: "unavailable", service: "auth_api" });
}
});
module.exports = app;
-48
View File
@@ -1,48 +0,0 @@
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.build/
.buildlog/
.history
.svn/
.swiftpm/
migrate_working_dir/
# IntelliJ related
*.iml
*.ipr
*.iws
.idea/
# The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line
# is commented out by default.
#.vscode/
# Flutter/Dart/Pub related
**/doc/api/
**/ios/Flutter/.last_build_id
.dart_tool/
.flutter-plugins-dependencies
.pub-cache/
.pub/
/build/
/coverage/
# Symbolication related
app.*.symbols
# Obfuscation related
app.*.map.json
# Android Studio will place build artifacts here
/android/app/debug
/android/app/profile
/android/app/release
#github
.github/
-45
View File
@@ -1,45 +0,0 @@
# This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc.
#
# This file should be version controlled and should not be manually edited.
version:
revision: "00b0c91f06209d9e4a41f71b7a512d6eb3b9c694"
channel: "stable"
project_type: app
# Tracks metadata for the flutter migrate command
migration:
platforms:
- platform: root
create_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694
base_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694
- platform: android
create_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694
base_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694
- platform: ios
create_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694
base_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694
- platform: linux
create_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694
base_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694
- platform: macos
create_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694
base_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694
- platform: web
create_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694
base_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694
- platform: windows
create_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694
base_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694
# User provided section
# List of Local paths (relative to this file) that should be
# ignored by the migrate tool.
#
# Files that are not part of the templates will be ignored by default.
unmanaged_files:
- 'lib/main.dart'
- 'ios/Runner.xcodeproj/project.pbxproj'
-3
View File
@@ -1,3 +0,0 @@
# bus_terminal
A new Flutter project.
-5
View File
@@ -1,5 +0,0 @@
analyzer:
errors:
deprecated_member_use: ignore
use_build_context_synchronously: ignore
include: package:flutter_lints/flutter.yaml
-14
View File
@@ -1,14 +0,0 @@
gradle-wrapper.jar
/.gradle
/captures/
/gradlew
/gradlew.bat
/local.properties
GeneratedPluginRegistrant.java
.cxx/
# Remember to never publicly share your keystore.
# See https://flutter.dev/to/reference-keystore
key.properties
**/*.keystore
**/*.jks
-44
View File
@@ -1,44 +0,0 @@
plugins {
id("com.android.application")
id("kotlin-android")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin")
}
android {
namespace = "com.example.bus_terminal"
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_17.toString()
}
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "com.example.bus_terminal"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
}
buildTypes {
release {
// TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.getByName("debug")
}
}
}
flutter {
source = "../.."
}
@@ -1,7 +0,0 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>
@@ -1,48 +0,0 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
<application
android:label="bus_terminal"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
</manifest>
@@ -1,5 +0,0 @@
package com.example.bus_terminal
import io.flutter.embedding.android.FlutterActivity
class MainActivity : FlutterActivity()
@@ -1,12 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?android:colorBackground" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>
@@ -1,12 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 544 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 442 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 721 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

@@ -1,18 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>
@@ -1,18 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>
@@ -1,7 +0,0 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>
-24
View File
@@ -1,24 +0,0 @@
allprojects {
repositories {
google()
mavenCentral()
}
}
val newBuildDir: Directory =
rootProject.layout.buildDirectory
.dir("../../build")
.get()
rootProject.layout.buildDirectory.value(newBuildDir)
subprojects {
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
project.layout.buildDirectory.value(newSubprojectBuildDir)
}
subprojects {
project.evaluationDependsOn(":app")
}
tasks.register<Delete>("clean") {
delete(rootProject.layout.buildDirectory)
}
-2
View File
@@ -1,2 +0,0 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
@@ -1,5 +0,0 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip
-26
View File
@@ -1,26 +0,0 @@
pluginManagement {
val flutterSdkPath =
run {
val properties = java.util.Properties()
file("local.properties").inputStream().use { properties.load(it) }
val flutterSdkPath = properties.getProperty("flutter.sdk")
require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
flutterSdkPath
}
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "8.11.1" apply false
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
}
include(":app")
-3
View File
@@ -1,3 +0,0 @@
description: This file stores settings for Dart & Flutter DevTools.
documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
extensions:
-34
View File
@@ -1,34 +0,0 @@
**/dgph
*.mode1v3
*.mode2v3
*.moved-aside
*.pbxuser
*.perspectivev3
**/*sync/
.sconsign.dblite
.tags*
**/.vagrant/
**/DerivedData/
Icon?
**/Pods/
**/.symlinks/
profile
xcuserdata
**/.generated/
Flutter/App.framework
Flutter/Flutter.framework
Flutter/Flutter.podspec
Flutter/Generated.xcconfig
Flutter/ephemeral/
Flutter/app.flx
Flutter/app.zip
Flutter/flutter_assets/
Flutter/flutter_export_environment.sh
ServiceDefinitions.json
Runner/GeneratedPluginRegistrant.*
# Exceptions to above rules.
!default.mode1v3
!default.mode2v3
!default.pbxuser
!default.perspectivev3
@@ -1,24 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string>App</string>
<key>CFBundleIdentifier</key>
<string>io.flutter.flutter.app</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>App</string>
<key>CFBundlePackageType</key>
<string>FMWK</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>1.0</string>
</dict>
</plist>
-1
View File
@@ -1 +0,0 @@
#include "Generated.xcconfig"
@@ -1 +0,0 @@
#include "Generated.xcconfig"
@@ -1,620 +0,0 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 54;
objects = {
/* Begin PBXBuildFile section */
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; };
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 97C146E61CF9000F007C117D /* Project object */;
proxyType = 1;
remoteGlobalIDString = 97C146ED1CF9000F007C117D;
remoteInfo = Runner;
};
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
9705A1C41CF9048500538489 /* Embed Frameworks */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 10;
files = (
);
name = "Embed Frameworks";
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; };
97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; };
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
97C146EB1CF9000F007C117D /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
331C8082294A63A400263BE5 /* RunnerTests */ = {
isa = PBXGroup;
children = (
331C807B294A618700263BE5 /* RunnerTests.swift */,
);
path = RunnerTests;
sourceTree = "<group>";
};
9740EEB11CF90186004384FC /* Flutter */ = {
isa = PBXGroup;
children = (
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
9740EEB21CF90195004384FC /* Debug.xcconfig */,
7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
9740EEB31CF90195004384FC /* Generated.xcconfig */,
);
name = Flutter;
sourceTree = "<group>";
};
97C146E51CF9000F007C117D = {
isa = PBXGroup;
children = (
9740EEB11CF90186004384FC /* Flutter */,
97C146F01CF9000F007C117D /* Runner */,
97C146EF1CF9000F007C117D /* Products */,
331C8082294A63A400263BE5 /* RunnerTests */,
);
sourceTree = "<group>";
};
97C146EF1CF9000F007C117D /* Products */ = {
isa = PBXGroup;
children = (
97C146EE1CF9000F007C117D /* Runner.app */,
331C8081294A63A400263BE5 /* RunnerTests.xctest */,
);
name = Products;
sourceTree = "<group>";
};
97C146F01CF9000F007C117D /* Runner */ = {
isa = PBXGroup;
children = (
97C146FA1CF9000F007C117D /* Main.storyboard */,
97C146FD1CF9000F007C117D /* Assets.xcassets */,
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */,
97C147021CF9000F007C117D /* Info.plist */,
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */,
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
);
path = Runner;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
331C8080294A63A400263BE5 /* RunnerTests */ = {
isa = PBXNativeTarget;
buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
buildPhases = (
331C807D294A63A400263BE5 /* Sources */,
331C807F294A63A400263BE5 /* Resources */,
);
buildRules = (
);
dependencies = (
331C8086294A63A400263BE5 /* PBXTargetDependency */,
);
name = RunnerTests;
productName = RunnerTests;
productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */;
productType = "com.apple.product-type.bundle.unit-test";
};
97C146ED1CF9000F007C117D /* Runner */ = {
isa = PBXNativeTarget;
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
buildPhases = (
9740EEB61CF901F6004384FC /* Run Script */,
97C146EA1CF9000F007C117D /* Sources */,
97C146EB1CF9000F007C117D /* Frameworks */,
97C146EC1CF9000F007C117D /* Resources */,
9705A1C41CF9048500538489 /* Embed Frameworks */,
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
);
buildRules = (
);
dependencies = (
);
name = Runner;
productName = Runner;
productReference = 97C146EE1CF9000F007C117D /* Runner.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
97C146E61CF9000F007C117D /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = YES;
LastUpgradeCheck = 1510;
ORGANIZATIONNAME = "";
TargetAttributes = {
331C8080294A63A400263BE5 = {
CreatedOnToolsVersion = 14.0;
TestTargetID = 97C146ED1CF9000F007C117D;
};
97C146ED1CF9000F007C117D = {
CreatedOnToolsVersion = 7.3.1;
LastSwiftMigration = 1100;
};
};
};
buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */;
compatibilityVersion = "Xcode 9.3";
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = 97C146E51CF9000F007C117D;
productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
97C146ED1CF9000F007C117D /* Runner */,
331C8080294A63A400263BE5 /* RunnerTests */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
331C807F294A63A400263BE5 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
97C146EC1CF9000F007C117D /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */,
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */,
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */,
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
"${TARGET_BUILD_DIR}/${INFOPLIST_PATH}",
);
name = "Thin Binary";
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin";
};
9740EEB61CF901F6004384FC /* Run Script */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
);
name = "Run Script";
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
331C807D294A63A400263BE5 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
97C146EA1CF9000F007C117D /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
331C8086294A63A400263BE5 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 97C146ED1CF9000F007C117D /* Runner */;
targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin PBXVariantGroup section */
97C146FA1CF9000F007C117D /* Main.storyboard */ = {
isa = PBXVariantGroup;
children = (
97C146FB1CF9000F007C117D /* Base */,
);
name = Main.storyboard;
sourceTree = "<group>";
};
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = {
isa = PBXVariantGroup;
children = (
97C147001CF9000F007C117D /* Base */,
);
name = LaunchScreen.storyboard;
sourceTree = "<group>";
};
/* End PBXVariantGroup section */
/* Begin XCBuildConfiguration section */
249021D3217E4FDB00AE95B9 /* Profile */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
TARGETED_DEVICE_FAMILY = "1,2";
VALIDATE_PRODUCT = YES;
};
name = Profile;
};
249021D4217E4FDB00AE95B9 /* Profile */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.example.busTerminal;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
};
name = Profile;
};
331C8088294A63A400263BE5 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.example.busTerminal.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
};
name = Debug;
};
331C8089294A63A400263BE5 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.example.busTerminal.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
};
name = Release;
};
331C808A294A63A400263BE5 /* Profile */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.example.busTerminal.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
};
name = Profile;
};
97C147031CF9000F007C117D /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
97C147041CF9000F007C117D /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
TARGETED_DEVICE_FAMILY = "1,2";
VALIDATE_PRODUCT = YES;
};
name = Release;
};
97C147061CF9000F007C117D /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.example.busTerminal;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
};
name = Debug;
};
97C147071CF9000F007C117D /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.example.busTerminal;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
331C8088294A63A400263BE5 /* Debug */,
331C8089294A63A400263BE5 /* Release */,
331C808A294A63A400263BE5 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = {
isa = XCConfigurationList;
buildConfigurations = (
97C147031CF9000F007C117D /* Debug */,
97C147041CF9000F007C117D /* Release */,
249021D3217E4FDB00AE95B9 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = {
isa = XCConfigurationList;
buildConfigurations = (
97C147061CF9000F007C117D /* Debug */,
97C147071CF9000F007C117D /* Release */,
249021D4217E4FDB00AE95B9 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = 97C146E61CF9000F007C117D /* Project object */;
}
@@ -1,7 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>
@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>
@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PreviewsEnabled</key>
<false/>
</dict>
</plist>
@@ -1,101 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1510"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
shouldUseLaunchSchemeArgsEnv = "YES">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</MacroExpansion>
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "331C8080294A63A400263BE5"
BuildableName = "RunnerTests.xctest"
BlueprintName = "RunnerTests"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
enableGPUValidationMode = "1"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Profile"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
@@ -1,7 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "group:Runner.xcodeproj">
</FileRef>
</Workspace>
@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>
@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PreviewsEnabled</key>
<false/>
</dict>
</plist>
-16
View File
@@ -1,16 +0,0 @@
import Flutter
import UIKit
@main
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
}
}
@@ -1,122 +0,0 @@
{
"images" : [
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "Icon-App-20x20@2x.png",
"scale" : "2x"
},
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "Icon-App-20x20@3x.png",
"scale" : "3x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@1x.png",
"scale" : "1x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@2x.png",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@3x.png",
"scale" : "3x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "Icon-App-40x40@2x.png",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "Icon-App-40x40@3x.png",
"scale" : "3x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "Icon-App-60x60@2x.png",
"scale" : "2x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "Icon-App-60x60@3x.png",
"scale" : "3x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "Icon-App-20x20@1x.png",
"scale" : "1x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "Icon-App-20x20@2x.png",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "Icon-App-29x29@1x.png",
"scale" : "1x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "Icon-App-29x29@2x.png",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "Icon-App-40x40@1x.png",
"scale" : "1x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "Icon-App-40x40@2x.png",
"scale" : "2x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "Icon-App-76x76@1x.png",
"scale" : "1x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "Icon-App-76x76@2x.png",
"scale" : "2x"
},
{
"size" : "83.5x83.5",
"idiom" : "ipad",
"filename" : "Icon-App-83.5x83.5@2x.png",
"scale" : "2x"
},
{
"size" : "1024x1024",
"idiom" : "ios-marketing",
"filename" : "Icon-App-1024x1024@1x.png",
"scale" : "1x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 295 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 450 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 282 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 462 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 704 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 586 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 762 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

@@ -1,23 +0,0 @@
{
"images" : [
{
"idiom" : "universal",
"filename" : "LaunchImage.png",
"scale" : "1x"
},
{
"idiom" : "universal",
"filename" : "LaunchImage@2x.png",
"scale" : "2x"
},
{
"idiom" : "universal",
"filename" : "LaunchImage@3x.png",
"scale" : "3x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 B

@@ -1,5 +0,0 @@
# Launch Screen Assets
You can customize the launch screen with your own desired assets by replacing the image files in this directory.
You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images.
@@ -1,37 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="12121" systemVersion="16G29" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="12089"/>
</dependencies>
<scenes>
<!--View Controller-->
<scene sceneID="EHf-IW-A2E">
<objects>
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
<layoutGuides>
<viewControllerLayoutGuide type="top" id="Ydg-fD-yQy"/>
<viewControllerLayoutGuide type="bottom" id="xbc-2k-c8Z"/>
</layoutGuides>
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4">
</imageView>
</subviews>
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<constraints>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="1a2-6s-vTC"/>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" id="4X2-HB-R7a"/>
</constraints>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="53" y="375"/>
</scene>
</scenes>
<resources>
<image name="LaunchImage" width="168" height="185"/>
</resources>
</document>
@@ -1,26 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="10117" systemVersion="15F34" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" initialViewController="BYZ-38-t0r">
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="10085"/>
</dependencies>
<scenes>
<!--Flutter View Controller-->
<scene sceneID="tne-QT-ifu">
<objects>
<viewController id="BYZ-38-t0r" customClass="FlutterViewController" sceneMemberID="viewController">
<layoutGuides>
<viewControllerLayoutGuide type="top" id="y3c-jy-aDJ"/>
<viewControllerLayoutGuide type="bottom" id="wfy-db-euE"/>
</layoutGuides>
<view key="view" contentMode="scaleToFill" id="8bC-Xf-vdC">
<rect key="frame" x="0.0" y="0.0" width="600" height="600"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="calibratedWhite"/>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
</objects>
</scene>
</scenes>
</document>
-74
View File
@@ -1,74 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Bus Terminal</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>bus_terminal</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
<key>UISceneConfigurations</key>
<dict>
<key>UIWindowSceneSessionRoleApplication</key>
<array>
<dict>
<key>UISceneClassName</key>
<string>UIWindowScene</string>
<key>UISceneConfigurationName</key>
<string>flutter</string>
<key>UISceneDelegateClassName</key>
<string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
<key>UISceneStoryboardFile</key>
<string>Main</string>
</dict>
</array>
</dict>
</dict>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>NSLocationWhenInUseUsageDescription</key>
<string>We need your location to show the bus route on the map and navigate you through your scheduled stops.</string>
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
<string>We need your location to show the bus route on the map and navigate you through your scheduled stops.</string>
</dict>
</plist>

Some files were not shown because too many files have changed in this diff Show More