| @@ -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)" | |||
| ] | |||
| } | |||
| } | |||
| @@ -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 | |||
| @@ -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 | |||
| @@ -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'); | |||
| } | |||
| } | |||
| @@ -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 · last <?= (int) ($logLines ?? 300) ?> lines · <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> | |||
| @@ -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); | |||
| @@ -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), | |||
| ]; | |||
| @@ -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}" | |||
| @@ -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 | |||
| @@ -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 | |||
| @@ -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 { | |||
| @@ -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']); | |||
| @@ -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 +1,5 @@ | |||
| { | |||
| "enabled": true, | |||
| "interval_minutes": 5, | |||
| "last_run": "2026-05-21 19:33:01" | |||
| } | |||
Powered by TurnKey Linux.