Browse Source

cron setup

master
Daniel Covington 1 month ago
parent
commit
58bd0a96c7
15 changed files with 797 additions and 61 deletions
  1. +8
    -1
      .claude/settings.local.json
  2. +17
    -0
      AGENTS.md
  3. +22
    -5
      Dockerfile
  4. +144
    -0
      app/Controllers/AdminController.php
  5. +132
    -0
      app/Views/admin/cron.php
  6. +1
    -0
      app/Views/partials/header.php
  7. +14
    -0
      config/printstream.php
  8. +5
    -0
      docker-compose.yml
  9. +4
    -0
      docker/crontab
  10. +15
    -2
      docker/entrypoint.sh
  11. +56
    -53
      public/css/site.css
  12. +7
    -0
      routes/web.php
  13. +367
    -0
      scripts/import-printstream.php
  14. +0
    -0
      storage/.gitkeep
  15. +5
    -0
      storage/cron-settings.json

+ 8
- 1
.claude/settings.local.json View File

@@ -23,7 +23,14 @@
"Bash(cp \"/c/Users/danielc.NTP/AppData/Local/Temp/KCI-KANBAN-inspect/public/js/kanban-board.js\" \"d:/Development/PHP/KCI-PHP-KANBAN/public/js/kanban-board.js\")",
"Bash(cp \"/c/Users/danielc.NTP/AppData/Local/Temp/KCI-KANBAN-inspect/public/js/kanban-modal.js\" \"d:/Development/PHP/KCI-PHP-KANBAN/public/js/kanban-modal.js\")",
"Bash(cp \"/c/Users/danielc.NTP/AppData/Local/Temp/KCI-KANBAN-inspect/public/js/kanban-settings.js\" \"d:/Development/PHP/KCI-PHP-KANBAN/public/js/kanban-settings.js\")",
"Bash(/c/Users/danielc.NTP/AppData/Local/Microsoft/WinGet/Packages/PHP.PHP.8.5_Microsoft.Winget.Source_8wekyb3d8bbwe/php.exe -d extension_dir=/c/Users/danielc.NTP/AppData/Local/Microsoft/WinGet/Packages/PHP.PHP.8.5_Microsoft.Winget.Source_8wekyb3d8bbwe/ext -d extension=php_pdo.dll -d extension=php_pdo_sqlite.dll scripts/migrate.php fresh)"
"Bash(/c/Users/danielc.NTP/AppData/Local/Microsoft/WinGet/Packages/PHP.PHP.8.5_Microsoft.Winget.Source_8wekyb3d8bbwe/php.exe -d extension_dir=/c/Users/danielc.NTP/AppData/Local/Microsoft/WinGet/Packages/PHP.PHP.8.5_Microsoft.Winget.Source_8wekyb3d8bbwe/ext -d extension=php_pdo.dll -d extension=php_pdo_sqlite.dll scripts/migrate.php fresh)",
"Bash(Get-ChildItem -Path \"d:\\\\Development\\\\PHP\\\\KCI-PHP-KANBAN\" -Directory)",
"PowerShell(New-Item -ItemType Directory -Force \"d:\\\\Development\\\\PHP\\\\KCI-PHP-KANBAN\\\\app\\\\Views\\\\admin\")",
"PowerShell(\"done\")",
"Bash(docker compose *)",
"Bash(docker exec *)",
"PowerShell(docker exec kci-php-kanban-app-1 ls -la /var/www/html/storage/)",
"PowerShell(docker exec kci-php-kanban-app-1 cat /var/www/html/storage/import.log)"
]
}
}

+ 17
- 0
AGENTS.md View File

