diff --git a/.claude/settings.local.json b/.claude/settings.local.json index e8bad42..8d2a0e8 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -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)" ] } } diff --git a/AGENTS.md b/AGENTS.md index 4453a76..32970dc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 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 diff --git a/Dockerfile b/Dockerfile index d92a5ac..cdf43e4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/app/Controllers/AdminController.php b/app/Controllers/AdminController.php new file mode 100644 index 0000000..3c0e1da --- /dev/null +++ b/app/Controllers/AdminController.php @@ -0,0 +1,144 @@ +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'); + } +} diff --git a/app/Views/admin/cron.php b/app/Views/admin/cron.php new file mode 100644 index 0000000..13240dc --- /dev/null +++ b/app/Views/admin/cron.php @@ -0,0 +1,132 @@ +
+

PrintStream Import

+
+ +
+
+ +
+ + +
+
+
+ Schedule +
+
+
+ +
+
+ > + +
+
When disabled, the background cron will skip every run.
+
+ +
+ + +
+ +

+ + + Last run: + + Never run + +

+ + + +
+
+
+
+ + +
+
+
+ Board Import Status +
+ +
No boards exist yet.
+ +
+ + + + + + + + + + + + + + + + + +
BoardStatus
+ + name) ?> + + + importFromPrintstream): ?> + Enabled + + Disabled + + +
+ +
+
+
+ +
+
+ + +
+
+
+ Import Log +
+ newest first · last lines · storage/import.log + + Refresh + +
+
+
+
No log entries yet (storage/import.log is empty or does not exist).
+
+
+
+ +
diff --git a/app/Views/partials/header.php b/app/Views/partials/header.php index 4f7feb2..e941fb2 100644 --- a/app/Views/partials/header.php +++ b/app/Views/partials/header.php @@ -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); diff --git a/config/printstream.php b/config/printstream.php new file mode 100644 index 0000000..04e76e6 --- /dev/null +++ b/config/printstream.php @@ -0,0 +1,14 @@ + 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), +]; diff --git a/docker-compose.yml b/docker-compose.yml index d4965e9..0280c16 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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}" diff --git a/docker/crontab b/docker/crontab new file mode 100644 index 0000000..f06f4dd --- /dev/null +++ b/docker/crontab @@ -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 diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 4b6dfc3..e821b7b 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -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 diff --git a/public/css/site.css b/public/css/site.css index f0e1fa1..dbd821f 100644 --- a/public/css/site.css +++ b/public/css/site.css @@ -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 { diff --git a/routes/web.php b/routes/web.php index cb39e23..a4ea117 100644 --- a/routes/web.php +++ b/routes/web.php @@ -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']); diff --git a/scripts/import-printstream.php b/scripts/import-printstream.php new file mode 100644 index 0000000..7392d48 --- /dev/null +++ b/scripts/import-printstream.php @@ -0,0 +1,367 @@ + 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}"); diff --git a/storage/.gitkeep b/storage/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/storage/cron-settings.json b/storage/cron-settings.json new file mode 100644 index 0000000..bb96fd8 --- /dev/null +++ b/storage/cron-settings.json @@ -0,0 +1,5 @@ +{ + "enabled": true, + "interval_minutes": 5, + "last_run": "2026-05-21 19:33:01" +}