Compare commits
44 Commits
27880b2355
..
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 3c35999a84 | |||
| 30f4ca6701 | |||
| 46fb9b9d6b | |||
| e169e37b5e | |||
| 7d55580d87 | |||
| 2ae1720383 | |||
| b766725b3b | |||
| 2707bff6fd | |||
| b36975b680 | |||
| 50634a3ed7 | |||
| 18c330854e | |||
| 0da6d07283 | |||
| 04bb0e3347 | |||
| a5924086f4 | |||
| 89e4f90fc3 | |||
| 39b0b07a77 | |||
| 48c710bfbf | |||
| 1c055d84e8 | |||
| d7100e6f2a | |||
| 6e0b7e4793 | |||
| da5c6b2839 | |||
| 38782bca6d | |||
| a17125bdee | |||
| 217dfd3abc | |||
| 635a130f0b | |||
| f935bbeb8c | |||
| 1154d1d658 | |||
| 3f0a02aa04 | |||
| ebcc485f51 | |||
| c09b3f3f66 | |||
| 9d1a37e248 | |||
| fdbdb98b21 | |||
| 0d37750855 | |||
| e95b10abbb | |||
| 9a56b40f50 | |||
| 0e19aa0521 | |||
| 73de0d713e | |||
| 0e10757c49 | |||
| ad48e79db2 | |||
| b04dfba4c7 | |||
| 6e62a9cac1 | |||
| 1e41c05214 | |||
| 86ffce07b0 | |||
| 8caf189c4c |
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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 }}
|
||||||
@@ -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
|
|
||||||
@@ -1,21 +1,63 @@
|
|||||||
|
# Environment and secrets
|
||||||
.env
|
.env
|
||||||
/*.env
|
.env.*
|
||||||
transit_api/.env
|
!.env.example
|
||||||
|
**/.env
|
||||||
|
**/.env.*
|
||||||
|
!**/.env.example
|
||||||
|
|
||||||
# Local build / node_modules
|
# Node.js
|
||||||
|
node_modules/
|
||||||
admin_ui/node_modules/
|
admin_ui/node_modules/
|
||||||
admin_ui/dist/
|
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
|
# Flutter / Dart
|
||||||
ADMIN_DEPLOYMENT_SUMMARY.md
|
**/.dart_tool/
|
||||||
CI_CD_PORTAINER_GUIDE.md
|
**/.pub-cache/
|
||||||
DEPLOYMENT.md
|
**/.pub/
|
||||||
GETTING_STARTED.md
|
**/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
|
INTERNAL_*.md
|
||||||
SETUP_*.md
|
SETUP_*.md
|
||||||
DEPLOY_*.md
|
DEPLOY_*.md
|
||||||
|
|
||||||
# Custom instruction files (private)
|
# Private instructions and tooling files
|
||||||
.instructions.md
|
.instructions.md
|
||||||
.prompt.md
|
.prompt.md
|
||||||
.agent.md
|
.agent.md
|
||||||
@@ -23,15 +65,30 @@ SKILL.md
|
|||||||
copilot-instructions.md
|
copilot-instructions.md
|
||||||
AGENTS.md
|
AGENTS.md
|
||||||
|
|
||||||
# Private scripts
|
# Local/private scripts
|
||||||
scripts/private/
|
scripts/private/
|
||||||
scripts/*.local.*
|
scripts/*.local.*
|
||||||
scripts/.env
|
scripts/.env
|
||||||
|
|
||||||
# Private timetable note
|
# Monitoring UI local configuration
|
||||||
transit_api/LOCAL_BUS_TIMETABLE_README.md
|
monitoring_ui/.env.local
|
||||||
|
monitoring_ui/.env.*.local
|
||||||
|
|
||||||
# Runtime files
|
# Project-specific private notes
|
||||||
*.log
|
monitoring_ui/docs/transit_api/LOCAL_BUS_TIMETABLE_README.md
|
||||||
*.sqlite3
|
|
||||||
.DS_Store
|
# 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/
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
|
||||||
@@ -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`
|
|
||||||
@@ -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.
|
|
||||||
@@ -1,20 +1,37 @@
|
|||||||
# Transit Project
|
# 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)
|
- `auth_api` — Authentication and token issuance (default port 5000)
|
||||||
- `employee_api` — Employee onboarding / management (standaard port 4000)
|
- `employee_api` — Employee onboarding and management (default port 4000)
|
||||||
- `transit_api` — Transit/schedule endpoints (standaard port 5001)
|
- `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
|
## 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
|
- What files are kept private and why
|
||||||
- How `.gitignore` and `.dockerignore` protect sensitive information
|
- 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
|
- Guidelines for adding new private files
|
||||||
|
|
||||||
**Private files are excluded** (not in git/docker):
|
**Private files are excluded** (not in git/docker):
|
||||||
|
|
||||||
- `GETTING_STARTED.md` - Internal setup guide
|
- `GETTING_STARTED.md` - Internal setup guide
|
||||||
- `ADMIN_DEPLOYMENT_SUMMARY.md` - Deployment status
|
- `ADMIN_DEPLOYMENT_SUMMARY.md` - Deployment status
|
||||||
- `CI_CD_PORTAINER_GUIDE.md` - Infrastructure docs
|
- `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
|
- `auth_api/` — Source code for the authentication service
|
||||||
- `employee_api/` — source voor employee service
|
- `employee_api/` — Source code for the employee service
|
||||||
- `transit_api/` — source voor transit service
|
- `transit_api/` — Source code for the transit service
|
||||||
- `.github/workflows/docker-build.yml` — CI: bouw en push images naar GHCR
|
- `error_log_api/` — Source code for centralized error collection
|
||||||
- `docker-compose.yml` — compose file die (in deze workspace-versie) images trekt van `ghcr.io/...` en verwacht dat je een externe PostgreSQL hebt draaien
|
- `admin_ui/` — Source code for React admin panel
|
||||||
- `.env.example` — voorbeeld environment variabelen
|
- `monitoring_ui/` — Source code for React monitoring dashboard and documentation hub
|
||||||
- `DOCKER_README.md` & `CI_CD_PORTAINER_GUIDE.md` — extra docs voor Docker/CI
|
- `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
|
- Git
|
||||||
- Docker (20.10+)
|
- Docker (20.10+)
|
||||||
- Docker Compose (v2+ of `docker compose`)
|
- Docker Compose (v2+ or `docker compose`)
|
||||||
- (Optioneel) een remote omgeving met Portainer
|
- (Optional) A remote environment running Portainer
|
||||||
- (Optioneel) Een GitHub account met rechten om images naar GHCR te publiceren
|
- (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.
|
- Never commit secrets into git. Use a local `.env` file (kept in `.gitignore`) or a secret manager.
|
||||||
- JWT secrets, database wachtwoorden en andere credentials moeten via environment variables of Portainer secrets geleverd worden.
|
- 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.
|
Your `docker-compose.yml` expects environment variables defining the database connection and JWT secret.
|
||||||
Maak een local `.env` bestand in de projectroot (NIET commiten). Gebruik deze variabelen als leidraad:
|
Create a local `.env` file in the project root (DO NOT commit). Use these variables as a guide:
|
||||||
|
|
||||||
```
|
```env
|
||||||
# Database (externe Postgres)
|
# Database (external Postgres)
|
||||||
POSTGRES_HOST=your.db.host.or.ip
|
POSTGRES_HOST=your.db.host.or.ip
|
||||||
POSTGRES_PORT=5432
|
POSTGRES_PORT=5432
|
||||||
POSTGRES_DB=transit_db
|
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_USER_AUTH=auth_user
|
||||||
POSTGRES_PASSWORD_AUTH=strong_password_auth
|
POSTGRES_PASSWORD_AUTH=strong_password_auth
|
||||||
POSTGRES_USER_EMPLOYEE=employee_user
|
POSTGRES_USER_EMPLOYEE=employee_user
|
||||||
POSTGRES_PASSWORD_EMPLOYEE=strong_password_employee
|
POSTGRES_PASSWORD_EMPLOYEE=strong_password_employee
|
||||||
POSTGRES_USER_TRANSIT=transit_user
|
POSTGRES_USER_TRANSIT=transit_user
|
||||||
POSTGRES_PASSWORD_TRANSIT=strong_password_transit
|
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
|
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).
|
1. Ensure you have a running PostgreSQL instance (local or remote).
|
||||||
2. Kopieer per API een `.env` of export de benodigde vars.
|
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
|
```bash
|
||||||
cd auth_api
|
cd auth_api
|
||||||
# installeer dependencies
|
# Install dependencies
|
||||||
npm install
|
npm install
|
||||||
# export env vars (voorbeeld):
|
# Export env vars (example):
|
||||||
export DB_HOST=your.db.host
|
export DB_HOST=your.db.host
|
||||||
export DB_PORT=5432
|
export DB_PORT=5432
|
||||||
export DB_NAME=transit_db
|
export DB_NAME=transit_db
|
||||||
@@ -108,93 +136,97 @@ export JWT_SECRET=...
|
|||||||
npm start
|
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:
|
Before starting:
|
||||||
- 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.
|
|
||||||
|
|
||||||
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
|
```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
|
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)
|
# 2. Login to GHCR (to pull private images)
|
||||||
# Username = je GitHub-gebruikersnaam
|
# Username = your GitHub username
|
||||||
# Password = PAT met 'write:packages'/'read:packages' scopes of je GITHUB_TOKEN
|
# Password = PAT with 'write:packages'/'read:packages' scopes or your GITHUB_TOKEN
|
||||||
docker login ghcr.io
|
docker login ghcr.io
|
||||||
|
|
||||||
# 3. Start stack
|
# 3. Start stack
|
||||||
docker-compose --env-file .env up -d
|
docker-compose --env-file .env up -d
|
||||||
# of met compose V2:
|
# or with compose V2:
|
||||||
docker compose --env-file .env up -d
|
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)
|
## 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
|
- Checkout repository
|
||||||
- Build Docker images per API (met BuildKit/Buildx)
|
- Build Docker images for each API (using BuildKit / Buildx)
|
||||||
- Login naar de Container Registry (GHCR) en push images
|
- Login to the Container Registry (GHCR) and push images
|
||||||
- Genereert tags (`latest`, branch/sha, semver als aanwezig)
|
- Generate tags (`latest`, branch/sha, semver if present)
|
||||||
|
|
||||||
Om GHCR te gebruiken op je server:
|
To use GHCR on your 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).
|
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
|
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
|
- `GET /health` — (available in `employee_api`, `transit_api`, `error_log_api`) checks reachability
|
||||||
- `POST /login` — (`auth_api`) authenticatie
|
- `POST /login` — (`auth_api`) authentication
|
||||||
- `GET /schedule/daily?date=YYYY-MM-DD` — (`transit_api`) vereist auth
|
- `GET /schedule/daily?date=YYYY-MM-DD` — (`transit_api`) requires authentication
|
||||||
|
|
||||||
Logs bekijken:
|
Viewing logs:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Logs van service
|
# Logs from a service
|
||||||
docker-compose logs -f auth_api
|
docker-compose logs -f auth_api
|
||||||
|
|
||||||
# Of via container id
|
# Or via container ID
|
||||||
docker logs -f auth_api
|
docker logs -f auth_api
|
||||||
```
|
```
|
||||||
|
|
||||||
Problemen met DB-verbinding:
|
Troubleshooting DB connection:
|
||||||
- Controleer netwerkconnectiviteit naar `POSTGRES_HOST`
|
|
||||||
- Controleer users/permissions in Postgres
|
- Verify network connectivity to `POSTGRES_HOST`
|
||||||
- Controleer dat de juiste schema (zoals `workers`) bestaat als de queries die verwachten
|
- 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
|
```bash
|
||||||
# Build & run (pull images van GHCR)
|
# Build & run (pull images from GHCR)
|
||||||
docker-compose --env-file .env up -d
|
docker-compose --env-file .env up -d
|
||||||
|
|
||||||
# Pull latest images
|
# 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:
|
Database schemas and migrations are maintained separately. Ensure you:
|
||||||
- de juiste schema's (`workers`, `routes`, etc.) aanmaakt
|
|
||||||
- gebruikers en rechten configureert voor de verschillende services
|
|
||||||
|
|
||||||
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
|
```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
|
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
|
- Use distinct database users per service with minimal privileges
|
||||||
- Gebruik secrets in Portainer of Docker swarm/kubernetes secrets in productie
|
- Use secrets in Portainer, Docker swarm, or Kubernetes in production
|
||||||
- Gebruik een reverse proxy (nginx, Traefik) voor TLS/HTTPS
|
- Use a reverse proxy (Nginx, Traefik) for TLS/HTTPS
|
||||||
- Stel log aggregatie en monitoring in (Prometheus/Grafana, ELK, etc.)
|
- Set up log aggregation and monitoring (Prometheus/Grafana, ELK, etc.)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Contributie
|
## Contributing
|
||||||
|
|
||||||
- Maak feature branches en open pull requests
|
- Create feature branches and open pull requests
|
||||||
- Push naar `develop` of `main` afhankelijk van je branching policy
|
- Push to `dev` or `main` depending on your branching policy
|
||||||
- CI zal automatisch images bouwen en pushen voor `main`/`develop`
|
- 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/`
|
- API code: `auth_api/`, `employee_api/`, `transit_api/`, `error_log_api/`
|
||||||
- CI workflow: `.github/workflows/docker-build.yml`
|
- Frontend UI: `admin_ui/`, `monitoring_ui/`
|
||||||
- Extra docs: `DOCKER_README.md`, `CI_CD_PORTAINER_GUIDE.md`
|
- 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
|
│ └── styles.css # Responsive UI styling
|
||||||
├── docker-entrypoint.sh # Runtime env config
|
├── docker-entrypoint.sh # Runtime env config
|
||||||
└── Dockerfile # Multi-stage build
|
└── Dockerfile # Multi-stage build
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Building the Admin UI
|
### Building the Admin UI
|
||||||
@@ -310,25 +367,28 @@ docker-compose up admin_ui
|
|||||||
The Transit API exposes public (unauthenticated) schedule endpoints:
|
The Transit API exposes public (unauthenticated) schedule endpoints:
|
||||||
|
|
||||||
**Endpoints:**
|
**Endpoints:**
|
||||||
|
|
||||||
- `GET /schedules?date=YYYY-MM-DD&lineId=ID` — Get schedules for a date/line
|
- `GET /schedules?date=YYYY-MM-DD&lineId=ID` — Get schedules for a date/line
|
||||||
- `GET /schedules/:scheduleId` — Get schedule details
|
- `GET /schedules/:scheduleId` — Get schedule details
|
||||||
- `GET /schedules/:scheduleId/stops` — Get stops on a schedule's line
|
- `GET /schedules/:scheduleId/stops` — Get stops on a schedule's line
|
||||||
|
|
||||||
**Example:**
|
**Example:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl http://localhost:5001/schedules?date=2026-05-06
|
curl https://transit.xtransit.testinstance.nl/schedules?date=2026-05-06
|
||||||
```
|
```
|
||||||
|
|
||||||
**TimetableViewer Component:**
|
**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
|
### 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`
|
2. Receive JWT token from `auth_api`
|
||||||
3. Use token for admin operations (`/admin/*` endpoints)
|
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
|
```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):
|
Use `DEV_MOCK=1` to work without a database (in-memory mock storage):
|
||||||
|
|
||||||
```bash
|
```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
|
### Production Mode
|
||||||
|
|
||||||
Set up real PostgreSQL database and provide credentials via environment variables.
|
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).
|
|
||||||
@@ -7,6 +7,7 @@ RUN npm run build
|
|||||||
|
|
||||||
FROM nginx:stable-alpine
|
FROM nginx:stable-alpine
|
||||||
COPY --from=build /app/dist /usr/share/nginx/html
|
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
|
COPY docker-entrypoint.sh /docker-entrypoint.sh
|
||||||
RUN chmod +x /docker-entrypoint.sh
|
RUN chmod +x /docker-entrypoint.sh
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
version: '3.8'
|
version: "3.8"
|
||||||
services:
|
services:
|
||||||
admin-ui:
|
admin-ui:
|
||||||
build: .
|
build: .
|
||||||
ports:
|
ports:
|
||||||
- "5174:80"
|
- "5174:80"
|
||||||
environment:
|
environment:
|
||||||
VITE_AUTH_API: ${VITE_AUTH_API:-http://localhost:5000}
|
VITE_AUTH_API: ${VITE_AUTH_API:-https://auth.xtransit.testinstance.nl}
|
||||||
VITE_TRANSIT_API: ${VITE_TRANSIT_API:-http://localhost:5001}
|
VITE_TRANSIT_API: ${VITE_TRANSIT_API:-https://transit.xtransit.testinstance.nl}
|
||||||
|
|||||||
@@ -3,8 +3,9 @@ set -e
|
|||||||
|
|
||||||
cat > /usr/share/nginx/html/env-config.js <<EOF
|
cat > /usr/share/nginx/html/env-config.js <<EOF
|
||||||
window.__ENV__ = {
|
window.__ENV__ = {
|
||||||
AUTH_API: "${AUTH_API:-http://localhost:5000}",
|
AUTH_API: "${AUTH_API:-https://auth.xtransit.testinstance.nl}",
|
||||||
TRANSIT_API: "${TRANSIT_API:-http://localhost:5001}"
|
TRANSIT_API: "${TRANSIT_API:-https://transit.xtransit.testinstance.nl}",
|
||||||
|
ERROR_LOG_API: "${ERROR_LOG_API:-https://error.xtransit.testinstance.nl}"
|
||||||
}
|
}
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,16 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html>
|
<html lang="nl">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,14 +1,37 @@
|
|||||||
import React, { useState, useEffect } from 'react'
|
import React, { useEffect, useState } from 'react'
|
||||||
import Login from './components/Login'
|
import Login from './components/Login'
|
||||||
|
import Overview from './components/Overview'
|
||||||
import StopsManager from './components/StopsManager'
|
import StopsManager from './components/StopsManager'
|
||||||
import LinkStop from './components/LinkStop'
|
import LinkStop from './components/LinkStop'
|
||||||
import BusLinesManager from './components/BusLinesManager'
|
import BusLinesManager from './components/BusLinesManager'
|
||||||
import FleetManager from './components/FleetManager'
|
import FleetManager from './components/FleetManager'
|
||||||
import ScheduleMaker from './components/ScheduleMaker'
|
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() {
|
export default function App() {
|
||||||
const [token, setToken] = useState(localStorage.getItem('token'))
|
const [token, setToken] = useState(localStorage.getItem('token'))
|
||||||
const [user, setUser] = useState(JSON.parse(localStorage.getItem('user') || 'null'))
|
const [user, setUser] = useState(JSON.parse(localStorage.getItem('user') || 'null'))
|
||||||
|
const [section, setSection] = useState('overview')
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (token) localStorage.setItem('token', token)
|
if (token) localStorage.setItem('token', token)
|
||||||
@@ -20,33 +43,78 @@ export default function App() {
|
|||||||
else localStorage.removeItem('user')
|
else localStorage.removeItem('user')
|
||||||
}, [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 (
|
return (
|
||||||
<div className="app">
|
<div className="app-shell">
|
||||||
<header className="topbar">
|
<aside className="sidebar">
|
||||||
<div>
|
<div className="brand">
|
||||||
<p className="eyebrow">Transit operations</p>
|
<div className="brand-mark">🚌</div>
|
||||||
<h1>Admin control center</h1>
|
<div className="brand-text">
|
||||||
|
<div className="brand-name">Transit Ops</div>
|
||||||
|
<div className="brand-sub">Beheercentrum</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="userbox">
|
</div>
|
||||||
<strong>{user?.userCode ? `User ${user.userCode}` : 'User'}</strong>
|
|
||||||
<button onClick={() => { setToken(null); setUser(null); }}>Logout</button>
|
<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>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div className="hero">
|
|
||||||
<div>
|
<main className="content">
|
||||||
<h2>Fleet, lijnen en stops</h2>
|
{section === 'overview' && <Overview token={token} user={user} />}
|
||||||
<p>Beheer haltes, buslijnen, gekoppelde stops en voertuigen vanuit één overzicht.</p>
|
{section === 'stops' && <StopsManager token={token} user={user} />}
|
||||||
</div>
|
{section === 'lines' && <BusLinesManager token={token} user={user} />}
|
||||||
</div>
|
{section === 'routes' && <LinkStop token={token} user={user} />}
|
||||||
<main>
|
{section === 'fleet' && <FleetManager token={token} user={user} />}
|
||||||
<StopsManager token={token} user={user} />
|
{section === 'schedules' && <ScheduleMaker token={token} user={user} />}
|
||||||
<BusLinesManager token={token} user={user} />
|
{section === 'logs' && <ErrorLogViewer token={token} />}
|
||||||
<FleetManager token={token} user={user} />
|
{section === 'lab' && <RequestLab token={token} />}
|
||||||
<LinkStop token={token} user={user} />
|
|
||||||
<ScheduleMaker token={token} user={user} />
|
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -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 normalizeApiBaseUrl = (value, fallbackPath) => {
|
||||||
const rawValue = value || ''
|
const rawValue = value || "";
|
||||||
if (!rawValue) return fallbackPath
|
if (!rawValue) return fallbackPath;
|
||||||
if (/^https?:\/\//i.test(rawValue) || rawValue.startsWith('//') || rawValue.startsWith('/')) {
|
if (
|
||||||
return rawValue
|
/^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')
|
export const authClient = axios.create({ baseURL: AUTH_API });
|
||||||
const TRANSIT_API = normalizeApiBaseUrl(RUNTIME.TRANSIT_API || import.meta.env.VITE_TRANSIT_API, 'http://localhost:5001')
|
|
||||||
|
|
||||||
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) => {
|
export const transitClient = (token) => {
|
||||||
const client = axios.create({
|
const client = axios.create({
|
||||||
baseURL: TRANSIT_API,
|
baseURL: `${TRANSIT_API}/bus`,
|
||||||
headers: { Authorization: `Bearer ${token}` }
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
})
|
});
|
||||||
|
|
||||||
// Auto-logout on unauthorized responses to avoid delayed failures
|
// Auto-logout on unauthorized responses to avoid delayed failures
|
||||||
client.interceptors.response.use(
|
client.interceptors.response.use(
|
||||||
response => response,
|
(response) => response,
|
||||||
error => {
|
(error) => {
|
||||||
try {
|
try {
|
||||||
const status = error?.response?.status
|
const status = error?.response?.status;
|
||||||
if (status === 401 || status === 403) {
|
if (status === 401 || status === 403) {
|
||||||
localStorage.removeItem('token')
|
localStorage.removeItem("token");
|
||||||
localStorage.removeItem('user')
|
localStorage.removeItem("user");
|
||||||
// reload to let App show the Login screen
|
// 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) {
|
} catch (e) {
|
||||||
// ignore
|
// 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 };
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React, { useEffect, useState } from 'react'
|
import React, { useEffect, useState } from 'react'
|
||||||
import { transitClient } from '../api'
|
import { transitClient } from '../api'
|
||||||
|
import Modal from './Modal'
|
||||||
|
|
||||||
export default function BusLinesManager({ token, user }) {
|
export default function BusLinesManager({ token, user }) {
|
||||||
const client = transitClient(token)
|
const client = transitClient(token)
|
||||||
@@ -11,16 +12,19 @@ export default function BusLinesManager({ token, user }) {
|
|||||||
const [estimatedDurationMinutes, setEstimatedDurationMinutes] = useState('')
|
const [estimatedDurationMinutes, setEstimatedDurationMinutes] = useState('')
|
||||||
const [description, setDescription] = useState('')
|
const [description, setDescription] = useState('')
|
||||||
const [message, setMessage] = useState(null)
|
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 [deleteConfirm, setDeleteConfirm] = useState(null)
|
||||||
const [editingLine, setEditingLine] = useState(null)
|
const [editingLine, setEditingLine] = useState(null)
|
||||||
|
|
||||||
|
const notify = (text, type = '') => { setMessage(text); setMessageType(type) }
|
||||||
|
|
||||||
const loadBusLines = async () => {
|
const loadBusLines = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await client.get('/admin/bus-lines')
|
const res = await client.get('/admin/bus-lines')
|
||||||
setBusLines(res.data.busLines || [])
|
setBusLines(res.data.busLines || [])
|
||||||
} catch (err) {
|
} 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')
|
const res = await client.get('/admin/stops')
|
||||||
setStops(res.data.stops || [])
|
setStops(res.data.stops || [])
|
||||||
} catch (err) {
|
} 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()
|
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) => {
|
const createBusLine = async (e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setMessage(null)
|
notify(null)
|
||||||
try {
|
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', {
|
const res = await client.post('/admin/bus-lines', {
|
||||||
lineNumber: Number(lineNumber),
|
lineNumber: Number(lineNumber),
|
||||||
startStop: startStopName,
|
startStop: stopNameById(startStop) || startStop,
|
||||||
endStop: endStopName,
|
endStop: stopNameById(endStop) || endStop,
|
||||||
estimatedDurationMinutes: Number(estimatedDurationMinutes),
|
estimatedDurationMinutes: Number(estimatedDurationMinutes),
|
||||||
description,
|
description,
|
||||||
})
|
})
|
||||||
setLineNumber('')
|
setLineNumber(''); setStartStop(''); setEndStop(''); setEstimatedDurationMinutes(''); setDescription('')
|
||||||
setStartStop('')
|
notify('Buslijn aangemaakt: #' + (res.data.busLine?.id || 'ok'))
|
||||||
setEndStop('')
|
|
||||||
setEstimatedDurationMinutes('')
|
|
||||||
setDescription('')
|
|
||||||
setMessage('Bus line created: #' + (res.data.busLine?.id || 'ok'))
|
|
||||||
await loadBusLines()
|
await loadBusLines()
|
||||||
} catch (err) {
|
} 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) => {
|
const deleteBusLine = async (lineId, lineNumber) => {
|
||||||
setMessage(null)
|
notify(null)
|
||||||
try {
|
try {
|
||||||
await client.delete(`/admin/bus-lines/${lineId}`)
|
await client.delete(`/admin/bus-lines/${lineId}`)
|
||||||
setMessage(`Buslijn ${lineNumber} verwijderd`)
|
notify(`Buslijn ${lineNumber} verwijderd`)
|
||||||
setDeleteConfirm(null)
|
setDeleteConfirm(null)
|
||||||
await loadBusLines()
|
await loadBusLines()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const errMsg = err.response?.data?.error || err.message
|
notify('Error: ' + (err.response?.data?.error || err.message), 'error')
|
||||||
setMessage('Error: ' + errMsg)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,42 +100,31 @@ export default function BusLinesManager({ token, user }) {
|
|||||||
<div className="section-header">
|
<div className="section-header">
|
||||||
<div>
|
<div>
|
||||||
<h2>Buslijnen</h2>
|
<h2>Buslijnen</h2>
|
||||||
<p>Maak en bewerk buslijnen.</p>
|
<p>Definieer lijnen tussen start- en eindhalte.</p>
|
||||||
</div>
|
</div>
|
||||||
<button className="secondary" onClick={loadBusLines}>Refresh</button>
|
<button className="secondary" onClick={loadBusLines}>Ververs</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="list-grid">
|
<div className="list-grid">
|
||||||
{busLines.map(line => (
|
{busLines.map(line => (
|
||||||
<article className="mini-card" key={line.id}>
|
<article className="mini-card" key={line.id}>
|
||||||
<strong>#{line.id} Lijn {line.lineNumber}</strong>
|
<strong><span className="badge badge-primary">Lijn {line.lineNumber}</span></strong>
|
||||||
<div>{line.startStop} → {line.endStop}</div>
|
<div className="meta-line">#{line.id}</div>
|
||||||
<div>{line.estimatedDurationMinutes} min</div>
|
<div className="meta-line">{line.startStop} → {line.endStop}</div>
|
||||||
{line.description ? <div className="muted">{line.description}</div> : null}
|
<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 && (
|
{isAdmin && (
|
||||||
<>
|
<div className="card-actions" style={{ gridTemplateColumns: '1fr 1fr' }}>
|
||||||
<button
|
<button className="secondary" onClick={() => setEditingLine({
|
||||||
className="danger"
|
|
||||||
onClick={() => setDeleteConfirm(line)}
|
|
||||||
style={{ marginTop: '0.5rem', width: '100%' }}
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="secondary"
|
|
||||||
onClick={() => setEditingLine({
|
|
||||||
id: line.id,
|
id: line.id,
|
||||||
lineNumber: line.lineNumber,
|
lineNumber: line.lineNumber,
|
||||||
startStop: (stops.find(s => s.name === line.startStop) || {}).id || line.startStop,
|
startStop: stopIdByName(line.startStop) || line.startStop,
|
||||||
endStop: (stops.find(s => s.name === line.endStop) || {}).id || line.endStop,
|
endStop: stopIdByName(line.endStop) || line.endStop,
|
||||||
estimatedDurationMinutes: line.estimatedDurationMinutes,
|
estimatedDurationMinutes: line.estimatedDurationMinutes,
|
||||||
description: line.description || '',
|
description: line.description || '',
|
||||||
})}
|
})}>Bewerken</button>
|
||||||
style={{ marginTop: '0.5rem', width: '100%' }}
|
<button className="danger" onClick={() => setDeleteConfirm(line)}>Verwijderen</button>
|
||||||
>
|
</div>
|
||||||
Edit
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</article>
|
</article>
|
||||||
))}
|
))}
|
||||||
@@ -127,120 +133,66 @@ export default function BusLinesManager({ token, user }) {
|
|||||||
{isAdmin ? (
|
{isAdmin ? (
|
||||||
<form onSubmit={createBusLine} className="stack-form">
|
<form onSubmit={createBusLine} className="stack-form">
|
||||||
<div className="form-row">
|
<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)}>
|
<select value={startStop} onChange={e => setStartStop(e.target.value)}>
|
||||||
<option value="">Start stop</option>
|
<option value="">Start halte</option>
|
||||||
{stops.map(stop => (
|
{stops.map(stop => <option key={stop.id} value={stop.id}>#{stop.id} {stop.name}</option>)}
|
||||||
<option key={stop.id} value={stop.id}>#{stop.id} {stop.name}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
</select>
|
||||||
<select value={endStop} onChange={e => setEndStop(e.target.value)}>
|
<select value={endStop} onChange={e => setEndStop(e.target.value)}>
|
||||||
<option value="">End stop</option>
|
<option value="">Eind halte</option>
|
||||||
{stops.map(stop => (
|
{stops.map(stop => <option key={stop.id} value={stop.id}>#{stop.id} {stop.name}</option>)}
|
||||||
<option key={stop.id} value={stop.id}>#{stop.id} {stop.name}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
<input placeholder="Duration in minutes" value={estimatedDurationMinutes} onChange={e => setEstimatedDurationMinutes(e.target.value)} />
|
<input placeholder="Reistijd in minuten" value={estimatedDurationMinutes} onChange={e => setEstimatedDurationMinutes(e.target.value)} />
|
||||||
<input placeholder="Description" value={description} onChange={e => setDescription(e.target.value)} />
|
<input placeholder="Omschrijving (optioneel)" value={description} onChange={e => setDescription(e.target.value)} />
|
||||||
<button type="submit">Create bus line</button>
|
<button type="submit" className="no-grow">Buslijn aanmaken</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</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 && (
|
{editingLine && (
|
||||||
<div style={overlay}>
|
<Modal title="Buslijn bewerken" onClose={() => setEditingLine(null)}
|
||||||
<div style={modal}>
|
footer={
|
||||||
<h3>Edit Bus Line</h3>
|
<>
|
||||||
<form onSubmit={async (e) => {
|
<button className="secondary" onClick={() => setEditingLine(null)}>Annuleren</button>
|
||||||
e.preventDefault()
|
<button onClick={saveBusLine}>Opslaan</button>
|
||||||
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">
|
|
||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
<input value={editingLine.lineNumber} onChange={e => setEditingLine({ ...editingLine, lineNumber: e.target.value })} />
|
<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 })}>
|
<select value={editingLine.startStop} onChange={e => setEditingLine({ ...editingLine, startStop: e.target.value })}>
|
||||||
<option value="">Start stop</option>
|
<option value="">Start halte</option>
|
||||||
{stops.map(stop => (
|
{stops.map(stop => <option key={stop.id} value={stop.id}>#{stop.id} {stop.name}</option>)}
|
||||||
<option key={stop.id} value={stop.id}>#{stop.id} {stop.name}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
</select>
|
||||||
<select value={editingLine.endStop} onChange={e => setEditingLine({ ...editingLine, endStop: e.target.value })}>
|
<select value={editingLine.endStop} onChange={e => setEditingLine({ ...editingLine, endStop: e.target.value })}>
|
||||||
<option value="">End stop</option>
|
<option value="">Eind halte</option>
|
||||||
{stops.map(stop => (
|
{stops.map(stop => <option key={stop.id} value={stop.id}>#{stop.id} {stop.name}</option>)}
|
||||||
<option key={stop.id} value={stop.id}>#{stop.id} {stop.name}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
<input value={editingLine.estimatedDurationMinutes} onChange={e => setEditingLine({ ...editingLine, estimatedDurationMinutes: e.target.value })} />
|
<input value={editingLine.estimatedDurationMinutes} onChange={e => setEditingLine({ ...editingLine, estimatedDurationMinutes: e.target.value })} />
|
||||||
<input value={editingLine.description} onChange={e => setEditingLine({ ...editingLine, description: e.target.value })} />
|
<input value={editingLine.description} onChange={e => setEditingLine({ ...editingLine, description: e.target.value })} />
|
||||||
</div>
|
</div>
|
||||||
<div className="form-row" style={{ marginTop: '1rem' }}>
|
</Modal>
|
||||||
<button className="secondary" type="button" onClick={() => setEditingLine(null)}>Cancel</button>
|
|
||||||
<button type="submit">Save</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{deleteConfirm && (
|
{deleteConfirm && (
|
||||||
<div style={overlay}>
|
<Modal title="Buslijn verwijderen?" onClose={() => setDeleteConfirm(null)}
|
||||||
<div style={modal}>
|
footer={
|
||||||
<h3>Delete Bus Line?</h3>
|
<>
|
||||||
<p><strong>Lijn {deleteConfirm.lineNumber}</strong> ({deleteConfirm.startStop} → {deleteConfirm.endStop})</p>
|
<button className="secondary" onClick={() => setDeleteConfirm(null)}>Annuleren</button>
|
||||||
<p style={{ color: '#999', fontSize: '0.9rem' }}>This will also delete all stops on this line. Cannot be undone.</p>
|
<button className="danger" onClick={() => deleteBusLine(deleteConfirm.id, deleteConfirm.lineNumber)}>Verwijderen</button>
|
||||||
<div className="form-row" style={{ marginTop: '1rem' }}>
|
</>
|
||||||
<button className="secondary" onClick={() => setDeleteConfirm(null)}>Cancel</button>
|
}>
|
||||||
<button className="danger" onClick={() => deleteBusLine(deleteConfirm.id, deleteConfirm.lineNumber)}>
|
<p>Weet je zeker dat je <strong>Lijn {deleteConfirm.lineNumber}</strong> ({deleteConfirm.startStop} → {deleteConfirm.endStop}) wilt verwijderen?</p>
|
||||||
Delete
|
<p className="faint">Hiermee worden ook alle gekoppelde stops op deze lijn verwijderd. Dit kan niet worden ongedaan gemaakt.</p>
|
||||||
</button>
|
</Modal>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</section>
|
</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,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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import React, { useEffect, useState } from 'react'
|
import React, { useEffect, useState } from 'react'
|
||||||
import { transitClient } from '../api'
|
import { transitClient } from '../api'
|
||||||
|
import Modal from './Modal'
|
||||||
|
import { IconBus } from './icons'
|
||||||
|
|
||||||
export default function FleetManager({ token, user }) {
|
export default function FleetManager({ token, user }) {
|
||||||
const client = transitClient(token)
|
const client = transitClient(token)
|
||||||
@@ -8,67 +10,74 @@ export default function FleetManager({ token, user }) {
|
|||||||
const [seatCapacity, setSeatCapacity] = useState('')
|
const [seatCapacity, setSeatCapacity] = useState('')
|
||||||
const [licensePlate, setLicensePlate] = useState('')
|
const [licensePlate, setLicensePlate] = useState('')
|
||||||
const [message, setMessage] = useState(null)
|
const [message, setMessage] = useState(null)
|
||||||
|
const [messageType, setMessageType] = useState('')
|
||||||
const [canCreateBus, setCanCreateBus] = useState(true)
|
const [canCreateBus, setCanCreateBus] = useState(true)
|
||||||
const [isAdmin] = useState(Boolean(user?.userCode))
|
const [isAdmin] = useState(Boolean(user?.isAdmin))
|
||||||
const [editingBus, setEditingBus] = useState(null)
|
const [editingBus, setEditingBus] = useState(null)
|
||||||
const [deleteConfirm, setDeleteConfirm] = useState(null)
|
const [deleteConfirm, setDeleteConfirm] = useState(null)
|
||||||
|
|
||||||
|
const notify = (text, type = '') => { setMessage(text); setMessageType(type) }
|
||||||
|
|
||||||
const loadBuses = async () => {
|
const loadBuses = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await client.get('/admin/fleet/buses')
|
const res = await client.get('/admin/fleet/buses')
|
||||||
setBuses(res.data.buses || [])
|
setBuses(res.data.buses || [])
|
||||||
} catch (err) {
|
} 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(() => {
|
useEffect(() => {
|
||||||
loadBuses()
|
loadBuses()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const createBus = async (e) => {
|
const createBus = async (e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setMessage(null)
|
notify(null)
|
||||||
try {
|
try {
|
||||||
const res = await client.post('/admin/fleet/buses', {
|
const res = await client.post('/admin/fleet/buses', {
|
||||||
name,
|
name, seatCapacity: Number(seatCapacity), licensePlate,
|
||||||
seatCapacity: Number(seatCapacity),
|
|
||||||
licensePlate,
|
|
||||||
})
|
})
|
||||||
setName('')
|
setName(''); setSeatCapacity(''); setLicensePlate('')
|
||||||
setSeatCapacity('')
|
notify('Bus aangemaakt: #' + (res.data.bus?.id || 'ok'))
|
||||||
setLicensePlate('')
|
|
||||||
setMessage('Bus created: #' + (res.data.bus?.id || 'ok'))
|
|
||||||
await loadBuses()
|
await loadBuses()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const details = err.response?.data?.details || err.response?.data?.error || err.message
|
const details = err.response?.data?.details || err.response?.data?.error || err.message
|
||||||
if (String(details).includes('permission denied') || err.response?.status === 403) {
|
if (String(details).includes('permission denied') || err.response?.status === 403) {
|
||||||
setCanCreateBus(false)
|
setCanCreateBus(false)
|
||||||
setMessage('Fleet create is read-only on this database user.')
|
notify('Vloot is read-only voor deze database-gebruiker.', 'error')
|
||||||
return
|
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">
|
<section className="card">
|
||||||
<div className="section-header">
|
<div className="section-header">
|
||||||
<div>
|
<div>
|
||||||
<h2>Fleet</h2>
|
<h2>Vloot</h2>
|
||||||
<p>Beheer voertuigen voor de vloot.</p>
|
<p>Beheer voertuigen die deel uitmaken van de vloot.</p>
|
||||||
</div>
|
</div>
|
||||||
<button className="secondary" onClick={loadBuses}>Refresh</button>
|
<button className="secondary" onClick={loadBuses}>Ververs</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="list-grid">
|
<div className="list-grid">
|
||||||
{buses.map(bus => (
|
{buses.map(bus => (
|
||||||
<article className="mini-card" key={bus.id}>
|
<article className="mini-card" key={bus.id}>
|
||||||
<strong>#{bus.id} {bus.name}</strong>
|
<strong><IconBus size={16} style={{ verticalAlign: '-2px', color: 'var(--primary)' }} /> {bus.name}</strong>
|
||||||
<div>Seats: {bus.seatCapacity}</div>
|
<div className="meta-line">#{bus.id}</div>
|
||||||
<div>Plate: {bus.licensePlate}</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 && (
|
{isAdmin && (
|
||||||
<>
|
<div className="card-actions">
|
||||||
<button className="secondary" style={{ marginTop: '0.5rem', width: '100%' }} onClick={() => setEditingBus(bus)}>Edit</button>
|
<button className="secondary" onClick={() => setEditingBus(bus)}>Bewerken</button>
|
||||||
<button className="danger" style={{ marginTop: '0.5rem', width: '100%' }} onClick={() => setDeleteConfirm(bus)}>Delete</button>
|
<button className="danger" onClick={() => setDeleteConfirm(bus)}>Verwijderen</button>
|
||||||
</>
|
</div>
|
||||||
)}
|
)}
|
||||||
</article>
|
</article>
|
||||||
))}
|
))}
|
||||||
@@ -101,72 +111,46 @@ export default function FleetManager({ token, user }) {
|
|||||||
{isAdmin && canCreateBus ? (
|
{isAdmin && canCreateBus ? (
|
||||||
<form onSubmit={createBus} className="stack-form">
|
<form onSubmit={createBus} className="stack-form">
|
||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
<input placeholder="Bus name" value={name} onChange={e => setName(e.target.value)} />
|
<input placeholder="Busnaam" value={name} onChange={e => setName(e.target.value)} />
|
||||||
<input placeholder="Seat capacity" value={seatCapacity} onChange={e => setSeatCapacity(e.target.value)} />
|
<input placeholder="Zitplaatsen" value={seatCapacity} onChange={e => setSeatCapacity(e.target.value)} />
|
||||||
<input placeholder="License plate" value={licensePlate} onChange={e => setLicensePlate(e.target.value)} />
|
<input placeholder="Kenteken" value={licensePlate} onChange={e => setLicensePlate(e.target.value)} />
|
||||||
<button type="submit">Create bus</button>
|
<button type="submit" className="no-grow">Bus toevoegen</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</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 && (
|
{editingBus && (
|
||||||
<div style={overlay}>
|
<Modal title="Bus bewerken" onClose={() => setEditingBus(null)}
|
||||||
<div style={modal}>
|
footer={
|
||||||
<h3>Edit Bus</h3>
|
<>
|
||||||
<form onSubmit={async (e) => {
|
<button className="secondary" onClick={() => setEditingBus(null)}>Annuleren</button>
|
||||||
e.preventDefault()
|
<button onClick={saveBus}>Opslaan</button>
|
||||||
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">
|
|
||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
<input value={editingBus.name} onChange={e => setEditingBus({ ...editingBus, name: e.target.value })} />
|
<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.seatCapacity} onChange={e => setEditingBus({ ...editingBus, seatCapacity: e.target.value })} />
|
||||||
<input value={editingBus.licensePlate} onChange={e => setEditingBus({ ...editingBus, licensePlate: e.target.value })} />
|
<input value={editingBus.licensePlate} onChange={e => setEditingBus({ ...editingBus, licensePlate: e.target.value })} />
|
||||||
</div>
|
</div>
|
||||||
<div className="form-row" style={{ marginTop: '1rem' }}>
|
</Modal>
|
||||||
<button className="secondary" type="button" onClick={() => setEditingBus(null)}>Cancel</button>
|
|
||||||
<button type="submit">Save</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{deleteConfirm && (
|
{deleteConfirm && (
|
||||||
<div style={overlay}>
|
<Modal title="Bus verwijderen?" onClose={() => setDeleteConfirm(null)}
|
||||||
<div style={modal}>
|
footer={
|
||||||
<h3>Delete Bus?</h3>
|
<>
|
||||||
<p><strong>{deleteConfirm.name}</strong></p>
|
<button className="secondary" onClick={() => setDeleteConfirm(null)}>Annuleren</button>
|
||||||
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '1rem' }}>
|
<button className="danger" onClick={deleteBus}>Verwijderen</button>
|
||||||
<button className="secondary" onClick={() => setDeleteConfirm(null)}>Cancel</button>
|
</>
|
||||||
<button className="danger" onClick={async () => {
|
}>
|
||||||
try {
|
<p>Weet je zeker dat je <strong>{deleteConfirm.name}</strong> ({deleteConfirm.licensePlate}) wilt verwijderen?</p>
|
||||||
await client.delete(`/admin/fleet/buses/${deleteConfirm.id}`)
|
</Modal>
|
||||||
setMessage('Bus deleted')
|
|
||||||
setDeleteConfirm(null)
|
|
||||||
await loadBuses()
|
|
||||||
} catch (err) {
|
|
||||||
setMessage('Error: ' + (err.response?.data?.error || err.message))
|
|
||||||
}
|
|
||||||
}}>Delete</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React, { useEffect, useMemo, useState } from 'react'
|
import React, { useEffect, useMemo, useState } from 'react'
|
||||||
import { transitClient } from '../api'
|
import { transitClient } from '../api'
|
||||||
|
import Modal from './Modal'
|
||||||
|
|
||||||
export default function LinkStop({ token, user }) {
|
export default function LinkStop({ token, user }) {
|
||||||
const client = transitClient(token)
|
const client = transitClient(token)
|
||||||
@@ -9,31 +10,33 @@ export default function LinkStop({ token, user }) {
|
|||||||
const [stopId, setStopId] = useState('')
|
const [stopId, setStopId] = useState('')
|
||||||
const [eta, setEta] = useState('')
|
const [eta, setEta] = useState('')
|
||||||
const [message, setMessage] = useState(null)
|
const [message, setMessage] = useState(null)
|
||||||
|
const [messageType, setMessageType] = useState('')
|
||||||
const [stops, setStops] = useState([])
|
const [stops, setStops] = useState([])
|
||||||
const [draggedItem, setDraggedItem] = useState(null)
|
const [draggedItem, setDraggedItem] = useState(null)
|
||||||
const [deleteConfirm, setDeleteConfirm] = 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 nextOrder = useMemo(() => stops.length + 1, [stops])
|
||||||
|
|
||||||
|
const notify = (text, type = '') => { setMessage(text); setMessageType(type) }
|
||||||
|
|
||||||
const loadBusLines = async () => {
|
const loadBusLines = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await client.get('/admin/bus-lines')
|
const res = await client.get('/admin/bus-lines')
|
||||||
setBusLines(res.data.busLines || [])
|
setBusLines(res.data.busLines || [])
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setMessage('Error: ' + (err.response?.data?.error || err.message))
|
notify('Error: ' + (err.response?.data?.error || err.message), 'error')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadStopsForLine = async (lineId = busLineId) => {
|
const loadStopsForLine = async (lineId = busLineId) => {
|
||||||
if (!lineId) return
|
if (!lineId) return
|
||||||
setMessage(null)
|
notify(null)
|
||||||
try {
|
try {
|
||||||
const res = await client.get(`/bus-lines/${lineId}/stops`)
|
const res = await client.get(`/bus-lines/${lineId}/stops`)
|
||||||
setStops(res.data.stops || [])
|
setStops(res.data.stops || [])
|
||||||
} catch (err) {
|
} 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')
|
const res = await client.get('/admin/stops')
|
||||||
setAvailableStops(res.data.stops || [])
|
setAvailableStops(res.data.stops || [])
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setMessage('Error: ' + (err.response?.data?.error || err.message))
|
notify('Error: ' + (err.response?.data?.error || err.message), 'error')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const refreshBoard = async () => {
|
const refreshBoard = async () => {
|
||||||
await Promise.all([
|
await Promise.all([loadBusLines(), loadAvailableStops(), loadStopsForLine()])
|
||||||
loadBusLines(),
|
|
||||||
loadAvailableStops(),
|
|
||||||
loadStopsForLine(),
|
|
||||||
])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => { loadBusLines(); loadAvailableStops() }, [])
|
||||||
loadBusLines()
|
useEffect(() => { loadStopsForLine() }, [busLineId])
|
||||||
loadAvailableStops()
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadStopsForLine()
|
|
||||||
}, [busLineId])
|
|
||||||
|
|
||||||
const link = async (e) => {
|
const link = async (e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setMessage(null)
|
notify(null)
|
||||||
try {
|
try {
|
||||||
const res = await client.post(`/admin/bus-lines/${busLineId}/stops`, {
|
const res = await client.post(`/admin/bus-lines/${busLineId}/stops`, {
|
||||||
stopId: parseInt(stopId, 10),
|
stopId: parseInt(stopId, 10),
|
||||||
stopOrder: nextOrder,
|
stopOrder: nextOrder,
|
||||||
estimatedArrivalMinutes: eta ? parseInt(eta, 10) : null,
|
estimatedArrivalMinutes: eta ? parseInt(eta, 10) : null,
|
||||||
})
|
})
|
||||||
setMessage('Linked: ' + res.data.routeStop.id)
|
notify('Gekoppeld: ' + res.data.routeStop.id)
|
||||||
setStopId('')
|
setStopId('')
|
||||||
await loadStopsForLine()
|
await loadStopsForLine()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err.response?.status === 403) setMessage('Forbidden: not admin')
|
if (err.response?.status === 403) notify('Forbidden: not admin', 'error')
|
||||||
else setMessage('Error: ' + (err.response?.data?.error || err.message))
|
else notify('Error: ' + (err.response?.data?.error || err.message), 'error')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const appendDraggedStop = async (stopToLink) => {
|
const appendDraggedStop = async (stopToLink) => {
|
||||||
if (!busLineId) {
|
if (!busLineId) { notify('Kies eerst een buslijn', 'error'); return }
|
||||||
setMessage('Select a bus line first')
|
notify(null)
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setMessage(null)
|
|
||||||
try {
|
try {
|
||||||
const res = await client.post(`/admin/bus-lines/${busLineId}/stops`, {
|
const res = await client.post(`/admin/bus-lines/${busLineId}/stops`, {
|
||||||
stopId: Number(stopToLink.id),
|
stopId: Number(stopToLink.id),
|
||||||
stopOrder: nextOrder,
|
stopOrder: nextOrder,
|
||||||
estimatedArrivalMinutes: eta ? parseInt(eta, 10) : null,
|
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()
|
await loadStopsForLine()
|
||||||
} catch (err) {
|
} 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)
|
nextStops.splice(toIndex, 0, moved)
|
||||||
const normalized = nextStops.map((stop, index) => ({ ...stop, order: index + 1 }))
|
const normalized = nextStops.map((stop, index) => ({ ...stop, order: index + 1 }))
|
||||||
setStops(normalized)
|
setStops(normalized)
|
||||||
setMessage('Saving new route order...')
|
notify('Nieuwe volgorde opslaan…')
|
||||||
try {
|
try {
|
||||||
await persistOrder(normalized)
|
await persistOrder(normalized)
|
||||||
setMessage('Route order updated')
|
notify('Routenvolgorde bijgewerkt')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setMessage('Error: ' + (err.response?.data?.error || err.message))
|
notify('Error: ' + (err.response?.data?.error || err.message), 'error')
|
||||||
await loadStopsForLine()
|
await loadStopsForLine()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -139,23 +128,22 @@ export default function LinkStop({ token, user }) {
|
|||||||
setStops(normalized)
|
setStops(normalized)
|
||||||
try {
|
try {
|
||||||
await persistOrder(normalized)
|
await persistOrder(normalized)
|
||||||
setMessage('Route order updated')
|
notify('Routenvolgorde bijgewerkt')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setMessage('Error: ' + (err.response?.data?.error || err.message))
|
notify('Error: ' + (err.response?.data?.error || err.message), 'error')
|
||||||
await loadStopsForLine()
|
await loadStopsForLine()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const deleteRouteStop = async (routeStopId, stopName) => {
|
const deleteRouteStop = async (routeStopId, stopName) => {
|
||||||
setMessage(null)
|
notify(null)
|
||||||
try {
|
try {
|
||||||
await client.delete(`/admin/bus-lines/${busLineId}/stops/${routeStopId}`)
|
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)
|
setDeleteConfirm(null)
|
||||||
await loadStopsForLine()
|
await loadStopsForLine()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const errMsg = err.response?.data?.error || err.message
|
notify('Error: ' + (err.response?.data?.error || err.message), 'error')
|
||||||
setMessage('Error: ' + errMsg)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,28 +152,27 @@ export default function LinkStop({ token, user }) {
|
|||||||
<div className="section-header">
|
<div className="section-header">
|
||||||
<div>
|
<div>
|
||||||
<h2>Stops per lijn</h2>
|
<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>
|
</div>
|
||||||
<button className="secondary" onClick={refreshBoard}>Refresh</button>
|
<button className="secondary" onClick={refreshBoard}>Ververs</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
<select
|
<select value={busLineId} onChange={e => setBusLineId(e.target.value)}>
|
||||||
value={busLineId}
|
<option value="">Kies lijn</option>
|
||||||
onChange={e => setBusLineId(e.target.value)}
|
|
||||||
>
|
|
||||||
<option value="">Choose line</option>
|
|
||||||
{busLines.map(line => (
|
{busLines.map(line => (
|
||||||
<option key={line.id} value={line.id}>
|
<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>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</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>
|
||||||
|
|
||||||
<div className="muted" style={{ marginBottom: 10 }}>
|
<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>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -199,6 +186,11 @@ export default function LinkStop({ token, user }) {
|
|||||||
setDraggedItem(null)
|
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) => (
|
{stops.map((s, index) => (
|
||||||
<article
|
<article
|
||||||
className={`route-stop-item ${draggedItem?.type === 'route' && Number(draggedItem.id) === Number(s.id) ? 'dragging' : ''}`}
|
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)}
|
onDragEnd={() => setDraggedItem(null)}
|
||||||
onDragOver={(event) => event.preventDefault()}
|
onDragOver={(event) => event.preventDefault()}
|
||||||
onDrop={() => {
|
onDrop={() => {
|
||||||
if (draggedItem?.type === 'route') {
|
if (draggedItem?.type === 'route') reorderStops(draggedItem.id, s.id)
|
||||||
reorderStops(draggedItem.id, s.id)
|
|
||||||
}
|
|
||||||
if (draggedItem?.type === 'available') {
|
if (draggedItem?.type === 'available') {
|
||||||
const stop = availableStops.find(item => Number(item.id) === Number(draggedItem.id))
|
const stop = availableStops.find(item => Number(item.id) === Number(draggedItem.id))
|
||||||
if (stop) appendDraggedStop(stop)
|
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-stop-handle" aria-hidden="true">⋮⋮</div>
|
||||||
|
<div className="route-order-badge">{s.order}</div>
|
||||||
<div className="route-stop-content">
|
<div className="route-stop-content">
|
||||||
<strong>{s.order}. {s.name}</strong>
|
<strong>{s.name}</strong>
|
||||||
<div className="muted">{s.latitude}, {s.longitude}</div>
|
<div className="muted">{s.latitude}, {s.longitude}</div>
|
||||||
{s.estimatedArrivalMinutes !== null && s.estimatedArrivalMinutes !== undefined ? (
|
{s.estimatedArrivalMinutes !== null && s.estimatedArrivalMinutes !== undefined ? (
|
||||||
<div className="muted">ETA {s.estimatedArrivalMinutes} min</div>
|
<div className="muted">ETA {s.estimatedArrivalMinutes} min</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="route-stop-actions">
|
<div className="route-stop-actions">
|
||||||
<button type="button" className="secondary" onClick={() => moveStop(index, -1)} disabled={index === 0}>↑</button>
|
<button type="button" className="secondary sm" 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 === stops.length - 1}>↓</button>
|
||||||
{isAdmin && (
|
{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>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3>Beschikbare haltes</h3>
|
<h3 style={{ marginBottom: 0 }}>Beschikbare haltes</h3>
|
||||||
<div className="available-stops-grid">
|
<div className="available-stops-grid">
|
||||||
{availableStops.map(stop => (
|
{availableStops.map(stop => (
|
||||||
<article
|
<article
|
||||||
@@ -246,82 +237,52 @@ export default function LinkStop({ token, user }) {
|
|||||||
draggable
|
draggable
|
||||||
onDragStart={() => setDraggedItem({ type: 'available', id: stop.id })}
|
onDragStart={() => setDraggedItem({ type: 'available', id: stop.id })}
|
||||||
onDragEnd={() => setDraggedItem(null)}
|
onDragEnd={() => setDraggedItem(null)}
|
||||||
onClick={() => {
|
onClick={() => { if (busLineId) { setStopId(String(stop.id)); appendDraggedStop(stop) } }}
|
||||||
if (busLineId) {
|
|
||||||
setStopId(String(stop.id))
|
|
||||||
appendDraggedStop(stop)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
>
|
>
|
||||||
<strong>#{stop.id} {stop.name}</strong>
|
<strong>#{stop.id} {stop.name}</strong>
|
||||||
<div className="muted">{stop.latitude}, {stop.longitude}</div>
|
<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>
|
</article>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3>Link a Stop to Line</h3>
|
<div className="stack-form">
|
||||||
|
<h3 style={{ marginTop: 0 }}>Halte koppelen aan lijn</h3>
|
||||||
{isAdmin ? (
|
{isAdmin ? (
|
||||||
<form onSubmit={link} className="stack-form">
|
<form onSubmit={link} className="stack-form" style={{ borderTop: 'none', paddingTop: 0 }}>
|
||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
<select value={stopId} onChange={e => setStopId(e.target.value)}>
|
<select value={stopId} onChange={e => setStopId(e.target.value)}>
|
||||||
<option value="">Select stop</option>
|
<option value="">Selecteer halte</option>
|
||||||
{availableStops.map(stop => (
|
{availableStops.map(stop => <option key={stop.id} value={stop.id}>#{stop.id} {stop.name}</option>)}
|
||||||
<option key={stop.id} value={stop.id}>
|
|
||||||
#{stop.id} {stop.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
</select>
|
||||||
<input value={`Next order: ${nextOrder}`} readOnly />
|
<input value={`Volgende: ${nextOrder}`} readOnly className="no-grow" />
|
||||||
<input placeholder="ETA minutes (optional)" value={eta} onChange={e=>setEta(e.target.value)} />
|
<input placeholder="ETA minuten (optioneel)" value={eta} onChange={e => setEta(e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
<button type="submit">Link Stop</button>
|
<button type="submit" className="no-grow">Halte koppelen</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</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 && (
|
{deleteConfirm && (
|
||||||
<div style={overlay}>
|
<Modal title="Halte verwijderen uit lijn?" onClose={() => setDeleteConfirm(null)}
|
||||||
<div style={modal}>
|
footer={
|
||||||
<h3>Remove Stop from Line?</h3>
|
<>
|
||||||
<p><strong>{deleteConfirm.name}</strong></p>
|
<button className="secondary" onClick={() => setDeleteConfirm(null)}>Annuleren</button>
|
||||||
<p style={{ color: '#999', fontSize: '0.9rem' }}>This will remove the stop from the route and re-order the remaining stops.</p>
|
<button className="danger" onClick={() => deleteRouteStop(deleteConfirm.id, deleteConfirm.name)}>Verwijderen</button>
|
||||||
<div className="form-row" style={{ marginTop: '1rem' }}>
|
</>
|
||||||
<button className="secondary" onClick={() => setDeleteConfirm(null)}>Cancel</button>
|
}>
|
||||||
<button className="danger" onClick={() => deleteRouteStop(deleteConfirm.id, deleteConfirm.name)}>
|
<p>Weet je zeker dat je <strong>{deleteConfirm.name}</strong> uit deze lijn wilt verwijderen?</p>
|
||||||
Remove
|
<p className="faint">De halte wordt uit de route gehaald en de resterende stops worden opnieuw geordend.</p>
|
||||||
</button>
|
</Modal>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</section>
|
</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)',
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React, { useState } from 'react'
|
import React, { useState } from 'react'
|
||||||
import { authClient } from '../api'
|
import { authClient } from '../api'
|
||||||
|
import { IconBusFront } from './icons'
|
||||||
|
|
||||||
export default function Login({ onLogin }) {
|
export default function Login({ onLogin }) {
|
||||||
const [userCode, setUserCode] = useState('')
|
const [userCode, setUserCode] = useState('')
|
||||||
@@ -14,29 +15,51 @@ export default function Login({ onLogin }) {
|
|||||||
try {
|
try {
|
||||||
const res = await authClient.post('/login', { userCode, password })
|
const res = await authClient.post('/login', { userCode, password })
|
||||||
const token = res.data.token
|
const token = res.data.token
|
||||||
// fetch /me to get payload
|
|
||||||
const me = await authClient.get('/me', { headers: { Authorization: `Bearer ${token}` } })
|
const me = await authClient.get('/me', { headers: { Authorization: `Bearer ${token}` } })
|
||||||
onLogin(token, me.data.user)
|
onLogin(token, me.data.user)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.response?.data?.error || 'login_failed')
|
setError(err.response?.data?.error || 'login_failed')
|
||||||
} finally { setLoading(false) }
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="login">
|
<div className="auth-page">
|
||||||
<h2>Admin Login</h2>
|
<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}>
|
<form onSubmit={submit}>
|
||||||
<label>
|
<label>
|
||||||
User Code
|
Gebruikercode
|
||||||
<input value={userCode} onChange={e => setUserCode(e.target.value)} />
|
<input
|
||||||
|
value={userCode}
|
||||||
|
onChange={(e) => setUserCode(e.target.value)}
|
||||||
|
placeholder="Bijv. 1234"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
Password
|
Wachtwoord
|
||||||
<input type="password" value={password} onChange={e => setPassword(e.target.value)} />
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder="••••••••"
|
||||||
|
/>
|
||||||
</label>
|
</label>
|
||||||
<button type="submit" disabled={loading}>{loading ? 'Please wait...' : 'Login'}</button>
|
|
||||||
{error && <div className="error">{error}</div>}
|
{error && <div className="error">{error}</div>}
|
||||||
|
<button type="submit" className="auth-submit" disabled={loading}>
|
||||||
|
{loading ? 'Bezig met inloggen…' : 'Inloggen'}
|
||||||
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
<div className="auth-footer">Alleen beheerders hebben toegang</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,606 +1,110 @@
|
|||||||
import React, { useEffect, useState } from 'react'
|
import React, { useEffect, useState } from "react";
|
||||||
import { transitClient } from '../api'
|
import { transitClient } from "../api";
|
||||||
|
import WeeklyScheduleEditor from "./WeeklyScheduleEditor";
|
||||||
const WEEKDAYS = ['Ma', 'Di', 'Wo', 'Do', 'Vr', 'Za', 'Zo']
|
import FixedScheduleEditor from "./FixedScheduleEditor";
|
||||||
const WEEKDAY_VALUES = [1, 2, 3, 4, 5, 6, 0] // 0=Sun, 1=Mon...
|
|
||||||
|
|
||||||
export default function ScheduleMaker({ token, user }) {
|
export default function ScheduleMaker({ token, user }) {
|
||||||
const client = transitClient(token)
|
const client = transitClient(token);
|
||||||
const [schedules, setSchedules] = useState([])
|
const [tab, setTab] = useState("weekly");
|
||||||
const [overviewSchedules, setOverviewSchedules] = useState([])
|
const [weeklySchedules, setWeeklySchedules] = useState([]);
|
||||||
const [busLines, setBusLines] = useState([])
|
const [templates, setTemplates] = useState([]);
|
||||||
const [buses, setBuses] = useState([])
|
const [busLines, setBusLines] = useState([]);
|
||||||
const [drivers, setDrivers] = useState([])
|
const [buses, setBuses] = useState([]);
|
||||||
const [selectedDriverFilter, setSelectedDriverFilter] = useState('')
|
const [drivers, setDrivers] = useState([]);
|
||||||
const [message, setMessage] = useState(null)
|
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({})
|
|
||||||
|
|
||||||
// Form state
|
const loadWeekly = async () => {
|
||||||
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 () => {
|
|
||||||
try {
|
try {
|
||||||
const res = await client.get('/admin/schedules')
|
const res = await client.get("/admin/weekly-schedules");
|
||||||
setSchedules(res.data.schedules || [])
|
setWeeklySchedules(res.data.schedules || []);
|
||||||
} catch (err) {
|
} 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 {
|
try {
|
||||||
const params = new URLSearchParams({ date: overviewDate })
|
const res = await client.get("/admin/schedules");
|
||||||
if (selectedDriverFilter) {
|
setTemplates(res.data.schedules || []);
|
||||||
params.set('driverId', selectedDriverFilter)
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await client.get(`/schedules/overview?${params.toString()}`)
|
|
||||||
setOverviewSchedules(res.data.schedules || [])
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setMessage('Error loading timetable: ' + (err.response?.data?.error || err.message))
|
setTemplates([]);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const loadBusLines = async () => {
|
const loadBusLines = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await client.get('/admin/bus-lines')
|
const res = await client.get("/admin/bus-lines");
|
||||||
setBusLines(res.data.busLines || [])
|
setBusLines(res.data.busLines || []);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setMessage('Error loading bus lines: ' + (err.response?.data?.error || err.message))
|
setMessage("Kon buslijnen niet laden.");
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const loadBuses = async () => {
|
const loadBuses = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await client.get('/admin/fleet/buses')
|
const res = await client.get("/admin/fleet/buses");
|
||||||
setBuses(res.data.buses || [])
|
setBuses(res.data.buses || []);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setMessage('Error loading buses: ' + (err.response?.data?.error || err.message))
|
setBuses([]);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const loadDrivers = async () => {
|
const loadDrivers = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await client.get('/admin/drivers')
|
const res = await client.get("/admin/shift-drivers");
|
||||||
setDrivers(res.data.drivers || [])
|
setDrivers(res.data.drivers || []);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setMessage('Error loading drivers: ' + (err.response?.data?.error || err.message))
|
setMessage("Kon chauffeurs niet laden.");
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadSchedules()
|
loadWeekly();
|
||||||
loadBusLines()
|
loadTemplates();
|
||||||
loadBuses()
|
loadBusLines();
|
||||||
loadDrivers()
|
loadBuses();
|
||||||
}, [])
|
loadDrivers();
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
const handleScheduleChange = () => {
|
||||||
loadOverviewSchedules()
|
loadWeekly();
|
||||||
}, [overviewDate, selectedDriverFilter])
|
loadTemplates();
|
||||||
|
};
|
||||||
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)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="card">
|
<div className="stack">
|
||||||
<div className="section-header">
|
<div className="segmented">
|
||||||
<div>
|
<button type="button" className={tab === "weekly" ? "active" : ""} onClick={() => setTab("weekly")}>
|
||||||
<h2>Diensten Inplannen</h2>
|
Wekelijks schema
|
||||||
<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}
|
|
||||||
</button>
|
</button>
|
||||||
))}
|
<button type="button" className={tab === "fixed" ? "active" : ""} onClick={() => setTab("fixed")}>
|
||||||
</div>
|
Vaste diensten
|
||||||
</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>
|
</button>
|
||||||
</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>
|
|
||||||
)}
|
|
||||||
{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 */}
|
{tab === "weekly" ? (
|
||||||
<button
|
<WeeklyScheduleEditor
|
||||||
type="button"
|
client={client}
|
||||||
onClick={() => setExpandedSchedule(expandedSchedule === schedule.id ? null : schedule.id)}
|
busLines={busLines}
|
||||||
style={{
|
drivers={drivers}
|
||||||
marginTop: '0.5rem',
|
schedules={weeklySchedules}
|
||||||
width: '100%',
|
onChange={handleScheduleChange}
|
||||||
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' }}
|
|
||||||
/>
|
/>
|
||||||
<input
|
) : (
|
||||||
type="number"
|
<FixedScheduleEditor
|
||||||
value={Number(rs.estimatedArrivalMinutes ?? 0)}
|
client={client}
|
||||||
onChange={e => {
|
user={user}
|
||||||
const val = e.target.value === '' ? 0 : Number(e.target.value)
|
scheduleTemplates={templates}
|
||||||
setRouteStopsByLine(prev => ({
|
busLines={busLines}
|
||||||
...prev,
|
buses={buses}
|
||||||
[lineId]: prev[lineId].map(item => item.id === rs.id ? { ...item, estimatedArrivalMinutes: val } : item)
|
drivers={drivers}
|
||||||
}))
|
message={message}
|
||||||
}}
|
setMessage={setMessage}
|
||||||
style={{ width: '4.5rem' }}
|
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 && (
|
{tab === "weekly" && message && <div className="message">{message}</div>}
|
||||||
<button
|
|
||||||
className="danger"
|
|
||||||
style={{ marginTop: '0.5rem', width: '100%', fontSize: '0.85rem', padding: '0.4rem' }}
|
|
||||||
onClick={() => setDeleteConfirm(schedule)}
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</article>
|
|
||||||
))}
|
|
||||||
</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)',
|
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import React, { useEffect, useState } from 'react'
|
import React, { useEffect, useState } from 'react'
|
||||||
import { transitClient } from '../api'
|
import { transitClient } from '../api'
|
||||||
|
import Modal from './Modal'
|
||||||
|
|
||||||
export default function StopsManager({ token, user }) {
|
export default function StopsManager({ token, user }) {
|
||||||
const client = transitClient(token)
|
const client = transitClient(token)
|
||||||
@@ -8,17 +9,19 @@ export default function StopsManager({ token, user }) {
|
|||||||
const [lat, setLat] = useState('')
|
const [lat, setLat] = useState('')
|
||||||
const [lon, setLon] = useState('')
|
const [lon, setLon] = useState('')
|
||||||
const [message, setMessage] = useState(null)
|
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 [deleteConfirm, setDeleteConfirm] = useState(null)
|
||||||
const [editingStop, setEditingStop] = useState(null)
|
const [editingStop, setEditingStop] = useState(null)
|
||||||
|
|
||||||
|
const notify = (text, type = '') => { setMessage(text); setMessageType(type) }
|
||||||
|
|
||||||
const loadStops = async () => {
|
const loadStops = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await client.get('/admin/stops')
|
const res = await client.get('/admin/stops')
|
||||||
setStops(res.data.stops || [])
|
setStops(res.data.stops || [])
|
||||||
} catch (err) {
|
} 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) => {
|
const createStop = async (e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setMessage(null)
|
notify(null)
|
||||||
try {
|
try {
|
||||||
const res = await client.post('/admin/stops', {
|
const res = await client.post('/admin/stops', {
|
||||||
name,
|
name,
|
||||||
latitude: parseFloat(lat),
|
latitude: parseFloat(lat),
|
||||||
longitude: parseFloat(lon)
|
longitude: parseFloat(lon)
|
||||||
})
|
})
|
||||||
setMessage('Stop created: #' + res.data.stop.id)
|
notify('Halte aangemaakt: #' + res.data.stop.id)
|
||||||
setName(''); setLat(''); setLon('')
|
setName(''); setLat(''); setLon('')
|
||||||
await loadStops()
|
await loadStops()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const errMsg = err.response?.data?.error || err.message
|
const errMsg = err.response?.data?.error || err.message
|
||||||
if (err.response?.status === 401) {
|
if (err.response?.status === 401) notify('Unauthorized: invalid token', 'error')
|
||||||
setMessage('Unauthorized: invalid token')
|
else if (err.response?.status === 403) notify('Forbidden: you are not an admin', 'error')
|
||||||
} else if (err.response?.status === 403) {
|
else notify('Error: ' + errMsg, 'error')
|
||||||
setMessage('Forbidden: you are not an admin')
|
|
||||||
} else {
|
|
||||||
setMessage('Error: ' + errMsg)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const deleteStop = async (stopId, stopName) => {
|
const deleteStop = async (stopId, stopName) => {
|
||||||
setMessage(null)
|
notify(null)
|
||||||
try {
|
try {
|
||||||
await client.delete(`/admin/stops/${stopId}`)
|
await client.delete(`/admin/stops/${stopId}`)
|
||||||
setMessage(`Halte "${stopName}" verwijderd`)
|
notify(`Halte "${stopName}" verwijderd`)
|
||||||
setDeleteConfirm(null)
|
setDeleteConfirm(null)
|
||||||
await loadStops()
|
await loadStops()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const errMsg = err.response?.data?.error || err.message
|
notify('Error: ' + (err.response?.data?.error || err.message), 'error')
|
||||||
setMessage('Error: ' + errMsg)
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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">
|
<section className="card">
|
||||||
<div className="section-header">
|
<div className="section-header">
|
||||||
<div>
|
<div>
|
||||||
<h2>Stops</h2>
|
<h2>Haltes</h2>
|
||||||
<p>Maak herbruikbare haltes aan.</p>
|
<p>Maak herbruikbare haltes aan en bewerk hun coördinaten.</p>
|
||||||
</div>
|
</div>
|
||||||
<button className="secondary" onClick={loadStops}>Refresh</button>
|
<button className="secondary" onClick={loadStops}>Ververs</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="list-grid">
|
<div className="list-grid">
|
||||||
{stops.map(stop => (
|
{stops.map(stop => (
|
||||||
<article className="mini-card" key={stop.id}>
|
<article className="mini-card" key={stop.id}>
|
||||||
<strong>#{stop.id} {stop.name}</strong>
|
<strong>Halte #{stop.id} · {stop.name}</strong>
|
||||||
<div>{stop.latitude}, {stop.longitude}</div>
|
<div className="meta-line"><span className="badge badge-primary">#</span> {stop.latitude}, {stop.longitude}</div>
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<button
|
<div className="card-actions">
|
||||||
className="danger"
|
<button className="secondary" onClick={() => setEditingStop(stop)}>Bewerken</button>
|
||||||
onClick={() => setDeleteConfirm(stop)}
|
<button className="danger" onClick={() => setDeleteConfirm(stop)}>Verwijderen</button>
|
||||||
style={{ marginTop: '0.5rem', width: '100%' }}
|
</div>
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{isAdmin && (
|
|
||||||
<button
|
|
||||||
className="secondary"
|
|
||||||
onClick={() => setEditingStop(stop)}
|
|
||||||
style={{ marginTop: '0.5rem', width: '100%' }}
|
|
||||||
>
|
|
||||||
Edit
|
|
||||||
</button>
|
|
||||||
)}
|
)}
|
||||||
</article>
|
</article>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isAdmin ? (
|
{isAdmin ? (
|
||||||
<form onSubmit={createStop} className="stack-form">
|
<form onSubmit={createStop} className="stack-form">
|
||||||
<div className="form-row">
|
<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="Latitude" value={lat} onChange={e => setLat(e.target.value)} />
|
||||||
<input placeholder="Longitude" value={lon} onChange={e => setLon(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>
|
</div>
|
||||||
</form>
|
</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 && (
|
{deleteConfirm && (
|
||||||
<div style={overlay}>
|
<Modal title="Halte verwijderen?" onClose={() => setDeleteConfirm(null)}
|
||||||
<div style={modal}>
|
footer={
|
||||||
<h3>Delete Stop?</h3>
|
<>
|
||||||
<p><strong>{deleteConfirm.name}</strong></p>
|
<button className="secondary" onClick={() => setDeleteConfirm(null)}>Annuleren</button>
|
||||||
<p style={{ color: '#999', fontSize: '0.9rem' }}>This cannot be undone.</p>
|
<button className="danger" onClick={() => deleteStop(deleteConfirm.id, deleteConfirm.name)}>Verwijderen</button>
|
||||||
<div className="form-row" style={{ marginTop: '1rem' }}>
|
</>
|
||||||
<button className="secondary" onClick={() => setDeleteConfirm(null)}>Cancel</button>
|
}>
|
||||||
<button className="danger" onClick={() => deleteStop(deleteConfirm.id, deleteConfirm.name)}>
|
<p>Weet je zeker dat je <strong>{deleteConfirm.name}</strong> wilt verwijderen?</p>
|
||||||
Delete
|
<p className="faint">Dit kan niet ongedaan worden gemaakt.</p>
|
||||||
</button>
|
</Modal>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{editingStop && (
|
{editingStop && (
|
||||||
<div style={overlay}>
|
<Modal title="Halte bewerken" onClose={() => setEditingStop(null)}
|
||||||
<div style={modal}>
|
footer={
|
||||||
<h3>Edit Stop</h3>
|
<>
|
||||||
<form onSubmit={async (e) => {
|
<button className="secondary" onClick={() => setEditingStop(null)}>Annuleren</button>
|
||||||
e.preventDefault()
|
<button onClick={saveStop}>Opslaan</button>
|
||||||
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">
|
|
||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
<input value={editingStop.name} onChange={e => setEditingStop({ ...editingStop, name: e.target.value })} />
|
<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.latitude} onChange={e => setEditingStop({ ...editingStop, latitude: e.target.value })} />
|
||||||
<input value={editingStop.longitude} onChange={e => setEditingStop({ ...editingStop, longitude: e.target.value })} />
|
<input value={editingStop.longitude} onChange={e => setEditingStop({ ...editingStop, longitude: e.target.value })} />
|
||||||
</div>
|
</div>
|
||||||
<div className="form-row" style={{ marginTop: '1rem' }}>
|
</Modal>
|
||||||
<button className="secondary" type="button" onClick={() => setEditingStop(null)}>Cancel</button>
|
|
||||||
<button type="submit">Save</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</section>
|
</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
|
||||||
@@ -1,70 +1,85 @@
|
|||||||
import React, { useEffect, useState } from 'react'
|
import React, { useEffect, useState } from "react";
|
||||||
|
|
||||||
export default function TimetableViewer({ apiBaseUrl = 'http://localhost:5001' }) {
|
export default function TimetableViewer({
|
||||||
const [schedules, setSchedules] = useState([])
|
apiBaseUrl = "https://transit.xtransit.testinstance.nl",
|
||||||
const [selectedDate, setSelectedDate] = useState(new Date().toISOString().split('T')[0])
|
}) {
|
||||||
const [selectedLineId, setSelectedLineId] = useState('')
|
const [schedules, setSchedules] = useState([]);
|
||||||
const [lines, setLines] = useState([])
|
const [summary, setSummary] = useState(null);
|
||||||
const [loading, setLoading] = useState(false)
|
const [selectedDate, setSelectedDate] = useState(
|
||||||
const [error, setError] = useState(null)
|
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
|
// Fetch available bus lines
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchLines = async () => {
|
const fetchLines = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${apiBaseUrl}/admin/bus-lines`)
|
const res = await fetch(`${apiBaseUrl}/bus/admin/bus-lines`);
|
||||||
const data = await res.json()
|
const data = await res.json();
|
||||||
setLines(data.busLines || [])
|
setLines(data.busLines || []);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load lines:', err)
|
console.error("Failed to load lines:", err);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
fetchLines()
|
fetchLines();
|
||||||
}, [apiBaseUrl])
|
}, [apiBaseUrl]);
|
||||||
|
|
||||||
// Fetch schedules based on date and line filter
|
// Fetch schedules based on date and line filter
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchSchedules = async () => {
|
const fetchSchedules = async () => {
|
||||||
setLoading(true)
|
setLoading(true);
|
||||||
setError(null)
|
setError(null);
|
||||||
try {
|
try {
|
||||||
let url = `${apiBaseUrl}/schedules?date=${selectedDate}`
|
let url = `${apiBaseUrl}/bus/schedules/overview?date=${selectedDate}`;
|
||||||
if (selectedLineId) {
|
if (selectedLineId) {
|
||||||
url += `&lineId=${selectedLineId}`
|
url += `&lineId=${selectedLineId}`;
|
||||||
}
|
}
|
||||||
const res = await fetch(url)
|
const res = await fetch(url);
|
||||||
const data = await res.json()
|
const data = await res.json();
|
||||||
setSchedules(data.schedules || [])
|
setSchedules(data.schedules || []);
|
||||||
|
setSummary(data.summary || null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError('Failed to load schedules: ' + err.message)
|
setError("Failed to load schedules: " + err.message);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
fetchSchedules()
|
fetchSchedules();
|
||||||
}, [selectedDate, selectedLineId, apiBaseUrl])
|
}, [selectedDate, selectedLineId, apiBaseUrl]);
|
||||||
|
|
||||||
const timeSlots = generateTimeSlots()
|
const timeSlots = generateTimeSlots();
|
||||||
const schedulesBySlot = groupSchedulesByTimeSlot(schedules, timeSlots)
|
const schedulesBySlot = groupSchedulesByTimeSlot(schedules, timeSlots);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: '20px', maxWidth: '1200px', margin: '0 auto' }}>
|
<div style={{ padding: "20px", maxWidth: "1200px", margin: "0 auto" }}>
|
||||||
<h1>Public Transit Timetable</h1>
|
<h1>Public Transit Timetable</h1>
|
||||||
|
|
||||||
<div style={{ marginBottom: '20px', display: 'flex', gap: '10px' }}>
|
<div style={{ marginBottom: "20px", display: "flex", gap: "10px" }}>
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
value={selectedDate}
|
value={selectedDate}
|
||||||
onChange={e => setSelectedDate(e.target.value)}
|
onChange={(e) => setSelectedDate(e.target.value)}
|
||||||
style={{ padding: '8px', borderRadius: '6px', border: '1px solid #ccc' }}
|
style={{
|
||||||
|
padding: "8px",
|
||||||
|
borderRadius: "6px",
|
||||||
|
border: "1px solid #ccc",
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<select
|
<select
|
||||||
value={selectedLineId}
|
value={selectedLineId}
|
||||||
onChange={e => setSelectedLineId(e.target.value)}
|
onChange={(e) => setSelectedLineId(e.target.value)}
|
||||||
style={{ padding: '8px', borderRadius: '6px', border: '1px solid #ccc', minWidth: '200px' }}
|
style={{
|
||||||
|
padding: "8px",
|
||||||
|
borderRadius: "6px",
|
||||||
|
border: "1px solid #ccc",
|
||||||
|
minWidth: "200px",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<option value="">All lines</option>
|
<option value="">All lines</option>
|
||||||
{lines.map(line => (
|
{lines.map((line) => (
|
||||||
<option key={line.id} value={line.id}>
|
<option key={line.id} value={line.id}>
|
||||||
Line {line.lineNumber} ({line.startStop} → {line.endStop})
|
Line {line.lineNumber} ({line.startStop} → {line.endStop})
|
||||||
</option>
|
</option>
|
||||||
@@ -72,82 +87,192 @@ export default function TimetableViewer({ apiBaseUrl = 'http://localhost:5001' }
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loading && <p>Loading schedules...</p>}
|
{summary && (
|
||||||
{error && <p style={{ color: 'red' }}>{error}</p>}
|
<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' }}>
|
{loading && <p>Loading schedules...</p>}
|
||||||
<thead style={{ backgroundColor: '#f1f5f9' }}>
|
{error && <p style={{ color: "red" }}>{error}</p>}
|
||||||
|
|
||||||
|
<table
|
||||||
|
style={{ width: "100%", borderCollapse: "collapse", marginTop: "20px" }}
|
||||||
|
>
|
||||||
|
<thead style={{ backgroundColor: "#f1f5f9" }}>
|
||||||
<tr>
|
<tr>
|
||||||
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '2px solid #ddd' }}>Time</th>
|
<th
|
||||||
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '2px solid #ddd' }}>Line</th>
|
style={{
|
||||||
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '2px solid #ddd' }}>Vehicle</th>
|
padding: "12px",
|
||||||
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '2px solid #ddd' }}>Status</th>
|
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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{timeSlots.map((slot, idx) => {
|
{timeSlots.map((slot, idx) => {
|
||||||
const slotSchedules = schedulesBySlot[slot] || []
|
const slotSchedules = schedulesBySlot[slot] || [];
|
||||||
if (slotSchedules.length === 0) return null
|
if (slotSchedules.length === 0) return null;
|
||||||
return slotSchedules.map((schedule, sidx) => (
|
return slotSchedules.map((schedule, sidx) => (
|
||||||
<tr key={`${idx}-${sidx}`} style={{ borderBottom: '1px solid #eee' }}>
|
<tr
|
||||||
<td style={{ padding: '12px' }}>
|
key={`${idx}-${sidx}`}
|
||||||
{sidx === 0 ? slot : ''}
|
style={{ borderBottom: "1px solid #eee" }}
|
||||||
</td>
|
>
|
||||||
<td style={{ padding: '12px', fontWeight: 'bold', color: '#4f46e5' }}>
|
<td style={{ padding: "12px" }}>{sidx === 0 ? slot : ""}</td>
|
||||||
|
<td
|
||||||
|
style={{
|
||||||
|
padding: "12px",
|
||||||
|
fontWeight: "bold",
|
||||||
|
color: "#4f46e5",
|
||||||
|
}}
|
||||||
|
>
|
||||||
Line {schedule.lineNumber}
|
Line {schedule.lineNumber}
|
||||||
</td>
|
</td>
|
||||||
<td style={{ padding: '12px' }}>
|
<td style={{ padding: "12px" }}>
|
||||||
{schedule.busName} ({schedule.licensePlate})
|
{schedule.busName} ({schedule.licensePlate})
|
||||||
</td>
|
</td>
|
||||||
<td style={{ padding: '12px' }}>
|
<td style={{ padding: "12px" }}>
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
padding: '4px 8px',
|
padding: "4px 8px",
|
||||||
borderRadius: '4px',
|
borderRadius: "4px",
|
||||||
backgroundColor: schedule.status === 'active' ? '#dcfce7' : '#f3f4f6',
|
backgroundColor:
|
||||||
color: schedule.status === 'active' ? '#166534' : '#374151',
|
schedule.status === "in_progress"
|
||||||
fontSize: '0.9rem',
|
? "#dbeafe"
|
||||||
|
: schedule.status === "completed"
|
||||||
|
? "#dcfce7"
|
||||||
|
: "#f3f4f6",
|
||||||
|
color:
|
||||||
|
schedule.status === "in_progress"
|
||||||
|
? "#1d4ed8"
|
||||||
|
: schedule.status === "completed"
|
||||||
|
? "#166534"
|
||||||
|
: "#374151",
|
||||||
|
fontSize: "0.9rem",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{schedule.status}
|
{schedule.status}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
|
<td style={{ padding: "12px" }}>
|
||||||
|
{typeof schedule.currentDelayMinutes === "number" &&
|
||||||
|
schedule.currentDelayMinutes !== null
|
||||||
|
? `${schedule.currentDelayMinutes > 0 ? "+" : ""}${schedule.currentDelayMinutes} min`
|
||||||
|
: "—"}
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))
|
));
|
||||||
})}
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
{schedules.length === 0 && !loading && (
|
{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.
|
No schedules found for the selected date and line.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function generateTimeSlots() {
|
function generateTimeSlots() {
|
||||||
const slots = []
|
const slots = [];
|
||||||
for (let hour = 0; hour < 24; hour++) {
|
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) {
|
function groupSchedulesByTimeSlot(schedules, slots) {
|
||||||
const grouped = {}
|
const grouped = {};
|
||||||
slots.forEach(slot => {
|
slots.forEach((slot) => {
|
||||||
grouped[slot] = []
|
grouped[slot] = [];
|
||||||
})
|
});
|
||||||
|
|
||||||
schedules.forEach(schedule => {
|
schedules.forEach((schedule) => {
|
||||||
const startHour = new Date(schedule.startTime).getHours()
|
const startHour = new Date(schedule.startTime).getHours();
|
||||||
const slot = `${String(startHour).padStart(2, '0')}:00-${String((startHour + 1) % 24).padStart(2, '0')}:00`
|
const slot = `${String(startHour).padStart(2, "0")}:00-${String((startHour + 1) % 24).padStart(2, "0")}:00`;
|
||||||
if (grouped[slot]) {
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
@@ -15,8 +15,27 @@ pool.query('SELECT 1')
|
|||||||
.catch(err => {
|
.catch(err => {
|
||||||
console.error('PostgreSQL connection FAILED');
|
console.error('PostgreSQL connection FAILED');
|
||||||
console.error(err);
|
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);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
query: (text, params) => pool.query(text, params),
|
query: (text, params) => pool.query(text, params),
|
||||||
|
|||||||
@@ -1,57 +1,167 @@
|
|||||||
const express = require('express');
|
const express = require("express");
|
||||||
const bcrypt = require('bcryptjs');
|
const bcrypt = require("bcryptjs");
|
||||||
const jwt = require('jsonwebtoken');
|
const jwt = require("jsonwebtoken");
|
||||||
const db = require('./db');
|
const db = require("./db");
|
||||||
require('dotenv').config();
|
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();
|
const app = express();
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
|
|
||||||
// Simple CORS middleware for browser-based admin UI testing.
|
// Simple CORS middleware for browser-based admin UI testing.
|
||||||
app.use((req, res, next) => {
|
app.use((req, res, next) => {
|
||||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
res.setHeader("Access-Control-Allow-Origin", "*");
|
||||||
res.setHeader('Access-Control-Allow-Methods', 'GET,POST,OPTIONS');
|
res.setHeader("Access-Control-Allow-Methods", "GET,POST,OPTIONS");
|
||||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
|
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
|
||||||
if (req.method === 'OPTIONS') {
|
if (req.method === "OPTIONS") {
|
||||||
return res.sendStatus(204);
|
return res.sendStatus(204);
|
||||||
}
|
}
|
||||||
next();
|
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 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 {
|
try {
|
||||||
const { userCode, password } = req.body;
|
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];
|
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);
|
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 isAdmin = ADMIN_USER_CODES.has(Number(user.usercode));
|
||||||
const token = jwt.sign(payload, JWT_SECRET, { expiresIn: '1h' });
|
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) {
|
} catch (err) {
|
||||||
console.error(err);
|
log("error", "Login error", { error: err.message, stack: err.stack });
|
||||||
res.status(500).json({ error: 'internal_error' });
|
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;
|
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);
|
const token = auth.slice(7);
|
||||||
try {
|
try {
|
||||||
const payload = jwt.verify(token, JWT_SECRET);
|
const payload = jwt.verify(token, JWT_SECRET);
|
||||||
res.json({ user: payload });
|
res.json({ user: payload });
|
||||||
} catch (err) {
|
} 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;
|
const PORT = process.env.PORT || 5000;
|
||||||
app.listen(PORT, () => console.log(`Auth API listening on ${PORT}`));
|
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;
|
module.exports = app;
|
||||||
|
|||||||
@@ -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/
|
|
||||||
@@ -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'
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
# bus_terminal
|
|
||||||
|
|
||||||
A new Flutter project.
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
analyzer:
|
|
||||||
errors:
|
|
||||||
deprecated_member_use: ignore
|
|
||||||
use_build_context_synchronously: ignore
|
|
||||||
include: package:flutter_lints/flutter.yaml
|
|
||||||
@@ -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
|
|
||||||
@@ -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>
|
|
||||||
|
Before Width: | Height: | Size: 544 B |
|
Before Width: | Height: | Size: 442 B |
|
Before Width: | Height: | Size: 721 B |
|
Before Width: | Height: | Size: 1.0 KiB |
|
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>
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
@@ -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")
|
|
||||||
@@ -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:
|
|
||||||
@@ -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 +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>
|
|
||||||
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 295 B |
|
Before Width: | Height: | Size: 406 B |
|
Before Width: | Height: | Size: 450 B |
|
Before Width: | Height: | Size: 282 B |
|
Before Width: | Height: | Size: 462 B |
|
Before Width: | Height: | Size: 704 B |
|
Before Width: | Height: | Size: 406 B |
|
Before Width: | Height: | Size: 586 B |
|
Before Width: | Height: | Size: 862 B |
|
Before Width: | Height: | Size: 862 B |
|
Before Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 762 B |
|
Before Width: | Height: | Size: 1.2 KiB |
|
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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 68 B |
|
Before Width: | Height: | Size: 68 B |
|
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>
|
|
||||||
@@ -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>
|
|
||||||