@@ -117,6 +117,23 @@ Composer.phar is at `D:\Development\PHP\PHP-TERRITORY\composer.phar`. Requires `

The `vendor/` directory is excluded by `.dockerignore`, so `composer install` runs inside the container at build time. The Dockerfile must install `libzip-dev unzip` (apt) and `zip` (PHP ext) **before** the `composer install` step — without them, Composer cannot extract downloaded package archives and exits with code 1. This is already in the Dockerfile. Do not remove those packages if updating the Dockerfile.

### PrintStream background import

`scripts/import-printstream.php` runs every 30 minutes via cron inside the Docker container. It:
1. Reads all boards with `import_from_printstream = 1`
2. Parses `printstream_job_name` into filter tokens (newline-separated)
3. Connects to the PrintStream SQL Server (`KCI-PS-2024 / Livedata_dosrun`) via **FreeTDS + pdo_odbc**
4. For each token runs a CTE query against `dbo.SCHEDFIL`, `dbo.ESTIMATE`, `dbo.DEBTOR`, `dbo.NOTES`
5. Inserts new cards (first column, first lane) or refreshes PrintStream fields on existing cards

**Schedule:** `docker/crontab` — `*/30 * * * *`. To change the interval, update both the crontab line and `IMPORT_RUN_EVERY_MINUTES` in `.env`.

**Log:** `/var/log/kanban-import.log` inside the container (`docker exec <id> tail -f /var/log/kanban-import.log`).

**SQL Server connection:** FreeTDS via ODBC. DSN built from `PRINTSTREAM_*` env vars in `.env`. No Microsoft drivers required. TDS version 7.4 (SQL Server 2012+).

**Dockerfile dependencies added for this feature:** `freetds-dev freetds-bin unixodbc-dev tdsodbc cron` (apt) + `pdo_odbc` (PHP ext). Do not remove them.

It is intentionally inspired by a Classic ASP MVC framework style:

- Central dispatcher


+ 22
- 5
Dockerfile View File

@@ -1,10 +1,23 @@
FROM php:8.5-apache

# Install pdo_sqlite and enable mod_rewrite
# Install PHP extensions + system tools
# - pdo_sqlite / zip: required by the app and Composer
# - freetds / unixodbc / tdsodbc: SQL Server access via ODBC for PrintStream import
# - cron: runs the PrintStream background import on a schedule
RUN apt-get update \
&& apt-get install -y libsqlite3-dev libzip-dev unzip \
&& apt-get install -y \
libsqlite3-dev \
libzip-dev \
unzip \
freetds-dev \
freetds-bin \
unixodbc-dev \
tdsodbc \
cron \
&& rm -rf /var/lib/apt/lists/* \
&& docker-php-ext-install pdo_sqlite zip \
&& docker-php-ext-configure pdo_odbc --with-pdo-odbc=unixODBC,/usr \
&& docker-php-ext-install pdo_odbc \
&& a2enmod rewrite

# Install Composer
@@ -23,10 +36,14 @@ COPY . .
# Generate autoloader (no external dependencies — just generates vendor/autoload.php)
RUN composer install --no-dev --optimize-autoloader --no-interaction

# Create database directory and set correct permissions
RUN mkdir -p database \
# Install crontab for background PrintStream import
COPY docker/crontab /etc/cron.d/kanban-import
RUN chmod 0644 /etc/cron.d/kanban-import

# Create writable directories and set correct permissions
RUN mkdir -p database storage \
&& chown -R www-data:www-data /var/www/html \
&& chmod 775 database
&& chmod 775 database storage

EXPOSE 80



+ 144
- 0
app/Controllers/AdminController.php View File

@@ -0,0 +1,144 @@
<?php

declare(strict_types=1);

namespace App\Controllers;

use App\Repositories\BoardRepository;
use App\Services\AuthService;
use Core\Controller;
use Core\Request;

class AdminController extends Controller
{
private const SETTINGS_FILE = '/storage/cron-settings.json';
private const LOG_LINES = 300;

private function settingsPath(): string
{
return dirname(__DIR__, 2) . self::SETTINGS_FILE;
}

private function logPath(): string
{
return dirname(__DIR__, 2) . '/storage/import.log';
}

private function loadCronSettings(): array
{
$path = $this->settingsPath();
if (!file_exists($path)) {
return ['enabled' => true, 'interval_minutes' => 30, 'last_run' => null];
}
return json_decode((string) file_get_contents($path), true)
?? ['enabled' => true, 'interval_minutes' => 30, 'last_run' => null];
}

private function writeCronSettings(array $settings): void
{
file_put_contents(
$this->settingsPath(),
json_encode($settings, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n"
);
}

private function boards(): BoardRepository
{
return new BoardRepository(database());
}

private function readLog(): string
{
$path = $this->logPath();
if (!file_exists($path) || !is_readable($path)) {
return '';
}
$lines = file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [];
$slice = array_slice($lines, -self::LOG_LINES);
return implode("\n", array_reverse($slice));
}

public function index(): mixed
{
if ($guard = AuthService::requireLogin()) {
return $guard;
}

return $this->view('admin.cron', [
'pageTitle' => 'PrintStream Import',
'boards' => $this->boards()->getAll(),
'settings' => $this->loadCronSettings(),
'log' => $this->readLog(),
'logLines' => self::LOG_LINES,
]);
}

public function saveSettings(Request $request): mixed
{
if ($guard = AuthService::requireLogin()) {
return $guard;
}

$settings = $this->loadCronSettings();
$settings['enabled'] = $request->input('enabled') === 'on';
$settings['interval_minutes'] = max(1, min(120, (int) $request->input('interval_minutes', 30)));

$this->writeCronSettings($settings);

return $this->redirect('/admin/cron');
}

public function runNow(): mixed
{
if ($guard = AuthService::requireLogin()) {
return $guard;
}

// Prefer the known Docker CLI binary; fall back to PHP_BINARY for other environments.
$php = is_executable('/usr/local/bin/php') ? '/usr/local/bin/php' : PHP_BINARY;

$script = dirname(__DIR__, 2) . '/scripts/import-printstream.php';
$log = $this->logPath();

set_time_limit(120);

$output = [];
$returnCode = 0;
exec(escapeshellarg($php) . ' ' . escapeshellarg($script) . ' --force 2>&1', $output, $returnCode);

if (!empty($output)) {
file_put_contents($log, implode("\n", $output) . "\n", FILE_APPEND | LOCK_EX);
}

return $this->redirect('/admin/cron');
}

public function toggleBoard(int $id): mixed
{
if ($guard = AuthService::requireLogin()) {
return $guard;
}

$db = database();
$row = $db->first('SELECT import_from_printstream FROM boards WHERE id = :id', ['id' => $id]);

if ($row === null) {
return $this->redirect('/admin/cron');
}

$newVal = ((int) $row['import_from_printstream']) === 0 ? 1 : 0;
$db->execute(
'UPDATE boards
SET import_from_printstream = :v, updated_at = :t, updated_by = :u
WHERE id = :id',
[
'v' => $newVal,
't' => date('Y-m-d H:i:s'),
'u' => AuthService::getCurrentUsername(),
'id' => $id,
]
);

return $this->redirect('/admin/cron');
}
}

+ 132
- 0
app/Views/admin/cron.php View File

@@ -0,0 +1,132 @@
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h3 mb-0">PrintStream Import</h1>
<form method="POST" action="/admin/cron/run">
<button type="submit" class="btn btn-success">
<i class="bi bi-play-fill me-1"></i>Run Now
</button>
</form>
</div>

<div class="row g-4">

<!-- Schedule Settings -->
<div class="col-12 col-lg-5">
<div class="card h-100">
<div class="card-header fw-semibold">
<i class="bi bi-clock me-1"></i>Schedule
</div>
<div class="card-body">
<form method="POST" action="/admin/cron/settings">

<div class="mb-4">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" role="switch"
id="enabled" name="enabled"
<?= ($settings['enabled'] ?? true) ? 'checked' : '' ?>>
<label class="form-check-label fw-semibold" for="enabled">
Import Enabled
</label>
</div>
<div class="form-text">When disabled, the background cron will skip every run.</div>
</div>

<div class="mb-4">
<label for="interval_minutes" class="form-label fw-semibold">Run every</label>
<select class="form-select" id="interval_minutes" name="interval_minutes">
<?php foreach ([5, 10, 15, 30, 60] as $min): ?>
<option value="<?= $min ?>"
<?= (int) ($settings['interval_minutes'] ?? 30) === $min ? 'selected' : '' ?>>
<?= $min ?> minutes
</option>
<?php endforeach; ?>
</select>
</div>

<p class="text-muted small mb-4">
<i class="bi bi-clock-history me-1"></i>
<?php if (!empty($settings['last_run'])): ?>
Last run: <strong><?= e($settings['last_run']) ?></strong>
<?php else: ?>
Never run
<?php endif; ?>
</p>

<button type="submit" class="btn btn-primary">
<i class="bi bi-floppy me-1"></i>Save
</button>

</form>
</div>
</div>
</div>

<!-- Board Status -->
<div class="col-12 col-lg-7">
<div class="card h-100">
<div class="card-header fw-semibold">
<i class="bi bi-kanban me-1"></i>Board Import Status
</div>
<?php if (empty($boards)): ?>
<div class="card-body text-muted text-center py-4">No boards exist yet.</div>
<?php else: ?>
<div class="table-responsive">
<table class="table table-sm table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th>Board</th>
<th class="text-center" style="width:110px">Status</th>
<th style="width:90px"></th>
</tr>
</thead>
<tbody>
<?php foreach ($boards as $board): ?>
<tr>
<td>
<a href="/board/<?= e($board->slug) ?>" class="text-decoration-none">
<?= e($board->name) ?>
</a>
</td>
<td class="text-center">
<?php if ($board->importFromPrintstream): ?>
<span class="badge bg-success">Enabled</span>
<?php else: ?>
<span class="badge bg-secondary">Disabled</span>
<?php endif; ?>
</td>
<td class="text-end">
<form method="POST" action="/admin/cron/board/<?= $board->id ?>/toggle">
<button type="submit"
class="btn btn-sm <?= $board->importFromPrintstream ? 'btn-outline-danger' : 'btn-outline-success' ?>">
<?= $board->importFromPrintstream ? 'Disable' : 'Enable' ?>
</button>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</div>
</div>

<!-- Log Viewer -->
<div class="col-12">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<span class="fw-semibold"><i class="bi bi-terminal me-1"></i>Import Log</span>
<div class="d-flex align-items-center gap-2">
<span class="text-muted small">newest first &middot; last <?= (int) ($logLines ?? 300) ?> lines &middot; <code>storage/import.log</code></span>
<a href="/admin/cron" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-arrow-clockwise me-1"></i>Refresh
</a>
</div>
</div>
<div class="card-body p-0">
<pre class="m-0 p-3 bg-dark text-light rounded-bottom"
style="height:420px;overflow-y:auto;font-size:0.78rem;line-height:1.5;white-space:pre-wrap;word-break:break-all;"><?php if ($log !== ''): ?><?= e($log) ?><?php else: ?><span class="text-secondary">No log entries yet (storage/import.log is empty or does not exist).</span><?php endif; ?></pre>
</div>
</div>
</div>

</div>

+ 1
- 0
app/Views/partials/header.php View File

@@ -4,6 +4,7 @@ declare(strict_types=1);

$navigationItems = [
['label' => 'Boards', 'href' => '/boards'],
['label' => 'Admin', 'href' => '/admin/cron'],
];

$currentPath = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH);


+ 14
- 0
config/printstream.php View File

@@ -0,0 +1,14 @@
<?php

declare(strict_types=1);

return [
'host' => getenv('PRINTSTREAM_HOST') ?: '',
'port' => (int) (getenv('PRINTSTREAM_PORT') ?: 1433),
'database' => getenv('PRINTSTREAM_DATABASE') ?: '',
'user' => getenv('PRINTSTREAM_USER') ?: '',
'password' => getenv('PRINTSTREAM_PASSWORD') ?: '',

// Interval used by the cron schedule comment — actual schedule is in docker/crontab
'import_every_minutes' => (int) (getenv('IMPORT_RUN_EVERY_MINUTES') ?: 30),
];

+ 5
- 0
docker-compose.yml View File

@@ -6,3 +6,8 @@ services:
volumes:
- .:/var/www/html
env_file: .env
extra_hosts:
# Maps the PrintStream server hostname inside the container.
# Set PRINTSTREAM_HOST to the server's IP address in .env
# (Docker cannot resolve Windows LAN hostnames by name).
- "KCI-PS-2024:${PRINTSTREAM_HOST}"

+ 4
- 0
docker/crontab View File

@@ -0,0 +1,4 @@
# KCI Kanban — PrintStream background import
# Runs every minute; the PHP script manages its own interval via storage/cron-settings.json.
# Change the interval in the admin UI at /admin/cron — no container restart needed.
* * * * * root /usr/local/bin/php /var/www/html/scripts/import-printstream.php >> /var/www/html/storage/import.log 2>&1

+ 15
- 2
docker/entrypoint.sh View File

@@ -1,8 +1,18 @@
#!/bin/bash
set -e

mkdir -p database
chmod 777 database
mkdir -p database storage
chmod 777 database storage

# Create default cron settings on first deploy
if [ ! -f storage/cron-settings.json ]; then
echo '{"enabled":true,"interval_minutes":30,"last_run":null}' > storage/cron-settings.json
fi
chmod 666 storage/cron-settings.json

# Ensure the import log exists and is writable by both cron (root) and Apache (www-data)
touch storage/import.log
chmod 666 storage/import.log

composer install --no-interaction --quiet

@@ -14,4 +24,7 @@ php scripts/migrate.php up
chmod 777 database
chmod 666 database/app.sqlite

# Start cron daemon for the PrintStream background import
service cron start

exec apache2-foreground

+ 56
- 53
public/css/site.css View File

@@ -1,16 +1,16 @@
:root {
--page-background: #f4efe7;
--surface: rgba(255, 252, 247, 0.88);
--surface-strong: #fffdf8;
--surface-border: rgba(26, 72, 64, 0.12);
--text-primary: #143631;
--text-secondary: #4f655f;
--accent: #1d7a6d;
--accent-strong: #135c52;
--accent-soft: #daf1ec;
--highlight: #ef7c4d;
--shadow-soft: 0 18px 50px rgba(20, 54, 49, 0.1);
--shadow-card: 0 20px 40px rgba(20, 54, 49, 0.08);
--page-background: #eef4ff;
--surface: rgba(247, 251, 255, 0.88);
--surface-strong: #f7fbff;
--surface-border: rgba(19, 99, 223, 0.12);
--text-primary: #1a2d4e;
--text-secondary: #4a6080;
--accent: #1363df;
--accent-strong: #0e4fae;
--accent-soft: #e7f0ff;
--highlight: #3d96f5;
--shadow-soft: 0 18px 50px rgba(16, 44, 90, 0.1);
--shadow-card: 0 20px 40px rgba(16, 44, 90, 0.08);
}

* {
@@ -27,9 +27,9 @@ body {
font-family: "Trebuchet MS", "Lucida Sans Unicode", "Lucida Grande", sans-serif;
color: var(--text-primary);
background:
radial-gradient(circle at top left, rgba(239, 124, 77, 0.18), transparent 28%),
radial-gradient(circle at top right, rgba(29, 122, 109, 0.18), transparent 32%),
linear-gradient(180deg, #f8f2e8 0%, var(--page-background) 48%, #efe6da 100%);
radial-gradient(75rem 35rem at -12% -18%, #dae8ff 0%, transparent 44%),
radial-gradient(68rem 32rem at 115% -16%, #d8f3ff 0%, transparent 40%),
linear-gradient(180deg, #eef4ff 0%, #f4f8ff 58%, #f3f7ff 100%);
}

a {
@@ -55,9 +55,10 @@ code {
position: sticky;
top: 0;
z-index: 20;
backdrop-filter: blur(14px);
background: rgba(248, 242, 232, 0.78);
border-bottom: 1px solid rgba(20, 54, 49, 0.08);
backdrop-filter: blur(9px);
background: linear-gradient(120deg, #102241 0%, #173a72 56%, #1c4c90 100%);
border-bottom: 1px solid rgba(255, 255, 255, 0.18);
box-shadow: 0 8px 24px rgba(8, 20, 48, 0.26);
}

.header-inner {
@@ -73,6 +74,7 @@ code {
align-items: center;
gap: 0.85rem;
text-decoration: none;
color: #f4f8ff;
}

.brand-mark {
@@ -82,11 +84,11 @@ code {
width: 2.75rem;
height: 2.75rem;
border-radius: 0.95rem;
background: linear-gradient(135deg, var(--accent), var(--highlight));
background: linear-gradient(135deg, #1363df, #0e4fae);
color: #fff;
font-weight: 700;
letter-spacing: 0.08em;
box-shadow: var(--shadow-soft);
box-shadow: 0 4px 14px rgba(14, 79, 174, 0.4);
}

.brand-copy {
@@ -97,10 +99,11 @@ code {

.brand-copy strong {
font-size: 1rem;
color: #f4f8ff;
}

.brand-copy small {
color: var(--text-secondary);
color: rgba(235, 243, 255, 0.65);
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.14em;
@@ -115,7 +118,7 @@ code {

.nav-link {
text-decoration: none;
color: var(--text-secondary);
color: rgba(235, 243, 255, 0.82);
font-weight: 600;
padding: 0.7rem 1rem;
border-radius: 999px;
@@ -125,8 +128,8 @@ code {
.nav-link:hover,
.nav-link:focus-visible,
.nav-link.is-active {
color: var(--accent-strong);
background: rgba(29, 122, 109, 0.12);
color: #fff;
background: rgba(255, 255, 255, 0.16);
transform: translateY(-1px);
}

@@ -228,13 +231,13 @@ code {
}

.button-primary {
background: linear-gradient(135deg, var(--accent), var(--accent-strong));
background: linear-gradient(135deg, #1363df, #0e4fae);
color: #fff;
box-shadow: 0 18px 30px rgba(19, 92, 82, 0.25);
box-shadow: 0 18px 30px rgba(14, 79, 174, 0.28);
}

.button-secondary {
background: rgba(29, 122, 109, 0.08);
background: rgba(19, 99, 223, 0.08);
color: var(--accent-strong);
}

@@ -259,8 +262,8 @@ code {
display: block;
padding: 1rem 1.1rem;
border-radius: 1.2rem;
background: #173d37;
color: #eefbf6;
background: #102241;
color: #d8ecff;
line-height: 1.7;
white-space: normal;
}
@@ -320,7 +323,7 @@ code {
.table-shell {
overflow: hidden;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.92), rgba(248, 242, 232, 0.88)),
linear-gradient(180deg, rgba(247, 251, 255, 0.92), rgba(238, 244, 255, 0.88)),
var(--surface);
}

@@ -384,7 +387,7 @@ code {
.input {
width: 100%;
padding: 0.95rem 1rem;
border: 1px solid rgba(20, 54, 49, 0.16);
border: 1px solid rgba(19, 99, 223, 0.16);
border-radius: 1rem;
background: rgba(255, 255, 255, 0.92);
color: var(--text-primary);
@@ -392,8 +395,8 @@ code {
}

.input:focus {
outline: 2px solid rgba(29, 122, 109, 0.22);
border-color: rgba(29, 122, 109, 0.45);
outline: 2px solid rgba(19, 99, 223, 0.22);
border-color: rgba(19, 99, 223, 0.45);
}

.field-error {
@@ -436,7 +439,7 @@ code {
}

.alert-success {
background: rgba(218, 241, 236, 0.92);
background: rgba(231, 240, 255, 0.92);
color: var(--accent-strong);
}

@@ -481,7 +484,7 @@ code {
.employee-card-top span {
padding: 0.4rem 0.7rem;
border-radius: 999px;
background: rgba(29, 122, 109, 0.09);
background: rgba(19, 99, 223, 0.09);
color: var(--accent-strong);
font-size: 0.78rem;
font-weight: 700;
@@ -525,7 +528,7 @@ code {
padding: 1rem;
border-radius: 1.3rem;
background: rgba(255, 255, 255, 0.72);
border: 1px solid rgba(20, 54, 49, 0.08);
border: 1px solid rgba(19, 99, 223, 0.08);
}

.stat-card span {
@@ -547,7 +550,7 @@ code {
margin-top: 1rem;
padding: 1.15rem;
border-radius: 1.3rem;
background: linear-gradient(135deg, rgba(29, 122, 109, 0.12), rgba(239, 124, 77, 0.12));
background: linear-gradient(135deg, rgba(19, 99, 223, 0.10), rgba(61, 150, 245, 0.10));
}

.summary-label {
@@ -576,7 +579,7 @@ code {
margin-bottom: 1rem;
flex-wrap: wrap;
padding: 0.9rem 1rem;
border: 1px solid rgba(20, 54, 49, 0.08);
border: 1px solid rgba(19, 99, 223, 0.08);
border-radius: 1rem;
background: rgba(255, 255, 255, 0.58);
}
@@ -586,7 +589,7 @@ code {
align-items: center;
padding: 0.5rem 0.8rem;
border-radius: 999px;
background: rgba(29, 122, 109, 0.12);
background: rgba(19, 99, 223, 0.12);
color: var(--accent-strong);
font-size: 0.82rem;
font-weight: 700;
@@ -609,18 +612,18 @@ code {
background: rgba(255, 255, 255, 0.82);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.5),
0 18px 35px rgba(20, 54, 49, 0.08);
0 18px 35px rgba(16, 44, 90, 0.08);
}

.tabulator-host .tabulator-header {
border-bottom: 1px solid rgba(20, 54, 49, 0.08);
background: linear-gradient(180deg, rgba(29, 122, 109, 0.14), rgba(29, 122, 109, 0.08));
border-bottom: 1px solid rgba(19, 99, 223, 0.08);
background: linear-gradient(180deg, rgba(19, 99, 223, 0.14), rgba(19, 99, 223, 0.08));
}

.tabulator-host .tabulator-header .tabulator-col {
min-height: 3.25rem;
background: transparent;
border-right: 1px solid rgba(20, 54, 49, 0.06);
border-right: 1px solid rgba(19, 99, 223, 0.06);
}

.tabulator-host .tabulator-header .tabulator-col:last-child {
@@ -641,7 +644,7 @@ code {

.tabulator-host .tabulator-col,
.tabulator-host .tabulator-cell {
border-right: 1px solid rgba(20, 54, 49, 0.06);
border-right: 1px solid rgba(19, 99, 223, 0.06);
}

.tabulator-host .tabulator-row .tabulator-cell:last-child {
@@ -650,20 +653,20 @@ code {

.tabulator-host .tabulator-row {
background: rgba(255, 255, 255, 0.96);
border-bottom: 1px solid rgba(20, 54, 49, 0.06);
border-bottom: 1px solid rgba(19, 99, 223, 0.06);
transition: background-color 160ms ease, transform 160ms ease;
}

.tabulator-host .tabulator-row:nth-child(even) {
background: rgba(248, 242, 232, 0.82);
background: rgba(238, 244, 255, 0.82);
}

.tabulator-host .tabulator-row:hover {
background: rgba(218, 241, 236, 0.72);
background: rgba(231, 240, 255, 0.72);
}

.tabulator-host .tabulator-row.tabulator-selected {
background: rgba(29, 122, 109, 0.18);
background: rgba(19, 99, 223, 0.18);
}

.tabulator-host .tabulator-cell {
@@ -680,7 +683,7 @@ code {
.tabulator-host .tabulator-footer {
padding: 0.55rem 0.7rem;
background: rgba(255, 255, 255, 0.88);
border-top: 1px solid rgba(20, 54, 49, 0.08);
border-top: 1px solid rgba(19, 99, 223, 0.08);
}

.tabulator-host .tabulator-footer .tabulator-paginator {
@@ -690,7 +693,7 @@ code {
.tabulator-host .tabulator-footer .tabulator-page {
margin: 0 0.2rem;
padding: 0.45rem 0.7rem;
border: 1px solid rgba(20, 54, 49, 0.1);
border: 1px solid rgba(19, 99, 223, 0.1);
border-radius: 0.8rem;
background: rgba(255, 255, 255, 0.9);
color: var(--text-secondary);
@@ -699,7 +702,7 @@ code {

.tabulator-host .tabulator-footer .tabulator-page.active,
.tabulator-host .tabulator-footer .tabulator-page:hover {
background: linear-gradient(135deg, var(--accent), var(--accent-strong));
background: linear-gradient(135deg, #1363df, #0e4fae);
border-color: transparent;
color: #fff;
}
@@ -717,8 +720,8 @@ code {

.site-footer {
margin-top: auto;
border-top: 1px solid rgba(20, 54, 49, 0.08);
background: rgba(255, 252, 247, 0.72);
border-top: 1px solid rgba(19, 99, 223, 0.08);
background: rgba(247, 251, 255, 0.72);
}

.footer-inner {


+ 7
- 0
routes/web.php View File

@@ -2,6 +2,7 @@

declare(strict_types=1);

use App\Controllers\AdminController;
use App\Controllers\AuthController;
use App\Controllers\BoardsController;
use App\Controllers\CardsController;
@@ -39,6 +40,12 @@ $router->post('/swimlanes/{id}/delete', [SwimLanesController::class, 'destroy'])
$router->post('/swimlanes/{id}', [SwimLanesController::class, 'update']);
$router->post('/swimlanes', [SwimLanesController::class, 'store']);

// Admin — /admin/cron/settings and /admin/cron/run must be before any param routes
$router->get('/admin/cron', [AdminController::class, 'index']);
$router->post('/admin/cron/settings', [AdminController::class, 'saveSettings']);
$router->post('/admin/cron/run', [AdminController::class, 'runNow']);
$router->post('/admin/cron/board/{id}/toggle', [AdminController::class, 'toggleBoard']);

// Auth (Keycloak SSO)
$router->get('/auth/login', [AuthController::class, 'login']);
$router->get('/auth/callback', [AuthController::class, 'callback']);


+ 367
- 0
scripts/import-printstream.php View File

@@ -0,0 +1,367 @@
<?php

declare(strict_types=1);

/**
* PrintStream → Kanban importer.
*
* Runs on a cron schedule (see docker/crontab).
* For each board with import_from_printstream = 1:
* - Parses printstream_job_name into filter tokens (one per line)
* - Queries PrintStream SQL Server for open jobs matching each token
* - Inserts new cards (first column, first lane) or refreshes PrintStream
* fields on cards that already exist for this board
*/

require_once __DIR__ . '/../vendor/autoload.php';

// ── Load .env ─────────────────────────────────────────────────────────────────
(static function (): void {
$file = __DIR__ . '/../.env';
if (!file_exists($file)) {
return;
}
foreach (file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
$line = trim($line);
if ($line === '' || $line[0] === '#' || !str_contains($line, '=')) {
continue;
}
[$name, $value] = explode('=', $line, 2);
$name = trim($name);
$value = trim($value);
if ($name !== '' && getenv($name) === false) {
putenv("{$name}={$value}");
$_ENV[$name] = $value;
}
}
})();

// ── Helpers ───────────────────────────────────────────────────────────────────
function log_msg(string $msg): void
{
echo '[' . date('Y-m-d H:i:s') . '] ' . $msg . PHP_EOL;
}

function safe_str(mixed $val): string
{
return $val === null ? '' : (string) $val;
}

// ── Cron scheduling / enable check ───────────────────────────────────────────
// Pass --force (e.g. from the admin "Run Now" button) to bypass timing/enabled.
$force = in_array('--force', $argv ?? []);
$settingsFile = __DIR__ . '/../storage/cron-settings.json';

$cronSettings = [];
if (file_exists($settingsFile)) {
$cronSettings = json_decode((string) file_get_contents($settingsFile), true) ?? [];
}

$cronEnabled = (bool) ($cronSettings['enabled'] ?? true);
$intervalMinutes = (int) ($cronSettings['interval_minutes'] ?? 30);
$lastRun = $cronSettings['last_run'] ?? null;

if (!$cronEnabled && !$force) {
log_msg('Import disabled via admin settings. Exiting.');
exit(0);
}

if (!$force && $lastRun !== null) {
$elapsed = time() - (int) strtotime($lastRun);
if ($elapsed < $intervalMinutes * 60) {
exit(0); // silent — not time yet, avoids log noise every minute
}
}

// Record start time before connecting so concurrent ticks skip.
$cronSettings['last_run'] = date('Y-m-d H:i:s');
@file_put_contents($settingsFile, json_encode($cronSettings, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n");

// ── Connect to kanban SQLite DB ───────────────────────────────────────────────
$kanban = database();

// ── Connect to PrintStream SQL Server (FreeTDS via ODBC) ─────────────────────
$psCfg = require __DIR__ . '/../config/printstream.php';

if ($psCfg['host'] === '') {
log_msg('ERROR: PRINTSTREAM_HOST is not set. Aborting.');
exit(1);
}

$dsn = sprintf(
'odbc:Driver={FreeTDS};Server=%s;Port=%d;Database=%s;UID=%s;PWD=%s;TDS_Version=7.4',
$psCfg['host'],
$psCfg['port'],
$psCfg['database'],
$psCfg['user'],
$psCfg['password']
);

try {
$ps = new PDO($dsn, null, null, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]);
} catch (PDOException $e) {
log_msg('ERROR: Cannot connect to PrintStream: ' . $e->getMessage());
exit(1);
}

log_msg('Connected to PrintStream (' . $psCfg['host'] . '/' . $psCfg['database'] . ')');

// ── PrintStream queries ───────────────────────────────────────────────────────
// pdo_odbc uses SQLPrepare/SQLExecute for every call — SQLPrepare maps to
// sp_prepare in SQL Server which REJECTS CTEs (error 8180). ATTR_EMULATE_PREPARES
// is ignored by pdo_odbc. Solution: flatten to plain JOINs (no CTEs) and embed
// the token value directly after escaping single quotes (admin-controlled data).

// Main job query — flat JOINs, no CTEs.
// Token appended as: WHERE e2.[DETAILS1] LIKE '%escaped_token%'
$psSqlBase = "
SELECT DISTINCT
s.[JOB NUMBER] AS JobNumber,
d.[NAMES] AS CustomerName,
e2.[DETAILS1] AS JobName,
e2.[FINAL DELIVERY] AS FinalDelivery,
e2.[ORIG_Qty1] AS Quantity,
CONCAT(ISNULL(e2.[DETAILS2],''),ISNULL(e2.[DETAILS3],''),ISNULL(e2.[DETAILS4],''),
ISNULL(e2.[DETAILS5],''),ISNULL(e2.[DETAILS6],''),ISNULL(e2.[DETAILS7],''),
ISNULL(e2.[DETAILS8],''),ISNULL(e2.[DETAILS9],''),ISNULL(e2.[DETAILS10],'')) AS Notes,
e2.[ORIG_QuoteNo] AS QuoteNo
FROM dbo.SCHEDFIL AS s
JOIN dbo.ESTIMATE AS e ON e.[DATAFLEX RECNUM ONE] = s.[ESTIMATE RECNUM]
JOIN dbo.CostCenters AS cc ON cc.[Code] = s.[COST CENTRE]
LEFT JOIN dbo.DEBTOR AS d ON e.[DEBTOR] = d.[AC NO]
LEFT JOIN dbo.ESTIMATE AS e2 ON s.[JOB NUMBER] = e2.[JOB NUMBER]
WHERE ISNULL(s.[COMPLETED],'') <> 'Y'
AND ISNULL(s.[CLOSED OUT],'') <> 'Y'
AND ISNULL(e.[CLOSED OUT],'') <> 'Y'
AND e.[DELIVERY DATE] > DATEADD(DAY, -365, CAST(GETDATE() AS date))
AND s.[FILETYPE] IN ('D','Q','X','I','W','R','T','P')
AND (s.[STATUS] IN ('*','6','7','0','1','2','3','4','5',' ') OR s.[STATUS] IS NULL)
AND e2.[DETAILS1] LIKE ";

// Notes query — fetches all MDP notes for a list of QuoteNo values in one round-trip.
// PHP concatenates lines per QuoteNo. Token placeholder: __QUOTENOS__
$psNotesSqlBase = "
SELECT n.[RELATED TO] AS QuoteNo, ISNULL(n.[NOTE],'') AS NoteLine
FROM dbo.NOTES AS n
WHERE n.[MODULE] = 'MDP'
AND n.[RELATED TO] IN (__QUOTENOS__)
ORDER BY n.[RELATED TO], TRY_CAST(n.[LINE NO] AS INT)
";

// ── Process boards ────────────────────────────────────────────────────────────
$boards = $kanban->query(
'SELECT id, printstream_job_name FROM boards WHERE import_from_printstream = 1'
);

if (empty($boards)) {
log_msg('No boards with import_from_printstream enabled. Nothing to do.');
exit(0);
}

$totalBoards = 0;
$totalCreated = 0;
$totalUpdated = 0;

foreach ($boards as $boardRow) {
$boardId = (int) $boardRow['id'];
$filterMemo = safe_str($boardRow['printstream_job_name']);
$totalBoards++;

log_msg("Board ID {$boardId}:");

try {
$firstCol = $kanban->first(
'SELECT id FROM board_columns WHERE board_id = :b ORDER BY position ASC LIMIT 1',
['b' => $boardId]
);
$firstLane = $kanban->first(
'SELECT id FROM swim_lanes WHERE board_id = :b ORDER BY position ASC LIMIT 1',
['b' => $boardId]
);
} catch (\Throwable $e) {
log_msg(' ERROR querying kanban DB: ' . $e->getMessage());
continue;
}

if ($firstCol === null || $firstLane === null) {
log_msg(' No columns or swim lanes — skipping.');
continue;
}

$firstColId = (int) $firstCol['id'];
$firstLaneId = (int) $firstLane['id'];

// Parse filter tokens (one per line, case-insensitive, deduplicated)
$tokens = [];
foreach (preg_split('/\r?\n/', $filterMemo) as $line) {
$tok = trim($line);
if ($tok !== '') {
$tokens[strtolower($tok)] = $tok;
}
}

if (empty($tokens)) {
log_msg(' No job name filter configured — skipping.');
continue;
}

// Query PrintStream for each token, collect unique jobs
$jobs = []; // keyed by strtolower(jobNumber)

foreach ($tokens as $tok) {
try {
$escaped = str_replace("'", "''", $tok);
$sql = $psSqlBase . "'%" . $escaped . "%'";
$stmt = $ps->query($sql);
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
} catch (\Throwable $e) {
log_msg(" WARNING: PrintStream query failed for '{$tok}': " . get_class($e) . ': ' . $e->getMessage());
continue;
}

foreach ($rows as $row) {
$jn = safe_str($row['JobNumber'] ?? '');
if ($jn === '') {
continue;
}
$key = strtolower($jn);
if (!isset($jobs[$key])) {
$rawDate = $row['FinalDelivery'] ?? null;
$deliveryDate = null;
if ($rawDate !== null && $rawDate !== '') {
$parsed = date_create((string) $rawDate);
$deliveryDate = $parsed ? $parsed->format('Y-m-d') : null;
}

$jobs[$key] = [
'job_number' => $jn,
'customer_name' => safe_str($row['CustomerName'] ?? ''),
'job_name' => safe_str($row['JobName'] ?? ''),
'delivery_date' => $deliveryDate,
'quantity' => safe_str($row['Quantity'] ?? ''),
'notes' => safe_str($row['Notes'] ?? ''),
'full_note' => '',
'_quote_no' => safe_str($row['QuoteNo'] ?? ''),
];
}
}
}

log_msg(' Found ' . count($jobs) . ' unique open job(s) matching filters.');

// Fetch FullNote for all jobs in one batch query, concatenate lines in PHP
if (!empty($jobs)) {
$quoteNos = array_filter(array_column(array_values($jobs), '_quote_no'));
if (!empty($quoteNos)) {
try {
$inList = implode(',', array_map(fn($q) => "'" . str_replace("'", "''", $q) . "'", $quoteNos));
$notesSql = str_replace('__QUOTENOS__', $inList, $psNotesSqlBase);
$notesStmt = $ps->query($notesSql);
$noteRows = $notesStmt->fetchAll(PDO::FETCH_ASSOC);

// Group note lines by QuoteNo and concatenate
$notesByQuote = [];
foreach ($noteRows as $nr) {
$qn = (string) ($nr['QuoteNo'] ?? '');
$notesByQuote[$qn] = ($notesByQuote[$qn] ?? '') . (string) ($nr['NoteLine'] ?? '');
}

// Map FullNote back to jobs via _quote_no
foreach ($jobs as &$job) {
$qn = $job['_quote_no'];
if ($qn !== '' && isset($notesByQuote[$qn])) {
$job['full_note'] = $notesByQuote[$qn];
}
}
unset($job);
} catch (\Throwable $e) {
log_msg(' WARNING: FullNote query failed (notes will be empty): ' . $e->getMessage());
}
}
}

$now = date('Y-m-d H:i:s');

foreach ($jobs as $job) {
unset($job['_quote_no']);
$existing = $kanban->first(
'SELECT COUNT(*) AS cnt FROM cards WHERE board_id = :b AND job_number = :jn',
['b' => $boardId, 'jn' => $job['job_number']]
);
$alreadyExists = (int) ($existing['cnt'] ?? 0) > 0;

if (!$alreadyExists) {
// Determine next position in target cell
$maxPos = $kanban->first(
'SELECT MAX(position) AS m FROM cards WHERE column_id = :c AND swim_lane_id = :l',
['c' => $firstColId, 'l' => $firstLaneId]
);
$nextPos = ((int) ($maxPos['m'] ?? -1)) + 1;

$kanban->execute(
'INSERT INTO cards
(board_id, column_id, swim_lane_id, job_number, job_name, customer_name,
delivery_date, quantity, notes, full_note, position,
created_at, created_by, updated_at, updated_by)
VALUES
(:board_id, :column_id, :swim_lane_id, :job_number, :job_name, :customer_name,
:delivery_date, :quantity, :notes, :full_note, :position,
:created_at, :created_by, :updated_at, :updated_by)',
[
'board_id' => $boardId,
'column_id' => $firstColId,
'swim_lane_id' => $firstLaneId,
'job_number' => $job['job_number'],
'job_name' => $job['job_name'],
'customer_name' => $job['customer_name'],
'delivery_date' => $job['delivery_date'],
'quantity' => $job['quantity'] !== '' ? $job['quantity'] : null,
'notes' => $job['notes'],
'full_note' => $job['full_note'],
'position' => $nextPos,
'created_at' => $now,
'created_by' => 'printstream-import',
'updated_at' => $now,
'updated_by' => 'printstream-import',
]
);

log_msg(' + ' . $job['job_number'] . ' — ' . $job['job_name']);
$totalCreated++;
} else {
// Refresh PrintStream fields without touching column/lane/position
$kanban->execute(
'UPDATE cards
SET job_name = :job_name, customer_name = :customer_name,
delivery_date = :delivery_date, quantity = :quantity,
notes = :notes, full_note = :full_note,
updated_at = :updated_at, updated_by = :updated_by
WHERE board_id = :board_id AND job_number = :job_number',
[
'job_name' => $job['job_name'],
'customer_name' => $job['customer_name'],
'delivery_date' => $job['delivery_date'],
'quantity' => $job['quantity'] !== '' ? $job['quantity'] : null,
'notes' => $job['notes'],
'full_note' => $job['full_note'],
'updated_at' => $now,
'updated_by' => 'printstream-import',
'board_id' => $boardId,
'job_number' => $job['job_number'],
]
);

$totalUpdated++;
}
}
}

$ps = null;

log_msg('');
log_msg('Import complete.');
log_msg(" Boards processed : {$totalBoards}");
log_msg(" Cards created : {$totalCreated}");
log_msg(" Cards updated : {$totalUpdated}");

+ 0
- 0
storage/.gitkeep View File


+ 5
- 0
storage/cron-settings.json View File

@@ -0,0 +1,5 @@
{
"enabled": true,
"interval_minutes": 5,
"last_run": "2026-05-21 19:33:01"
}

Loading…
Cancel
Save

Powered by TurnKey Linux.