| @@ -0,0 +1,333 @@ | |||||
| # SKILLS.md | |||||
| ## Purpose | |||||
| This is the main skill index for AI coding agents working on **MindVisionCode PHP**. | |||||
| Read this file after `AGENTS.md`, then load only the focused skill files needed for the current task. | |||||
| --- | |||||
| ## Project Overview | |||||
| MindVisionCode PHP is a small PHP MVC framework inspired by a Classic ASP MVC framework style. | |||||
| The project favors: | |||||
| - Central dispatcher | |||||
| - Controllers and actions | |||||
| - ViewModels | |||||
| - Repository classes | |||||
| - Simple validation | |||||
| - Database migrations | |||||
| - Small, readable files | |||||
| - Minimal dependencies | |||||
| Do not turn this into Laravel, Symfony, Slim, CakePHP, or another large framework. | |||||
| --- | |||||
| ## Configuration Files | |||||
| | File | Purpose | | |||||
| |------|---------| | |||||
| | `config/database.php` | PDO DSN, credentials, and PDO options | | |||||
| | `config/view.php` | Views directory path and layout file path | | |||||
| Add new config files to `config/` — never hardcode environment-specific paths in `core/`. | |||||
| --- | |||||
| ## Tech Stack | |||||
| - PHP 8.2+ | |||||
| - Composer | |||||
| - PSR-4 autoloading | |||||
| - PDO | |||||
| - PHP views | |||||
| - Optional SQLite/MySQL/SQL Server through PDO | |||||
| - Minimal dependencies | |||||
| --- | |||||
| ## Preferred Project Structure | |||||
| ```text | |||||
| project-root/ | |||||
| AGENTS.md | |||||
| .ai/ | |||||
| SKILLS.md | |||||
| skills/ | |||||
| php/ | |||||
| SKILL.md | |||||
| mvc/ | |||||
| SKILL.md | |||||
| database/ | |||||
| SKILL.md | |||||
| security/ | |||||
| SKILL.md | |||||
| testing/ | |||||
| SKILL.md | |||||
| workflow/ | |||||
| SKILL.md | |||||
| public/ | |||||
| index.php | |||||
| src/ | |||||
| Controller/ | |||||
| Service/ | |||||
| Repository/ | |||||
| Entity/ | |||||
| ValueObject/ | |||||
| ViewModel/ | |||||
| Http/ | |||||
| Routing/ | |||||
| Validation/ | |||||
| Database/ | |||||
| Migration/ | |||||
| templates/ | |||||
| config/ | |||||
| tests/ | |||||
| var/ | |||||
| cache/ | |||||
| logs/ | |||||
| vendor/ | |||||
| composer.json | |||||
| ``` | |||||
| Rules: | |||||
| - `public/` is the web root. | |||||
| - Do not expose `src/`, `config/`, `tests/`, `vendor/`, `.ai/`, or `.env` files through the web server. | |||||
| - Put application code under `src/`. | |||||
| - Put generated cache/log files under `var/` or another ignored runtime directory. | |||||
| - Keep secrets outside the web root. | |||||
| --- | |||||
| ## Setup Commands | |||||
| Install dependencies: | |||||
| ```bash | |||||
| composer install | |||||
| ``` | |||||
| Regenerate autoload files: | |||||
| ```bash | |||||
| composer dump-autoload | |||||
| ``` | |||||
| Run local server: | |||||
| ```bash | |||||
| php -S localhost:8000 -t public | |||||
| ``` | |||||
| Run basic tests: | |||||
| ```bash | |||||
| php tests/run.php | |||||
| ``` | |||||
| Run database migrations: | |||||
| ```bash | |||||
| php scripts/migrate.php up | |||||
| ``` | |||||
| Roll back the last migration: | |||||
| ```bash | |||||
| php scripts/migrate.php down | |||||
| ``` | |||||
| Check migration status: | |||||
| ```bash | |||||
| php scripts/migrate.php status | |||||
| ``` | |||||
| Create a new migration file: | |||||
| ```bash | |||||
| php scripts/migrate.php make <name> | |||||
| ``` | |||||
| Reset and re-run all migrations: | |||||
| ```bash | |||||
| php scripts/migrate.php fresh | |||||
| php scripts/migrate.php fresh --seed | |||||
| ``` | |||||
| --- | |||||
| ## Request Flow | |||||
| ```text | |||||
| Browser | |||||
| → public/index.php | |||||
| → Request | |||||
| → Dispatcher | |||||
| → Router | |||||
| → Route | |||||
| → Controller | |||||
| → ViewModel/Repository/Service | |||||
| → View | |||||
| → Response | |||||
| ``` | |||||
| --- | |||||
| ## Skill Routes | |||||
| ### PHP Language, Style, Composer, OOP | |||||
| Read: | |||||
| ```text | |||||
| ./.ai/skills/php/SKILL.md | |||||
| ``` | |||||
| Use for: | |||||
| - PHP version decisions | |||||
| - PSR standards | |||||
| - Composer dependencies | |||||
| - Namespaces and autoloading | |||||
| - OOP design | |||||
| - Dependency injection | |||||
| - Documentation and PHPDoc | |||||
| - Performance and caching | |||||
| --- | |||||
| ### MVC Framework Architecture | |||||
| Read: | |||||
| ```text | |||||
| ./.ai/skills/mvc/SKILL.md | |||||
| ``` | |||||
| Use for: | |||||
| - Dispatcher changes | |||||
| - Router changes | |||||
| - Controllers and actions | |||||
| - ViewModels | |||||
| - PHP templates/views | |||||
| - HTTP request/response flow | |||||
| - Framework structure | |||||
| --- | |||||
| ### Database and Persistence | |||||
| Read: | |||||
| ```text | |||||
| ./.ai/skills/database/SKILL.md | |||||
| ``` | |||||
| Use for: | |||||
| - PDO | |||||
| - SQL | |||||
| - Repositories | |||||
| - Migrations | |||||
| - Transactions | |||||
| - Database configuration | |||||
| - SQLite/MySQL/SQL Server support | |||||
| --- | |||||
| ### Security | |||||
| Read: | |||||
| ```text | |||||
| ./.ai/skills/security/SKILL.md | |||||
| ``` | |||||
| Use for: | |||||
| - Input validation | |||||
| - Output escaping | |||||
| - Passwords | |||||
| - Authentication | |||||
| - Authorization | |||||
| - Sessions | |||||
| - CSRF | |||||
| - Secrets | |||||
| - Error disclosure | |||||
| - Dangerous functions | |||||
| --- | |||||
| ### Testing and Quality | |||||
| Read: | |||||
| ```text | |||||
| ./.ai/skills/testing/SKILL.md | |||||
| ``` | |||||
| Use for: | |||||
| - Tests | |||||
| - Test runner changes | |||||
| - Static analysis | |||||
| - Composer quality scripts | |||||
| - Code style tools | |||||
| - Verification steps | |||||
| --- | |||||
| ### Agent Workflow | |||||
| Read: | |||||
| ```text | |||||
| ./.ai/skills/workflow/SKILL.md | |||||
| ``` | |||||
| Use for: | |||||
| - Multi-file changes | |||||
| - Pull-request style review | |||||
| - Legacy PHP changes | |||||
| - Non-negotiable rules | |||||
| - Response format | |||||
| - Skill feedback updates | |||||
| --- | |||||
| ## Default Coding Rules | |||||
| - Keep code simple and readable. | |||||
| - Prefer small classes. | |||||
| - Use typed properties and return types where practical. | |||||
| - Avoid hidden magic. | |||||
| - Do not add dependencies without a clear reason. | |||||
| - Preserve the framework style. | |||||
| - Explain any architectural changes. | |||||
| --- | |||||
| ## Default Security Rules | |||||
| - Validate input. | |||||
| - Escape output. | |||||
| - Use prepared statements for SQL. | |||||
| - Do not expose sensitive errors. | |||||
| - Check authorization separately from authentication. | |||||
| --- | |||||
| ## Default Testing Rules | |||||
| - Add or update tests for meaningful behavior changes. | |||||
| - Explain how to verify changes. | |||||
| - If tests are not added, explain why. | |||||
| @@ -0,0 +1,197 @@ | |||||
| # Database Skill | |||||
| ## Purpose | |||||
| Use this skill for PDO, repositories, SQL, migrations, transactions, database configuration, and persistence rules. | |||||
| --- | |||||
| ## Database Stack | |||||
| Preferred database access: | |||||
| - PDO | |||||
| - Repositories or data access classes | |||||
| - Optional SQLite, MySQL, or SQL Server through PDO | |||||
| - Prepared statements for all untrusted values | |||||
| --- | |||||
| ## Database Class API | |||||
| `Core\Database` wraps PDO and exposes these methods: | |||||
| | Method | Returns | Description | | |||||
| |--------|---------|-------------| | |||||
| | `query(string $sql, array $params = [])` | `array` | Runs a SELECT and returns all rows as associative arrays | | |||||
| | `first(string $sql, array $params = [])` | `?array` | Runs a SELECT and returns the first row, or `null` | | |||||
| | `execute(string $sql, array $params = [])` | `bool` | Runs INSERT / UPDATE / DELETE | | |||||
| | `lastInsertId()` | `string` | Returns the last auto-increment ID as a string — cast to `int` for integer PKs | | |||||
| | `transaction(callable $fn)` | `mixed` | Runs `$fn($db)` inside a transaction; commits on success, rolls back and rethrows on failure | | |||||
| | `pdo()` | `PDO` | Returns the raw PDO instance for advanced use | | |||||
| `lastInsertId()` is only meaningful immediately after an `execute()` INSERT on the same connection. Calling it at any other point returns `"0"`. | |||||
| Typical INSERT + ID retrieval in a repository: | |||||
| ```php | |||||
| public function create(Employee $employee): int | |||||
| { | |||||
| $this->database->execute( | |||||
| 'INSERT INTO employees (first_name, email) VALUES (:first_name, :email)', | |||||
| ['first_name' => $employee->firstName, 'email' => $employee->email] | |||||
| ); | |||||
| return (int) $this->database->lastInsertId(); | |||||
| } | |||||
| ``` | |||||
| --- | |||||
| ## Database Access Rules | |||||
| Use PDO or a well-maintained database abstraction layer/ORM. | |||||
| Never concatenate untrusted input into SQL. | |||||
| Bad: | |||||
| ```php | |||||
| $sql = "SELECT * FROM users WHERE id = " . $_GET['id']; | |||||
| ``` | |||||
| Good: | |||||
| ```php | |||||
| $stmt = $pdo->prepare('SELECT * FROM users WHERE id = :id'); | |||||
| $stmt->bindValue(':id', $id, PDO::PARAM_INT); | |||||
| $stmt->execute(); | |||||
| $user = $stmt->fetch(PDO::FETCH_ASSOC); | |||||
| ``` | |||||
| Rules: | |||||
| - Use prepared statements and bound parameters. | |||||
| - Validate input before using it in writes. | |||||
| - Keep SQL out of templates. | |||||
| - Keep database access out of controllers where practical. | |||||
| - Use transactions when multiple writes must succeed or fail together. | |||||
| - Do not rely only on client-side validation. | |||||
| - Do not expose raw database errors to users. | |||||
| --- | |||||
| ## Transactions | |||||
| Use `Database::transaction()` when multiple writes must succeed or fail together. It begins a transaction, runs the callback, commits on success, and rolls back and rethrows on any `Throwable`. | |||||
| ```php | |||||
| $database->transaction(function (Database $db) use ($order): void { | |||||
| $orders->create($order); | |||||
| $auditLog->record('order.created', $order->id()); | |||||
| }); | |||||
| ``` | |||||
| Do not call `$database->pdo()->beginTransaction()` directly for transaction management — use `transaction()` instead. Reserve `pdo()` for driver-specific features that have no `Database` API equivalent. | |||||
| --- | |||||
| ## Repository Rules | |||||
| - Put persistence logic in repositories or data access classes. | |||||
| - Keep repositories focused around a table, aggregate, or use case. | |||||
| - Do not let repositories render HTML. | |||||
| - Do not let repositories read directly from `$_GET`, `$_POST`, or other superglobals. | |||||
| - Return domain objects, entities, DTOs, arrays, or ViewModels according to existing project convention. | |||||
| - Prefer explicit methods such as `findById`, `findAllActive`, and `save` over generic magic calls. | |||||
| --- | |||||
| ## Migration System | |||||
| The migration system is made up of three files: | |||||
| - `core/Migration.php` — abstract base class all migration files extend | |||||
| - `core/MigrationManager.php` — runs, rolls back, and tracks applied migrations | |||||
| - `core/helpers.php` — provides the `migration_manager()` helper that wires a `MigrationManager` to the app database and the `database/migrations/` path | |||||
| The CLI entry point is `scripts/migrate.php`. Run it from the project root: | |||||
| | Command | Description | | |||||
| |---------|-------------| | |||||
| | `php scripts/migrate.php up` | Run all pending migrations | | |||||
| | `php scripts/migrate.php down [steps]` | Roll back the last N migrations (default: 1) | | |||||
| | `php scripts/migrate.php status` | Show which migrations have run and when | | |||||
| | `php scripts/migrate.php make <name>` | Scaffold a new timestamped migration file | | |||||
| | `php scripts/migrate.php fresh` | Roll back everything and re-run from scratch | | |||||
| | `php scripts/migrate.php fresh --seed` | Same as fresh, then run the employee seed | | |||||
| Migration files live in `database/migrations/` and are named `YYYYMMDD_HHMMSS_<slug>.php`. | |||||
| Each file must return a `Migration` instance: | |||||
| ```php | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| use Core\Database; | |||||
| use Core\Migration; | |||||
| return new class extends Migration | |||||
| { | |||||
| public function up(Database $database): void | |||||
| { | |||||
| $database->execute('CREATE TABLE ...'); | |||||
| } | |||||
| public function down(Database $database): void | |||||
| { | |||||
| $database->execute('DROP TABLE IF EXISTS ...'); | |||||
| } | |||||
| }; | |||||
| ``` | |||||
| --- | |||||
| ## Migration Rules | |||||
| - Keep migrations small and reversible when practical. | |||||
| - Document destructive migrations clearly. | |||||
| - Do not mix schema changes with unrelated feature logic. | |||||
| - Use project migration conventions before inventing new ones. | |||||
| - Support SQLite/MySQL/SQL Server differences explicitly when the project targets multiple engines. | |||||
| - Do not use database-specific SQL in framework or migration code. `INSERT OR IGNORE` (SQLite) and `INSERT IGNORE` (MySQL) are not portable — use a check-then-insert pattern instead. | |||||
| - Migration records in the `migrations` table are permanent. A row means "this migration ran against this database." Deleting a migration file does not remove the record and does not allow the migration to be re-run. To intentionally re-run a migration, delete its row from the `migrations` table manually — this makes the action explicit. | |||||
| - To reset completely, use `php scripts/migrate.php fresh`, which rolls back all migrations in reverse order and re-runs them from scratch. | |||||
| --- | |||||
| ## SQL Safety Checklist | |||||
| Before completing database work, verify: | |||||
| - [ ] SQL uses prepared statements or a safe query builder. | |||||
| - [ ] Untrusted values are never concatenated into SQL. | |||||
| - [ ] Writes validate input server-side. | |||||
| - [ ] Multi-step writes use transactions where needed. | |||||
| - [ ] Database errors are logged safely and not displayed raw to users. | |||||
| - [ ] Schema changes are documented. | |||||
| - [ ] Tests or verification steps cover the changed behavior. | |||||
| --- | |||||
| ## Database Configuration | |||||
| - Keep database credentials out of source control. | |||||
| - Prefer environment variables or ignored local config files for secrets. | |||||
| - Provide safe examples such as `.env.example`. | |||||
| - Do not commit production DSNs, passwords, tokens, or private keys. | |||||
| Example `.env.example`: | |||||
| ```text | |||||
| APP_ENV=local | |||||
| APP_DEBUG=true | |||||
| DATABASE_URL=mysql://user:password@localhost:3306/app | |||||
| ``` | |||||
| @@ -0,0 +1,321 @@ | |||||
| # MVC Framework Skill | |||||
| ## Purpose | |||||
| Use this skill for MindVisionCode PHP framework architecture, routing, dispatching, controllers, actions, ViewModels, templates, and HTTP request/response flow. | |||||
| --- | |||||
| ## Project Identity | |||||
| This project is a small PHP MVC framework called **MindVisionCode PHP**. | |||||
| It is intentionally inspired by a Classic ASP MVC framework style: | |||||
| - Central dispatcher | |||||
| - Controllers and actions | |||||
| - ViewModels | |||||
| - Repository classes | |||||
| - Simple validation | |||||
| - Database migrations | |||||
| - Small, readable files | |||||
| - Minimal dependencies | |||||
| Do not turn this into Laravel, Symfony, Slim, CakePHP, or another large framework. | |||||
| --- | |||||
| ## Request Flow | |||||
| ```text | |||||
| Browser | |||||
| → public/index.php | |||||
| → Request | |||||
| → Dispatcher | |||||
| → Router | |||||
| → Route | |||||
| → Controller | |||||
| → ViewModel/Repository/Service | |||||
| → View | |||||
| → Response | |||||
| ``` | |||||
| --- | |||||
| ## Project Structure | |||||
| Preferred structure: | |||||
| ```text | |||||
| project-root/ | |||||
| public/ | |||||
| index.php | |||||
| src/ | |||||
| Controller/ | |||||
| Service/ | |||||
| Repository/ | |||||
| Entity/ | |||||
| ValueObject/ | |||||
| ViewModel/ | |||||
| Http/ | |||||
| Routing/ | |||||
| Validation/ | |||||
| Database/ | |||||
| Migration/ | |||||
| templates/ | |||||
| config/ | |||||
| tests/ | |||||
| var/ | |||||
| cache/ | |||||
| logs/ | |||||
| vendor/ | |||||
| composer.json | |||||
| ``` | |||||
| Rules: | |||||
| - `public/` is the web root. | |||||
| - Do not expose `src/`, `config/`, `tests/`, `vendor/`, `.ai/`, or `.env` files through the web server. | |||||
| - Put application code under `src/`. | |||||
| - Put generated cache/log files under `var/` or another ignored runtime directory. | |||||
| - Keep secrets outside the web root. | |||||
| --- | |||||
| ## Development Commands | |||||
| Install dependencies: | |||||
| ```bash | |||||
| composer install | |||||
| ``` | |||||
| Regenerate autoload files: | |||||
| ```bash | |||||
| composer dump-autoload | |||||
| ``` | |||||
| Run local server: | |||||
| ```bash | |||||
| php -S localhost:8000 -t public | |||||
| ``` | |||||
| Run basic tests: | |||||
| ```bash | |||||
| php tests/run.php | |||||
| ``` | |||||
| --- | |||||
| ## Framework Coding Rules | |||||
| - Keep code simple and readable. | |||||
| - Prefer small classes. | |||||
| - Use typed properties and return types where practical. | |||||
| - Avoid hidden magic. | |||||
| - Do not add dependencies without a clear reason. | |||||
| - Preserve the framework style. | |||||
| - Explain any architectural changes. | |||||
| --- | |||||
| ## Service and Request Injection | |||||
| `Core\App::resolveArgs()` injects constructor and action parameters by type. Any service registered via `$app->bind(SomeClass::class, $instance)` in `public/index.php` is automatically injected when an action declares a typed parameter of that class. | |||||
| `Core\Request` is registered as a binding in `public/index.php` before dispatch, so controller actions can declare it as a parameter without calling `Request::capture()` manually: | |||||
| ```php | |||||
| public function index(Request $request): Response | |||||
| { | |||||
| $search = $request->input('search', ''); | |||||
| // ... | |||||
| } | |||||
| ``` | |||||
| Route segment parameters (e.g. `{id}`) are still resolved by name before the binding lookup. | |||||
| To make a service injectable, register it once at bootstrap: | |||||
| ```php | |||||
| // public/index.php | |||||
| $app->bind(Database::class, database()); | |||||
| ``` | |||||
| Then declare it as a typed parameter in any action: | |||||
| ```php | |||||
| public function index(Database $db): Response | |||||
| { | |||||
| // $db is injected automatically | |||||
| } | |||||
| ``` | |||||
| **Binding concrete instances:** Use `$app->instance($name, $obj)` to bind a specific object by key. Instances take precedence over bindings when resolving. | |||||
| **Auto-wiring:** Use `$app->make(SomeClass::class)` to resolve a class with its constructor dependencies injected automatically: | |||||
| ```php | |||||
| $repo = $app->make(EmployeeRepository::class); | |||||
| // $app checks bindings first, then instantiates the class and resolves its constructor | |||||
| ``` | |||||
| **Test isolation:** Call `$app->clear()` to reset all bindings and instances between test runs. | |||||
| Do not call `Request::capture()` inside action bodies. Declare the parameter instead. | |||||
| --- | |||||
| ## Controller Rules | |||||
| - Keep controllers thin. | |||||
| - Validate request method and request shape at the boundary. | |||||
| - Do not put database query details directly in controllers when a repository or service is more appropriate. | |||||
| - Do not put template rendering logic inside business services. | |||||
| - Return or produce a response through the framework’s response mechanism. | |||||
| - Use `requirePost($request)` to guard POST-only actions. It returns `?Response` (null when the method is POST, a 405 Response otherwise). Always return it immediately if non-null: | |||||
| ```php | |||||
| if ($guard = $this->requirePost($request)) { | |||||
| return $guard; | |||||
| } | |||||
| ``` | |||||
| - Verify CSRF **before** field validation on any state-changing action. CSRF failure is a security gate, not a form validation error. See the Security skill for the helper pattern. | |||||
| - When a controller uses a repository across multiple methods, store it as a nullable property and lazy-initialize once — do not call `new Repository(database())` on every method call: | |||||
| ```php | |||||
| private ?EmployeeRepository $employees = null; | |||||
| private function employees(): EmployeeRepository | |||||
| { | |||||
| if ($this->employees === null) { | |||||
| $this->employees = new EmployeeRepository(database()); | |||||
| } | |||||
| return $this->employees; | |||||
| } | |||||
| ``` | |||||
| --- | |||||
| ## ViewModel Rules | |||||
| - Use ViewModels to shape data for views. | |||||
| - Keep ViewModels simple and explicit. | |||||
| - Avoid passing raw database rows directly into complex templates when a ViewModel would make the template clearer. | |||||
| - Do not put database access inside ViewModels unless the existing project convention explicitly does that. | |||||
| --- | |||||
| ## View Configuration | |||||
| View paths are set in `config/view.php`: | |||||
| ```php | |||||
| return [ | |||||
| 'views_path' => __DIR__ . '/../app/Views', | |||||
| 'layout_path' => __DIR__ . '/../app/Views/layouts/app.php', | |||||
| ]; | |||||
| ``` | |||||
| `core/View.php` reads this file lazily on first use and caches the result. To change where views or the layout live, edit `config/view.php` — do not edit `core/View.php`. This follows the same pattern as `config/database.php`. | |||||
| --- | |||||
| ## Templates and Views | |||||
| Keep presentation separate from business logic. | |||||
| Rules: | |||||
| - Do not query the database from templates. | |||||
| - Do not place business rules in templates. | |||||
| - Escape output by default. | |||||
| - Prefer simple view models or arrays passed into templates. | |||||
| - Use a template engine with automatic escaping only if it fits the project and does not make the framework unnecessarily large. | |||||
| Plain PHP template example: | |||||
| ```php | |||||
| <h1><?= e($pageTitle) ?></h1> | |||||
| <ul> | |||||
| <?php foreach ($users as $user): ?> | |||||
| <li><?= e($user->name()) ?></li> | |||||
| <?php endforeach; ?> | |||||
| </ul> | |||||
| ``` | |||||
| --- | |||||
| ## Router Methods | |||||
| `Core\Router` exposes one method per HTTP verb: | |||||
| | Method | HTTP verb | | |||||
| |--------|-----------| | |||||
| | `$router->get($path, $handler)` | GET | | |||||
| | `$router->post($path, $handler)` | POST | | |||||
| | `$router->put($path, $handler)` | PUT | | |||||
| | `$router->patch($path, $handler)` | PATCH | | |||||
| | `$router->delete($path, $handler)` | DELETE | | |||||
| | `$router->add($method, $path, $handler)` | Any verb | | |||||
| --- | |||||
| ## Method Override for HTML Forms | |||||
| HTML forms only support GET and POST. To route a form submission to a PUT, PATCH, or DELETE handler, add a hidden `_method` field: | |||||
| ```html | |||||
| <form method="POST" action="/employees/42"> | |||||
| <?= csrf_field() ?> | |||||
| <input type="hidden" name="_method" value="PUT"> | |||||
| <!-- fields --> | |||||
| </form> | |||||
| ``` | |||||
| `Core\Request::method()` checks for this field (and the `X-HTTP-Method-Override` header from JavaScript clients) when the base method is POST, and returns the overridden verb. Only `PUT`, `PATCH`, and `DELETE` are accepted as override values — all others are ignored. | |||||
| --- | |||||
| ## HTTP and Web Application Rules | |||||
| Rules: | |||||
| - Use the front controller pattern where appropriate. | |||||
| - Keep routing separate from business logic. | |||||
| - Validate request methods. | |||||
| - Use CSRF protection for state-changing forms. | |||||
| - Use proper HTTP status codes. | |||||
| - Redirect after successful POST to avoid duplicate form submission. | |||||
| - Do not trust headers such as `X-Forwarded-For` unless configured behind a trusted proxy. | |||||
| Example POST guard using the framework helper: | |||||
| ```php | |||||
| public function store(Request $request): Response | |||||
| { | |||||
| if ($guard = $this->requirePost($request)) { | |||||
| return $guard; | |||||
| } | |||||
| // POST-only logic here | |||||
| } | |||||
| ``` | |||||
| --- | |||||
| ## Architecture Guardrail | |||||
| When adding features, preserve the small-framework character: | |||||
| - Prefer explicit code over hidden convention. | |||||
| - Prefer simple routing over complex annotation systems. | |||||
| - Prefer plain PHP views unless a project decision says otherwise. | |||||
| - Prefer focused services and repositories over large framework abstractions. | |||||
| - Do not introduce a large package just to solve a small problem. | |||||
| @@ -0,0 +1,331 @@ | |||||
| # PHP Skill | |||||
| ## Purpose | |||||
| Use this skill for PHP language rules, coding style, Composer, namespacing, autoloading, object-oriented design, dependency injection, documentation, and performance. | |||||
| Source reference: | |||||
| ```text | |||||
| https://phptherightway.com/ | |||||
| ``` | |||||
| --- | |||||
| ## Core Philosophy | |||||
| Write PHP that is: | |||||
| - Readable before clever. | |||||
| - Secure by default. | |||||
| - Consistent with community standards. | |||||
| - Easy to test, debug, and refactor. | |||||
| - Separated by responsibility. | |||||
| PHP does not have only one canonical “right way,” so prefer widely accepted standards, documented project conventions, and clear tradeoffs over personal style. | |||||
| --- | |||||
| ## PHP Version Standard | |||||
| Use the current stable PHP version supported by the project. | |||||
| Default expectation: | |||||
| ```text | |||||
| PHP 8.2+ | |||||
| ``` | |||||
| Do not introduce code that depends on unsupported PHP versions unless the project explicitly targets a legacy runtime. | |||||
| When adding a language feature, verify that it is supported by the project’s configured PHP version in `composer.json`. | |||||
| Example: | |||||
| ```json | |||||
| { | |||||
| "require": { | |||||
| "php": ">=8.2" | |||||
| } | |||||
| } | |||||
| ``` | |||||
| --- | |||||
| ## Coding Style | |||||
| Follow recognized PHP standards unless the repository already defines stricter rules. | |||||
| Preferred standards: | |||||
| - PSR-1: Basic Coding Standard | |||||
| - PSR-12: Extended Coding Style | |||||
| - PSR-4: Autoloading | |||||
| Use automated tooling rather than manual formatting arguments. | |||||
| Recommended tools: | |||||
| ```bash | |||||
| composer require --dev squizlabs/php_codesniffer | |||||
| composer require --dev friendsofphp/php-cs-fixer | |||||
| ``` | |||||
| Example checks: | |||||
| ```bash | |||||
| vendor/bin/phpcs --standard=PSR12 src tests | |||||
| vendor/bin/php-cs-fixer fix --dry-run --diff | |||||
| ``` | |||||
| Example fix: | |||||
| ```bash | |||||
| vendor/bin/php-cs-fixer fix | |||||
| ``` | |||||
| --- | |||||
| ## Naming Rules | |||||
| Use English names for code symbols and infrastructure. | |||||
| Good: | |||||
| ```php | |||||
| class InvoiceRepository | |||||
| { | |||||
| public function findByCustomerId(int $customerId): array | |||||
| { | |||||
| // ... | |||||
| } | |||||
| } | |||||
| ``` | |||||
| Avoid unclear abbreviations: | |||||
| ```php | |||||
| class InvRepo | |||||
| { | |||||
| public function fbcid($cid) | |||||
| { | |||||
| // ... | |||||
| } | |||||
| } | |||||
| ``` | |||||
| --- | |||||
| ## Formatting Rules | |||||
| - Use `<?php` tags. Do not use short open tags. | |||||
| - Use strict types at the top of new PHP files when practical: | |||||
| ```php | |||||
| declare(strict_types=1); | |||||
| ``` | |||||
| - One class per file. | |||||
| - Match namespaces to directory structure. | |||||
| - Keep functions and methods small and focused. | |||||
| - Prefer explicit visibility: `public`, `protected`, or `private`. | |||||
| - Avoid global state unless required by the framework or legacy integration. | |||||
| --- | |||||
| ## Namespaces and Autoloading | |||||
| All new application classes must use namespaces. | |||||
| Use PSR-4 autoloading through Composer. | |||||
| Example `composer.json`: | |||||
| ```json | |||||
| { | |||||
| "autoload": { | |||||
| "psr-4": { | |||||
| "App\\": "src/" | |||||
| } | |||||
| }, | |||||
| "autoload-dev": { | |||||
| "psr-4": { | |||||
| "Tests\\": "tests/" | |||||
| } | |||||
| } | |||||
| } | |||||
| ``` | |||||
| After changing autoload rules, run: | |||||
| ```bash | |||||
| composer dump-autoload | |||||
| ``` | |||||
| Example class: | |||||
| ```php | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| namespace App\Service; | |||||
| final class InvoiceCalculator | |||||
| { | |||||
| public function calculateTotal(array $items): int | |||||
| { | |||||
| return array_sum(array_column($items, 'amountCents')); | |||||
| } | |||||
| } | |||||
| ``` | |||||
| --- | |||||
| ## Dependency Management | |||||
| Use Composer for PHP dependencies. | |||||
| Rules: | |||||
| - Add packages with `composer require` or `composer require --dev`. | |||||
| - Commit `composer.json` and `composer.lock` for applications. | |||||
| - Do not manually copy vendor libraries into the project. | |||||
| - Do not edit files under `vendor/`. | |||||
| - Prefer maintained packages with clear documentation, tests, and recent releases. | |||||
| - Remove unused packages. | |||||
| Commands: | |||||
| ```bash | |||||
| composer install | |||||
| composer update vendor/package | |||||
| composer audit | |||||
| composer validate | |||||
| ``` | |||||
| Use `composer update` intentionally. Do not casually update every dependency in unrelated work. | |||||
| --- | |||||
| ## Object-Oriented Design | |||||
| Prefer clear object-oriented code for domain and application logic. | |||||
| Example: | |||||
| ```php | |||||
| final class CustomerName | |||||
| { | |||||
| public function __construct(private string $value) | |||||
| { | |||||
| if (trim($value) === '') { | |||||
| throw new InvalidArgumentException('Customer name is required.'); | |||||
| } | |||||
| } | |||||
| public function value(): string | |||||
| { | |||||
| return $this->value; | |||||
| } | |||||
| } | |||||
| ``` | |||||
| Guidelines: | |||||
| - Keep controllers thin. | |||||
| - Put business rules in services or domain objects. | |||||
| - Put persistence logic in repositories or data access classes. | |||||
| - Use interfaces when multiple implementations are expected or when it improves testing. | |||||
| - Avoid huge “utility” classes. | |||||
| - Avoid magic methods unless they provide clear framework integration or a documented benefit. | |||||
| --- | |||||
| ## Dependency Injection | |||||
| Prefer dependency injection over creating dependencies inside classes. | |||||
| Good: | |||||
| ```php | |||||
| final class RegisterUser | |||||
| { | |||||
| public function __construct( | |||||
| private UserRepository $users, | |||||
| private PasswordHasher $passwords | |||||
| ) { | |||||
| } | |||||
| public function handle(string $email, string $plainPassword): void | |||||
| { | |||||
| $hash = $this->passwords->hash($plainPassword); | |||||
| $this->users->create($email, $hash); | |||||
| } | |||||
| } | |||||
| ``` | |||||
| Avoid: | |||||
| ```php | |||||
| final class RegisterUser | |||||
| { | |||||
| public function handle(string $email, string $plainPassword): void | |||||
| { | |||||
| $users = new UserRepository(); | |||||
| $passwords = new PasswordHasher(); | |||||
| } | |||||
| } | |||||
| ``` | |||||
| Rules: | |||||
| - Constructor injection is preferred for required dependencies. | |||||
| - Do not use service locators casually. | |||||
| - Do not hide dependencies in global variables. | |||||
| - Keep dependency containers at application boundaries, not inside domain logic. | |||||
| --- | |||||
| ## Documentation Rules | |||||
| Use PHPDoc where it adds clarity, especially for arrays, complex return values, and public APIs. | |||||
| Good: | |||||
| ```php | |||||
| /** | |||||
| * @return list<Customer> | |||||
| */ | |||||
| public function findActiveCustomers(): array | |||||
| { | |||||
| // ... | |||||
| } | |||||
| ``` | |||||
| Avoid noisy comments that repeat the code: | |||||
| ```php | |||||
| // Increment i by one. | |||||
| $i++; | |||||
| ``` | |||||
| Rules: | |||||
| - Explain why, not just what. | |||||
| - Document non-obvious tradeoffs. | |||||
| - Keep README setup instructions current. | |||||
| - Update examples when behavior changes. | |||||
| --- | |||||
| ## Performance and Caching | |||||
| Rules: | |||||
| - Measure before optimizing. | |||||
| - Avoid unnecessary database queries in loops. | |||||
| - Use pagination for large result sets. | |||||
| - Cache expensive reads where appropriate. | |||||
| - Use OPcache in production. | |||||
| - Do not cache user-specific sensitive data in shared caches without a clear key strategy. | |||||
| @@ -0,0 +1,297 @@ | |||||
| # Security Skill | |||||
| ## Purpose | |||||
| Use this skill for input validation, output escaping, passwords, authentication, authorization, sessions, CSRF, secrets, error disclosure, dangerous functions, serialization, and file/path safety. | |||||
| --- | |||||
| ## Security Baseline | |||||
| Treat all external data as untrusted. | |||||
| Untrusted data includes: | |||||
| - `$_GET` | |||||
| - `$_POST` | |||||
| - `$_REQUEST` | |||||
| - `$_COOKIE` | |||||
| - `$_SERVER` | |||||
| - uploaded files | |||||
| - request bodies | |||||
| - session values | |||||
| - database values originally supplied by users | |||||
| - third-party API responses | |||||
| --- | |||||
| ## Input Validation | |||||
| Validate on input. Use `Core\Validator` for field validation — it is a fluent chain that collects all errors before returning. | |||||
| ### Validator API | |||||
| | Method | Description | | |||||
| |--------|-------------| | |||||
| | `required(field, value, message?)` | Fails if value is null or blank | | |||||
| | `maxLength(field, value, max, message?)` | Fails if string length exceeds max | | |||||
| | `minLength(field, value, min, message?)` | Fails if string length is below min | | |||||
| | `numeric(field, value, message?)` | Fails if value is not numeric | | |||||
| | `min(field, value, min, message?)` | Fails if numeric value is below min | | |||||
| | `max(field, value, max, message?)` | Fails if numeric value exceeds max | | |||||
| | `email(field, value, message?)` | Fails if non-empty value is not a valid email | | |||||
| | `date(field, value, format?, message?)` | Fails if non-empty value does not match the date format (default `Y-m-d`) | | |||||
| | `in(field, value, allowed[], message?)` | Fails if value is not in the allowed list (strict comparison) | | |||||
| | `passes()` | Returns `true` when no errors were collected | | |||||
| | `fails()` | Returns `true` when any errors were collected | | |||||
| | `errors()` | Returns `array<string, list<string>>` of field errors | | |||||
| `email()`, `date()`, `min()`, and `max()` skip empty or non-numeric values respectively — pair them with `required()` or `numeric()` when the field is mandatory. | |||||
| Example: | |||||
| ```php | |||||
| $validator = new Validator(); | |||||
| $validator | |||||
| ->required('email', $form['email'], 'Email is required.') | |||||
| ->maxLength('email', $form['email'], 255) | |||||
| ->email('email', $form['email'], 'Enter a valid email address.') | |||||
| ->required('start_date', $form['start_date'], 'Start date is required.') | |||||
| ->date('start_date', $form['start_date'], 'Y-m-d', 'Enter a valid start date.'); | |||||
| if ($validator->fails()) { | |||||
| // $validator->errors() returns field => [messages] map | |||||
| } | |||||
| ``` | |||||
| Rules: | |||||
| - Validate type, range, length, format, and allowed values. | |||||
| - Validate server-side even when client-side validation exists. | |||||
| - Reject unexpected fields when appropriate. | |||||
| - Normalize data intentionally, not accidentally. | |||||
| - Do not reimplement email or date validation inline in controllers — use the Validator methods. | |||||
| --- | |||||
| ## Output Escaping | |||||
| Escape on output based on context. | |||||
| For HTML output: | |||||
| ```php | |||||
| function e(string $value): string | |||||
| { | |||||
| return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); | |||||
| } | |||||
| ``` | |||||
| Usage: | |||||
| ```php | |||||
| <p><?= e($user->name()) ?></p> | |||||
| ``` | |||||
| Rules: | |||||
| - Escape based on context: HTML, attribute, JavaScript, CSS, URL, SQL, shell. | |||||
| - Do not use the same escaping function for every context. | |||||
| - Prefer template engines with automatic escaping only when appropriate for the project. | |||||
| - Avoid allowing raw HTML from users. If required, sanitize with a proven whitelist sanitizer. | |||||
| - Use `escapeshellarg()` when passing controlled values to shell commands, and avoid shell execution when possible. | |||||
| - Never trust file paths supplied by users. | |||||
| - Reject path traversal values such as `../`, `/`, `\`, and null bytes when user-provided paths are not allowed. | |||||
| --- | |||||
| ## Passwords and Authentication | |||||
| Never store plain-text passwords. | |||||
| Use PHP’s password API: | |||||
| ```php | |||||
| $hash = password_hash($plainPassword, PASSWORD_DEFAULT); | |||||
| if (! password_verify($plainPassword, $hash)) { | |||||
| throw new RuntimeException('Invalid credentials.'); | |||||
| } | |||||
| ``` | |||||
| Rules: | |||||
| - Use `password_hash()` for new password hashes. | |||||
| - Use `password_verify()` for login checks. | |||||
| - Use `password_needs_rehash()` when algorithm/cost settings change. | |||||
| - Do not create your own password hashing algorithm. | |||||
| - Do not use general-purpose hashes like `md5`, `sha1`, or raw `sha256` for passwords. | |||||
| - Rate-limit login attempts. | |||||
| - Regenerate session IDs after login. | |||||
| - Use secure, HTTP-only, SameSite cookies for sessions. | |||||
| --- | |||||
| ## Authorization | |||||
| - Check authorization separately from authentication. | |||||
| - Do not assume logged-in means allowed. | |||||
| - Enforce permissions server-side. | |||||
| - Avoid hiding buttons as the only authorization control. | |||||
| - Prefer explicit permission checks near protected actions or service boundaries. | |||||
| --- | |||||
| ## CSRF | |||||
| Use CSRF protection for state-changing forms and unsafe HTTP methods. | |||||
| State-changing actions include: | |||||
| - Create | |||||
| - Update | |||||
| - Delete | |||||
| - Login/logout state changes | |||||
| - Password changes | |||||
| - Email changes | |||||
| - Permission changes | |||||
| When using `_method` override to tunnel PUT, PATCH, or DELETE through a POST form, always include a CSRF token. The override is only honoured for POST requests, and only for the values `PUT`, `PATCH`, and `DELETE` — all other values are rejected by the framework. | |||||
| In MindVisionCode PHP, use the built-in helpers from `core/helpers.php`: | |||||
| | Helper | Purpose | | |||||
| |--------|---------| | |||||
| | `csrf_token()` | Generates and persists the token in the session | | |||||
| | `csrf_field()` | Outputs a hidden `<input>` carrying the token — use in every state-changing form | | |||||
| | `verify_csrf_token(string $token)` | Returns `bool` — call before any business logic in POST actions | | |||||
| **Always verify CSRF before field validation and business logic.** A token failure is a security event, not a form validation error. Use a dedicated private method that returns `?Response` and short-circuits the action: | |||||
| ```php | |||||
| private function verifyCsrf(Request $request): ?Response | |||||
| { | |||||
| if (!verify_csrf_token((string) $request->input('_token', ''))) { | |||||
| return new Response('Your session has expired. Please go back and try again.', 419); | |||||
| } | |||||
| return null; | |||||
| } | |||||
| ``` | |||||
| Call it as the first thing in the action: | |||||
| ```php | |||||
| public function store(): Response | |||||
| { | |||||
| $request = Request::capture(); | |||||
| if ($guard = $this->verifyCsrf($request)) { | |||||
| return $guard; | |||||
| } | |||||
| // field validation and business logic follow | |||||
| } | |||||
| ``` | |||||
| --- | |||||
| ## Serialization and Data Exchange | |||||
| Do not call `unserialize()` on untrusted data. | |||||
| Prefer JSON for data exchange: | |||||
| ```php | |||||
| $data = json_decode($json, true, flags: JSON_THROW_ON_ERROR); | |||||
| $json = json_encode($data, JSON_THROW_ON_ERROR); | |||||
| ``` | |||||
| Rules: | |||||
| - Use `JSON_THROW_ON_ERROR` for new code. | |||||
| - Validate decoded data before using it. | |||||
| - Avoid PHP serialization for data that crosses trust boundaries. | |||||
| --- | |||||
| ## Configuration and Secrets | |||||
| Rules: | |||||
| - Keep secrets out of source control. | |||||
| - Do not commit passwords, API keys, private keys, tokens, or production DSNs. | |||||
| - Store configuration outside the public web root. | |||||
| - Use environment variables or ignored local config files for secrets. | |||||
| - Provide a safe example file such as `.env.example`. | |||||
| Example `.gitignore` entries: | |||||
| ```text | |||||
| .env | |||||
| .env.local | |||||
| /config/local.php | |||||
| /var/cache/ | |||||
| /var/log/ | |||||
| /vendor/ | |||||
| ``` | |||||
| --- | |||||
| ## Error Handling and Logging | |||||
| Development: | |||||
| - Show errors locally. | |||||
| - Log errors. | |||||
| - Use Xdebug when debugging complex issues. | |||||
| Production: | |||||
| - Do not display errors to users. | |||||
| - Log errors to a secure log destination. | |||||
| - Return safe, generic error messages. | |||||
| - Preserve enough context in logs for troubleshooting. | |||||
| Do not leak: | |||||
| - stack traces to users | |||||
| - SQL statements with secrets | |||||
| - environment variables | |||||
| - full filesystem paths | |||||
| - tokens or passwords | |||||
| Example: | |||||
| ```php | |||||
| try { | |||||
| $service->handle($request); | |||||
| } catch (Throwable $e) { | |||||
| $logger->error('Order processing failed.', [ | |||||
| 'exception' => $e, | |||||
| 'requestId' => $requestId, | |||||
| ]); | |||||
| http_response_code(500); | |||||
| echo 'An unexpected error occurred.'; | |||||
| } | |||||
| ``` | |||||
| --- | |||||
| ## Security Checklist | |||||
| Before completing any feature, verify: | |||||
| - [ ] All external input is validated. | |||||
| - [ ] All output is escaped for the correct context. | |||||
| - [ ] SQL uses prepared statements or safe query builders. | |||||
| - [ ] Authentication and authorization are checked server-side. | |||||
| - [ ] Secrets are not committed. | |||||
| - [ ] Errors are not exposed in production responses. | |||||
| - [ ] File uploads validate size, extension, MIME type, and storage path. | |||||
| - [ ] Passwords use `password_hash()` and `password_verify()`. | |||||
| - [ ] CSRF protection exists for state-changing requests. | |||||
| - [ ] Dangerous functions are avoided or justified: `eval`, `exec`, `shell_exec`, `system`, `passthru`, `unserialize`. | |||||
| - [ ] Dependencies have no known vulnerabilities according to `composer audit`. | |||||
| @@ -0,0 +1,162 @@ | |||||
| # Testing and Quality Skill | |||||
| ## Purpose | |||||
| Use this skill for tests, static analysis, quality gates, Composer scripts, verification steps, and code style checks. | |||||
| --- | |||||
| ## Testing Standard | |||||
| Automated tests are expected for new behavior. | |||||
| Preferred tools: | |||||
| - The project’s existing `tests/run.php` runner | |||||
| - PHPUnit, if the project adds or already uses it | |||||
| - Pest, if the project already uses it | |||||
| Rules: | |||||
| - Add or update tests with every meaningful behavior change. | |||||
| - Cover success paths and failure paths. | |||||
| - Unit-test business logic. | |||||
| - Integration-test database and framework wiring where useful. | |||||
| - Functional-test important user flows. | |||||
| - Avoid relying on `var_dump()` or manual browser testing as the only verification. | |||||
| --- | |||||
| ## Basic Test Command | |||||
| Run basic tests: | |||||
| ```bash | |||||
| php tests/run.php | |||||
| ``` | |||||
| If PHPUnit is installed: | |||||
| ```bash | |||||
| vendor/bin/phpunit | |||||
| ``` | |||||
| Example PHPUnit test: | |||||
| ```php | |||||
| final class InvoiceCalculatorTest extends TestCase | |||||
| { | |||||
| public function testItCalculatesTotalInCents(): void | |||||
| { | |||||
| $calculator = new InvoiceCalculator(); | |||||
| $total = $calculator->calculateTotal([ | |||||
| ['amountCents' => 1000], | |||||
| ['amountCents' => 2500], | |||||
| ]); | |||||
| self::assertSame(3500, $total); | |||||
| } | |||||
| } | |||||
| ``` | |||||
| --- | |||||
| ## Static Analysis and Quality Gates | |||||
| Use static analysis when available. | |||||
| Recommended tools: | |||||
| ```bash | |||||
| composer require --dev phpstan/phpstan | |||||
| composer require --dev vimeo/psalm | |||||
| ``` | |||||
| Common quality commands: | |||||
| ```bash | |||||
| composer validate | |||||
| composer audit | |||||
| vendor/bin/phpcs --standard=PSR12 src tests | |||||
| vendor/bin/phpunit | |||||
| vendor/bin/phpstan analyse src tests | |||||
| ``` | |||||
| Do not ignore tool failures without documenting why. | |||||
| --- | |||||
| ## Recommended Style Tools | |||||
| ```bash | |||||
| composer require --dev squizlabs/php_codesniffer | |||||
| composer require --dev friendsofphp/php-cs-fixer | |||||
| ``` | |||||
| Example checks: | |||||
| ```bash | |||||
| vendor/bin/phpcs --standard=PSR12 src tests | |||||
| vendor/bin/php-cs-fixer fix --dry-run --diff | |||||
| ``` | |||||
| Example fix: | |||||
| ```bash | |||||
| vendor/bin/php-cs-fixer fix | |||||
| ``` | |||||
| --- | |||||
| ## Recommended Composer Scripts | |||||
| A project may include scripts like this: | |||||
| ```json | |||||
| { | |||||
| "scripts": { | |||||
| "test": "phpunit", | |||||
| "style": "phpcs --standard=PSR12 src tests", | |||||
| "style:fix": "php-cs-fixer fix", | |||||
| "analyse": "phpstan analyse src tests", | |||||
| "quality": [ | |||||
| "@style", | |||||
| "@test", | |||||
| "@analyse" | |||||
| ] | |||||
| } | |||||
| } | |||||
| ``` | |||||
| Then run: | |||||
| ```bash | |||||
| composer quality | |||||
| ``` | |||||
| --- | |||||
| ## Verification Rules | |||||
| When completing work: | |||||
| - Run relevant checks when possible. | |||||
| - Explain any checks that could not be run. | |||||
| - If tests are not added, explain why. | |||||
| - If a test fails, do not hide the failure. | |||||
| - Do not change unrelated tests just to make failures disappear. | |||||
| --- | |||||
| ## Quality Checklist | |||||
| Before considering work complete: | |||||
| - [ ] New behavior is tested. | |||||
| - [ ] Existing tests pass, or failures are explained. | |||||
| - [ ] Code follows PSR-12 or project-specific style. | |||||
| - [ ] Composer files are valid. | |||||
| - [ ] No unrelated dependency updates were introduced. | |||||
| - [ ] Static analysis passes if configured. | |||||
| - [ ] Manual verification steps are documented when automated testing is not practical. | |||||
| @@ -0,0 +1,126 @@ | |||||
| # Agent Workflow Skill | |||||
| ## Purpose | |||||
| Use this skill for AI agent behavior, multi-file work, pull-request checks, legacy PHP changes, non-negotiable rules, response format, and skill feedback. | |||||
| --- | |||||
| ## Agent Workflow | |||||
| When modifying this codebase, the AI agent must: | |||||
| 1. Inspect existing project conventions before adding new patterns. | |||||
| 2. Prefer small, focused changes. | |||||
| 3. Preserve public behavior unless explicitly asked to change it. | |||||
| 4. Add or update tests when behavior changes. | |||||
| 5. Run relevant checks when possible. | |||||
| 6. Explain any checks that could not be run. | |||||
| 7. Avoid introducing new dependencies unless they solve a clear problem. | |||||
| 8. Never place secrets in code, tests, fixtures, logs, or documentation. | |||||
| 9. Keep generated code consistent with this file and the loaded skill files. | |||||
| 10. Leave the repository better organized than it was found. | |||||
| --- | |||||
| ## Pull Request / Review Checklist | |||||
| Before considering work complete: | |||||
| - [ ] Code follows PSR-12 or project-specific style. | |||||
| - [ ] Namespaces and autoloading are correct. | |||||
| - [ ] Composer files are valid. | |||||
| - [ ] No unrelated dependency updates were introduced. | |||||
| - [ ] New behavior is tested. | |||||
| - [ ] Existing tests pass. | |||||
| - [ ] SQL is parameterized. | |||||
| - [ ] User input is validated. | |||||
| - [ ] Output is escaped. | |||||
| - [ ] No secrets are committed. | |||||
| - [ ] Errors are handled safely. | |||||
| - [ ] Documentation was updated where needed. | |||||
| --- | |||||
| ## Legacy PHP Exception Policy | |||||
| If this project contains legacy PHP: | |||||
| - Do not rewrite large areas without approval. | |||||
| - Add tests around legacy behavior before refactoring. | |||||
| - Improve safety incrementally. | |||||
| - Replace deprecated patterns as touched. | |||||
| - Avoid mixing modernization with unrelated feature work. | |||||
| - Document any compatibility constraints. | |||||
| Legacy code should still move toward: | |||||
| - Composer autoloading | |||||
| - Namespaces | |||||
| - PDO/prepared statements | |||||
| - Centralized configuration | |||||
| - Automated tests | |||||
| - Safer error handling | |||||
| --- | |||||
| ## Non-Negotiable Rules | |||||
| The agent must not: | |||||
| - Commit secrets. | |||||
| - Build SQL using untrusted string concatenation. | |||||
| - Store plain-text passwords. | |||||
| - Use `md5`, `sha1`, or raw fast hashes for passwords. | |||||
| - Display production errors to users. | |||||
| - `unserialize()` untrusted data. | |||||
| - Put database queries in templates. | |||||
| - Edit files under `vendor/`. | |||||
| - Add dependencies without a clear reason. | |||||
| - Ignore failing tests or quality checks without explanation. | |||||
| --- | |||||
| ## Response Format | |||||
| For non-trivial tasks, respond using this structure: | |||||
| ```text | |||||
| Goal: | |||||
| Route: | |||||
| Assumptions: | |||||
| Plan: | |||||
| Implementation: | |||||
| Tests: | |||||
| Risks: | |||||
| ``` | |||||
| For simple questions, answer directly. | |||||
| --- | |||||
| ## Skill Feedback Rule | |||||
| If project guidance is missing or unclear, suggest an update. | |||||
| ```text | |||||
| Suggested SKILLS.md update: | |||||
| - Add/update: ... | |||||
| - Reason: ... | |||||
| ``` | |||||
| --- | |||||
| ## Final Instruction to Coding Agents | |||||
| When in doubt, choose the boring, obvious, secure PHP solution: | |||||
| - Composer-managed dependencies | |||||
| - PSR-style code | |||||
| - Namespaced classes | |||||
| - Dependency injection | |||||
| - PDO prepared statements | |||||
| - Escaped output | |||||
| - Tested behavior | |||||
| - Clear errors and logs | |||||
| - No secrets in source control | |||||
| @@ -0,0 +1,29 @@ | |||||
| { | |||||
| "permissions": { | |||||
| "allow": [ | |||||
| "PowerShell(\\(Get-Item \"d:\\\\Development\\\\PHP\\\\Mind-Vision-Code\\\\CLAUDE.md\"\\).Target)", | |||||
| "Bash(Get-ChildItem -Recurse \"C:\\\\Users\\\\danielc.NTP\\\\AppData\\\\Local\\\\Temp\\\\KCI-KANBAN-inspect\")", | |||||
| "Bash(Select-Object FullName)", | |||||
| "Bash(Sort-Object FullName)", | |||||
| "Bash(find /mnt/c/Users/danielc.NTP/AppData/Local/Temp/KCI-KANBAN-inspect -type f | sort 2>&1)", | |||||
| "Read(//mnt/c/Users/danielc.NTP/AppData/Local/Temp/**)", | |||||
| "PowerShell(git clone https://onefortheroadgit.sytes.net/dcovington/KCI-KANBAN \"$env:TEMP\\\\KCI-KANBAN-inspect\" 2>&1; Get-ChildItem -Recurse \"$env:TEMP\\\\KCI-KANBAN-inspect\" | Select-Object FullName | Sort-Object FullName)", | |||||
| "PowerShell(Get-ChildItem -Recurse \"$env:TEMP\\\\KCI-KANBAN-inspect\" | Select-Object -ExpandProperty FullName | Sort-Object)", | |||||
| "Bash(composer require *)", | |||||
| "Bash(php -r \"echo PHP_VERSION;\")", | |||||
| "Read(//usr/local/**)", | |||||
| "Bash(find /usr /opt /home -name \"composer\" -o -name \"composer.phar\")", | |||||
| "Bash(php /d/Development/PHP/PHP-TERRITORY/composer.phar require stevenmaguire/oauth2-keycloak)", | |||||
| "Bash(php -m)", | |||||
| "Bash(find /c/Users/danielc.NTP/AppData/Local/JetBrains -name \"composer.phar\" 2>/dev/null; find /d/Development -name \"composer.phar\" 2>/dev/null)", | |||||
| "Read(//d/Development/**)", | |||||
| "Bash(/c/Users/danielc.NTP/AppData/Local/Microsoft/WinGet/Packages/PHP.PHP.8.5_Microsoft.Winget.Source_8wekyb3d8bbwe/php.exe -m)", | |||||
| "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_openssl.dll /d/Development/PHP/PHP-TERRITORY/composer.phar require stevenmaguire/oauth2-keycloak)", | |||||
| "Bash(cp \"/c/Users/danielc.NTP/AppData/Local/Temp/KCI-KANBAN-inspect/public/css/kanban.css\" \"d:/Development/PHP/KCI-PHP-KANBAN/public/css/kanban.css\")", | |||||
| "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)" | |||||
| ] | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,4 @@ | |||||
| .git | |||||
| .ai | |||||
| vendor | |||||
| database/app.sqlite | |||||
| @@ -0,0 +1,19 @@ | |||||
| /vendor/ | |||||
| .env | |||||
| .env.local | |||||
| .env.* | |||||
| *.log | |||||
| .DS_Store | |||||
| .idea/ | |||||
| .vscode/ | |||||
| /public/uploads/ | |||||
| /storage/cache/ | |||||
| /storage/logs/ | |||||
| /database/app.sqlite | |||||
| /database/*.sqlite | |||||
| /database/*.sqlite-shm | |||||
| /database/*.sqlite-wal | |||||
| /database/*.sqlite-journal | |||||
| .phpunit.result.cache | |||||
| Thumbs.db | |||||
| @@ -0,0 +1,242 @@ | |||||
| # AGENTS.md | |||||
| ## Purpose | |||||
| Describe how AI coding agents should work in this repository. | |||||
| This file is intentionally small. It acts as the repository-level router and startup guide. Detailed rules live in focused files under `./.ai/skills/` and should be loaded only when relevant to the current task. | |||||
| --- | |||||
| ## Startup Instructions | |||||
| Before working on any task: | |||||
| 1. Read this `AGENTS.md`. | |||||
| 2. Read the main skill index: | |||||
| ```text | |||||
| ./.ai/SKILLS.md | |||||
| ``` | |||||
| 3. Load only the relevant skill files for the current task. | |||||
| 4. Follow project-specific instructions before general best practices. | |||||
| 5. Inspect existing code before introducing new patterns. | |||||
| 6. State important assumptions before making major changes. | |||||
| 7. Prefer small, focused, reviewable changes. | |||||
| --- | |||||
| ## Instruction Priority | |||||
| When instructions conflict, follow this order: | |||||
| 1. User's current request | |||||
| 2. Safety, security, privacy, and data-loss prevention | |||||
| 3. This `AGENTS.md` | |||||
| 4. `./.ai/SKILLS.md` | |||||
| 5. Referenced files in `./.ai/skills/` | |||||
| 6. Existing project conventions | |||||
| 7. General best practices | |||||
| --- | |||||
| ## Project Summary | |||||
| This project is the **KCI Kanban Board** — a print/mail job tracking kanban application built on the **MindVisionCode PHP** framework. | |||||
| It was migrated from an ASP Classic implementation (source: `https://onefortheroadgit.sytes.net/dcovington/KCI-KANBAN`). | |||||
| ### What this app does | |||||
| - Manage multiple kanban **boards** (each board has a slug-based URL) | |||||
| - Each board has configurable **columns** (job stages) and **swim lanes** (job categories/priority rows) | |||||
| - **Cards** represent print jobs: job_number, job_name, customer_name, delivery_date, quantity, notes, full_note (PrintStream raw data) | |||||
| - Cards drag-and-drop between cells (column × lane intersection) via SortableJS | |||||
| - Board settings panel: add/rename/delete/reorder columns and swim lanes | |||||
| - **PrintStream integration flag** on boards (`import_from_printstream`, `printstream_job_name`) — the actual import script is a separate process not yet ported to PHP | |||||
| - Authentication via **Keycloak SSO** (OIDC authorization-code flow) | |||||
| ### Domain tables | |||||
| | Table | Purpose | | |||||
| |-------|---------| | |||||
| | `boards` | One row per kanban board | | |||||
| | `board_columns` | Columns (stages) belonging to a board | | |||||
| | `swim_lanes` | Swim lanes (rows) belonging to a board | | |||||
| | `cards` | Job cards placed in a column × lane cell | | |||||
| ### Auth pattern | |||||
| - `app/Services/AuthService.php` — static helpers: `requireLogin()`, `isLoggedIn()`, `getCurrentUsername()` | |||||
| - Page controllers call `if ($guard = AuthService::requireLogin()) return $guard;` | |||||
| - JSON API controllers call `if (!AuthService::isLoggedIn()) return $this->json([...], 401);` | |||||
| - Keycloak config lives in `config/auth.php`, reads from env vars — see `.env.example` | |||||
| ### Repository instantiation pattern | |||||
| Repositories are instantiated directly inside controllers using the `database()` helper (not DI container): | |||||
| ```php | |||||
| private function boards(): BoardRepository | |||||
| { | |||||
| return new BoardRepository(database()); | |||||
| } | |||||
| ``` | |||||
| ### Route ordering rule | |||||
| `/columns/reorder` and `/swimlanes/reorder` **must be registered before** `/columns/{id}` and `/swimlanes/{id}`. If the literal route comes after the param route, "reorder" is treated as an id. See `routes/web.php` comments. | |||||
| ### JSON body endpoints | |||||
| `ColumnsController::reorder()` and `SwimLanesController::reorder()` receive a JSON array body (not form data). They read it with `json_decode(file_get_contents('php://input'), true)`. Do not try to use `$request->input()` for these. | |||||
| ### Board show view | |||||
| `BoardsController::show()` uses `$this->fragment()` (not `$this->view()`) because the kanban board is a fully self-contained HTML page with its own `<html>/<head>/<body>` — it does not use the shared `app.php` layout. | |||||
| ### Static assets | |||||
| - `public/css/kanban.css` — kanban grid layout and card styles (copied from ASP repo) | |||||
| - `public/js/kanban-board.js` — grid rendering, drag-drop, search | |||||
| - `public/js/kanban-modal.js` — card create/edit modal | |||||
| - `public/js/kanban-settings.js` — settings panel (add/rename/delete/reorder columns and lanes) | |||||
| The JS posts to `/cards/*`, `/columns/*`, `/swimlanes/*` — the PHP routes must match exactly. | |||||
| ### Composer | |||||
| Run with the PHP binary found at: | |||||
| ``` | |||||
| C:\Users\danielc.NTP\AppData\Local\Microsoft\WinGet\Packages\PHP.PHP.8.5_Microsoft.Winget.Source_8wekyb3d8bbwe\php.exe | |||||
| ``` | |||||
| Composer.phar is at `D:\Development\PHP\PHP-TERRITORY\composer.phar`. Requires `-d extension=php_openssl.dll`. | |||||
| ### Docker build notes | |||||
| 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. | |||||
| It is intentionally inspired by a Classic ASP MVC framework style: | |||||
| - Central dispatcher | |||||
| - Controllers and actions | |||||
| - ViewModels | |||||
| - Repository classes | |||||
| - Simple validation | |||||
| - Database migrations | |||||
| - Small, readable files | |||||
| - Minimal dependencies | |||||
| Do **not** turn this project into Laravel, Symfony, Slim, CakePHP, or another large framework. | |||||
| The goal is to keep the framework understandable, practical, and easy to extend. | |||||
| --- | |||||
| ## Main Route | |||||
| Always start here: | |||||
| ```text | |||||
| ./.ai/SKILLS.md | |||||
| ``` | |||||
| Then load only the skill files needed for the task. | |||||
| --- | |||||
| ## Common Skill Routes | |||||
| ```text | |||||
| PHP language, style, Composer, OOP: | |||||
| ./.ai/skills/php/SKILL.md | |||||
| MVC framework architecture, routing, controllers, views, ViewModels: | |||||
| ./.ai/skills/mvc/SKILL.md | |||||
| PDO, repositories, migrations, SQL, database safety: | |||||
| ./.ai/skills/database/SKILL.md | |||||
| Input validation, escaping, passwords, sessions, secrets, web security: | |||||
| ./.ai/skills/security/SKILL.md | |||||
| Tests, static analysis, quality gates, composer scripts: | |||||
| ./.ai/skills/testing/SKILL.md | |||||
| Agent behavior, PR checklist, legacy policy, response format: | |||||
| ./.ai/skills/workflow/SKILL.md | |||||
| ``` | |||||
| --- | |||||
| ## Response Format | |||||
| For non-trivial tasks, respond using this structure: | |||||
| ```text | |||||
| Goal: | |||||
| Route: | |||||
| Assumptions: | |||||
| Plan: | |||||
| Implementation: | |||||
| Tests: | |||||
| Risks: | |||||
| ``` | |||||
| For simple questions, answer directly. | |||||
| --- | |||||
| ## Framework Change Policy | |||||
| The framework core may be modified to add functionality or optimize existing code, but **never silently**. Any time an agent identifies a change to framework-level code (dispatcher, routing, base controller, base repository, migration runner, validation engine, autoloader, or any file under `core/`), it must stop and present the following proposal to the user before writing a single line: | |||||
| ```text | |||||
| FRAMEWORK CHANGE PROPOSAL | |||||
| ========================== | |||||
| Issue: | |||||
| What problem or limitation was found, and where in the framework it lives. | |||||
| Proposed Change: | |||||
| What would be added, modified, or removed. | |||||
| Why It Is Needed: | |||||
| The specific reason application code cannot solve this without a framework change. | |||||
| Risks / Dangers: | |||||
| - Breaking changes to existing controllers, repositories, or views | |||||
| - Behavioral differences across PHP versions | |||||
| - Security surface changes | |||||
| - Performance regressions | |||||
| - Any other relevant concerns | |||||
| Benefits: | |||||
| - What improves or is unlocked by the change | |||||
| Alternatives Considered: | |||||
| Any application-level workarounds that were ruled out and why. | |||||
| Ai Agent Skills Update: | |||||
| - What skills need to be changed to support this framework-level change? | |||||
| Awaiting your approval before proceeding. Reply YES to apply, NO to skip, or ask questions. | |||||
| ``` | |||||
| **Rules:** | |||||
| - Do not apply the change until the user explicitly approves. | |||||
| - If the user says NO, document the limitation as a comment or note and continue with the best available application-level workaround. | |||||
| - Keep framework changes small and focused — one concern per change. | |||||
| - After approval, note the change in the commit message so the history is clear. | |||||
| - Update the proper skill file so that the new process can be applied to all future changes in this repository. | |||||
| --- | |||||
| ## Skill Feedback Rule | |||||
| If project guidance is missing or unclear, suggest an update. | |||||
| ```text | |||||
| Suggested SKILLS.md update: | |||||
| - Add/update: ... | |||||
| - Reason: ... | |||||
| ``` | |||||
| @@ -0,0 +1 @@ | |||||
| AGENTS.md | |||||
| @@ -0,0 +1,33 @@ | |||||
| FROM php:8.5-apache | |||||
| # Install pdo_sqlite and enable mod_rewrite | |||||
| RUN apt-get update \ | |||||
| && apt-get install -y libsqlite3-dev libzip-dev unzip \ | |||||
| && rm -rf /var/lib/apt/lists/* \ | |||||
| && docker-php-ext-install pdo_sqlite zip \ | |||||
| && a2enmod rewrite | |||||
| # Install Composer | |||||
| COPY --from=composer:latest /usr/bin/composer /usr/bin/composer | |||||
| # Configure Apache virtual host | |||||
| COPY docker/vhost.conf /etc/apache2/sites-available/000-default.conf | |||||
| COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh | |||||
| RUN chmod +x /usr/local/bin/entrypoint.sh | |||||
| WORKDIR /var/www/html | |||||
| # Copy application files | |||||
| 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 \ | |||||
| && chown -R www-data:www-data /var/www/html \ | |||||
| && chmod 775 database | |||||
| EXPOSE 80 | |||||
| ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] | |||||
| @@ -0,0 +1,80 @@ | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| namespace App\Controllers; | |||||
| use App\Services\AuthService; | |||||
| use Core\Controller; | |||||
| use Core\Request; | |||||
| class AuthController extends Controller | |||||
| { | |||||
| public function login(Request $request): mixed | |||||
| { | |||||
| $returnTo = trim((string) $request->input('returnTo', '')); | |||||
| if ($returnTo !== '') { | |||||
| $_SESSION['auth_return_to'] = $returnTo; | |||||
| } | |||||
| $provider = AuthService::provider(); | |||||
| $authUrl = $provider->getAuthorizationUrl(); | |||||
| $_SESSION['oauth2_state'] = $provider->getState(); | |||||
| return $this->redirect($authUrl); | |||||
| } | |||||
| public function callback(Request $request): mixed | |||||
| { | |||||
| $state = (string) $request->input('state', ''); | |||||
| $code = (string) $request->input('code', ''); | |||||
| if ($state === '' || $state !== ($_SESSION['oauth2_state'] ?? '')) { | |||||
| unset($_SESSION['oauth2_state']); | |||||
| return $this->view('auth.callback-error', [ | |||||
| 'pageTitle' => 'Authentication Error', | |||||
| 'error' => 'Invalid state parameter. Please try logging in again.', | |||||
| ]); | |||||
| } | |||||
| unset($_SESSION['oauth2_state']); | |||||
| try { | |||||
| $provider = AuthService::provider(); | |||||
| $token = $provider->getAccessToken('authorization_code', ['code' => $code]); | |||||
| $userInfo = AuthService::claimsFromToken($token->getToken()); | |||||
| if (empty($userInfo)) { | |||||
| throw new \RuntimeException('Access token payload was empty or undecodable.'); | |||||
| } | |||||
| AuthService::storeUser($userInfo); | |||||
| $redirectTo = $_SESSION['auth_return_to'] ?? '/boards'; | |||||
| unset($_SESSION['auth_return_to']); | |||||
| return $this->redirect($redirectTo); | |||||
| } catch (\Throwable $e) { | |||||
| error_log('Keycloak callback error: ' . $e->getMessage()); | |||||
| $debug = filter_var(getenv('APP_DEBUG'), FILTER_VALIDATE_BOOLEAN); | |||||
| $error = $debug | |||||
| ? get_class($e) . ': ' . $e->getMessage() | |||||
| : 'Authentication failed. Please try again.'; | |||||
| return $this->view('auth.callback-error', [ | |||||
| 'pageTitle' => 'Authentication Error', | |||||
| 'error' => $error, | |||||
| ]); | |||||
| } | |||||
| } | |||||
| public function logout(): mixed | |||||
| { | |||||
| AuthService::clearSession(); | |||||
| return $this->redirect(AuthService::logoutUrl()); | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,204 @@ | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| namespace App\Controllers; | |||||
| use App\Models\Board; | |||||
| use App\Repositories\BoardColumnRepository; | |||||
| use App\Repositories\BoardRepository; | |||||
| use App\Repositories\CardRepository; | |||||
| use App\Repositories\SwimLaneRepository; | |||||
| use App\Services\AuthService; | |||||
| use Core\Controller; | |||||
| use Core\Request; | |||||
| class BoardsController extends Controller | |||||
| { | |||||
| private function boards(): BoardRepository | |||||
| { | |||||
| return new BoardRepository(database()); | |||||
| } | |||||
| private function columns(): BoardColumnRepository | |||||
| { | |||||
| return new BoardColumnRepository(database()); | |||||
| } | |||||
| private function lanes(): SwimLaneRepository | |||||
| { | |||||
| return new SwimLaneRepository(database()); | |||||
| } | |||||
| private function cards(): CardRepository | |||||
| { | |||||
| return new CardRepository(database()); | |||||
| } | |||||
| public function index(): mixed | |||||
| { | |||||
| if ($guard = AuthService::requireLogin()) { | |||||
| return $guard; | |||||
| } | |||||
| $boards = $this->boards()->getAll(); | |||||
| return $this->view('boards.index', [ | |||||
| 'pageTitle' => 'Boards', | |||||
| 'boards' => $boards, | |||||
| ]); | |||||
| } | |||||
| public function create(): mixed | |||||
| { | |||||
| if ($guard = AuthService::requireLogin()) { | |||||
| return $guard; | |||||
| } | |||||
| return $this->view('boards.create', ['pageTitle' => 'New Board']); | |||||
| } | |||||
| public function store(Request $request): mixed | |||||
| { | |||||
| if ($guard = AuthService::requireLogin()) { | |||||
| return $guard; | |||||
| } | |||||
| $name = trim((string) $request->input('name', '')); | |||||
| if ($name === '') { | |||||
| return $this->view('boards.create', [ | |||||
| 'pageTitle' => 'New Board', | |||||
| 'error' => 'Board name is required.', | |||||
| 'old' => $request->all(), | |||||
| ]); | |||||
| } | |||||
| $username = AuthService::getCurrentUsername(); | |||||
| $now = date('Y-m-d H:i:s'); | |||||
| $board = new Board(); | |||||
| $board->name = $name; | |||||
| $board->slug = $this->boards()->uniqueSlug($this->generateSlug($name)); | |||||
| $board->importFromPrintstream = $request->input('import_from_printstream') === 'on'; | |||||
| $board->printstreamJobName = trim((string) $request->input('printstream_job_name', '')); | |||||
| $board->createdAt = $now; | |||||
| $board->createdBy = $username; | |||||
| $board->updatedAt = $now; | |||||
| $board->updatedBy = $username; | |||||
| $this->boards()->insert($board); | |||||
| return $this->redirect('/board/' . $board->slug); | |||||
| } | |||||
| public function show(string $slug): mixed | |||||
| { | |||||
| if ($guard = AuthService::requireLogin()) { | |||||
| return $guard; | |||||
| } | |||||
| $board = $this->boards()->findBySlug($slug); | |||||
| if ($board === null) { | |||||
| return \Core\Response::notFound('Board not found.'); | |||||
| } | |||||
| $columns = $this->columns()->findByBoardId($board->id); | |||||
| $lanes = $this->lanes()->findByBoardId($board->id); | |||||
| $allCards = $this->cards()->findByBoardId($board->id); | |||||
| $cardsJson = json_encode( | |||||
| array_map(fn($c) => $c->toJsonArray(), $allCards), | |||||
| JSON_THROW_ON_ERROR | |||||
| ); | |||||
| return $this->fragment('boards.show', [ | |||||
| 'board' => $board, | |||||
| 'columns' => $columns, | |||||
| 'lanes' => $lanes, | |||||
| 'cardsJson' => $cardsJson, | |||||
| ]); | |||||
| } | |||||
| public function edit(string $slug): mixed | |||||
| { | |||||
| if ($guard = AuthService::requireLogin()) { | |||||
| return $guard; | |||||
| } | |||||
| $board = $this->boards()->findBySlug($slug); | |||||
| if ($board === null) { | |||||
| return \Core\Response::notFound('Board not found.'); | |||||
| } | |||||
| return $this->view('boards.edit', [ | |||||
| 'pageTitle' => 'Edit Board', | |||||
| 'board' => $board, | |||||
| ]); | |||||
| } | |||||
| public function update(Request $request, string $slug): mixed | |||||
| { | |||||
| if ($guard = AuthService::requireLogin()) { | |||||
| return $guard; | |||||
| } | |||||
| $board = $this->boards()->findBySlug($slug); | |||||
| if ($board === null) { | |||||
| return \Core\Response::notFound('Board not found.'); | |||||
| } | |||||
| $newName = trim((string) $request->input('name', '')); | |||||
| if ($newName === '') { | |||||
| return $this->view('boards.edit', [ | |||||
| 'pageTitle' => 'Edit Board', | |||||
| 'board' => $board, | |||||
| 'error' => 'Board name is required.', | |||||
| ]); | |||||
| } | |||||
| $board->name = $newName; | |||||
| $board->slug = $this->boards()->uniqueSlug($this->generateSlug($newName), $board->id); | |||||
| $board->importFromPrintstream = $request->input('import_from_printstream') === 'on'; | |||||
| $board->printstreamJobName = trim((string) $request->input('printstream_job_name', '')); | |||||
| $board->updatedAt = date('Y-m-d H:i:s'); | |||||
| $board->updatedBy = AuthService::getCurrentUsername(); | |||||
| $this->boards()->update($board); | |||||
| return $this->redirect('/board/' . $board->slug); | |||||
| } | |||||
| public function destroy(string $slug): mixed | |||||
| { | |||||
| if ($guard = AuthService::requireLogin()) { | |||||
| return $guard; | |||||
| } | |||||
| $board = $this->boards()->findBySlug($slug); | |||||
| if ($board === null) { | |||||
| return $this->redirect('/boards'); | |||||
| } | |||||
| $this->cards()->deleteByBoardId($board->id); | |||||
| $this->columns()->deleteByBoardId($board->id); | |||||
| $this->lanes()->deleteByBoardId($board->id); | |||||
| $this->boards()->delete($board->id); | |||||
| return $this->redirect('/boards'); | |||||
| } | |||||
| private function generateSlug(string $text): string | |||||
| { | |||||
| $slug = strtolower($text); | |||||
| $slug = preg_replace('/[^a-z0-9\s-]/', '', $slug) ?? $slug; | |||||
| $slug = preg_replace('/[\s-]+/', '-', trim($slug)) ?? $slug; | |||||
| return trim($slug, '-'); | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,132 @@ | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| namespace App\Controllers; | |||||
| use App\Models\Card; | |||||
| use App\Repositories\CardRepository; | |||||
| use App\Services\AuthService; | |||||
| use Core\Controller; | |||||
| use Core\Request; | |||||
| class CardsController extends Controller | |||||
| { | |||||
| private function cards(): CardRepository | |||||
| { | |||||
| return new CardRepository(database()); | |||||
| } | |||||
| public function store(Request $request): mixed | |||||
| { | |||||
| if (!AuthService::isLoggedIn()) { | |||||
| return $this->json(['ok' => false, 'error' => 'Unauthorized'], 401); | |||||
| } | |||||
| $boardId = (int) $request->input('board_id', 0); | |||||
| $columnId = (int) $request->input('column_id', 0); | |||||
| $swimLaneId = (int) $request->input('swim_lane_id', 0); | |||||
| if ($boardId === 0 || $columnId === 0 || $swimLaneId === 0) { | |||||
| return $this->json(['ok' => false, 'error' => 'board_id, column_id, and swim_lane_id are required']); | |||||
| } | |||||
| $now = date('Y-m-d H:i:s'); | |||||
| $username = AuthService::getCurrentUsername(); | |||||
| $nextPos = $this->cards()->maxPosition($columnId, $swimLaneId) + 1; | |||||
| $card = new Card(); | |||||
| $card->boardId = $boardId; | |||||
| $card->columnId = $columnId; | |||||
| $card->swimLaneId = $swimLaneId; | |||||
| $card->jobNumber = trim((string) $request->input('job_number', '')); | |||||
| $card->jobName = trim((string) $request->input('job_name', '')); | |||||
| $card->customerName = trim((string) $request->input('customer_name', '')); | |||||
| $card->deliveryDate = trim((string) $request->input('delivery_date', '')) ?: null; | |||||
| $card->quantity = trim((string) $request->input('quantity', '')); | |||||
| $card->notes = trim((string) $request->input('notes', '')); | |||||
| $card->fullNote = (string) $request->input('full_note', ''); | |||||
| $card->position = $nextPos; | |||||
| $card->createdAt = $now; | |||||
| $card->createdBy = $username; | |||||
| $card->updatedAt = $now; | |||||
| $card->updatedBy = $username; | |||||
| try { | |||||
| $this->cards()->insert($card); | |||||
| } catch (\Throwable $e) { | |||||
| return $this->json(['ok' => false, 'error' => $e->getMessage()]); | |||||
| } | |||||
| return $this->json(['ok' => true] + $card->toJsonArray()); | |||||
| } | |||||
| public function update(Request $request, int $id): mixed | |||||
| { | |||||
| if (!AuthService::isLoggedIn()) { | |||||
| return $this->json(['ok' => false, 'error' => 'Unauthorized'], 401); | |||||
| } | |||||
| $card = $this->cards()->findById($id); | |||||
| if ($card === null) { | |||||
| return $this->json(['ok' => false, 'error' => 'Not found'], 404); | |||||
| } | |||||
| $card->jobNumber = trim((string) $request->input('job_number', '')); | |||||
| $card->jobName = trim((string) $request->input('job_name', '')); | |||||
| $card->customerName = trim((string) $request->input('customer_name', '')); | |||||
| $card->deliveryDate = trim((string) $request->input('delivery_date', '')) ?: null; | |||||
| $card->quantity = trim((string) $request->input('quantity', '')); | |||||
| $card->notes = trim((string) $request->input('notes', '')); | |||||
| $card->fullNote = (string) $request->input('full_note', ''); | |||||
| $card->updatedAt = date('Y-m-d H:i:s'); | |||||
| $card->updatedBy = AuthService::getCurrentUsername(); | |||||
| try { | |||||
| $this->cards()->update($card); | |||||
| } catch (\Throwable $e) { | |||||
| return $this->json(['ok' => false, 'error' => $e->getMessage()]); | |||||
| } | |||||
| return $this->json(['ok' => true] + $card->toJsonArray()); | |||||
| } | |||||
| public function move(Request $request, int $id): mixed | |||||
| { | |||||
| if (!AuthService::isLoggedIn()) { | |||||
| return $this->json(['ok' => false, 'error' => 'Unauthorized'], 401); | |||||
| } | |||||
| $columnId = (int) $request->input('column_id', 0); | |||||
| $swimLaneId = (int) $request->input('swim_lane_id', 0); | |||||
| $position = (int) $request->input('position', 0); | |||||
| $now = date('Y-m-d H:i:s'); | |||||
| $username = AuthService::getCurrentUsername(); | |||||
| $this->cards()->move($id, $columnId, $swimLaneId, $position, $now, $username); | |||||
| $siblings = trim((string) $request->input('sibling_ids', '')); | |||||
| if ($siblings !== '') { | |||||
| foreach (explode(',', $siblings) as $idx => $sibId) { | |||||
| $sibId = (int) trim($sibId); | |||||
| if ($sibId > 0) { | |||||
| $this->cards()->updatePosition($sibId, $idx, $now, $username); | |||||
| } | |||||
| } | |||||
| } | |||||
| return $this->json(['ok' => true]); | |||||
| } | |||||
| public function destroy(int $id): mixed | |||||
| { | |||||
| if (!AuthService::isLoggedIn()) { | |||||
| return $this->json(['ok' => false, 'error' => 'Unauthorized'], 401); | |||||
| } | |||||
| $this->cards()->delete($id); | |||||
| return $this->json(['ok' => true]); | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,120 @@ | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| namespace App\Controllers; | |||||
| use App\Models\BoardColumn; | |||||
| use App\Repositories\BoardColumnRepository; | |||||
| use App\Repositories\CardRepository; | |||||
| use App\Services\AuthService; | |||||
| use Core\Controller; | |||||
| use Core\Request; | |||||
| class ColumnsController extends Controller | |||||
| { | |||||
| private function columns(): BoardColumnRepository | |||||
| { | |||||
| return new BoardColumnRepository(database()); | |||||
| } | |||||
| private function cards(): CardRepository | |||||
| { | |||||
| return new CardRepository(database()); | |||||
| } | |||||
| public function store(Request $request): mixed | |||||
| { | |||||
| if (!AuthService::isLoggedIn()) { | |||||
| return $this->json(['ok' => false, 'error' => 'Unauthorized'], 401); | |||||
| } | |||||
| $boardId = (int) $request->input('board_id', 0); | |||||
| $name = trim((string) $request->input('name', '')); | |||||
| if ($boardId === 0 || $name === '') { | |||||
| return $this->json(['ok' => false, 'error' => 'board_id and name are required']); | |||||
| } | |||||
| $now = date('Y-m-d H:i:s'); | |||||
| $username = AuthService::getCurrentUsername(); | |||||
| $col = new BoardColumn(); | |||||
| $col->boardId = $boardId; | |||||
| $col->name = $name; | |||||
| $col->position = $this->columns()->maxPosition($boardId) + 1; | |||||
| $col->createdAt = $now; | |||||
| $col->createdBy = $username; | |||||
| $col->updatedAt = $now; | |||||
| $col->updatedBy = $username; | |||||
| $this->columns()->insert($col); | |||||
| return $this->json(['ok' => true, 'id' => $col->id, 'name' => $col->name, 'position' => $col->position]); | |||||
| } | |||||
| public function update(Request $request, int $id): mixed | |||||
| { | |||||
| if (!AuthService::isLoggedIn()) { | |||||
| return $this->json(['ok' => false, 'error' => 'Unauthorized'], 401); | |||||
| } | |||||
| $name = trim((string) $request->input('name', '')); | |||||
| if ($name === '') { | |||||
| return $this->json(['ok' => false, 'error' => 'name is required']); | |||||
| } | |||||
| $row = $this->columns()->find($id); | |||||
| if ($row === null) { | |||||
| return $this->json(['ok' => false, 'error' => 'Not found'], 404); | |||||
| } | |||||
| $col = \App\Models\BoardColumn::fromRow($row); | |||||
| $col->name = $name; | |||||
| $col->updatedAt = date('Y-m-d H:i:s'); | |||||
| $col->updatedBy = AuthService::getCurrentUsername(); | |||||
| $this->columns()->update($col); | |||||
| return $this->json(['ok' => true]); | |||||
| } | |||||
| public function destroy(int $id): mixed | |||||
| { | |||||
| if (!AuthService::isLoggedIn()) { | |||||
| return $this->json(['ok' => false, 'error' => 'Unauthorized'], 401); | |||||
| } | |||||
| $this->cards()->deleteByColumnId($id); | |||||
| $this->columns()->delete($id); | |||||
| return $this->json(['ok' => true]); | |||||
| } | |||||
| public function reorder(): mixed | |||||
| { | |||||
| if (!AuthService::isLoggedIn()) { | |||||
| return $this->json(['ok' => false, 'error' => 'Unauthorized'], 401); | |||||
| } | |||||
| $raw = file_get_contents('php://input'); | |||||
| $items = json_decode((string) $raw, true); | |||||
| if (!is_array($items)) { | |||||
| return $this->json(['ok' => false, 'error' => 'Invalid JSON payload']); | |||||
| } | |||||
| $now = date('Y-m-d H:i:s'); | |||||
| $username = AuthService::getCurrentUsername(); | |||||
| foreach ($items as $item) { | |||||
| $colId = (int) ($item['id'] ?? 0); | |||||
| $position = (int) ($item['position'] ?? 0); | |||||
| if ($colId > 0) { | |||||
| $this->columns()->updatePosition($colId, $position, $now, $username); | |||||
| } | |||||
| } | |||||
| return $this->json(['ok' => true]); | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,15 @@ | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| namespace App\Controllers; | |||||
| use Core\Controller; | |||||
| class HomeController extends Controller | |||||
| { | |||||
| public function index(): mixed | |||||
| { | |||||
| return $this->redirect('/boards'); | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,120 @@ | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| namespace App\Controllers; | |||||
| use App\Models\SwimLane; | |||||
| use App\Repositories\CardRepository; | |||||
| use App\Repositories\SwimLaneRepository; | |||||
| use App\Services\AuthService; | |||||
| use Core\Controller; | |||||
| use Core\Request; | |||||
| class SwimLanesController extends Controller | |||||
| { | |||||
| private function lanes(): SwimLaneRepository | |||||
| { | |||||
| return new SwimLaneRepository(database()); | |||||
| } | |||||
| private function cards(): CardRepository | |||||
| { | |||||
| return new CardRepository(database()); | |||||
| } | |||||
| public function store(Request $request): mixed | |||||
| { | |||||
| if (!AuthService::isLoggedIn()) { | |||||
| return $this->json(['ok' => false, 'error' => 'Unauthorized'], 401); | |||||
| } | |||||
| $boardId = (int) $request->input('board_id', 0); | |||||
| $name = trim((string) $request->input('name', '')); | |||||
| if ($boardId === 0 || $name === '') { | |||||
| return $this->json(['ok' => false, 'error' => 'board_id and name are required']); | |||||
| } | |||||
| $now = date('Y-m-d H:i:s'); | |||||
| $username = AuthService::getCurrentUsername(); | |||||
| $lane = new SwimLane(); | |||||
| $lane->boardId = $boardId; | |||||
| $lane->name = $name; | |||||
| $lane->position = $this->lanes()->maxPosition($boardId) + 1; | |||||
| $lane->createdAt = $now; | |||||
| $lane->createdBy = $username; | |||||
| $lane->updatedAt = $now; | |||||
| $lane->updatedBy = $username; | |||||
| $this->lanes()->insert($lane); | |||||
| return $this->json(['ok' => true, 'id' => $lane->id, 'name' => $lane->name, 'position' => $lane->position]); | |||||
| } | |||||
| public function update(Request $request, int $id): mixed | |||||
| { | |||||
| if (!AuthService::isLoggedIn()) { | |||||
| return $this->json(['ok' => false, 'error' => 'Unauthorized'], 401); | |||||
| } | |||||
| $name = trim((string) $request->input('name', '')); | |||||
| if ($name === '') { | |||||
| return $this->json(['ok' => false, 'error' => 'name is required']); | |||||
| } | |||||
| $row = $this->lanes()->find($id); | |||||
| if ($row === null) { | |||||
| return $this->json(['ok' => false, 'error' => 'Not found'], 404); | |||||
| } | |||||
| $lane = SwimLane::fromRow($row); | |||||
| $lane->name = $name; | |||||
| $lane->updatedAt = date('Y-m-d H:i:s'); | |||||
| $lane->updatedBy = AuthService::getCurrentUsername(); | |||||
| $this->lanes()->update($lane); | |||||
| return $this->json(['ok' => true]); | |||||
| } | |||||
| public function destroy(int $id): mixed | |||||
| { | |||||
| if (!AuthService::isLoggedIn()) { | |||||
| return $this->json(['ok' => false, 'error' => 'Unauthorized'], 401); | |||||
| } | |||||
| $this->cards()->deleteBySwimLaneId($id); | |||||
| $this->lanes()->delete($id); | |||||
| return $this->json(['ok' => true]); | |||||
| } | |||||
| public function reorder(): mixed | |||||
| { | |||||
| if (!AuthService::isLoggedIn()) { | |||||
| return $this->json(['ok' => false, 'error' => 'Unauthorized'], 401); | |||||
| } | |||||
| $raw = file_get_contents('php://input'); | |||||
| $items = json_decode((string) $raw, true); | |||||
| if (!is_array($items)) { | |||||
| return $this->json(['ok' => false, 'error' => 'Invalid JSON payload']); | |||||
| } | |||||
| $now = date('Y-m-d H:i:s'); | |||||
| $username = AuthService::getCurrentUsername(); | |||||
| foreach ($items as $item) { | |||||
| $laneId = (int) ($item['id'] ?? 0); | |||||
| $position = (int) ($item['position'] ?? 0); | |||||
| if ($laneId > 0) { | |||||
| $this->lanes()->updatePosition($laneId, $position, $now, $username); | |||||
| } | |||||
| } | |||||
| return $this->json(['ok' => true]); | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,34 @@ | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| namespace App\Models; | |||||
| class Board | |||||
| { | |||||
| public int $id = 0; | |||||
| public string $name = ''; | |||||
| public string $slug = ''; | |||||
| public bool $importFromPrintstream = false; | |||||
| public string $printstreamJobName = ''; | |||||
| public ?string $createdAt = null; | |||||
| public string $createdBy = ''; | |||||
| public ?string $updatedAt = null; | |||||
| public string $updatedBy = ''; | |||||
| public static function fromRow(array $row): self | |||||
| { | |||||
| $model = new self(); | |||||
| $model->id = (int) ($row['id'] ?? 0); | |||||
| $model->name = (string) ($row['name'] ?? ''); | |||||
| $model->slug = (string) ($row['slug'] ?? ''); | |||||
| $model->importFromPrintstream = (bool) ($row['import_from_printstream'] ?? false); | |||||
| $model->printstreamJobName = (string) ($row['printstream_job_name'] ?? ''); | |||||
| $model->createdAt = $row['created_at'] ?? null; | |||||
| $model->createdBy = (string) ($row['created_by'] ?? ''); | |||||
| $model->updatedAt = $row['updated_at'] ?? null; | |||||
| $model->updatedBy = (string) ($row['updated_by'] ?? ''); | |||||
| return $model; | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,32 @@ | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| namespace App\Models; | |||||
| class BoardColumn | |||||
| { | |||||
| public int $id = 0; | |||||
| public int $boardId = 0; | |||||
| public string $name = ''; | |||||
| public int $position = 0; | |||||
| public ?string $createdAt = null; | |||||
| public string $createdBy = ''; | |||||
| public ?string $updatedAt = null; | |||||
| public string $updatedBy = ''; | |||||
| public static function fromRow(array $row): self | |||||
| { | |||||
| $model = new self(); | |||||
| $model->id = (int) ($row['id'] ?? 0); | |||||
| $model->boardId = (int) ($row['board_id'] ?? 0); | |||||
| $model->name = (string) ($row['name'] ?? ''); | |||||
| $model->position = (int) ($row['position'] ?? 0); | |||||
| $model->createdAt = $row['created_at'] ?? null; | |||||
| $model->createdBy = (string) ($row['created_by'] ?? ''); | |||||
| $model->updatedAt = $row['updated_at'] ?? null; | |||||
| $model->updatedBy = (string) ($row['updated_by'] ?? ''); | |||||
| return $model; | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,65 @@ | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| namespace App\Models; | |||||
| class Card | |||||
| { | |||||
| public int $id = 0; | |||||
| public int $boardId = 0; | |||||
| public int $columnId = 0; | |||||
| public int $swimLaneId = 0; | |||||
| public string $jobNumber = ''; | |||||
| public string $jobName = ''; | |||||
| public string $customerName = ''; | |||||
| public ?string $deliveryDate = null; | |||||
| public string $quantity = ''; | |||||
| public string $notes = ''; | |||||
| public string $fullNote = ''; | |||||
| public int $position = 0; | |||||
| public ?string $createdAt = null; | |||||
| public string $createdBy = ''; | |||||
| public ?string $updatedAt = null; | |||||
| public string $updatedBy = ''; | |||||
| public static function fromRow(array $row): self | |||||
| { | |||||
| $model = new self(); | |||||
| $model->id = (int) ($row['id'] ?? 0); | |||||
| $model->boardId = (int) ($row['board_id'] ?? 0); | |||||
| $model->columnId = (int) ($row['column_id'] ?? 0); | |||||
| $model->swimLaneId = (int) ($row['swim_lane_id'] ?? 0); | |||||
| $model->jobNumber = (string) ($row['job_number'] ?? ''); | |||||
| $model->jobName = (string) ($row['job_name'] ?? ''); | |||||
| $model->customerName = (string) ($row['customer_name'] ?? ''); | |||||
| $model->deliveryDate = $row['delivery_date'] ?: null; | |||||
| $model->quantity = (string) ($row['quantity'] ?? ''); | |||||
| $model->notes = (string) ($row['notes'] ?? ''); | |||||
| $model->fullNote = (string) ($row['full_note'] ?? ''); | |||||
| $model->position = (int) ($row['position'] ?? 0); | |||||
| $model->createdAt = $row['created_at'] ?? null; | |||||
| $model->createdBy = (string) ($row['created_by'] ?? ''); | |||||
| $model->updatedAt = $row['updated_at'] ?? null; | |||||
| $model->updatedBy = (string) ($row['updated_by'] ?? ''); | |||||
| return $model; | |||||
| } | |||||
| public function toJsonArray(): array | |||||
| { | |||||
| return [ | |||||
| 'id' => $this->id, | |||||
| 'column_id' => $this->columnId, | |||||
| 'swim_lane_id' => $this->swimLaneId, | |||||
| 'job_number' => $this->jobNumber, | |||||
| 'job_name' => $this->jobName, | |||||
| 'customer_name' => $this->customerName, | |||||
| 'delivery_date' => $this->deliveryDate, | |||||
| 'quantity' => $this->quantity, | |||||
| 'notes' => $this->notes, | |||||
| 'full_note' => $this->fullNote, | |||||
| 'position' => $this->position, | |||||
| ]; | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,32 @@ | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| namespace App\Models; | |||||
| class SwimLane | |||||
| { | |||||
| public int $id = 0; | |||||
| public int $boardId = 0; | |||||
| public string $name = ''; | |||||
| public int $position = 0; | |||||
| public ?string $createdAt = null; | |||||
| public string $createdBy = ''; | |||||
| public ?string $updatedAt = null; | |||||
| public string $updatedBy = ''; | |||||
| public static function fromRow(array $row): self | |||||
| { | |||||
| $model = new self(); | |||||
| $model->id = (int) ($row['id'] ?? 0); | |||||
| $model->boardId = (int) ($row['board_id'] ?? 0); | |||||
| $model->name = (string) ($row['name'] ?? ''); | |||||
| $model->position = (int) ($row['position'] ?? 0); | |||||
| $model->createdAt = $row['created_at'] ?? null; | |||||
| $model->createdBy = (string) ($row['created_by'] ?? ''); | |||||
| $model->updatedAt = $row['updated_at'] ?? null; | |||||
| $model->updatedBy = (string) ($row['updated_by'] ?? ''); | |||||
| return $model; | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,77 @@ | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| namespace App\Repositories; | |||||
| use App\Models\BoardColumn; | |||||
| use Core\Repository; | |||||
| class BoardColumnRepository extends Repository | |||||
| { | |||||
| protected string $table = 'board_columns'; | |||||
| /** @return BoardColumn[] */ | |||||
| public function findByBoardId(int $boardId): array | |||||
| { | |||||
| $rows = $this->database->query( | |||||
| 'SELECT * FROM board_columns WHERE board_id = :board_id ORDER BY position ASC', | |||||
| ['board_id' => $boardId] | |||||
| ); | |||||
| return array_map(fn(array $r) => BoardColumn::fromRow($r), $rows); | |||||
| } | |||||
| public function maxPosition(int $boardId): int | |||||
| { | |||||
| $row = $this->database->first( | |||||
| 'SELECT MAX(position) AS max_pos FROM board_columns WHERE board_id = :board_id', | |||||
| ['board_id' => $boardId] | |||||
| ); | |||||
| return (int) ($row['max_pos'] ?? -1); | |||||
| } | |||||
| public function insert(BoardColumn $col): BoardColumn | |||||
| { | |||||
| $this->database->execute( | |||||
| 'INSERT INTO board_columns (board_id, name, position, created_at, created_by, updated_at, updated_by) | |||||
| VALUES (:board_id, :name, :position, :created_at, :created_by, :updated_at, :updated_by)', | |||||
| [ | |||||
| 'board_id' => $col->boardId, | |||||
| 'name' => $col->name, | |||||
| 'position' => $col->position, | |||||
| 'created_at' => $col->createdAt, | |||||
| 'created_by' => $col->createdBy, | |||||
| 'updated_at' => $col->updatedAt, | |||||
| 'updated_by' => $col->updatedBy, | |||||
| ] | |||||
| ); | |||||
| $row = $this->database->first('SELECT last_insert_rowid() AS id'); | |||||
| $col->id = (int) ($row['id'] ?? 0); | |||||
| return $col; | |||||
| } | |||||
| public function update(BoardColumn $col): void | |||||
| { | |||||
| $this->database->execute( | |||||
| 'UPDATE board_columns SET name = :name, updated_at = :updated_at, updated_by = :updated_by WHERE id = :id', | |||||
| ['name' => $col->name, 'updated_at' => $col->updatedAt, 'updated_by' => $col->updatedBy, 'id' => $col->id] | |||||
| ); | |||||
| } | |||||
| public function updatePosition(int $id, int $position, string $updatedAt, string $updatedBy): void | |||||
| { | |||||
| $this->database->execute( | |||||
| 'UPDATE board_columns SET position = :position, updated_at = :updated_at, updated_by = :updated_by WHERE id = :id', | |||||
| ['position' => $position, 'updated_at' => $updatedAt, 'updated_by' => $updatedBy, 'id' => $id] | |||||
| ); | |||||
| } | |||||
| public function deleteByBoardId(int $boardId): void | |||||
| { | |||||
| $this->database->execute('DELETE FROM board_columns WHERE board_id = :board_id', ['board_id' => $boardId]); | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,105 @@ | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| namespace App\Repositories; | |||||
| use App\Models\Board; | |||||
| use Core\Repository; | |||||
| class BoardRepository extends Repository | |||||
| { | |||||
| protected string $table = 'boards'; | |||||
| public function findBySlug(string $slug): ?Board | |||||
| { | |||||
| $row = $this->database->first( | |||||
| 'SELECT * FROM boards WHERE slug = :slug', | |||||
| ['slug' => $slug] | |||||
| ); | |||||
| return $row ? Board::fromRow($row) : null; | |||||
| } | |||||
| /** @return Board[] */ | |||||
| public function getAll(): array | |||||
| { | |||||
| $rows = $this->database->query('SELECT * FROM boards ORDER BY name ASC'); | |||||
| return array_map(fn(array $r) => Board::fromRow($r), $rows); | |||||
| } | |||||
| public function slugExists(string $slug, int $excludeId = 0): bool | |||||
| { | |||||
| if ($excludeId > 0) { | |||||
| $row = $this->database->first( | |||||
| 'SELECT COUNT(*) AS cnt FROM boards WHERE slug = :slug AND id != :id', | |||||
| ['slug' => $slug, 'id' => $excludeId] | |||||
| ); | |||||
| } else { | |||||
| $row = $this->database->first( | |||||
| 'SELECT COUNT(*) AS cnt FROM boards WHERE slug = :slug', | |||||
| ['slug' => $slug] | |||||
| ); | |||||
| } | |||||
| return (int) ($row['cnt'] ?? 0) > 0; | |||||
| } | |||||
| public function uniqueSlug(string $base, int $excludeId = 0): string | |||||
| { | |||||
| $candidate = $base; | |||||
| $suffix = 2; | |||||
| while ($this->slugExists($candidate, $excludeId)) { | |||||
| $candidate = $base . '-' . $suffix; | |||||
| $suffix++; | |||||
| } | |||||
| return $candidate; | |||||
| } | |||||
| public function insert(Board $board): Board | |||||
| { | |||||
| $this->database->execute( | |||||
| 'INSERT INTO boards | |||||
| (name, slug, import_from_printstream, printstream_job_name, created_at, created_by, updated_at, updated_by) | |||||
| VALUES | |||||
| (:name, :slug, :import_from_printstream, :printstream_job_name, :created_at, :created_by, :updated_at, :updated_by)', | |||||
| [ | |||||
| 'name' => $board->name, | |||||
| 'slug' => $board->slug, | |||||
| 'import_from_printstream' => $board->importFromPrintstream ? 1 : 0, | |||||
| 'printstream_job_name' => $board->printstreamJobName, | |||||
| 'created_at' => $board->createdAt, | |||||
| 'created_by' => $board->createdBy, | |||||
| 'updated_at' => $board->updatedAt, | |||||
| 'updated_by' => $board->updatedBy, | |||||
| ] | |||||
| ); | |||||
| $row = $this->database->first('SELECT last_insert_rowid() AS id'); | |||||
| $board->id = (int) ($row['id'] ?? 0); | |||||
| return $board; | |||||
| } | |||||
| public function update(Board $board): void | |||||
| { | |||||
| $this->database->execute( | |||||
| 'UPDATE boards | |||||
| SET name = :name, slug = :slug, import_from_printstream = :import_from_printstream, | |||||
| printstream_job_name = :printstream_job_name, updated_at = :updated_at, updated_by = :updated_by | |||||
| WHERE id = :id', | |||||
| [ | |||||
| 'name' => $board->name, | |||||
| 'slug' => $board->slug, | |||||
| 'import_from_printstream' => $board->importFromPrintstream ? 1 : 0, | |||||
| 'printstream_job_name' => $board->printstreamJobName, | |||||
| 'updated_at' => $board->updatedAt, | |||||
| 'updated_by' => $board->updatedBy, | |||||
| 'id' => $board->id, | |||||
| ] | |||||
| ); | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,131 @@ | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| namespace App\Repositories; | |||||
| use App\Models\Card; | |||||
| use Core\Repository; | |||||
| class CardRepository extends Repository | |||||
| { | |||||
| protected string $table = 'cards'; | |||||
| public function findById(int $id): ?Card | |||||
| { | |||||
| $row = $this->database->first('SELECT * FROM cards WHERE id = :id', ['id' => $id]); | |||||
| return $row ? Card::fromRow($row) : null; | |||||
| } | |||||
| /** @return Card[] */ | |||||
| public function findByBoardId(int $boardId): array | |||||
| { | |||||
| $rows = $this->database->query( | |||||
| 'SELECT * FROM cards WHERE board_id = :board_id ORDER BY swim_lane_id ASC, column_id ASC, position ASC', | |||||
| ['board_id' => $boardId] | |||||
| ); | |||||
| return array_map(fn(array $r) => Card::fromRow($r), $rows); | |||||
| } | |||||
| public function maxPosition(int $columnId, int $swimLaneId): int | |||||
| { | |||||
| $row = $this->database->first( | |||||
| 'SELECT MAX(position) AS max_pos FROM cards WHERE column_id = :col AND swim_lane_id = :lane', | |||||
| ['col' => $columnId, 'lane' => $swimLaneId] | |||||
| ); | |||||
| return (int) ($row['max_pos'] ?? -1); | |||||
| } | |||||
| public function insert(Card $card): Card | |||||
| { | |||||
| $this->database->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' => $card->boardId, | |||||
| 'column_id' => $card->columnId, | |||||
| 'swim_lane_id' => $card->swimLaneId, | |||||
| 'job_number' => $card->jobNumber, | |||||
| 'job_name' => $card->jobName, | |||||
| 'customer_name' => $card->customerName, | |||||
| 'delivery_date' => $card->deliveryDate ?: null, | |||||
| 'quantity' => $card->quantity !== '' ? $card->quantity : null, | |||||
| 'notes' => $card->notes, | |||||
| 'full_note' => $card->fullNote, | |||||
| 'position' => $card->position, | |||||
| 'created_at' => $card->createdAt, | |||||
| 'created_by' => $card->createdBy, | |||||
| 'updated_at' => $card->updatedAt, | |||||
| 'updated_by' => $card->updatedBy, | |||||
| ] | |||||
| ); | |||||
| $row = $this->database->first('SELECT last_insert_rowid() AS id'); | |||||
| $card->id = (int) ($row['id'] ?? 0); | |||||
| return $card; | |||||
| } | |||||
| public function update(Card $card): void | |||||
| { | |||||
| $this->database->execute( | |||||
| 'UPDATE cards | |||||
| SET job_number = :job_number, 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 id = :id', | |||||
| [ | |||||
| 'job_number' => $card->jobNumber, | |||||
| 'job_name' => $card->jobName, | |||||
| 'customer_name' => $card->customerName, | |||||
| 'delivery_date' => $card->deliveryDate ?: null, | |||||
| 'quantity' => $card->quantity !== '' ? $card->quantity : null, | |||||
| 'notes' => $card->notes, | |||||
| 'full_note' => $card->fullNote, | |||||
| 'updated_at' => $card->updatedAt, | |||||
| 'updated_by' => $card->updatedBy, | |||||
| 'id' => $card->id, | |||||
| ] | |||||
| ); | |||||
| } | |||||
| public function move(int $id, int $columnId, int $swimLaneId, int $position, string $updatedAt, string $updatedBy): void | |||||
| { | |||||
| $this->database->execute( | |||||
| 'UPDATE cards SET column_id = :column_id, swim_lane_id = :swim_lane_id, position = :position, | |||||
| updated_at = :updated_at, updated_by = :updated_by WHERE id = :id', | |||||
| ['column_id' => $columnId, 'swim_lane_id' => $swimLaneId, 'position' => $position, | |||||
| 'updated_at' => $updatedAt, 'updated_by' => $updatedBy, 'id' => $id] | |||||
| ); | |||||
| } | |||||
| public function updatePosition(int $id, int $position, string $updatedAt, string $updatedBy): void | |||||
| { | |||||
| $this->database->execute( | |||||
| 'UPDATE cards SET position = :position, updated_at = :updated_at, updated_by = :updated_by WHERE id = :id', | |||||
| ['position' => $position, 'updated_at' => $updatedAt, 'updated_by' => $updatedBy, 'id' => $id] | |||||
| ); | |||||
| } | |||||
| public function deleteByBoardId(int $boardId): void | |||||
| { | |||||
| $this->database->execute('DELETE FROM cards WHERE board_id = :board_id', ['board_id' => $boardId]); | |||||
| } | |||||
| public function deleteByColumnId(int $columnId): void | |||||
| { | |||||
| $this->database->execute('DELETE FROM cards WHERE column_id = :column_id', ['column_id' => $columnId]); | |||||
| } | |||||
| public function deleteBySwimLaneId(int $swimLaneId): void | |||||
| { | |||||
| $this->database->execute('DELETE FROM cards WHERE swim_lane_id = :swim_lane_id', ['swim_lane_id' => $swimLaneId]); | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,77 @@ | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| namespace App\Repositories; | |||||
| use App\Models\SwimLane; | |||||
| use Core\Repository; | |||||
| class SwimLaneRepository extends Repository | |||||
| { | |||||
| protected string $table = 'swim_lanes'; | |||||
| /** @return SwimLane[] */ | |||||
| public function findByBoardId(int $boardId): array | |||||
| { | |||||
| $rows = $this->database->query( | |||||
| 'SELECT * FROM swim_lanes WHERE board_id = :board_id ORDER BY position ASC', | |||||
| ['board_id' => $boardId] | |||||
| ); | |||||
| return array_map(fn(array $r) => SwimLane::fromRow($r), $rows); | |||||
| } | |||||
| public function maxPosition(int $boardId): int | |||||
| { | |||||
| $row = $this->database->first( | |||||
| 'SELECT MAX(position) AS max_pos FROM swim_lanes WHERE board_id = :board_id', | |||||
| ['board_id' => $boardId] | |||||
| ); | |||||
| return (int) ($row['max_pos'] ?? -1); | |||||
| } | |||||
| public function insert(SwimLane $lane): SwimLane | |||||
| { | |||||
| $this->database->execute( | |||||
| 'INSERT INTO swim_lanes (board_id, name, position, created_at, created_by, updated_at, updated_by) | |||||
| VALUES (:board_id, :name, :position, :created_at, :created_by, :updated_at, :updated_by)', | |||||
| [ | |||||
| 'board_id' => $lane->boardId, | |||||
| 'name' => $lane->name, | |||||
| 'position' => $lane->position, | |||||
| 'created_at' => $lane->createdAt, | |||||
| 'created_by' => $lane->createdBy, | |||||
| 'updated_at' => $lane->updatedAt, | |||||
| 'updated_by' => $lane->updatedBy, | |||||
| ] | |||||
| ); | |||||
| $row = $this->database->first('SELECT last_insert_rowid() AS id'); | |||||
| $lane->id = (int) ($row['id'] ?? 0); | |||||
| return $lane; | |||||
| } | |||||
| public function update(SwimLane $lane): void | |||||
| { | |||||
| $this->database->execute( | |||||
| 'UPDATE swim_lanes SET name = :name, updated_at = :updated_at, updated_by = :updated_by WHERE id = :id', | |||||
| ['name' => $lane->name, 'updated_at' => $lane->updatedAt, 'updated_by' => $lane->updatedBy, 'id' => $lane->id] | |||||
| ); | |||||
| } | |||||
| public function updatePosition(int $id, int $position, string $updatedAt, string $updatedBy): void | |||||
| { | |||||
| $this->database->execute( | |||||
| 'UPDATE swim_lanes SET position = :position, updated_at = :updated_at, updated_by = :updated_by WHERE id = :id', | |||||
| ['position' => $position, 'updated_at' => $updatedAt, 'updated_by' => $updatedBy, 'id' => $id] | |||||
| ); | |||||
| } | |||||
| public function deleteByBoardId(int $boardId): void | |||||
| { | |||||
| $this->database->execute('DELETE FROM swim_lanes WHERE board_id = :board_id', ['board_id' => $boardId]); | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,121 @@ | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| namespace App\Services; | |||||
| use Core\Response; | |||||
| use Stevenmaguire\OAuth2\Client\Provider\Keycloak; | |||||
| class AuthService | |||||
| { | |||||
| private static function config(): array | |||||
| { | |||||
| static $config = null; | |||||
| if ($config === null) { | |||||
| $config = require __DIR__ . '/../../config/auth.php'; | |||||
| } | |||||
| return $config['keycloak']; | |||||
| } | |||||
| public static function provider(): Keycloak | |||||
| { | |||||
| $cfg = self::config(); | |||||
| return new Keycloak([ | |||||
| 'authServerUrl' => rtrim($cfg['base_url'], '/'), | |||||
| 'realm' => $cfg['realm'], | |||||
| 'clientId' => $cfg['client_id'], | |||||
| 'clientSecret' => $cfg['client_secret'], | |||||
| 'redirectUri' => $cfg['redirect_uri'], | |||||
| ]); | |||||
| } | |||||
| /** | |||||
| * Decode user claims from the access token JWT payload. | |||||
| * Avoids calling the userinfo endpoint, which Keycloak may return as a | |||||
| * signed JWT (application/jwt) rather than JSON — causing decryption errors. | |||||
| * | |||||
| * @return array<string, mixed> | |||||
| */ | |||||
| public static function claimsFromToken(string $jwt): array | |||||
| { | |||||
| $parts = explode('.', $jwt); | |||||
| if (count($parts) < 2) { | |||||
| return []; | |||||
| } | |||||
| $payload = base64_decode(strtr($parts[1], '-_', '+/'), true); | |||||
| if ($payload === false) { | |||||
| return []; | |||||
| } | |||||
| $data = json_decode($payload, true); | |||||
| return is_array($data) ? $data : []; | |||||
| } | |||||
| public static function requireLogin(): ?Response | |||||
| { | |||||
| if (!self::isLoggedIn()) { | |||||
| $_SESSION['auth_return_to'] = $_SERVER['REQUEST_URI'] ?? '/'; | |||||
| return Response::redirect('/auth/login'); | |||||
| } | |||||
| return null; | |||||
| } | |||||
| public static function isLoggedIn(): bool | |||||
| { | |||||
| return !empty($_SESSION['auth_user']); | |||||
| } | |||||
| public static function getCurrentUser(): array | |||||
| { | |||||
| return $_SESSION['auth_user'] ?? []; | |||||
| } | |||||
| public static function getCurrentUsername(): string | |||||
| { | |||||
| $user = self::getCurrentUser(); | |||||
| return $user['preferred_username'] ?? $user['email'] ?? ''; | |||||
| } | |||||
| public static function storeUser(array $userInfo): void | |||||
| { | |||||
| $_SESSION['auth_user'] = $userInfo; | |||||
| } | |||||
| public static function clearSession(): void | |||||
| { | |||||
| $_SESSION = []; | |||||
| if (ini_get('session.use_cookies')) { | |||||
| $params = session_get_cookie_params(); | |||||
| setcookie( | |||||
| session_name(), | |||||
| '', | |||||
| time() - 42000, | |||||
| $params['path'], | |||||
| $params['domain'], | |||||
| $params['secure'], | |||||
| $params['httponly'] | |||||
| ); | |||||
| } | |||||
| session_destroy(); | |||||
| } | |||||
| public static function logoutUrl(): string | |||||
| { | |||||
| $cfg = self::config(); | |||||
| $base = rtrim($cfg['base_url'], '/'); | |||||
| $realm = $cfg['realm']; | |||||
| $postLogout = urlencode($cfg['post_logout_redirect_uri']); | |||||
| return "{$base}/realms/{$realm}/protocol/openid-connect/logout?redirect_uri={$postLogout}"; | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,5 @@ | |||||
| <div class="text-center py-5"> | |||||
| <h1 class="h3 text-danger mb-3">Authentication Error</h1> | |||||
| <p class="text-muted mb-4"><?= e($error ?? 'An authentication error occurred.') ?></p> | |||||
| <a href="/auth/login" class="btn btn-primary">Try Again</a> | |||||
| </div> | |||||
| @@ -0,0 +1,39 @@ | |||||
| <div class="row justify-content-center"> | |||||
| <div class="col-md-6"> | |||||
| <h1 class="h3 mb-4">New Board</h1> | |||||
| <?php if (!empty($error)): ?> | |||||
| <div class="alert alert-danger"><?= e($error) ?></div> | |||||
| <?php endif; ?> | |||||
| <form method="POST" action="/boards"> | |||||
| <?= csrf_field() ?> | |||||
| <div class="mb-3"> | |||||
| <label for="name" class="form-label">Board Name <span class="text-danger">*</span></label> | |||||
| <input type="text" class="form-control" id="name" name="name" | |||||
| value="<?= e((string) ($old['name'] ?? '')) ?>" required autofocus /> | |||||
| </div> | |||||
| <div class="mb-3 form-check"> | |||||
| <input type="checkbox" class="form-check-input" id="import_from_printstream" | |||||
| name="import_from_printstream" | |||||
| <?= !empty($old['import_from_printstream']) ? 'checked' : '' ?> /> | |||||
| <label class="form-check-label" for="import_from_printstream"> | |||||
| Import from PrintStream | |||||
| </label> | |||||
| </div> | |||||
| <div class="mb-4"> | |||||
| <label for="printstream_job_name" class="form-label">PrintStream Job Name</label> | |||||
| <input type="text" class="form-control" id="printstream_job_name" name="printstream_job_name" | |||||
| value="<?= e((string) ($old['printstream_job_name'] ?? '')) ?>" /> | |||||
| </div> | |||||
| <div class="d-flex gap-2"> | |||||
| <button type="submit" class="btn btn-primary">Create Board</button> | |||||
| <a href="/boards" class="btn btn-outline-secondary">Cancel</a> | |||||
| </div> | |||||
| </form> | |||||
| </div> | |||||
| </div> | |||||
| @@ -0,0 +1,47 @@ | |||||
| <div class="row justify-content-center"> | |||||
| <div class="col-md-6"> | |||||
| <h1 class="h3 mb-4">Edit Board</h1> | |||||
| <?php if (!empty($error)): ?> | |||||
| <div class="alert alert-danger"><?= e($error) ?></div> | |||||
| <?php endif; ?> | |||||
| <form method="POST" action="/board/<?= e($board->slug) ?>/update"> | |||||
| <?= csrf_field() ?> | |||||
| <div class="mb-3"> | |||||
| <label for="name" class="form-label">Board Name <span class="text-danger">*</span></label> | |||||
| <input type="text" class="form-control" id="name" name="name" | |||||
| value="<?= e($board->name) ?>" required autofocus /> | |||||
| </div> | |||||
| <div class="mb-3 form-check"> | |||||
| <input type="checkbox" class="form-check-input" id="import_from_printstream" | |||||
| name="import_from_printstream" | |||||
| <?= $board->importFromPrintstream ? 'checked' : '' ?> /> | |||||
| <label class="form-check-label" for="import_from_printstream"> | |||||
| Import from PrintStream | |||||
| </label> | |||||
| </div> | |||||
| <div class="mb-4"> | |||||
| <label for="printstream_job_name" class="form-label">PrintStream Job Name</label> | |||||
| <input type="text" class="form-control" id="printstream_job_name" name="printstream_job_name" | |||||
| value="<?= e($board->printstreamJobName) ?>" /> | |||||
| </div> | |||||
| <div class="d-flex gap-2"> | |||||
| <button type="submit" class="btn btn-primary">Save Changes</button> | |||||
| <a href="/board/<?= e($board->slug) ?>" class="btn btn-outline-secondary">Cancel</a> | |||||
| </div> | |||||
| </form> | |||||
| <hr class="my-4" /> | |||||
| <form method="POST" action="/board/<?= e($board->slug) ?>/delete" | |||||
| onsubmit="return confirm('Delete this board and ALL its columns, lanes, and cards?')"> | |||||
| <?= csrf_field() ?> | |||||
| <button type="submit" class="btn btn-danger btn-sm">Delete Board</button> | |||||
| </form> | |||||
| </div> | |||||
| </div> | |||||
| @@ -0,0 +1,33 @@ | |||||
| <div class="d-flex justify-content-between align-items-center mb-4"> | |||||
| <h1 class="h3 mb-0">Boards</h1> | |||||
| <a href="/boards/create" class="btn btn-primary"> | |||||
| <i class="bi bi-plus-lg me-1"></i>New Board | |||||
| </a> | |||||
| </div> | |||||
| <?php if (empty($boards)): ?> | |||||
| <div class="text-center py-5 text-muted"> | |||||
| <i class="bi bi-kanban display-4 d-block mb-3"></i> | |||||
| <p class="mb-3">No boards yet.</p> | |||||
| <a href="/boards/create" class="btn btn-primary">Create your first board</a> | |||||
| </div> | |||||
| <?php else: ?> | |||||
| <div class="row g-3"> | |||||
| <?php foreach ($boards as $board): ?> | |||||
| <div class="col-sm-6 col-md-4 col-lg-3"> | |||||
| <div class="card h-100 shadow-sm"> | |||||
| <div class="card-body d-flex flex-column"> | |||||
| <h5 class="card-title"><?= e($board->name) ?></h5> | |||||
| <p class="card-text text-muted small mb-3"><code><?= e($board->slug) ?></code></p> | |||||
| <div class="mt-auto d-flex gap-2"> | |||||
| <a href="/board/<?= e($board->slug) ?>" class="btn btn-sm btn-primary flex-grow-1">Open</a> | |||||
| <a href="/board/<?= e($board->slug) ?>/edit" class="btn btn-sm btn-outline-secondary"> | |||||
| <i class="bi bi-pencil"></i> | |||||
| </a> | |||||
| </div> | |||||
| </div> | |||||
| </div> | |||||
| </div> | |||||
| <?php endforeach; ?> | |||||
| </div> | |||||
| <?php endif; ?> | |||||
| @@ -0,0 +1,99 @@ | |||||
| <!doctype html> | |||||
| <html lang="en"> | |||||
| <head> | |||||
| <meta charset="utf-8" /> | |||||
| <title><?= e($board->name) ?> — Kanban</title> | |||||
| <meta name="viewport" content="width=device-width, initial-scale=1" /> | |||||
| <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" /> | |||||
| <link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css" rel="stylesheet" /> | |||||
| <link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=Fraunces:opsz,wght@9..144,600&display=swap" rel="stylesheet" /> | |||||
| <link rel="preconnect" href="https://fonts.googleapis.com" /> | |||||
| <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> | |||||
| <link href="/css/site.css" rel="stylesheet" /> | |||||
| <link href="/css/kanban.css" rel="stylesheet" /> | |||||
| </head> | |||||
| <body class="kanban-page"> | |||||
| <!-- Top bar --> | |||||
| <nav class="navbar navbar-dark rk-topnav px-3 py-2"> | |||||
| <div class="d-flex align-items-center gap-3 flex-grow-1 board-header-main"> | |||||
| <a href="/boards" class="btn btn-sm btn-outline-secondary text-white border-secondary"> | |||||
| <i class="bi bi-arrow-left"></i> | |||||
| </a> | |||||
| <span class="navbar-brand mb-0 h5 kanban-board-title"><?= e($board->name) ?></span> | |||||
| </div> | |||||
| <div class="board-header-search"> | |||||
| <label for="job-search-input" class="visually-hidden">Search jobs</label> | |||||
| <div class="input-group input-group-sm"> | |||||
| <span class="input-group-text"><i class="bi bi-search"></i></span> | |||||
| <input type="search" id="job-search-input" class="form-control" | |||||
| placeholder="Search job #, name, customer..." autocomplete="off" /> | |||||
| </div> | |||||
| </div> | |||||
| <div class="d-flex align-items-center gap-2 board-header-actions"> | |||||
| <button class="btn btn-sm btn-outline-light" id="btn-add-card" | |||||
| data-board-id="<?= e((string) $board->id) ?>"> | |||||
| <i class="bi bi-plus-lg me-1"></i>Add Card | |||||
| </button> | |||||
| <button class="btn btn-sm btn-outline-light" id="btn-settings" title="Board Settings"> | |||||
| <i class="bi bi-gear"></i> | |||||
| </button> | |||||
| <a href="/auth/logout" class="btn btn-sm btn-outline-light" title="Sign Out"> | |||||
| <i class="bi bi-box-arrow-right"></i> | |||||
| </a> | |||||
| </div> | |||||
| </nav> | |||||
| <!-- Kanban grid --> | |||||
| <div class="kanban-wrapper"> | |||||
| <div class="kanban-grid" id="kanban-grid"> | |||||
| <div class="kanban-corner"></div> | |||||
| <?php foreach ($columns as $col): ?> | |||||
| <div class="kanban-col-header" data-col-id="<?= e((string) $col->id) ?>"> | |||||
| <span class="col-label"><?= e($col->name) ?></span> | |||||
| </div> | |||||
| <?php endforeach; ?> | |||||
| <?php foreach ($lanes as $lane): ?> | |||||
| <div class="kanban-lane-header" data-lane-id="<?= e((string) $lane->id) ?>"> | |||||
| <button type="button" class="lane-toggle" | |||||
| title="Collapse or expand swim lane" | |||||
| aria-label="Collapse or expand swim lane" | |||||
| aria-expanded="true"> | |||||
| <i class="bi bi-chevron-down" aria-hidden="true"></i> | |||||
| </button> | |||||
| <span class="lane-label"><?= e($lane->name) ?></span> | |||||
| </div> | |||||
| <?php foreach ($columns as $col): ?> | |||||
| <div class="kanban-cell" | |||||
| data-col-id="<?= e((string) $col->id) ?>" | |||||
| data-lane-id="<?= e((string) $lane->id) ?>"> | |||||
| </div> | |||||
| <?php endforeach; ?> | |||||
| <?php endforeach; ?> | |||||
| </div> | |||||
| </div> | |||||
| <?php require __DIR__ . '/../partials/card-modal.php'; ?> | |||||
| <?php require __DIR__ . '/../partials/settings-panel.php'; ?> | |||||
| <script> | |||||
| var KANBAN = { | |||||
| boardId: <?= (int) $board->id ?>, | |||||
| boardSlug: "<?= e($board->slug) ?>", | |||||
| cards: <?= $cardsJson ?> | |||||
| }; | |||||
| var KANBAN_COLS = <?= json_encode(array_map(fn($c) => ['id' => $c->id, 'name' => $c->name, 'position' => $c->position], $columns)) ?>; | |||||
| var KANBAN_LANES = <?= json_encode(array_map(fn($l) => ['id' => $l->id, 'name' => $l->name, 'position' => $l->position], $lanes)) ?>; | |||||
| </script> | |||||
| <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script> | |||||
| <script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.2/Sortable.min.js"></script> | |||||
| <script src="/js/kanban-modal.js"></script> | |||||
| <script src="/js/kanban-settings.js"></script> | |||||
| <script src="/js/kanban-board.js"></script> | |||||
| </body> | |||||
| </html> | |||||
| @@ -0,0 +1,39 @@ | |||||
| <section class="hero"> | |||||
| <div class="hero-copy"> | |||||
| <span class="eyebrow"><?= e($model->eyebrow) ?></span> | |||||
| <h1><?= e($model->title) ?></h1> | |||||
| <p class="hero-text"><?= e($model->message) ?></p> | |||||
| <div class="hero-actions"> | |||||
| <a class="button button-primary" href="<?= e($model->routeExample) ?>">Open Employee Form</a> | |||||
| <a class="button button-secondary" href="#framework-highlights">See Highlights</a> | |||||
| </div> | |||||
| </div> | |||||
| <aside class="hero-panel" aria-label="Framework route example"> | |||||
| <p class="panel-label">Request Flow</p> | |||||
| <code>Browser -> public/index.php -> Dispatcher -> Router -> Controller -> View</code> | |||||
| <div class="route-callout"> | |||||
| <span>Employee entry page</span> | |||||
| <a href="<?= e($model->routeExample) ?>"><?= e($model->routeExample) ?></a> | |||||
| </div> | |||||
| </aside> | |||||
| </section> | |||||
| <section class="feature-grid" id="framework-highlights"> | |||||
| <article class="feature-card"> | |||||
| <h2>Readable by design</h2> | |||||
| <p>Small files, explicit routing, and plain PHP views keep the framework approachable for day-to-day work.</p> | |||||
| </article> | |||||
| <article class="feature-card"> | |||||
| <h2>Classic MVC feel</h2> | |||||
| <p>Controllers, repositories, and view models stay separate so request handling remains predictable and easy to follow.</p> | |||||
| </article> | |||||
| <article class="feature-card"> | |||||
| <h2>SQLite ready</h2> | |||||
| <p>Typed PHP 8.2 code, Composer autoloading, PDO access, and auto-run migrations make the project feel current without becoming heavyweight.</p> | |||||
| </article> | |||||
| </section> | |||||
| @@ -0,0 +1,14 @@ | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| require __DIR__ . '/../partials/header.php'; | |||||
| ?> | |||||
| <main class="page-content"> | |||||
| <div class="container"> | |||||
| <?= $content ?> | |||||
| </div> | |||||
| </main> | |||||
| <?php require __DIR__ . '/../partials/footer.php'; ?> | |||||
| @@ -0,0 +1,54 @@ | |||||
| <!-- Card create/edit modal --> | |||||
| <div class="modal fade" id="cardModal" tabindex="-1" aria-labelledby="cardModalLabel" aria-hidden="true"> | |||||
| <div class="modal-dialog modal-dialog-centered"> | |||||
| <div class="modal-content"> | |||||
| <div class="modal-header"> | |||||
| <h5 class="modal-title" id="cardModalLabel">Add Card</h5> | |||||
| <button type="button" class="btn-close" data-bs-dismiss="modal"></button> | |||||
| </div> | |||||
| <div class="modal-body"> | |||||
| <input type="hidden" id="card-id" value="" /> | |||||
| <input type="hidden" id="card-column-id" value="" /> | |||||
| <input type="hidden" id="card-lane-id" value="" /> | |||||
| <div class="mb-3"> | |||||
| <label for="card-job-number" class="form-label">Job Number</label> | |||||
| <input type="text" class="form-control" id="card-job-number" placeholder="e.g. 10042" /> | |||||
| </div> | |||||
| <div class="mb-3"> | |||||
| <label for="card-job-name" class="form-label">Job Name</label> | |||||
| <input type="text" class="form-control" id="card-job-name" placeholder="e.g. Smith Residence" /> | |||||
| </div> | |||||
| <div class="mb-3"> | |||||
| <label for="card-customer-name" class="form-label">Customer</label> | |||||
| <input type="text" class="form-control" id="card-customer-name" /> | |||||
| </div> | |||||
| <div class="row g-2 mb-3"> | |||||
| <div class="col"> | |||||
| <label for="card-delivery-date" class="form-label">Delivery Date</label> | |||||
| <input type="date" class="form-control" id="card-delivery-date" /> | |||||
| </div> | |||||
| <div class="col"> | |||||
| <label for="card-quantity" class="form-label">Quantity</label> | |||||
| <input type="text" class="form-control" id="card-quantity" /> | |||||
| </div> | |||||
| </div> | |||||
| <div class="mb-3"> | |||||
| <label for="card-notes" class="form-label">Notes</label> | |||||
| <textarea class="form-control" id="card-notes" rows="3"></textarea> | |||||
| </div> | |||||
| <div class="mb-3" id="card-full-note-wrap"> | |||||
| <label for="card-full-note" class="form-label">PrintStream Notes</label> | |||||
| <textarea class="form-control" id="card-full-note" rows="4" readonly></textarea> | |||||
| </div> | |||||
| <div id="card-modal-error" class="alert alert-danger d-none"></div> | |||||
| </div> | |||||
| <div class="modal-footer"> | |||||
| <button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button> | |||||
| <button type="button" class="btn btn-danger d-none" id="btn-delete-card">Delete</button> | |||||
| <button type="button" class="btn btn-primary" id="btn-save-card">Save</button> | |||||
| </div> | |||||
| </div> | |||||
| </div> | |||||
| </div> | |||||
| @@ -0,0 +1,9 @@ | |||||
| <footer class="site-footer"> | |||||
| <div class="container footer-inner"> | |||||
| <p>MindVisionCode PHP keeps the framework small, readable, and ready for real features.</p> | |||||
| <p>© <?= e((string) date('Y')) ?> MindVisionCode</p> | |||||
| </div> | |||||
| </footer> | |||||
| </div> | |||||
| </body> | |||||
| </html> | |||||
| @@ -0,0 +1,43 @@ | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| $navigationItems = [ | |||||
| ['label' => 'Boards', 'href' => '/boards'], | |||||
| ]; | |||||
| $currentPath = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH); | |||||
| $currentPath = is_string($currentPath) && $currentPath !== '' ? $currentPath : '/'; | |||||
| ?> | |||||
| <!DOCTYPE html> | |||||
| <html lang="en"> | |||||
| <head> | |||||
| <meta charset="UTF-8"> | |||||
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |||||
| <title><?= e($pageTitle ?? 'KCI Kanban') ?></title> | |||||
| <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" /> | |||||
| <link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css" rel="stylesheet" /> | |||||
| <link rel="stylesheet" href="<?= e(asset('css/site.css')) ?>"> | |||||
| </head> | |||||
| <body> | |||||
| <div class="page-shell"> | |||||
| <header class="site-header"> | |||||
| <div class="container header-inner"> | |||||
| <a class="brand" href="/boards"> | |||||
| <span class="brand-mark">KC</span> | |||||
| <span class="brand-copy"> | |||||
| <strong>KCI Kanban</strong> | |||||
| <small>PHP</small> | |||||
| </span> | |||||
| </a> | |||||
| <nav class="site-nav" aria-label="Primary navigation"> | |||||
| <?php foreach ($navigationItems as $item): ?> | |||||
| <?php $isActive = str_starts_with($currentPath, $item['href']); ?> | |||||
| <a class="nav-link<?= $isActive ? ' is-active' : '' ?>" href="<?= e($item['href']) ?>"> | |||||
| <?= e($item['label']) ?> | |||||
| </a> | |||||
| <?php endforeach; ?> | |||||
| </nav> | |||||
| </div> | |||||
| </header> | |||||
| @@ -0,0 +1,79 @@ | |||||
| <!-- Settings slide-out panel --> | |||||
| <div id="settings-overlay" class="kanban-settings-overlay d-none"></div> | |||||
| <div id="settings-panel" class="kanban-settings-panel"> | |||||
| <div class="settings-header d-flex justify-content-between align-items-center p-3 border-bottom"> | |||||
| <h6 class="mb-0">Board Settings</h6> | |||||
| <button class="btn btn-sm btn-outline-secondary" id="btn-close-settings"> | |||||
| <i class="bi bi-x-lg"></i> | |||||
| </button> | |||||
| </div> | |||||
| <div class="settings-body p-3"> | |||||
| <!-- Columns section --> | |||||
| <div class="mb-4"> | |||||
| <div class="d-flex justify-content-between align-items-center mb-2"> | |||||
| <strong class="small">Columns</strong> | |||||
| <button class="btn btn-sm btn-outline-primary" id="btn-add-column"> | |||||
| <i class="bi bi-plus"></i> Add | |||||
| </button> | |||||
| </div> | |||||
| <div id="col-add-form" class="d-none mb-2"> | |||||
| <div class="input-group input-group-sm"> | |||||
| <input type="text" class="form-control" id="col-add-input" placeholder="Column name" /> | |||||
| <button class="btn btn-primary" id="btn-col-add-save">Add</button> | |||||
| <button class="btn btn-outline-secondary" id="btn-col-add-cancel">Cancel</button> | |||||
| </div> | |||||
| </div> | |||||
| <ul class="list-group settings-sortable" id="col-list"> | |||||
| <?php foreach ($columns as $col): ?> | |||||
| <li class="list-group-item d-flex align-items-center gap-2 py-2" | |||||
| data-id="<?= e((string) $col->id) ?>"> | |||||
| <i class="bi bi-grip-vertical text-muted drag-handle" style="cursor:grab;"></i> | |||||
| <span class="flex-grow-1 col-label-text"><?= e($col->name) ?></span> | |||||
| <button class="btn btn-sm btn-link p-0 text-secondary btn-edit-col" title="Rename"> | |||||
| <i class="bi bi-pencil"></i> | |||||
| </button> | |||||
| <button class="btn btn-sm btn-link p-0 text-danger btn-delete-col" title="Delete"> | |||||
| <i class="bi bi-trash"></i> | |||||
| </button> | |||||
| </li> | |||||
| <?php endforeach; ?> | |||||
| </ul> | |||||
| </div> | |||||
| <!-- Swim lanes section --> | |||||
| <div class="mb-2"> | |||||
| <div class="d-flex justify-content-between align-items-center mb-2"> | |||||
| <strong class="small">Swim Lanes</strong> | |||||
| <button class="btn btn-sm btn-outline-primary" id="btn-add-lane"> | |||||
| <i class="bi bi-plus"></i> Add | |||||
| </button> | |||||
| </div> | |||||
| <div id="lane-add-form" class="d-none mb-2"> | |||||
| <div class="input-group input-group-sm"> | |||||
| <input type="text" class="form-control" id="lane-add-input" placeholder="Swim lane name" /> | |||||
| <button class="btn btn-primary" id="btn-lane-add-save">Add</button> | |||||
| <button class="btn btn-outline-secondary" id="btn-lane-add-cancel">Cancel</button> | |||||
| </div> | |||||
| </div> | |||||
| <ul class="list-group settings-sortable" id="lane-list"> | |||||
| <?php foreach ($lanes as $lane): ?> | |||||
| <li class="list-group-item d-flex align-items-center gap-2 py-2" | |||||
| data-id="<?= e((string) $lane->id) ?>"> | |||||
| <i class="bi bi-grip-vertical text-muted drag-handle" style="cursor:grab;"></i> | |||||
| <span class="flex-grow-1 lane-label-text"><?= e($lane->name) ?></span> | |||||
| <button class="btn btn-sm btn-link p-0 text-secondary btn-edit-lane" title="Rename"> | |||||
| <i class="bi bi-pencil"></i> | |||||
| </button> | |||||
| <button class="btn btn-sm btn-link p-0 text-danger btn-delete-lane" title="Delete"> | |||||
| <i class="bi bi-trash"></i> | |||||
| </button> | |||||
| </li> | |||||
| <?php endforeach; ?> | |||||
| </ul> | |||||
| </div> | |||||
| </div> | |||||
| </div> | |||||
| @@ -0,0 +1,24 @@ | |||||
| { | |||||
| "name": "kci/mindvisioncode", | |||||
| "description": "A small PHP MVC framework inspired by a Classic ASP MVC framework.", | |||||
| "type": "project", | |||||
| "autoload": { | |||||
| "psr-4": { | |||||
| "App\\": "app/", | |||||
| "Core\\": "core/" | |||||
| }, | |||||
| "files": [ | |||||
| "core/helpers.php" | |||||
| ] | |||||
| }, | |||||
| "scripts": { | |||||
| "migrate": "php scripts/migrate.php up", | |||||
| "migrate:down": "php scripts/migrate.php down", | |||||
| "migrate:status": "php scripts/migrate.php status", | |||||
| "migrate:fresh": "php scripts/migrate.php fresh", | |||||
| "migrate:fresh-seed": "php scripts/migrate.php fresh --seed" | |||||
| }, | |||||
| "require": { | |||||
| "stevenmaguire/oauth2-keycloak": "^6.1" | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,812 @@ | |||||
| { | |||||
| "_readme": [ | |||||
| "This file locks the dependencies of your project to a known state", | |||||
| "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", | |||||
| "This file is @generated automatically" | |||||
| ], | |||||
| "content-hash": "60e95733c2ba68d85ecc8ec7828e8d39", | |||||
| "packages": [ | |||||
| { | |||||
| "name": "firebase/php-jwt", | |||||
| "version": "v7.0.5", | |||||
| "source": { | |||||
| "type": "git", | |||||
| "url": "https://github.com/googleapis/php-jwt.git", | |||||
| "reference": "47ad26bab5e7c70ae8a6f08ed25ff83631121380" | |||||
| }, | |||||
| "dist": { | |||||
| "type": "zip", | |||||
| "url": "https://api.github.com/repos/googleapis/php-jwt/zipball/47ad26bab5e7c70ae8a6f08ed25ff83631121380", | |||||
| "reference": "47ad26bab5e7c70ae8a6f08ed25ff83631121380", | |||||
| "shasum": "" | |||||
| }, | |||||
| "require": { | |||||
| "php": "^8.0" | |||||
| }, | |||||
| "require-dev": { | |||||
| "guzzlehttp/guzzle": "^7.4", | |||||
| "phpfastcache/phpfastcache": "^9.2", | |||||
| "phpspec/prophecy-phpunit": "^2.0", | |||||
| "phpunit/phpunit": "^9.5", | |||||
| "psr/cache": "^2.0||^3.0", | |||||
| "psr/http-client": "^1.0", | |||||
| "psr/http-factory": "^1.0" | |||||
| }, | |||||
| "suggest": { | |||||
| "ext-sodium": "Support EdDSA (Ed25519) signatures", | |||||
| "paragonie/sodium_compat": "Support EdDSA (Ed25519) signatures when libsodium is not present" | |||||
| }, | |||||
| "type": "library", | |||||
| "autoload": { | |||||
| "psr-4": { | |||||
| "Firebase\\JWT\\": "src" | |||||
| } | |||||
| }, | |||||
| "notification-url": "https://packagist.org/downloads/", | |||||
| "license": [ | |||||
| "BSD-3-Clause" | |||||
| ], | |||||
| "authors": [ | |||||
| { | |||||
| "name": "Neuman Vong", | |||||
| "email": "neuman+pear@twilio.com", | |||||
| "role": "Developer" | |||||
| }, | |||||
| { | |||||
| "name": "Anant Narayanan", | |||||
| "email": "anant@php.net", | |||||
| "role": "Developer" | |||||
| } | |||||
| ], | |||||
| "description": "A simple library to encode and decode JSON Web Tokens (JWT) in PHP. Should conform to the current spec.", | |||||
| "homepage": "https://github.com/firebase/php-jwt", | |||||
| "keywords": [ | |||||
| "jwt", | |||||
| "php" | |||||
| ], | |||||
| "support": { | |||||
| "issues": "https://github.com/googleapis/php-jwt/issues", | |||||
| "source": "https://github.com/googleapis/php-jwt/tree/v7.0.5" | |||||
| }, | |||||
| "time": "2026-04-01T20:38:03+00:00" | |||||
| }, | |||||
| { | |||||
| "name": "guzzlehttp/guzzle", | |||||
| "version": "7.10.3", | |||||
| "source": { | |||||
| "type": "git", | |||||
| "url": "https://github.com/guzzle/guzzle.git", | |||||
| "reference": "47ba23c7a55247e2e1b7407aca90e9bbed0d9d86" | |||||
| }, | |||||
| "dist": { | |||||
| "type": "zip", | |||||
| "url": "https://api.github.com/repos/guzzle/guzzle/zipball/47ba23c7a55247e2e1b7407aca90e9bbed0d9d86", | |||||
| "reference": "47ba23c7a55247e2e1b7407aca90e9bbed0d9d86", | |||||
| "shasum": "" | |||||
| }, | |||||
| "require": { | |||||
| "ext-json": "*", | |||||
| "guzzlehttp/promises": "^2.3", | |||||
| "guzzlehttp/psr7": "^2.8", | |||||
| "php": "^7.2.5 || ^8.0", | |||||
| "psr/http-client": "^1.0", | |||||
| "symfony/deprecation-contracts": "^2.2 || ^3.0" | |||||
| }, | |||||
| "provide": { | |||||
| "psr/http-client-implementation": "1.0" | |||||
| }, | |||||
| "require-dev": { | |||||
| "bamarni/composer-bin-plugin": "^1.8.2", | |||||
| "ext-curl": "*", | |||||
| "guzzle/client-integration-tests": "3.0.2", | |||||
| "guzzlehttp/test-server": "^0.3.2", | |||||
| "php-http/message-factory": "^1.1", | |||||
| "phpunit/phpunit": "^8.5.52 || ^9.6.34", | |||||
| "psr/log": "^1.1 || ^2.0 || ^3.0" | |||||
| }, | |||||
| "suggest": { | |||||
| "ext-curl": "Required for CURL handler support", | |||||
| "ext-intl": "Required for Internationalized Domain Name (IDN) support", | |||||
| "psr/log": "Required for using the Log middleware" | |||||
| }, | |||||
| "type": "library", | |||||
| "extra": { | |||||
| "bamarni-bin": { | |||||
| "bin-links": true, | |||||
| "forward-command": false | |||||
| } | |||||
| }, | |||||
| "autoload": { | |||||
| "files": [ | |||||
| "src/functions_include.php" | |||||
| ], | |||||
| "psr-4": { | |||||
| "GuzzleHttp\\": "src/" | |||||
| } | |||||
| }, | |||||
| "notification-url": "https://packagist.org/downloads/", | |||||
| "license": [ | |||||
| "MIT" | |||||
| ], | |||||
| "authors": [ | |||||
| { | |||||
| "name": "Graham Campbell", | |||||
| "email": "hello@gjcampbell.co.uk", | |||||
| "homepage": "https://github.com/GrahamCampbell" | |||||
| }, | |||||
| { | |||||
| "name": "Michael Dowling", | |||||
| "email": "mtdowling@gmail.com", | |||||
| "homepage": "https://github.com/mtdowling" | |||||
| }, | |||||
| { | |||||
| "name": "Jeremy Lindblom", | |||||
| "email": "jeremeamia@gmail.com", | |||||
| "homepage": "https://github.com/jeremeamia" | |||||
| }, | |||||
| { | |||||
| "name": "George Mponos", | |||||
| "email": "gmponos@gmail.com", | |||||
| "homepage": "https://github.com/gmponos" | |||||
| }, | |||||
| { | |||||
| "name": "Tobias Nyholm", | |||||
| "email": "tobias.nyholm@gmail.com", | |||||
| "homepage": "https://github.com/Nyholm" | |||||
| }, | |||||
| { | |||||
| "name": "Márk Sági-Kazár", | |||||
| "email": "mark.sagikazar@gmail.com", | |||||
| "homepage": "https://github.com/sagikazarmark" | |||||
| }, | |||||
| { | |||||
| "name": "Tobias Schultze", | |||||
| "email": "webmaster@tubo-world.de", | |||||
| "homepage": "https://github.com/Tobion" | |||||
| } | |||||
| ], | |||||
| "description": "Guzzle is a PHP HTTP client library", | |||||
| "keywords": [ | |||||
| "client", | |||||
| "curl", | |||||
| "framework", | |||||
| "http", | |||||
| "http client", | |||||
| "psr-18", | |||||
| "psr-7", | |||||
| "rest", | |||||
| "web service" | |||||
| ], | |||||
| "support": { | |||||
| "issues": "https://github.com/guzzle/guzzle/issues", | |||||
| "source": "https://github.com/guzzle/guzzle/tree/7.10.3" | |||||
| }, | |||||
| "funding": [ | |||||
| { | |||||
| "url": "https://github.com/GrahamCampbell", | |||||
| "type": "github" | |||||
| }, | |||||
| { | |||||
| "url": "https://github.com/Nyholm", | |||||
| "type": "github" | |||||
| }, | |||||
| { | |||||
| "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/guzzle", | |||||
| "type": "tidelift" | |||||
| } | |||||
| ], | |||||
| "time": "2026-05-20T22:59:19+00:00" | |||||
| }, | |||||
| { | |||||
| "name": "guzzlehttp/promises", | |||||
| "version": "2.4.1", | |||||
| "source": { | |||||
| "type": "git", | |||||
| "url": "https://github.com/guzzle/promises.git", | |||||
| "reference": "09e8a212562fb1fb6a512c4156ed71525969d6c2" | |||||
| }, | |||||
| "dist": { | |||||
| "type": "zip", | |||||
| "url": "https://api.github.com/repos/guzzle/promises/zipball/09e8a212562fb1fb6a512c4156ed71525969d6c2", | |||||
| "reference": "09e8a212562fb1fb6a512c4156ed71525969d6c2", | |||||
| "shasum": "" | |||||
| }, | |||||
| "require": { | |||||
| "php": "^7.2.5 || ^8.0" | |||||
| }, | |||||
| "require-dev": { | |||||
| "bamarni/composer-bin-plugin": "^1.8.2", | |||||
| "phpunit/phpunit": "^8.5.52 || ^9.6.34" | |||||
| }, | |||||
| "type": "library", | |||||
| "extra": { | |||||
| "bamarni-bin": { | |||||
| "bin-links": true, | |||||
| "forward-command": false | |||||
| } | |||||
| }, | |||||
| "autoload": { | |||||
| "psr-4": { | |||||
| "GuzzleHttp\\Promise\\": "src/" | |||||
| } | |||||
| }, | |||||
| "notification-url": "https://packagist.org/downloads/", | |||||
| "license": [ | |||||
| "MIT" | |||||
| ], | |||||
| "authors": [ | |||||
| { | |||||
| "name": "Graham Campbell", | |||||
| "email": "hello@gjcampbell.co.uk", | |||||
| "homepage": "https://github.com/GrahamCampbell" | |||||
| }, | |||||
| { | |||||
| "name": "Michael Dowling", | |||||
| "email": "mtdowling@gmail.com", | |||||
| "homepage": "https://github.com/mtdowling" | |||||
| }, | |||||
| { | |||||
| "name": "Tobias Nyholm", | |||||
| "email": "tobias.nyholm@gmail.com", | |||||
| "homepage": "https://github.com/Nyholm" | |||||
| }, | |||||
| { | |||||
| "name": "Tobias Schultze", | |||||
| "email": "webmaster@tubo-world.de", | |||||
| "homepage": "https://github.com/Tobion" | |||||
| } | |||||
| ], | |||||
| "description": "Guzzle promises library", | |||||
| "keywords": [ | |||||
| "promise" | |||||
| ], | |||||
| "support": { | |||||
| "issues": "https://github.com/guzzle/promises/issues", | |||||
| "source": "https://github.com/guzzle/promises/tree/2.4.1" | |||||
| }, | |||||
| "funding": [ | |||||
| { | |||||
| "url": "https://github.com/GrahamCampbell", | |||||
| "type": "github" | |||||
| }, | |||||
| { | |||||
| "url": "https://github.com/Nyholm", | |||||
| "type": "github" | |||||
| }, | |||||
| { | |||||
| "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/promises", | |||||
| "type": "tidelift" | |||||
| } | |||||
| ], | |||||
| "time": "2026-05-20T22:57:30+00:00" | |||||
| }, | |||||
| { | |||||
| "name": "guzzlehttp/psr7", | |||||
| "version": "2.10.1", | |||||
| "source": { | |||||
| "type": "git", | |||||
| "url": "https://github.com/guzzle/psr7.git", | |||||
| "reference": "73ab136360b5dfd858006eae9795e8fe43c80361" | |||||
| }, | |||||
| "dist": { | |||||
| "type": "zip", | |||||
| "url": "https://api.github.com/repos/guzzle/psr7/zipball/73ab136360b5dfd858006eae9795e8fe43c80361", | |||||
| "reference": "73ab136360b5dfd858006eae9795e8fe43c80361", | |||||
| "shasum": "" | |||||
| }, | |||||
| "require": { | |||||
| "php": "^7.2.5 || ^8.0", | |||||
| "psr/http-factory": "^1.0", | |||||
| "psr/http-message": "^1.1 || ^2.0", | |||||
| "ralouphie/getallheaders": "^3.0" | |||||
| }, | |||||
| "provide": { | |||||
| "psr/http-factory-implementation": "1.0", | |||||
| "psr/http-message-implementation": "1.0" | |||||
| }, | |||||
| "require-dev": { | |||||
| "bamarni/composer-bin-plugin": "^1.8.2", | |||||
| "http-interop/http-factory-tests": "1.1.0", | |||||
| "jshttp/mime-db": "1.54.0.1", | |||||
| "phpunit/phpunit": "^8.5.52 || ^9.6.34" | |||||
| }, | |||||
| "suggest": { | |||||
| "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses" | |||||
| }, | |||||
| "type": "library", | |||||
| "extra": { | |||||
| "bamarni-bin": { | |||||
| "bin-links": true, | |||||
| "forward-command": false | |||||
| } | |||||
| }, | |||||
| "autoload": { | |||||
| "psr-4": { | |||||
| "GuzzleHttp\\Psr7\\": "src/" | |||||
| } | |||||
| }, | |||||
| "notification-url": "https://packagist.org/downloads/", | |||||
| "license": [ | |||||
| "MIT" | |||||
| ], | |||||
| "authors": [ | |||||
| { | |||||
| "name": "Graham Campbell", | |||||
| "email": "hello@gjcampbell.co.uk", | |||||
| "homepage": "https://github.com/GrahamCampbell" | |||||
| }, | |||||
| { | |||||
| "name": "Michael Dowling", | |||||
| "email": "mtdowling@gmail.com", | |||||
| "homepage": "https://github.com/mtdowling" | |||||
| }, | |||||
| { | |||||
| "name": "George Mponos", | |||||
| "email": "gmponos@gmail.com", | |||||
| "homepage": "https://github.com/gmponos" | |||||
| }, | |||||
| { | |||||
| "name": "Tobias Nyholm", | |||||
| "email": "tobias.nyholm@gmail.com", | |||||
| "homepage": "https://github.com/Nyholm" | |||||
| }, | |||||
| { | |||||
| "name": "Márk Sági-Kazár", | |||||
| "email": "mark.sagikazar@gmail.com", | |||||
| "homepage": "https://github.com/sagikazarmark" | |||||
| }, | |||||
| { | |||||
| "name": "Tobias Schultze", | |||||
| "email": "webmaster@tubo-world.de", | |||||
| "homepage": "https://github.com/Tobion" | |||||
| }, | |||||
| { | |||||
| "name": "Márk Sági-Kazár", | |||||
| "email": "mark.sagikazar@gmail.com", | |||||
| "homepage": "https://sagikazarmark.hu" | |||||
| } | |||||
| ], | |||||
| "description": "PSR-7 message implementation that also provides common utility methods", | |||||
| "keywords": [ | |||||
| "http", | |||||
| "message", | |||||
| "psr-7", | |||||
| "request", | |||||
| "response", | |||||
| "stream", | |||||
| "uri", | |||||
| "url" | |||||
| ], | |||||
| "support": { | |||||
| "issues": "https://github.com/guzzle/psr7/issues", | |||||
| "source": "https://github.com/guzzle/psr7/tree/2.10.1" | |||||
| }, | |||||
| "funding": [ | |||||
| { | |||||
| "url": "https://github.com/GrahamCampbell", | |||||
| "type": "github" | |||||
| }, | |||||
| { | |||||
| "url": "https://github.com/Nyholm", | |||||
| "type": "github" | |||||
| }, | |||||
| { | |||||
| "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/psr7", | |||||
| "type": "tidelift" | |||||
| } | |||||
| ], | |||||
| "time": "2026-05-20T09:27:36+00:00" | |||||
| }, | |||||
| { | |||||
| "name": "league/oauth2-client", | |||||
| "version": "2.9.0", | |||||
| "source": { | |||||
| "type": "git", | |||||
| "url": "https://github.com/thephpleague/oauth2-client.git", | |||||
| "reference": "26e8c5da4f3d78cede7021e09b1330a0fc093d5e" | |||||
| }, | |||||
| "dist": { | |||||
| "type": "zip", | |||||
| "url": "https://api.github.com/repos/thephpleague/oauth2-client/zipball/26e8c5da4f3d78cede7021e09b1330a0fc093d5e", | |||||
| "reference": "26e8c5da4f3d78cede7021e09b1330a0fc093d5e", | |||||
| "shasum": "" | |||||
| }, | |||||
| "require": { | |||||
| "ext-json": "*", | |||||
| "guzzlehttp/guzzle": "^6.5.8 || ^7.4.5", | |||||
| "php": "^7.1 || >=8.0.0 <8.6.0" | |||||
| }, | |||||
| "require-dev": { | |||||
| "mockery/mockery": "^1.3.5", | |||||
| "php-parallel-lint/php-parallel-lint": "^1.4", | |||||
| "phpunit/phpunit": "^7 || ^8 || ^9 || ^10 || ^11", | |||||
| "squizlabs/php_codesniffer": "^3.11" | |||||
| }, | |||||
| "type": "library", | |||||
| "autoload": { | |||||
| "psr-4": { | |||||
| "League\\OAuth2\\Client\\": "src/" | |||||
| } | |||||
| }, | |||||
| "notification-url": "https://packagist.org/downloads/", | |||||
| "license": [ | |||||
| "MIT" | |||||
| ], | |||||
| "authors": [ | |||||
| { | |||||
| "name": "Alex Bilbie", | |||||
| "email": "hello@alexbilbie.com", | |||||
| "homepage": "http://www.alexbilbie.com", | |||||
| "role": "Developer" | |||||
| }, | |||||
| { | |||||
| "name": "Woody Gilk", | |||||
| "homepage": "https://github.com/shadowhand", | |||||
| "role": "Contributor" | |||||
| } | |||||
| ], | |||||
| "description": "OAuth 2.0 Client Library", | |||||
| "keywords": [ | |||||
| "Authentication", | |||||
| "SSO", | |||||
| "authorization", | |||||
| "identity", | |||||
| "idp", | |||||
| "oauth", | |||||
| "oauth2", | |||||
| "single sign on" | |||||
| ], | |||||
| "support": { | |||||
| "issues": "https://github.com/thephpleague/oauth2-client/issues", | |||||
| "source": "https://github.com/thephpleague/oauth2-client/tree/2.9.0" | |||||
| }, | |||||
| "time": "2025-11-25T22:17:17+00:00" | |||||
| }, | |||||
| { | |||||
| "name": "psr/http-client", | |||||
| "version": "1.0.3", | |||||
| "source": { | |||||
| "type": "git", | |||||
| "url": "https://github.com/php-fig/http-client.git", | |||||
| "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90" | |||||
| }, | |||||
| "dist": { | |||||
| "type": "zip", | |||||
| "url": "https://api.github.com/repos/php-fig/http-client/zipball/bb5906edc1c324c9a05aa0873d40117941e5fa90", | |||||
| "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90", | |||||
| "shasum": "" | |||||
| }, | |||||
| "require": { | |||||
| "php": "^7.0 || ^8.0", | |||||
| "psr/http-message": "^1.0 || ^2.0" | |||||
| }, | |||||
| "type": "library", | |||||
| "extra": { | |||||
| "branch-alias": { | |||||
| "dev-master": "1.0.x-dev" | |||||
| } | |||||
| }, | |||||
| "autoload": { | |||||
| "psr-4": { | |||||
| "Psr\\Http\\Client\\": "src/" | |||||
| } | |||||
| }, | |||||
| "notification-url": "https://packagist.org/downloads/", | |||||
| "license": [ | |||||
| "MIT" | |||||
| ], | |||||
| "authors": [ | |||||
| { | |||||
| "name": "PHP-FIG", | |||||
| "homepage": "https://www.php-fig.org/" | |||||
| } | |||||
| ], | |||||
| "description": "Common interface for HTTP clients", | |||||
| "homepage": "https://github.com/php-fig/http-client", | |||||
| "keywords": [ | |||||
| "http", | |||||
| "http-client", | |||||
| "psr", | |||||
| "psr-18" | |||||
| ], | |||||
| "support": { | |||||
| "source": "https://github.com/php-fig/http-client" | |||||
| }, | |||||
| "time": "2023-09-23T14:17:50+00:00" | |||||
| }, | |||||
| { | |||||
| "name": "psr/http-factory", | |||||
| "version": "1.1.0", | |||||
| "source": { | |||||
| "type": "git", | |||||
| "url": "https://github.com/php-fig/http-factory.git", | |||||
| "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a" | |||||
| }, | |||||
| "dist": { | |||||
| "type": "zip", | |||||
| "url": "https://api.github.com/repos/php-fig/http-factory/zipball/2b4765fddfe3b508ac62f829e852b1501d3f6e8a", | |||||
| "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a", | |||||
| "shasum": "" | |||||
| }, | |||||
| "require": { | |||||
| "php": ">=7.1", | |||||
| "psr/http-message": "^1.0 || ^2.0" | |||||
| }, | |||||
| "type": "library", | |||||
| "extra": { | |||||
| "branch-alias": { | |||||
| "dev-master": "1.0.x-dev" | |||||
| } | |||||
| }, | |||||
| "autoload": { | |||||
| "psr-4": { | |||||
| "Psr\\Http\\Message\\": "src/" | |||||
| } | |||||
| }, | |||||
| "notification-url": "https://packagist.org/downloads/", | |||||
| "license": [ | |||||
| "MIT" | |||||
| ], | |||||
| "authors": [ | |||||
| { | |||||
| "name": "PHP-FIG", | |||||
| "homepage": "https://www.php-fig.org/" | |||||
| } | |||||
| ], | |||||
| "description": "PSR-17: Common interfaces for PSR-7 HTTP message factories", | |||||
| "keywords": [ | |||||
| "factory", | |||||
| "http", | |||||
| "message", | |||||
| "psr", | |||||
| "psr-17", | |||||
| "psr-7", | |||||
| "request", | |||||
| "response" | |||||
| ], | |||||
| "support": { | |||||
| "source": "https://github.com/php-fig/http-factory" | |||||
| }, | |||||
| "time": "2024-04-15T12:06:14+00:00" | |||||
| }, | |||||
| { | |||||
| "name": "psr/http-message", | |||||
| "version": "2.0", | |||||
| "source": { | |||||
| "type": "git", | |||||
| "url": "https://github.com/php-fig/http-message.git", | |||||
| "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71" | |||||
| }, | |||||
| "dist": { | |||||
| "type": "zip", | |||||
| "url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71", | |||||
| "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71", | |||||
| "shasum": "" | |||||
| }, | |||||
| "require": { | |||||
| "php": "^7.2 || ^8.0" | |||||
| }, | |||||
| "type": "library", | |||||
| "extra": { | |||||
| "branch-alias": { | |||||
| "dev-master": "2.0.x-dev" | |||||
| } | |||||
| }, | |||||
| "autoload": { | |||||
| "psr-4": { | |||||
| "Psr\\Http\\Message\\": "src/" | |||||
| } | |||||
| }, | |||||
| "notification-url": "https://packagist.org/downloads/", | |||||
| "license": [ | |||||
| "MIT" | |||||
| ], | |||||
| "authors": [ | |||||
| { | |||||
| "name": "PHP-FIG", | |||||
| "homepage": "https://www.php-fig.org/" | |||||
| } | |||||
| ], | |||||
| "description": "Common interface for HTTP messages", | |||||
| "homepage": "https://github.com/php-fig/http-message", | |||||
| "keywords": [ | |||||
| "http", | |||||
| "http-message", | |||||
| "psr", | |||||
| "psr-7", | |||||
| "request", | |||||
| "response" | |||||
| ], | |||||
| "support": { | |||||
| "source": "https://github.com/php-fig/http-message/tree/2.0" | |||||
| }, | |||||
| "time": "2023-04-04T09:54:51+00:00" | |||||
| }, | |||||
| { | |||||
| "name": "ralouphie/getallheaders", | |||||
| "version": "3.0.3", | |||||
| "source": { | |||||
| "type": "git", | |||||
| "url": "https://github.com/ralouphie/getallheaders.git", | |||||
| "reference": "120b605dfeb996808c31b6477290a714d356e822" | |||||
| }, | |||||
| "dist": { | |||||
| "type": "zip", | |||||
| "url": "https://api.github.com/repos/ralouphie/getallheaders/zipball/120b605dfeb996808c31b6477290a714d356e822", | |||||
| "reference": "120b605dfeb996808c31b6477290a714d356e822", | |||||
| "shasum": "" | |||||
| }, | |||||
| "require": { | |||||
| "php": ">=5.6" | |||||
| }, | |||||
| "require-dev": { | |||||
| "php-coveralls/php-coveralls": "^2.1", | |||||
| "phpunit/phpunit": "^5 || ^6.5" | |||||
| }, | |||||
| "type": "library", | |||||
| "autoload": { | |||||
| "files": [ | |||||
| "src/getallheaders.php" | |||||
| ] | |||||
| }, | |||||
| "notification-url": "https://packagist.org/downloads/", | |||||
| "license": [ | |||||
| "MIT" | |||||
| ], | |||||
| "authors": [ | |||||
| { | |||||
| "name": "Ralph Khattar", | |||||
| "email": "ralph.khattar@gmail.com" | |||||
| } | |||||
| ], | |||||
| "description": "A polyfill for getallheaders.", | |||||
| "support": { | |||||
| "issues": "https://github.com/ralouphie/getallheaders/issues", | |||||
| "source": "https://github.com/ralouphie/getallheaders/tree/develop" | |||||
| }, | |||||
| "time": "2019-03-08T08:55:37+00:00" | |||||
| }, | |||||
| { | |||||
| "name": "stevenmaguire/oauth2-keycloak", | |||||
| "version": "6.1.1", | |||||
| "source": { | |||||
| "type": "git", | |||||
| "url": "https://github.com/stevenmaguire/oauth2-keycloak.git", | |||||
| "reference": "31bb3b1fa15b444212ed43facc898fafc7c2707a" | |||||
| }, | |||||
| "dist": { | |||||
| "type": "zip", | |||||
| "url": "https://api.github.com/repos/stevenmaguire/oauth2-keycloak/zipball/31bb3b1fa15b444212ed43facc898fafc7c2707a", | |||||
| "reference": "31bb3b1fa15b444212ed43facc898fafc7c2707a", | |||||
| "shasum": "" | |||||
| }, | |||||
| "require": { | |||||
| "firebase/php-jwt": "^7.0", | |||||
| "league/oauth2-client": "^2.8", | |||||
| "php": "^8.0" | |||||
| }, | |||||
| "require-dev": { | |||||
| "mockery/mockery": "^1.6", | |||||
| "phpstan/phpstan": "^1.12", | |||||
| "phpunit/phpunit": "~9.6.4", | |||||
| "squizlabs/php_codesniffer": "~3.7.0" | |||||
| }, | |||||
| "type": "library", | |||||
| "extra": { | |||||
| "branch-alias": { | |||||
| "dev-master": "1.0.x-dev" | |||||
| } | |||||
| }, | |||||
| "autoload": { | |||||
| "psr-4": { | |||||
| "Stevenmaguire\\OAuth2\\Client\\": "src/" | |||||
| } | |||||
| }, | |||||
| "notification-url": "https://packagist.org/downloads/", | |||||
| "license": [ | |||||
| "MIT" | |||||
| ], | |||||
| "authors": [ | |||||
| { | |||||
| "name": "Steven Maguire", | |||||
| "email": "stevenmaguire@gmail.com", | |||||
| "homepage": "https://github.com/stevenmaguire" | |||||
| } | |||||
| ], | |||||
| "description": "Keycloak OAuth 2.0 Client Provider for The PHP League OAuth2-Client", | |||||
| "keywords": [ | |||||
| "authorisation", | |||||
| "authorization", | |||||
| "client", | |||||
| "keycloak", | |||||
| "oauth", | |||||
| "oauth2" | |||||
| ], | |||||
| "support": { | |||||
| "issues": "https://github.com/stevenmaguire/oauth2-keycloak/issues", | |||||
| "source": "https://github.com/stevenmaguire/oauth2-keycloak/tree/6.1.1" | |||||
| }, | |||||
| "time": "2026-03-30T07:32:03+00:00" | |||||
| }, | |||||
| { | |||||
| "name": "symfony/deprecation-contracts", | |||||
| "version": "v3.7.0", | |||||
| "source": { | |||||
| "type": "git", | |||||
| "url": "https://github.com/symfony/deprecation-contracts.git", | |||||
| "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b" | |||||
| }, | |||||
| "dist": { | |||||
| "type": "zip", | |||||
| "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/50f59d1f3ca46d41ac911f97a78626b6756af35b", | |||||
| "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b", | |||||
| "shasum": "" | |||||
| }, | |||||
| "require": { | |||||
| "php": ">=8.1" | |||||
| }, | |||||
| "type": "library", | |||||
| "extra": { | |||||
| "thanks": { | |||||
| "url": "https://github.com/symfony/contracts", | |||||
| "name": "symfony/contracts" | |||||
| }, | |||||
| "branch-alias": { | |||||
| "dev-main": "3.7-dev" | |||||
| } | |||||
| }, | |||||
| "autoload": { | |||||
| "files": [ | |||||
| "function.php" | |||||
| ] | |||||
| }, | |||||
| "notification-url": "https://packagist.org/downloads/", | |||||
| "license": [ | |||||
| "MIT" | |||||
| ], | |||||
| "authors": [ | |||||
| { | |||||
| "name": "Nicolas Grekas", | |||||
| "email": "p@tchwork.com" | |||||
| }, | |||||
| { | |||||
| "name": "Symfony Community", | |||||
| "homepage": "https://symfony.com/contributors" | |||||
| } | |||||
| ], | |||||
| "description": "A generic function and convention to trigger deprecation notices", | |||||
| "homepage": "https://symfony.com", | |||||
| "support": { | |||||
| "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.0" | |||||
| }, | |||||
| "funding": [ | |||||
| { | |||||
| "url": "https://symfony.com/sponsor", | |||||
| "type": "custom" | |||||
| }, | |||||
| { | |||||
| "url": "https://github.com/fabpot", | |||||
| "type": "github" | |||||
| }, | |||||
| { | |||||
| "url": "https://github.com/nicolas-grekas", | |||||
| "type": "github" | |||||
| }, | |||||
| { | |||||
| "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", | |||||
| "type": "tidelift" | |||||
| } | |||||
| ], | |||||
| "time": "2026-04-13T15:52:40+00:00" | |||||
| } | |||||
| ], | |||||
| "packages-dev": [], | |||||
| "aliases": [], | |||||
| "minimum-stability": "stable", | |||||
| "stability-flags": {}, | |||||
| "prefer-stable": false, | |||||
| "prefer-lowest": false, | |||||
| "platform": {}, | |||||
| "platform-dev": {}, | |||||
| "plugin-api-version": "2.9.0" | |||||
| } | |||||
| @@ -0,0 +1,14 @@ | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| return [ | |||||
| 'keycloak' => [ | |||||
| 'base_url' => getenv('KEYCLOAK_BASE_URL') ?: '', | |||||
| 'realm' => getenv('KEYCLOAK_REALM') ?: '', | |||||
| 'client_id' => getenv('KEYCLOAK_CLIENT_ID') ?: '', | |||||
| 'client_secret' => getenv('KEYCLOAK_CLIENT_SECRET') ?: '', | |||||
| 'redirect_uri' => getenv('KEYCLOAK_REDIRECT_URI') ?: '', | |||||
| 'post_logout_redirect_uri' => getenv('APP_URL') ?: 'http://localhost:8000', | |||||
| ], | |||||
| ]; | |||||
| @@ -0,0 +1,11 @@ | |||||
| <?php | |||||
| return [ | |||||
| 'dsn' => 'sqlite:' . __DIR__ . '/../database/app.sqlite', | |||||
| 'username' => null, | |||||
| 'password' => null, | |||||
| 'options' => [ | |||||
| PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, | |||||
| PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, | |||||
| ], | |||||
| ]; | |||||
| @@ -0,0 +1,6 @@ | |||||
| <?php | |||||
| return [ | |||||
| 'views_path' => __DIR__ . '/../app/Views', | |||||
| 'layout_path' => __DIR__ . '/../app/Views/layouts/app.php', | |||||
| ]; | |||||
| @@ -0,0 +1,154 @@ | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| namespace Core; | |||||
| use Exception; | |||||
| use ReflectionClass; | |||||
| use ReflectionFunction; | |||||
| use ReflectionFunctionAbstract; | |||||
| use ReflectionMethod; | |||||
| class App | |||||
| { | |||||
| protected array $bindings = []; | |||||
| protected array $instances = []; | |||||
| public function bind(string $name, mixed $value): void | |||||
| { | |||||
| $this->bindings[$name] = $value; | |||||
| } | |||||
| public function instance(string $name, mixed $value): void | |||||
| { | |||||
| $this->instances[$name] = $value; | |||||
| } | |||||
| public function make(string $className): object | |||||
| { | |||||
| if (isset($this->bindings[$className])) { | |||||
| $binding = $this->bindings[$className]; | |||||
| if (is_string($binding) && is_a($binding, $className, true)) { | |||||
| return $this->instantiate($binding); | |||||
| } | |||||
| return $binding; | |||||
| } | |||||
| if (isset($this->instances[$className])) { | |||||
| return $this->instances[$className]; | |||||
| } | |||||
| return $this->instantiate($className); | |||||
| } | |||||
| public function clear(): void | |||||
| { | |||||
| $this->bindings = []; | |||||
| $this->instances = []; | |||||
| } | |||||
| public function get(string $name): mixed | |||||
| { | |||||
| return $this->instances[$name] ?? $this->bindings[$name] ?? null; | |||||
| } | |||||
| public function call(callable|array|string $handler, array $parameters = []): mixed | |||||
| { | |||||
| if (is_array($handler)) { | |||||
| return $this->callMethod($handler, $parameters); | |||||
| } | |||||
| if (is_callable($handler)) { | |||||
| return $this->callFunction($handler, $parameters); | |||||
| } | |||||
| throw new Exception('Invalid handler.'); | |||||
| } | |||||
| protected function callFunction(callable $handler, array $parameters): mixed | |||||
| { | |||||
| $reflection = new ReflectionFunction($handler); | |||||
| return $reflection->invokeArgs($this->resolveArgs($reflection, $parameters)); | |||||
| } | |||||
| protected function callMethod(array $handler, array $parameters): mixed | |||||
| { | |||||
| [$class, $method] = $handler; | |||||
| if (is_string($class)) { | |||||
| $class = new $class(); | |||||
| } | |||||
| $reflection = new ReflectionMethod($class, $method); | |||||
| return $reflection->invokeArgs($class, $this->resolveArgs($reflection, $parameters)); | |||||
| } | |||||
| protected function resolveArgs(\ReflectionFunctionAbstract $reflection, array $parameters): array | |||||
| { | |||||
| $args = []; | |||||
| foreach ($reflection->getParameters() as $param) { | |||||
| $name = $param->getName(); | |||||
| if (array_key_exists($name, $parameters)) { | |||||
| $args[] = $parameters[$name]; | |||||
| continue; | |||||
| } | |||||
| $type = $param->getType(); | |||||
| if ($type instanceof \ReflectionNamedType && !$type->isBuiltin()) { | |||||
| $typeName = $type->getName(); | |||||
| if (array_key_exists($typeName, $this->instances)) { | |||||
| $args[] = $this->instances[$typeName]; | |||||
| continue; | |||||
| } | |||||
| if (array_key_exists($typeName, $this->bindings)) { | |||||
| $binding = $this->bindings[$typeName]; | |||||
| if (is_string($binding) && is_a($binding, $typeName, true)) { | |||||
| $args[] = $this->instantiate($binding); | |||||
| continue; | |||||
| } | |||||
| $args[] = $binding; | |||||
| continue; | |||||
| } | |||||
| } | |||||
| $args[] = $param->isDefaultValueAvailable() ? $param->getDefaultValue() : null; | |||||
| } | |||||
| return $args; | |||||
| } | |||||
| /** | |||||
| * @param class-string $className | |||||
| */ | |||||
| protected function instantiate(string $className): object | |||||
| { | |||||
| $reflection = new ReflectionClass($className); | |||||
| if (!$reflection->isInstantiable()) { | |||||
| throw new Exception("Cannot instantiate {$className}: class is not instantiable."); | |||||
| } | |||||
| $constructor = $reflection->getConstructor(); | |||||
| if ($constructor === null) { | |||||
| return new $className(); | |||||
| } | |||||
| $args = $this->resolveArgs($constructor, []); | |||||
| return $reflection->newInstanceArgs($args); | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,37 @@ | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| namespace Core; | |||||
| abstract class Controller | |||||
| { | |||||
| protected function view(string $view, array $data = []): Response | |||||
| { | |||||
| return View::render($view, $data); | |||||
| } | |||||
| protected function fragment(string $view, array $data = [], int $status = 200, array $headers = []): Response | |||||
| { | |||||
| return View::fragment($view, $data, $status, $headers); | |||||
| } | |||||
| protected function redirect(string $url): Response | |||||
| { | |||||
| return Response::redirect($url); | |||||
| } | |||||
| protected function json(array $data): Response | |||||
| { | |||||
| return Response::json($data); | |||||
| } | |||||
| protected function requirePost(Request $request): ?Response | |||||
| { | |||||
| if ($request->method() !== 'POST') { | |||||
| return new Response('Method Not Allowed.', 405); | |||||
| } | |||||
| return null; | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,69 @@ | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| namespace Core; | |||||
| use PDO; | |||||
| class Database | |||||
| { | |||||
| protected PDO $pdo; | |||||
| public function __construct(array $config) | |||||
| { | |||||
| $this->pdo = new PDO( | |||||
| $config['dsn'], | |||||
| $config['username'] ?? null, | |||||
| $config['password'] ?? null, | |||||
| $config['options'] ?? [] | |||||
| ); | |||||
| } | |||||
| public function pdo(): PDO | |||||
| { | |||||
| return $this->pdo; | |||||
| } | |||||
| public function query(string $sql, array $parameters = []): array | |||||
| { | |||||
| $statement = $this->pdo->prepare($sql); | |||||
| $statement->execute($parameters); | |||||
| return $statement->fetchAll(PDO::FETCH_ASSOC); | |||||
| } | |||||
| public function first(string $sql, array $parameters = []): ?array | |||||
| { | |||||
| $rows = $this->query($sql, $parameters); | |||||
| return $rows[0] ?? null; | |||||
| } | |||||
| public function execute(string $sql, array $parameters = []): bool | |||||
| { | |||||
| $statement = $this->pdo->prepare($sql); | |||||
| return $statement->execute($parameters); | |||||
| } | |||||
| public function lastInsertId(): string | |||||
| { | |||||
| return $this->pdo->lastInsertId(); | |||||
| } | |||||
| public function transaction(callable $fn): mixed | |||||
| { | |||||
| $this->pdo->beginTransaction(); | |||||
| try { | |||||
| $result = $fn($this); | |||||
| $this->pdo->commit(); | |||||
| return $result; | |||||
| } catch (\Throwable $e) { | |||||
| $this->pdo->rollBack(); | |||||
| throw $e; | |||||
| } | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,61 @@ | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| namespace Core; | |||||
| use Throwable; | |||||
| class Dispatcher | |||||
| { | |||||
| protected Router $router; | |||||
| protected App $app; | |||||
| protected bool $debug; | |||||
| public function __construct(Router $router, App $app, bool $debug = false) | |||||
| { | |||||
| $this->router = $router; | |||||
| $this->app = $app; | |||||
| $this->debug = $debug; | |||||
| } | |||||
| public function dispatch(Request $request): Response | |||||
| { | |||||
| Request::setCurrent($request); | |||||
| try { | |||||
| $route = $this->router->match($request->method(), $request->path()); | |||||
| if (!$route) { | |||||
| return Response::notFound('Page not found.'); | |||||
| } | |||||
| $result = $route->dispatch($this->app); | |||||
| return $this->normalizeResponse($result); | |||||
| } catch (Throwable $e) { | |||||
| if (!$this->debug) { | |||||
| error_log($e->getMessage()); | |||||
| } | |||||
| $message = $this->debug ? $e->getMessage() : 'An unexpected error occurred.'; | |||||
| return Response::serverError($message); | |||||
| } finally { | |||||
| Request::clearCurrent(); | |||||
| } | |||||
| } | |||||
| protected function normalizeResponse(mixed $result): Response | |||||
| { | |||||
| if ($result instanceof Response) { | |||||
| return $result; | |||||
| } | |||||
| if (is_array($result)) { | |||||
| return Response::json($result); | |||||
| } | |||||
| return new Response((string) $result); | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,12 @@ | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| namespace Core; | |||||
| abstract class Migration | |||||
| { | |||||
| abstract public function up(Database $database): void; | |||||
| abstract public function down(Database $database): void; | |||||
| } | |||||
| @@ -0,0 +1,233 @@ | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| namespace Core; | |||||
| class MigrationManager | |||||
| { | |||||
| protected Database $database; | |||||
| protected string $path; | |||||
| public function __construct(Database $database, string $path) | |||||
| { | |||||
| $this->database = $database; | |||||
| $this->path = rtrim($path, '/'); | |||||
| } | |||||
| public function ensureTable(): void | |||||
| { | |||||
| $this->database->execute( | |||||
| 'CREATE TABLE IF NOT EXISTS migrations (id INTEGER PRIMARY KEY AUTOINCREMENT, migration VARCHAR(255) NOT NULL, ran_at DATETIME DEFAULT CURRENT_TIMESTAMP)' | |||||
| ); | |||||
| $this->database->execute( | |||||
| 'CREATE UNIQUE INDEX IF NOT EXISTS idx_migrations_migration_unique ON migrations (migration)' | |||||
| ); | |||||
| } | |||||
| public function status(): array | |||||
| { | |||||
| $this->ensureTable(); | |||||
| $ran = $this->database->query('SELECT migration, ran_at FROM migrations ORDER BY id ASC'); | |||||
| $ranByName = []; | |||||
| foreach ($ran as $row) { | |||||
| $ranByName[$row['migration']] = $row['ran_at']; | |||||
| } | |||||
| $files = $this->migrationFiles(); | |||||
| $status = []; | |||||
| foreach ($files as $file) { | |||||
| $name = basename($file); | |||||
| $status[] = [ | |||||
| 'migration' => $name, | |||||
| 'ran' => array_key_exists($name, $ranByName), | |||||
| 'ran_at' => $ranByName[$name] ?? null, | |||||
| ]; | |||||
| } | |||||
| return $status; | |||||
| } | |||||
| public function runPending(): array | |||||
| { | |||||
| $this->ensureTable(); | |||||
| $ran = $this->database->query('SELECT migration FROM migrations'); | |||||
| $ranNames = array_column($ran, 'migration'); | |||||
| $files = $this->migrationFiles(); | |||||
| $ranMigrations = []; | |||||
| foreach ($files as $file) { | |||||
| $name = basename($file); | |||||
| if (in_array($name, $ranNames, true)) { | |||||
| continue; | |||||
| } | |||||
| $migration = $this->loadMigration($file); | |||||
| $this->database->transaction(function (Database $db) use ($migration, $name, &$ranMigrations): void { | |||||
| $migration->up($db); | |||||
| if ($db->first('SELECT id FROM migrations WHERE migration = :migration', ['migration' => $name]) === null) { | |||||
| $db->execute('INSERT INTO migrations (migration) VALUES (:migration)', ['migration' => $name]); | |||||
| } | |||||
| $ranMigrations[] = $name; | |||||
| }); | |||||
| } | |||||
| return $ranMigrations; | |||||
| } | |||||
| public function rollback(int $steps = 1): array | |||||
| { | |||||
| $this->ensureTable(); | |||||
| $steps = max(1, $steps); | |||||
| $applied = $this->database->query( | |||||
| 'SELECT id, migration FROM migrations ORDER BY id DESC LIMIT :steps', | |||||
| ['steps' => $steps] | |||||
| ); | |||||
| $rolledBack = []; | |||||
| foreach ($applied as $row) { | |||||
| $file = $this->path . '/' . $row['migration']; | |||||
| if (!file_exists($file)) { | |||||
| throw new \RuntimeException("Migration file not found for rollback: {$row['migration']}"); | |||||
| } | |||||
| $migration = $this->loadMigration($file); | |||||
| $this->database->transaction(function (Database $db) use ($migration, $row, &$rolledBack): void { | |||||
| $migration->down($db); | |||||
| $db->execute('DELETE FROM migrations WHERE id = :id', ['id' => $row['id']]); | |||||
| $rolledBack[] = $row['migration']; | |||||
| }); | |||||
| } | |||||
| return $rolledBack; | |||||
| } | |||||
| public function fresh(): array | |||||
| { | |||||
| $this->ensureTable(); | |||||
| $files = array_reverse($this->migrationFiles()); | |||||
| $rolledBack = []; | |||||
| foreach ($files as $file) { | |||||
| $migration = $this->loadMigration($file); | |||||
| $name = basename($file); | |||||
| $this->database->transaction(function (Database $db) use ($migration, $name, &$rolledBack): void { | |||||
| $migration->down($db); | |||||
| $rolledBack[] = $name; | |||||
| }); | |||||
| } | |||||
| $this->database->execute('DELETE FROM migrations'); | |||||
| $ran = $this->runPending(); | |||||
| return [ | |||||
| 'rolled_back' => $rolledBack, | |||||
| 'migrated' => $ran, | |||||
| ]; | |||||
| } | |||||
| public function make(string $name): string | |||||
| { | |||||
| $slug = trim(strtolower(preg_replace('/[^a-zA-Z0-9]+/', '_', $name) ?? ''), '_'); | |||||
| if ($slug === '') { | |||||
| throw new \InvalidArgumentException('Migration name must contain letters or numbers.'); | |||||
| } | |||||
| if (!is_dir($this->path)) { | |||||
| mkdir($this->path, 0777, true); | |||||
| } | |||||
| $timestamp = date('Ymd_His'); | |||||
| $filename = $timestamp . '_' . $slug . '.php'; | |||||
| $path = $this->path . '/' . $filename; | |||||
| if (file_exists($path)) { | |||||
| throw new \RuntimeException("Migration already exists: {$filename}"); | |||||
| } | |||||
| $template = <<<PHP | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| use Core\Database; | |||||
| use Core\Migration; | |||||
| return new class extends Migration | |||||
| { | |||||
| public function up(Database \$database): void | |||||
| { | |||||
| // Write the forward migration here. | |||||
| } | |||||
| public function down(Database \$database): void | |||||
| { | |||||
| // Write the rollback migration here. | |||||
| } | |||||
| }; | |||||
| PHP; | |||||
| file_put_contents($path, $template . PHP_EOL); | |||||
| return $path; | |||||
| } | |||||
| private function migrationFiles(): array | |||||
| { | |||||
| $files = glob($this->path . '/*.php') ?: []; | |||||
| sort($files); | |||||
| return $files; | |||||
| } | |||||
| private function loadMigration(string $file): Migration | |||||
| { | |||||
| $migration = require $file; | |||||
| if ($migration instanceof Migration) { | |||||
| return $migration; | |||||
| } | |||||
| if (is_callable($migration)) { | |||||
| return new class ($migration, basename($file)) extends Migration | |||||
| { | |||||
| private $callback; | |||||
| private string $name; | |||||
| public function __construct(callable $callback, string $name) | |||||
| { | |||||
| $this->callback = $callback; | |||||
| $this->name = $name; | |||||
| } | |||||
| public function up(Database $database): void | |||||
| { | |||||
| ($this->callback)($database); | |||||
| } | |||||
| public function down(Database $database): void | |||||
| { | |||||
| throw new \RuntimeException("Migration {$this->name} cannot be rolled back because it has no down() method."); | |||||
| } | |||||
| }; | |||||
| } | |||||
| throw new \RuntimeException('Migration files must return a Migration instance.'); | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,38 @@ | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| namespace Core; | |||||
| abstract class Repository | |||||
| { | |||||
| protected Database $database; | |||||
| protected string $table; | |||||
| protected string $primaryKey = 'id'; | |||||
| public function __construct(Database $database) | |||||
| { | |||||
| $this->database = $database; | |||||
| } | |||||
| public function find(int|string $id): ?array | |||||
| { | |||||
| return $this->database->first( | |||||
| "SELECT * FROM {$this->table} WHERE {$this->primaryKey} = :id", | |||||
| ['id' => $id] | |||||
| ); | |||||
| } | |||||
| public function all(): array | |||||
| { | |||||
| return $this->database->query("SELECT * FROM {$this->table}"); | |||||
| } | |||||
| public function delete(int|string $id): bool | |||||
| { | |||||
| return $this->database->execute( | |||||
| "DELETE FROM {$this->table} WHERE {$this->primaryKey} = :id", | |||||
| ['id' => $id] | |||||
| ); | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,80 @@ | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| namespace Core; | |||||
| class Request | |||||
| { | |||||
| protected static ?self $current = null; | |||||
| protected string $method; | |||||
| protected string $uri; | |||||
| protected array $get; | |||||
| protected array $post; | |||||
| protected array $server; | |||||
| public function __construct(array $get, array $post, array $server) | |||||
| { | |||||
| $this->get = $get; | |||||
| $this->post = $post; | |||||
| $this->server = $server; | |||||
| $this->method = $server['REQUEST_METHOD'] ?? 'GET'; | |||||
| $this->uri = $server['REQUEST_URI'] ?? '/'; | |||||
| } | |||||
| public static function capture(): self | |||||
| { | |||||
| if (self::$current instanceof self) { | |||||
| return self::$current; | |||||
| } | |||||
| return new self($_GET, $_POST, $_SERVER); | |||||
| } | |||||
| public static function setCurrent(self $request): void | |||||
| { | |||||
| self::$current = $request; | |||||
| } | |||||
| public static function clearCurrent(): void | |||||
| { | |||||
| self::$current = null; | |||||
| } | |||||
| public function method(): string | |||||
| { | |||||
| $method = strtoupper($this->method); | |||||
| if ($method === 'POST') { | |||||
| $override = strtoupper((string) ($this->post['_method'] ?? $this->server['HTTP_X_HTTP_METHOD_OVERRIDE'] ?? '')); | |||||
| if (in_array($override, ['PUT', 'PATCH', 'DELETE'], true)) { | |||||
| return $override; | |||||
| } | |||||
| } | |||||
| return $method; | |||||
| } | |||||
| public function path(): string | |||||
| { | |||||
| $path = parse_url($this->uri, PHP_URL_PATH); | |||||
| return $path ?: '/'; | |||||
| } | |||||
| public function input(string $key, mixed $default = null): mixed | |||||
| { | |||||
| return $this->post[$key] ?? $this->get[$key] ?? $default; | |||||
| } | |||||
| public function server(string $key, mixed $default = null): mixed | |||||
| { | |||||
| return $this->server[$key] ?? $default; | |||||
| } | |||||
| public function all(): array | |||||
| { | |||||
| return array_merge($this->get, $this->post); | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,64 @@ | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| namespace Core; | |||||
| class Response | |||||
| { | |||||
| protected string $content; | |||||
| protected int $status; | |||||
| protected array $headers; | |||||
| public function __construct(string $content = '', int $status = 200, array $headers = []) | |||||
| { | |||||
| $this->content = $content; | |||||
| $this->status = $status; | |||||
| $this->headers = $headers; | |||||
| } | |||||
| public static function json(array $data, int $status = 200): self | |||||
| { | |||||
| return new self( | |||||
| json_encode($data, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR), | |||||
| $status, | |||||
| ['Content-Type' => 'application/json'] | |||||
| ); | |||||
| } | |||||
| public static function redirect(string $url): self | |||||
| { | |||||
| return new self('', 302, ['Location' => $url]); | |||||
| } | |||||
| public static function notFound(string $message = 'Not found'): self | |||||
| { | |||||
| return new self($message, 404); | |||||
| } | |||||
| public static function serverError(string $message = 'Server error'): self | |||||
| { | |||||
| return new self($message, 500); | |||||
| } | |||||
| public function send(): void | |||||
| { | |||||
| http_response_code($this->status); | |||||
| foreach ($this->headers as $name => $value) { | |||||
| header($name . ': ' . $value); | |||||
| } | |||||
| echo $this->content; | |||||
| } | |||||
| public function content(): string | |||||
| { | |||||
| return $this->content; | |||||
| } | |||||
| public function status(): int | |||||
| { | |||||
| return $this->status; | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,52 @@ | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| namespace Core; | |||||
| class Route | |||||
| { | |||||
| protected string $method; | |||||
| protected string $path; | |||||
| protected mixed $handler; | |||||
| protected array $parameters = []; | |||||
| protected string $compiledPattern; | |||||
| protected array $parameterNames = []; | |||||
| public function __construct(string $method, string $path, mixed $handler) | |||||
| { | |||||
| $this->method = strtoupper($method); | |||||
| $this->path = $path; | |||||
| $this->handler = $handler; | |||||
| preg_match_all('#\{([a-zA-Z_][a-zA-Z0-9_]*)\}#', $path, $names); | |||||
| $this->parameterNames = $names[1]; | |||||
| $compiled = preg_replace('#\{([a-zA-Z_][a-zA-Z0-9_]*)\}#', '([^/]+)', $path); | |||||
| $this->compiledPattern = '#^' . $compiled . '$#'; | |||||
| } | |||||
| public function matches(string $method, string $path): bool | |||||
| { | |||||
| if (strtoupper($method) !== $this->method) { | |||||
| return false; | |||||
| } | |||||
| if (!preg_match($this->compiledPattern, $path, $matches)) { | |||||
| return false; | |||||
| } | |||||
| array_shift($matches); | |||||
| $this->parameters = []; | |||||
| foreach ($this->parameterNames as $index => $name) { | |||||
| $this->parameters[$name] = $matches[$index] ?? null; | |||||
| } | |||||
| return true; | |||||
| } | |||||
| public function dispatch(App $app): mixed | |||||
| { | |||||
| return $app->call($this->handler, $this->parameters); | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,54 @@ | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| namespace Core; | |||||
| class Router | |||||
| { | |||||
| protected array $routes = []; | |||||
| public function get(string $path, callable|array|string $handler): Route | |||||
| { | |||||
| return $this->add('GET', $path, $handler); | |||||
| } | |||||
| public function post(string $path, callable|array|string $handler): Route | |||||
| { | |||||
| return $this->add('POST', $path, $handler); | |||||
| } | |||||
| public function put(string $path, callable|array|string $handler): Route | |||||
| { | |||||
| return $this->add('PUT', $path, $handler); | |||||
| } | |||||
| public function patch(string $path, callable|array|string $handler): Route | |||||
| { | |||||
| return $this->add('PATCH', $path, $handler); | |||||
| } | |||||
| public function delete(string $path, callable|array|string $handler): Route | |||||
| { | |||||
| return $this->add('DELETE', $path, $handler); | |||||
| } | |||||
| public function add(string $method, string $path, callable|array|string $handler): Route | |||||
| { | |||||
| $route = new Route($method, $path, $handler); | |||||
| $this->routes[] = $route; | |||||
| return $route; | |||||
| } | |||||
| public function match(string $method, string $path): ?Route | |||||
| { | |||||
| foreach ($this->routes as $route) { | |||||
| if ($route->matches($method, $path)) { | |||||
| return $route; | |||||
| } | |||||
| } | |||||
| return null; | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,114 @@ | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| namespace Core; | |||||
| class Validator | |||||
| { | |||||
| protected array $errors = []; | |||||
| public function required(string $field, mixed $value, string $message = ''): self | |||||
| { | |||||
| if ($value === null || trim((string) $value) === '') { | |||||
| $this->errors[$field][] = $message ?: "{$field} is required."; | |||||
| } | |||||
| return $this; | |||||
| } | |||||
| public function maxLength(string $field, mixed $value, int $max, string $message = ''): self | |||||
| { | |||||
| if (strlen((string) $value) > $max) { | |||||
| $this->errors[$field][] = $message ?: "{$field} must be {$max} characters or fewer."; | |||||
| } | |||||
| return $this; | |||||
| } | |||||
| public function numeric(string $field, mixed $value, string $message = ''): self | |||||
| { | |||||
| if (!is_numeric($value)) { | |||||
| $this->errors[$field][] = $message ?: "{$field} must be numeric."; | |||||
| } | |||||
| return $this; | |||||
| } | |||||
| public function email(string $field, mixed $value, string $message = ''): self | |||||
| { | |||||
| if ((string) $value !== '' && filter_var($value, FILTER_VALIDATE_EMAIL) === false) { | |||||
| $this->errors[$field][] = $message ?: "{$field} must be a valid email address."; | |||||
| } | |||||
| return $this; | |||||
| } | |||||
| public function date(string $field, mixed $value, string $format = 'Y-m-d', string $message = ''): self | |||||
| { | |||||
| $str = (string) $value; | |||||
| if ($str === '') { | |||||
| return $this; | |||||
| } | |||||
| $parsed = \DateTimeImmutable::createFromFormat($format, $str); | |||||
| if ($parsed === false || $parsed->format($format) !== $str) { | |||||
| $this->errors[$field][] = $message ?: "{$field} must be a valid date ({$format})."; | |||||
| } | |||||
| return $this; | |||||
| } | |||||
| public function minLength(string $field, mixed $value, int $min, string $message = ''): self | |||||
| { | |||||
| if (strlen((string) $value) < $min) { | |||||
| $this->errors[$field][] = $message ?: "{$field} must be at least {$min} characters."; | |||||
| } | |||||
| return $this; | |||||
| } | |||||
| public function min(string $field, mixed $value, int|float $min, string $message = ''): self | |||||
| { | |||||
| if (!is_numeric($value) || (float) $value < $min) { | |||||
| $this->errors[$field][] = $message ?: "{$field} must be at least {$min}."; | |||||
| } | |||||
| return $this; | |||||
| } | |||||
| public function max(string $field, mixed $value, int|float $max, string $message = ''): self | |||||
| { | |||||
| if (!is_numeric($value) || (float) $value > $max) { | |||||
| $this->errors[$field][] = $message ?: "{$field} must be no more than {$max}."; | |||||
| } | |||||
| return $this; | |||||
| } | |||||
| public function in(string $field, mixed $value, array $allowed, string $message = ''): self | |||||
| { | |||||
| if (!in_array($value, $allowed, true)) { | |||||
| $this->errors[$field][] = $message ?: "{$field} must be one of: " . implode(', ', $allowed) . '.'; | |||||
| } | |||||
| return $this; | |||||
| } | |||||
| public function passes(): bool | |||||
| { | |||||
| return empty($this->errors); | |||||
| } | |||||
| public function fails(): bool | |||||
| { | |||||
| return !$this->passes(); | |||||
| } | |||||
| public function errors(): array | |||||
| { | |||||
| return $this->errors; | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,79 @@ | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| namespace Core; | |||||
| class View | |||||
| { | |||||
| public static function render(string $view, array $data = []): Response | |||||
| { | |||||
| $content = self::renderContent($view, $data); | |||||
| $layoutPath = self::config()['layout_path']; | |||||
| if (!file_exists($layoutPath)) { | |||||
| return new Response($content); | |||||
| } | |||||
| $pageTitle = self::resolvePageTitle($data); | |||||
| extract($data, EXTR_SKIP); | |||||
| ob_start(); | |||||
| require $layoutPath; | |||||
| $content = ob_get_clean(); | |||||
| return new Response($content); | |||||
| } | |||||
| public static function fragment(string $view, array $data = [], int $status = 200, array $headers = []): Response | |||||
| { | |||||
| return new Response(self::renderContent($view, $data), $status, $headers); | |||||
| } | |||||
| private static function renderContent(string $view, array $data): string | |||||
| { | |||||
| $path = self::config()['views_path'] . '/' . str_replace('.', '/', $view) . '.php'; | |||||
| if (!file_exists($path)) { | |||||
| throw new \Exception("View not found: {$view}"); | |||||
| } | |||||
| extract($data, EXTR_SKIP); | |||||
| ob_start(); | |||||
| require $path; | |||||
| return (string) ob_get_clean(); | |||||
| } | |||||
| private static function config(): array | |||||
| { | |||||
| static $config = null; | |||||
| if ($config === null) { | |||||
| $config = require __DIR__ . '/../config/view.php'; | |||||
| } | |||||
| return $config; | |||||
| } | |||||
| private static function resolvePageTitle(array $data): string | |||||
| { | |||||
| if (isset($data['pageTitle']) && is_string($data['pageTitle']) && trim($data['pageTitle']) !== '') { | |||||
| return $data['pageTitle']; | |||||
| } | |||||
| if ( | |||||
| isset($data['model']) && | |||||
| is_object($data['model']) && | |||||
| property_exists($data['model'], 'title') && | |||||
| is_string($data['model']->title) && | |||||
| trim($data['model']->title) !== '' | |||||
| ) { | |||||
| return $data['model']->title; | |||||
| } | |||||
| return 'MindVisionCode PHP'; | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,134 @@ | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| use Core\App; | |||||
| use Core\Database; | |||||
| use Core\MigrationManager; | |||||
| use Core\Response; | |||||
| use Core\View; | |||||
| function app(): App | |||||
| { | |||||
| static $app = null; | |||||
| if ($app === null) { | |||||
| $app = new App(); | |||||
| } | |||||
| return $app; | |||||
| } | |||||
| function view(string $view, array $data = []): Response | |||||
| { | |||||
| return View::render($view, $data); | |||||
| } | |||||
| function redirect(string $url): Response | |||||
| { | |||||
| return Response::redirect($url); | |||||
| } | |||||
| function database(): Database | |||||
| { | |||||
| static $database = null; | |||||
| if ($database === null) { | |||||
| /** @var array<string, mixed> $config */ | |||||
| $config = require __DIR__ . '/../config/database.php'; | |||||
| prepareSqliteDatabase($config['dsn'] ?? ''); | |||||
| $database = new Database($config); | |||||
| } | |||||
| return $database; | |||||
| } | |||||
| function migration_manager(): MigrationManager | |||||
| { | |||||
| static $migrationManager = null; | |||||
| if ($migrationManager === null) { | |||||
| $migrationManager = new MigrationManager(database(), __DIR__ . '/../database/migrations'); | |||||
| } | |||||
| return $migrationManager; | |||||
| } | |||||
| function ensureSessionStarted(): void | |||||
| { | |||||
| if (session_status() === PHP_SESSION_NONE) { | |||||
| session_start(); | |||||
| } | |||||
| } | |||||
| function prepareSqliteDatabase(string $dsn): void | |||||
| { | |||||
| if (!str_starts_with($dsn, 'sqlite:')) { | |||||
| return; | |||||
| } | |||||
| $path = substr($dsn, 7); | |||||
| if ($path === false || $path === '') { | |||||
| return; | |||||
| } | |||||
| $directory = dirname($path); | |||||
| if (!is_dir($directory)) { | |||||
| mkdir($directory, 0777, true); | |||||
| } | |||||
| if (!is_writable($directory)) { | |||||
| @chmod($directory, 0777); | |||||
| } | |||||
| if (!file_exists($path)) { | |||||
| touch($path); | |||||
| } | |||||
| if (!is_writable($path)) { | |||||
| @chmod($path, 0666); | |||||
| } | |||||
| } | |||||
| function e(?string $value): string | |||||
| { | |||||
| return htmlspecialchars((string) $value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); | |||||
| } | |||||
| function asset(string $path): string | |||||
| { | |||||
| return '/' . ltrim($path, '/'); | |||||
| } | |||||
| function csrf_token(): string | |||||
| { | |||||
| ensureSessionStarted(); | |||||
| if (!isset($_SESSION['_csrf_token']) || !is_string($_SESSION['_csrf_token'])) { | |||||
| $_SESSION['_csrf_token'] = bin2hex(random_bytes(32)); | |||||
| } | |||||
| return $_SESSION['_csrf_token']; | |||||
| } | |||||
| function csrf_field(): string | |||||
| { | |||||
| return '<input type="hidden" name="_token" value="' . e(csrf_token()) . '">'; | |||||
| } | |||||
| function verify_csrf_token(?string $token): bool | |||||
| { | |||||
| ensureSessionStarted(); | |||||
| if (!is_string($token) || $token === '') { | |||||
| return false; | |||||
| } | |||||
| $sessionToken = $_SESSION['_csrf_token'] ?? null; | |||||
| return is_string($sessionToken) && hash_equals($sessionToken, $token); | |||||
| } | |||||
| @@ -0,0 +1,30 @@ | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| use Core\Database; | |||||
| use Core\Migration; | |||||
| return new class extends Migration | |||||
| { | |||||
| public function up(Database $database): void | |||||
| { | |||||
| $database->execute( | |||||
| 'CREATE TABLE IF NOT EXISTS employees ( | |||||
| id INTEGER PRIMARY KEY AUTOINCREMENT, | |||||
| first_name VARCHAR(100) NOT NULL, | |||||
| last_name VARCHAR(100) NOT NULL, | |||||
| email VARCHAR(255) NOT NULL UNIQUE, | |||||
| department VARCHAR(100) NOT NULL, | |||||
| job_title VARCHAR(150) NOT NULL, | |||||
| start_date DATE NOT NULL, | |||||
| created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | |||||
| )' | |||||
| ); | |||||
| } | |||||
| public function down(Database $database): void | |||||
| { | |||||
| $database->execute('DROP TABLE IF EXISTS employees'); | |||||
| } | |||||
| }; | |||||
| @@ -0,0 +1,33 @@ | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| use Core\Database; | |||||
| use Core\Migration; | |||||
| return new class extends Migration | |||||
| { | |||||
| public function up(Database $database): void | |||||
| { | |||||
| $database->execute( | |||||
| 'CREATE TABLE IF NOT EXISTS boards ( | |||||
| id INTEGER PRIMARY KEY AUTOINCREMENT, | |||||
| name VARCHAR(255) NOT NULL, | |||||
| slug VARCHAR(255) NOT NULL UNIQUE, | |||||
| import_from_printstream INTEGER NOT NULL DEFAULT 0, | |||||
| printstream_job_name TEXT, | |||||
| created_at DATETIME, | |||||
| created_by VARCHAR(255), | |||||
| updated_at DATETIME, | |||||
| updated_by VARCHAR(255) | |||||
| )' | |||||
| ); | |||||
| $database->execute('CREATE INDEX IF NOT EXISTS idx_boards_slug ON boards (slug)'); | |||||
| } | |||||
| public function down(Database $database): void | |||||
| { | |||||
| $database->execute('DROP TABLE IF EXISTS boards'); | |||||
| } | |||||
| }; | |||||
| @@ -0,0 +1,33 @@ | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| use Core\Database; | |||||
| use Core\Migration; | |||||
| return new class extends Migration | |||||
| { | |||||
| public function up(Database $database): void | |||||
| { | |||||
| $database->execute( | |||||
| 'CREATE TABLE IF NOT EXISTS board_columns ( | |||||
| id INTEGER PRIMARY KEY AUTOINCREMENT, | |||||
| board_id INTEGER NOT NULL, | |||||
| name VARCHAR(255) NOT NULL, | |||||
| position INTEGER NOT NULL DEFAULT 0, | |||||
| created_at DATETIME, | |||||
| created_by VARCHAR(255), | |||||
| updated_at DATETIME, | |||||
| updated_by VARCHAR(255), | |||||
| FOREIGN KEY (board_id) REFERENCES boards(id) | |||||
| )' | |||||
| ); | |||||
| $database->execute('CREATE INDEX IF NOT EXISTS idx_board_columns_board_id ON board_columns (board_id)'); | |||||
| } | |||||
| public function down(Database $database): void | |||||
| { | |||||
| $database->execute('DROP TABLE IF EXISTS board_columns'); | |||||
| } | |||||
| }; | |||||
| @@ -0,0 +1,33 @@ | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| use Core\Database; | |||||
| use Core\Migration; | |||||
| return new class extends Migration | |||||
| { | |||||
| public function up(Database $database): void | |||||
| { | |||||
| $database->execute( | |||||
| 'CREATE TABLE IF NOT EXISTS swim_lanes ( | |||||
| id INTEGER PRIMARY KEY AUTOINCREMENT, | |||||
| board_id INTEGER NOT NULL, | |||||
| name VARCHAR(255) NOT NULL, | |||||
| position INTEGER NOT NULL DEFAULT 0, | |||||
| created_at DATETIME, | |||||
| created_by VARCHAR(255), | |||||
| updated_at DATETIME, | |||||
| updated_by VARCHAR(255), | |||||
| FOREIGN KEY (board_id) REFERENCES boards(id) | |||||
| )' | |||||
| ); | |||||
| $database->execute('CREATE INDEX IF NOT EXISTS idx_swim_lanes_board_id ON swim_lanes (board_id)'); | |||||
| } | |||||
| public function down(Database $database): void | |||||
| { | |||||
| $database->execute('DROP TABLE IF EXISTS swim_lanes'); | |||||
| } | |||||
| }; | |||||
| @@ -0,0 +1,45 @@ | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| use Core\Database; | |||||
| use Core\Migration; | |||||
| return new class extends Migration | |||||
| { | |||||
| public function up(Database $database): void | |||||
| { | |||||
| $database->execute( | |||||
| 'CREATE TABLE IF NOT EXISTS cards ( | |||||
| id INTEGER PRIMARY KEY AUTOINCREMENT, | |||||
| board_id INTEGER NOT NULL, | |||||
| column_id INTEGER NOT NULL, | |||||
| swim_lane_id INTEGER NOT NULL, | |||||
| job_number VARCHAR(255), | |||||
| job_name VARCHAR(255), | |||||
| customer_name VARCHAR(255), | |||||
| delivery_date DATE, | |||||
| quantity VARCHAR(50), | |||||
| notes TEXT, | |||||
| full_note TEXT, | |||||
| position INTEGER NOT NULL DEFAULT 0, | |||||
| created_at DATETIME, | |||||
| created_by VARCHAR(255), | |||||
| updated_at DATETIME, | |||||
| updated_by VARCHAR(255), | |||||
| FOREIGN KEY (board_id) REFERENCES boards(id), | |||||
| FOREIGN KEY (column_id) REFERENCES board_columns(id), | |||||
| FOREIGN KEY (swim_lane_id) REFERENCES swim_lanes(id) | |||||
| )' | |||||
| ); | |||||
| $database->execute('CREATE INDEX IF NOT EXISTS idx_cards_board_id ON cards (board_id)'); | |||||
| $database->execute('CREATE INDEX IF NOT EXISTS idx_cards_column_id ON cards (column_id)'); | |||||
| $database->execute('CREATE INDEX IF NOT EXISTS idx_cards_swim_lane_id ON cards (swim_lane_id)'); | |||||
| } | |||||
| public function down(Database $database): void | |||||
| { | |||||
| $database->execute('DROP TABLE IF EXISTS cards'); | |||||
| } | |||||
| }; | |||||
| @@ -0,0 +1,107 @@ | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| require_once __DIR__ . '/../vendor/autoload.php'; | |||||
| function seed_employees(int $targetTotal = 1000, bool $resetExisting = false): void | |||||
| { | |||||
| $targetTotal = max(1, $targetTotal); | |||||
| $migrationManager = migration_manager(); | |||||
| $migrationManager->runPending(); | |||||
| $database = database(); | |||||
| if ($resetExisting) { | |||||
| $database->execute('DELETE FROM employees'); | |||||
| } | |||||
| $currentTotal = (int) (database()->first('SELECT COUNT(*) AS total FROM employees')['total'] ?? 0); | |||||
| if ($currentTotal >= $targetTotal) { | |||||
| echo "Employee table already has {$currentTotal} records." . PHP_EOL; | |||||
| return; | |||||
| } | |||||
| $firstNames = [ | |||||
| 'Ava', 'Liam', 'Noah', 'Emma', 'Olivia', 'Mason', 'Sophia', 'Ethan', 'Isabella', 'Lucas', | |||||
| 'Mia', 'Amelia', 'James', 'Harper', 'Benjamin', 'Ella', 'Henry', 'Evelyn', 'Jack', 'Abigail', | |||||
| 'Alexander', 'Emily', 'Michael', 'Charlotte', 'Daniel', 'Grace', 'Elijah', 'Scarlett', 'William', 'Chloe', | |||||
| 'Matthew', 'Victoria', 'Samuel', 'Lily', 'David', 'Aria', 'Joseph', 'Zoey', 'Carter', 'Hannah', | |||||
| 'Owen', 'Addison', 'Wyatt', 'Natalie', 'John', 'Aubrey', 'Luke', 'Brooklyn', 'Gabriel', 'Layla', | |||||
| 'Anthony', 'Zoe', 'Isaac', 'Penelope', 'Dylan', 'Riley', 'Grayson', 'Nora', 'Levi', 'Lillian', | |||||
| 'Julian', 'Eleanor', 'Christopher', 'Stella', 'Joshua', 'Savannah', 'Andrew', 'Audrey', 'Nathan', 'Claire', | |||||
| 'Thomas', 'Skylar', 'Caleb', 'Lucy', 'Ryan', 'Paisley', 'Christian', 'Everly', 'Hunter', 'Anna', | |||||
| 'Jonathan', 'Caroline', 'Aaron', 'Nova', 'Charles', 'Genesis', 'Connor', 'Kennedy', 'Eli', 'Samantha', | |||||
| 'Landon', 'Maya', 'Adrian', 'Willow', 'Nicholas', 'Kinsley', 'Jeremiah', 'Naomi', 'Easton', 'Ariana', | |||||
| ]; | |||||
| $lastNames = [ | |||||
| 'Carter', 'Brooks', 'Hayes', 'Parker', 'Turner', 'Sullivan', 'Reed', 'Ward', 'Price', 'Foster', | |||||
| 'Powell', 'Bennett', 'Coleman', 'Russell', 'Long', 'Perry', 'Morgan', 'Peterson', 'Cooper', 'Bailey', | |||||
| 'Smith', 'Johnson', 'Williams', 'Brown', 'Jones', 'Garcia', 'Miller', 'Davis', 'Rodriguez', 'Martinez', | |||||
| 'Hernandez', 'Lopez', 'Gonzalez', 'Wilson', 'Anderson', 'Thomas', 'Taylor', 'Moore', 'Jackson', 'Martin', | |||||
| 'Lee', 'Perez', 'Thompson', 'White', 'Harris', 'Sanchez', 'Clark', 'Ramirez', 'Lewis', 'Robinson', | |||||
| 'Walker', 'Young', 'Allen', 'King', 'Wright', 'Scott', 'Torres', 'Nguyen', 'Hill', 'Flores', | |||||
| 'Green', 'Adams', 'Nelson', 'Baker', 'Hall', 'Rivera', 'Campbell', 'Mitchell', 'Roberts', 'Gomez', | |||||
| 'Phillips', 'Evans', 'Edwards', 'Collins', 'Stewart', 'Morris', 'Rogers', 'Murphy', 'Cook', 'Ramos', | |||||
| 'Richardson', 'Cox', 'Howard', 'Bell', 'Ortiz', 'Gutierrez', 'Chavez', 'Wood', 'James', 'Bennett', | |||||
| 'Gray', 'Mendoza', 'Ruiz', 'Hughes', 'Grant', 'Stone', 'Spencer', 'Warren', 'Porter', 'Bryant', | |||||
| ]; | |||||
| $departments = [ | |||||
| 'Engineering', 'Finance', 'Operations', 'Sales', 'Marketing', 'People', 'Support', 'Legal', | |||||
| ]; | |||||
| $jobTitles = [ | |||||
| 'Coordinator', 'Analyst', 'Manager', 'Specialist', 'Administrator', 'Engineer', 'Consultant', 'Lead', | |||||
| ]; | |||||
| $statement = $database->pdo()->prepare( | |||||
| 'INSERT INTO employees (first_name, last_name, email, department, job_title, start_date) | |||||
| VALUES (:first_name, :last_name, :email, :department, :job_title, :start_date)' | |||||
| ); | |||||
| $database->pdo()->beginTransaction(); | |||||
| try { | |||||
| for ($i = $currentTotal + 1; $i <= $targetTotal; $i++) { | |||||
| $firstName = $firstNames[$i % count($firstNames)]; | |||||
| $lastName = $lastNames[$i % count($lastNames)]; | |||||
| $department = $departments[$i % count($departments)]; | |||||
| $jobTitle = $jobTitles[$i % count($jobTitles)]; | |||||
| $email = sprintf( | |||||
| '%s.%s.%04d@example.test', | |||||
| strtolower($firstName), | |||||
| strtolower($lastName), | |||||
| $i | |||||
| ); | |||||
| $month = (($i - 1) % 12) + 1; | |||||
| $day = (($i - 1) % 28) + 1; | |||||
| $year = 2019 + (($i - 1) % 8); | |||||
| $startDate = sprintf('%04d-%02d-%02d', $year, $month, $day); | |||||
| $statement->execute([ | |||||
| 'first_name' => $firstName, | |||||
| 'last_name' => $lastName, | |||||
| 'email' => $email, | |||||
| 'department' => $department, | |||||
| 'job_title' => $jobTitle, | |||||
| 'start_date' => $startDate, | |||||
| ]); | |||||
| } | |||||
| $database->pdo()->commit(); | |||||
| } catch (Throwable $exception) { | |||||
| $database->pdo()->rollBack(); | |||||
| throw $exception; | |||||
| } | |||||
| $inserted = $targetTotal - $currentTotal; | |||||
| echo "Inserted {$inserted} sample employees. Total is now {$targetTotal}." . PHP_EOL; | |||||
| } | |||||
| if (PHP_SAPI === 'cli' && realpath($_SERVER['SCRIPT_FILENAME'] ?? '') === __FILE__) { | |||||
| $targetTotal = isset($argv[1]) ? max(1, (int) $argv[1]) : 1000; | |||||
| seed_employees($targetTotal); | |||||
| } | |||||
| @@ -0,0 +1,8 @@ | |||||
| services: | |||||
| app: | |||||
| build: . | |||||
| ports: | |||||
| - "8080:80" | |||||
| volumes: | |||||
| - .:/var/www/html | |||||
| env_file: .env | |||||
| @@ -0,0 +1,17 @@ | |||||
| #!/bin/bash | |||||
| set -e | |||||
| mkdir -p database | |||||
| chmod 777 database | |||||
| composer install --no-interaction --quiet | |||||
| # Runs as root — migrations may create database/app.sqlite owned by root. | |||||
| php scripts/migrate.php up | |||||
| # Fix ownership after migrations so www-data (Apache) can write. | |||||
| # chmod works on Windows volume mounts even when chown is ignored by p9fs. | |||||
| chmod 777 database | |||||
| chmod 666 database/app.sqlite | |||||
| exec apache2-foreground | |||||
| @@ -0,0 +1,13 @@ | |||||
| <VirtualHost *:80> | |||||
| ServerName localhost | |||||
| DocumentRoot /var/www/html/public | |||||
| <Directory /var/www/html/public> | |||||
| Options -Indexes +FollowSymLinks | |||||
| AllowOverride All | |||||
| Require all granted | |||||
| </Directory> | |||||
| ErrorLog ${APACHE_LOG_DIR}/error.log | |||||
| CustomLog ${APACHE_LOG_DIR}/access.log combined | |||||
| </VirtualHost> | |||||
| @@ -0,0 +1,78 @@ | |||||
| # MindVisionCode PHP | |||||
| A small PHP MVC framework inspired by a Classic ASP MVC framework. | |||||
| ## Run | |||||
| ```bash | |||||
| composer install | |||||
| php scripts/migrate.php up | |||||
| php -S localhost:8000 -t public | |||||
| ``` | |||||
| Open: | |||||
| ```text | |||||
| http://localhost:8000 | |||||
| ``` | |||||
| Try: | |||||
| ```text | |||||
| http://localhost:8000/users/123 | |||||
| ``` | |||||
| Employee form: | |||||
| ```text | |||||
| http://localhost:8000/employees | |||||
| ``` | |||||
| ## Request Flow | |||||
| Browser → public/index.php → Request → Dispatcher → Router → Route → Controller → ViewModel/Repository → View → Response | |||||
| ## Main Folders | |||||
| - `core/` framework classes | |||||
| - `app/Controllers/` application controllers | |||||
| - `app/ViewModels/` view model classes | |||||
| - `app/Repositories/` data access classes | |||||
| - `app/Views/` PHP templates | |||||
| - `routes/web.php` route definitions | |||||
| - `database/migrations/` migrations | |||||
| - `scripts/` runnable PHP CLI scripts | |||||
| ## SQLite | |||||
| The default database is SQLite and points to: | |||||
| ```text | |||||
| database/app.sqlite | |||||
| ``` | |||||
| The database file is created automatically when the app first needs it. | |||||
| Run migrations from the PHP CLI: | |||||
| ```bash | |||||
| php scripts/migrate.php up | |||||
| php scripts/migrate.php down | |||||
| php scripts/migrate.php status | |||||
| php scripts/migrate.php make create_projects_table | |||||
| php scripts/migrate.php fresh | |||||
| php scripts/migrate.php fresh --seed | |||||
| php scripts/seed_employees.php 1000 | |||||
| ``` | |||||
| ## Frontend Libraries | |||||
| The employee directory page uses: | |||||
| - `htmx` for fragment-based form and summary updates | |||||
| - `Alpine.js` for lightweight page state | |||||
| - `Tabulator` for the interactive employee table | |||||
| ## Flow chart | |||||
| See [`REQUEST_FLOW.md`](./REQUEST_FLOW.md) for a chart of how requests and responses move through the framework. | |||||
| @@ -0,0 +1,80 @@ | |||||
| # Request / Response Flow | |||||
| This chart shows how a browser request moves through the MindVisionCode framework and how a response is built and returned. | |||||
| ```text | |||||
| Browser Request | |||||
| | | |||||
| v | |||||
| public/index.php | |||||
| |-- loads autoload.php / vendor autoload | |||||
| |-- starts the session | |||||
| |-- creates App + Router | |||||
| |-- loads routes/web.php | |||||
| | | |||||
| v | |||||
| Request::capture() | |||||
| | | |||||
| v | |||||
| Dispatcher::dispatch() | |||||
| | | |||||
| +--> no route matched ----> Response::notFound() | |||||
| | | |||||
| +--> route matched -------> Route::dispatch() | |||||
| | | |||||
| v | |||||
| App::call() | |||||
| | | |||||
| +--> controller method | |||||
| | | | |||||
| | v | |||||
| | Controller action | |||||
| | | | |||||
| | +--> repository / service / view model | |||||
| | +--> Database::query() / execute() | |||||
| | +--> view() / json() / redirect() | |||||
| | | |||||
| +--> closure route | |||||
| | | |||||
| v | |||||
| direct response data | |||||
| Dispatcher::normalizeResponse() | |||||
| | | |||||
| +--> Response object --------> Response::send() | |||||
| +--> array ------------------> Response::json() --> Response::send() | |||||
| +--> string -----------------> Response::send() | |||||
| Final result: | |||||
| Browser receives HTML, JSON, or a redirect | |||||
| ``` | |||||
| ## Response building paths | |||||
| ### View response | |||||
| ```text | |||||
| Controller -> view() -> View::render() -> template -> layout -> Response | |||||
| ``` | |||||
| ### JSON response | |||||
| ```text | |||||
| Controller -> json() -> Response::json() -> Response::send() | |||||
| ``` | |||||
| ### Redirect response | |||||
| ```text | |||||
| Controller -> redirect() -> Response::redirect() -> Response::send() | |||||
| ``` | |||||
| ## Key classes | |||||
| - `public/index.php` bootstraps the app | |||||
| - `Core\Dispatcher` matches routes and handles errors | |||||
| - `Core\Route` extracts route parameters | |||||
| - `Core\App` invokes controller methods or closures | |||||
| - `Core\Controller` gives actions helper methods | |||||
| - `Core\View` renders templates into a layout | |||||
| - `Core\Response` sends the final output | |||||
| @@ -0,0 +1,5 @@ | |||||
| RewriteEngine On | |||||
| RewriteCond %{REQUEST_FILENAME} !-f | |||||
| RewriteCond %{REQUEST_FILENAME} !-d | |||||
| RewriteRule ^ index.php [QSA,L] | |||||
| @@ -0,0 +1,507 @@ | |||||
| /* Kanban board theme aligned with public/css/site.css */ | |||||
| html, | |||||
| body.kanban-page { | |||||
| height: 100%; | |||||
| } | |||||
| body.kanban-page { | |||||
| margin: 0; | |||||
| overflow: hidden; | |||||
| background: | |||||
| 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%); | |||||
| } | |||||
| /* Top bar */ | |||||
| body.kanban-page .navbar { | |||||
| position: sticky; | |||||
| top: 0; | |||||
| z-index: 1000; | |||||
| border-bottom: 1px solid rgba(255, 255, 255, 0.18); | |||||
| backdrop-filter: blur(9px); | |||||
| background: linear-gradient(120deg, #102241 0%, #173a72 56%, #1c4c90 100%); | |||||
| box-shadow: 0 8px 24px rgba(8, 20, 48, 0.26); | |||||
| } | |||||
| body.kanban-page .navbar-brand { | |||||
| color: #f4f8ff !important; | |||||
| letter-spacing: -0.01em; | |||||
| } | |||||
| .board-header-main { | |||||
| min-width: 0; | |||||
| } | |||||
| .board-header-actions { | |||||
| flex-shrink: 0; | |||||
| } | |||||
| .board-header-search { | |||||
| width: min(440px, 36vw); | |||||
| margin: 0 0.75rem; | |||||
| } | |||||
| .board-header-search .input-group-text { | |||||
| border-color: rgba(214, 229, 250, 0.7); | |||||
| background: rgba(255, 255, 255, 0.15); | |||||
| color: #eff6ff; | |||||
| } | |||||
| .board-header-search .form-control { | |||||
| border-color: rgba(214, 229, 250, 0.7); | |||||
| background: rgba(255, 255, 255, 0.17); | |||||
| color: #f5f9ff; | |||||
| } | |||||
| .board-header-search .form-control::placeholder { | |||||
| color: rgba(235, 243, 255, 0.72); | |||||
| } | |||||
| .board-header-search .form-control:focus { | |||||
| border-color: rgba(235, 245, 255, 0.95); | |||||
| box-shadow: 0 0 0 0.2rem rgba(157, 194, 245, 0.25); | |||||
| background: rgba(255, 255, 255, 0.23); | |||||
| } | |||||
| .kanban-board-title { | |||||
| display: block; | |||||
| min-width: 0; | |||||
| max-width: 100%; | |||||
| overflow: hidden; | |||||
| text-overflow: ellipsis; | |||||
| white-space: nowrap; | |||||
| font-size: clamp(1rem, 0.6vw + 0.95rem, 1.28rem); | |||||
| } | |||||
| body.kanban-page .navbar .btn-outline-light, | |||||
| body.kanban-page .navbar .btn-outline-secondary { | |||||
| border-color: rgba(213, 230, 255, 0.55) !important; | |||||
| color: #eef5ff !important; | |||||
| background: rgba(255, 255, 255, 0.06); | |||||
| } | |||||
| body.kanban-page .navbar .btn-outline-light:hover, | |||||
| body.kanban-page .navbar .btn-outline-secondary:hover { | |||||
| background: rgba(255, 255, 255, 0.16); | |||||
| border-color: rgba(234, 243, 255, 0.85) !important; | |||||
| } | |||||
| /* Board wrapper */ | |||||
| .kanban-wrapper { | |||||
| width: 100%; | |||||
| margin: 0 auto; | |||||
| height: calc(100vh - 65px); | |||||
| overflow-x: auto; | |||||
| overflow-y: auto; | |||||
| padding: 0.9rem 1rem 1.1rem; | |||||
| -webkit-overflow-scrolling: touch; | |||||
| scroll-behavior: smooth; | |||||
| touch-action: pan-x pan-y; | |||||
| overscroll-behavior: contain; | |||||
| scrollbar-width: thin; | |||||
| scrollbar-color: #8fb0e0 #dce8f8; | |||||
| scrollbar-gutter: stable; | |||||
| } | |||||
| .kanban-grid { | |||||
| display: grid; | |||||
| width: max-content; | |||||
| min-width: 100%; | |||||
| border: 1px solid var(--line, #d9e3f5); | |||||
| border-radius: 14px; | |||||
| background: rgba(255, 255, 255, 0.72); | |||||
| box-shadow: 0 12px 34px rgba(22, 48, 92, 0.12); | |||||
| overflow: clip; | |||||
| } | |||||
| /* Sticky corner and headers */ | |||||
| .kanban-corner { | |||||
| position: sticky; | |||||
| top: 0; | |||||
| left: 0; | |||||
| z-index: 40; | |||||
| min-width: 240px; | |||||
| min-height: 56px; | |||||
| background: linear-gradient(135deg, #173864 0%, #1e4d8f 100%); | |||||
| border-right: 1px solid rgba(255, 255, 255, 0.15); | |||||
| border-bottom: 1px solid rgba(255, 255, 255, 0.15); | |||||
| } | |||||
| .kanban-col-header { | |||||
| position: sticky; | |||||
| top: 0; | |||||
| z-index: 30; | |||||
| min-width: 230px; | |||||
| padding: 0.75rem 0.88rem; | |||||
| color: #eff5ff; | |||||
| font-size: clamp(0.68rem, 0.16vw + 0.65rem, 0.78rem); | |||||
| font-weight: 700; | |||||
| text-transform: uppercase; | |||||
| letter-spacing: 0.06em; | |||||
| white-space: normal; | |||||
| line-height: 1.22; | |||||
| word-break: break-word; | |||||
| overflow-wrap: anywhere; | |||||
| background: linear-gradient(120deg, #173864 0%, #1e4d8f 100%); | |||||
| border-left: 1px solid rgba(255, 255, 255, 0.16); | |||||
| } | |||||
| .kanban-lane-header { | |||||
| position: sticky; | |||||
| left: 0; | |||||
| z-index: 20; | |||||
| min-width: 240px; | |||||
| display: flex; | |||||
| align-items: center; | |||||
| gap: 0.42rem; | |||||
| padding: 0.85rem 0.82rem; | |||||
| font-size: clamp(0.7rem, 0.14vw + 0.66rem, 0.78rem); | |||||
| font-weight: 700; | |||||
| color: #2a3a58; | |||||
| background: linear-gradient(180deg, #f6faff 0%, #edf3ff 100%); | |||||
| border-top: 1px solid #d7e1f5; | |||||
| border-right: 1px solid #d4deef; | |||||
| } | |||||
| .kanban-lane-header .lane-label { | |||||
| display: inline-block; | |||||
| max-width: 100%; | |||||
| white-space: normal; | |||||
| line-height: 1.2; | |||||
| word-break: break-word; | |||||
| overflow-wrap: anywhere; | |||||
| } | |||||
| .kanban-lane-header .lane-toggle { | |||||
| display: inline-flex; | |||||
| align-items: center; | |||||
| justify-content: center; | |||||
| width: 1.34rem; | |||||
| height: 1.34rem; | |||||
| padding: 0; | |||||
| border: 0; | |||||
| border-radius: 999px; | |||||
| background: rgba(26, 74, 145, 0.12); | |||||
| color: #1f4f96; | |||||
| cursor: pointer; | |||||
| flex: 0 0 auto; | |||||
| } | |||||
| .kanban-lane-header .lane-toggle:hover { | |||||
| background: rgba(26, 74, 145, 0.22); | |||||
| } | |||||
| .kanban-lane-header .lane-toggle i { | |||||
| transition: transform 140ms ease; | |||||
| } | |||||
| .kanban-lane-header.lane-collapsed .lane-toggle i { | |||||
| transform: rotate(-90deg); | |||||
| } | |||||
| /* Cells */ | |||||
| .kanban-cell { | |||||
| min-width: 230px; | |||||
| min-height: 132px; | |||||
| padding: 0.56rem; | |||||
| border-top: 1px solid #dce5f4; | |||||
| border-left: 1px solid #dce5f4; | |||||
| display: flex; | |||||
| flex-direction: column; | |||||
| gap: 0.45rem; | |||||
| vertical-align: top; | |||||
| background: | |||||
| linear-gradient(180deg, rgba(255, 255, 255, 0.88) 0%, rgba(244, 248, 255, 0.93) 100%); | |||||
| } | |||||
| .kanban-cell.lane-collapsed { | |||||
| min-height: 0; | |||||
| max-height: 0; | |||||
| padding-top: 0; | |||||
| padding-bottom: 0; | |||||
| border-top-color: transparent; | |||||
| overflow: hidden; | |||||
| pointer-events: none; | |||||
| } | |||||
| .kanban-cell.lane-collapsed .kanban-card { | |||||
| display: none !important; | |||||
| } | |||||
| .kanban-cell.drag-over { | |||||
| background: linear-gradient(180deg, #e9f2ff 0%, #deecff 100%); | |||||
| box-shadow: inset 0 0 0 2px rgba(19, 99, 223, 0.24); | |||||
| } | |||||
| /* Cards */ | |||||
| .kanban-card { | |||||
| border: 1px solid #d5e1f6; | |||||
| border-radius: 12px; | |||||
| background: #ffffff; | |||||
| padding: 0.56rem 0.62rem; | |||||
| cursor: pointer; | |||||
| user-select: none; | |||||
| box-shadow: 0 4px 12px rgba(16, 44, 90, 0.08); | |||||
| transition: transform 120ms ease, box-shadow 140ms ease, border-color 120ms ease; | |||||
| touch-action: manipulation; | |||||
| } | |||||
| .kanban-card:hover { | |||||
| transform: translateY(-1px); | |||||
| border-color: #8eb0ea; | |||||
| box-shadow: 0 10px 22px rgba(16, 44, 90, 0.14); | |||||
| } | |||||
| .kanban-card.sortable-ghost { | |||||
| opacity: 0.38; | |||||
| } | |||||
| .kanban-card.sortable-chosen { | |||||
| border-color: #4d87e2; | |||||
| box-shadow: 0 14px 30px rgba(17, 46, 94, 0.22); | |||||
| } | |||||
| .kanban-card-hidden { | |||||
| display: none !important; | |||||
| } | |||||
| .card-headline { | |||||
| display: flex; | |||||
| align-items: center; | |||||
| gap: 0.38rem; | |||||
| min-width: 0; | |||||
| } | |||||
| .card-job-number { | |||||
| display: inline-block; | |||||
| padding: 0.08rem 0.42rem; | |||||
| border-radius: 999px; | |||||
| font-size: 0.66rem; | |||||
| font-weight: 800; | |||||
| letter-spacing: 0.07em; | |||||
| text-transform: uppercase; | |||||
| color: #0e4fae; | |||||
| background: #e7f0ff; | |||||
| flex: 0 0 auto; | |||||
| } | |||||
| .card-customer { | |||||
| font-size: 0.78rem; | |||||
| font-weight: 600; | |||||
| color: #2b4a80; | |||||
| min-width: 0; | |||||
| white-space: nowrap; | |||||
| overflow: hidden; | |||||
| text-overflow: ellipsis; | |||||
| } | |||||
| .card-meta { | |||||
| display: flex; | |||||
| flex-wrap: wrap; | |||||
| gap: 0.5rem; | |||||
| margin-top: 0.25rem; | |||||
| } | |||||
| .card-meta-label { | |||||
| font-size: 0.63rem; | |||||
| font-weight: 700; | |||||
| color: #7a90b2; | |||||
| text-transform: uppercase; | |||||
| letter-spacing: 0.05em; | |||||
| } | |||||
| .card-delivery, | |||||
| .card-qty { | |||||
| font-size: 0.73rem; | |||||
| color: #3a5080; | |||||
| } | |||||
| .card-notes { | |||||
| margin-top: 0.22rem; | |||||
| font-size: 0.71rem; | |||||
| color: #647899; | |||||
| line-height: 1.3; | |||||
| } | |||||
| /* Settings panel */ | |||||
| .kanban-settings-overlay { | |||||
| position: fixed; | |||||
| inset: 0; | |||||
| z-index: 1040; | |||||
| background: rgba(13, 25, 48, 0.42); | |||||
| backdrop-filter: blur(2px); | |||||
| } | |||||
| .kanban-settings-panel { | |||||
| position: fixed; | |||||
| top: 0; | |||||
| right: 0; | |||||
| width: min(420px, 94vw); | |||||
| max-width: 100vw; | |||||
| height: 100%; | |||||
| z-index: 1050; | |||||
| background: linear-gradient(180deg, #f7fbff 0%, #f1f6ff 100%); | |||||
| border-left: 1px solid #d8e2f2; | |||||
| box-shadow: -8px 0 28px rgba(19, 40, 81, 0.2); | |||||
| display: flex; | |||||
| flex-direction: column; | |||||
| overflow: hidden; | |||||
| transform: translateX(104%); | |||||
| transition: transform 180ms cubic-bezier(0.2, 0.7, 0.2, 1); | |||||
| will-change: transform; | |||||
| } | |||||
| .kanban-settings-panel.open { | |||||
| transform: translateX(0); | |||||
| } | |||||
| .settings-header { | |||||
| background: rgba(255, 255, 255, 0.7); | |||||
| border-bottom-color: #dce6f5 !important; | |||||
| } | |||||
| .settings-header h6 { | |||||
| letter-spacing: -0.01em; | |||||
| } | |||||
| .settings-body { | |||||
| flex: 1; | |||||
| overflow-y: auto; | |||||
| } | |||||
| .settings-body .list-group-item { | |||||
| border-color: #d7e2f4; | |||||
| border-radius: 10px !important; | |||||
| margin-bottom: 0.45rem; | |||||
| background: #ffffff; | |||||
| box-shadow: 0 3px 9px rgba(25, 45, 84, 0.05); | |||||
| } | |||||
| .settings-sortable .drag-handle { | |||||
| color: #8fa1bf !important; | |||||
| } | |||||
| .settings-sortable .drag-handle:hover { | |||||
| color: var(--brand, #1363df) !important; | |||||
| } | |||||
| .inline-rename { | |||||
| height: auto; | |||||
| font-size: 0.84rem; | |||||
| padding: 0.23rem 0.45rem; | |||||
| } | |||||
| /* Modal polish */ | |||||
| #cardModal .modal-content { | |||||
| border: 1px solid #d7e2f5; | |||||
| border-radius: 14px; | |||||
| box-shadow: 0 22px 48px rgba(18, 43, 83, 0.22); | |||||
| } | |||||
| #cardModal .modal-header { | |||||
| border-bottom-color: #dfe8f7; | |||||
| background: #f7fbff; | |||||
| } | |||||
| #cardModal .modal-footer { | |||||
| border-top-color: #dfe8f7; | |||||
| background: #fbfdff; | |||||
| } | |||||
| /* Scrollbars */ | |||||
| .kanban-wrapper::-webkit-scrollbar, | |||||
| .settings-body::-webkit-scrollbar { | |||||
| width: 12px; | |||||
| height: 12px; | |||||
| } | |||||
| .kanban-wrapper::-webkit-scrollbar-track, | |||||
| .settings-body::-webkit-scrollbar-track { | |||||
| background: #dce8f8; | |||||
| border-radius: 999px; | |||||
| } | |||||
| .kanban-wrapper::-webkit-scrollbar-thumb, | |||||
| .settings-body::-webkit-scrollbar-thumb { | |||||
| background: #8fb0e0; | |||||
| border-radius: 999px; | |||||
| border: 2px solid #dce8f8; | |||||
| } | |||||
| .kanban-wrapper::-webkit-scrollbar-thumb:hover, | |||||
| .settings-body::-webkit-scrollbar-thumb:hover { | |||||
| background: #5e8ecb; | |||||
| } | |||||
| .kanban-wrapper::-webkit-scrollbar-corner { | |||||
| background: #dce8f8; | |||||
| } | |||||
| /* Small screens */ | |||||
| @media (max-width: 900px) { | |||||
| .kanban-wrapper { | |||||
| padding: 0.7rem; | |||||
| height: calc(100vh - 62px); | |||||
| } | |||||
| .kanban-corner, | |||||
| .kanban-lane-header { | |||||
| min-width: 190px; | |||||
| } | |||||
| .kanban-col-header, | |||||
| .kanban-cell { | |||||
| min-width: 206px; | |||||
| } | |||||
| .kanban-board-title { | |||||
| font-size: clamp(0.92rem, 2.8vw, 1.1rem); | |||||
| } | |||||
| .board-header-actions { | |||||
| gap: 0.35rem !important; | |||||
| } | |||||
| .board-header-search { | |||||
| width: min(270px, 44vw); | |||||
| margin: 0 0.35rem; | |||||
| } | |||||
| .board-header-actions .btn { | |||||
| padding-left: 0.48rem; | |||||
| padding-right: 0.48rem; | |||||
| } | |||||
| } | |||||
| @media (max-width: 640px) { | |||||
| .board-header-search { | |||||
| width: 100%; | |||||
| margin: 0.5rem 0 0; | |||||
| order: 3; | |||||
| } | |||||
| body.kanban-page .navbar { | |||||
| flex-wrap: wrap; | |||||
| align-items: flex-start !important; | |||||
| } | |||||
| .board-header-main { | |||||
| flex: 1 1 auto; | |||||
| } | |||||
| .board-header-actions { | |||||
| flex: 0 0 auto; | |||||
| } | |||||
| .kanban-settings-panel { | |||||
| width: 100vw; | |||||
| border-left: 0; | |||||
| box-shadow: none; | |||||
| } | |||||
| .settings-body { | |||||
| padding-bottom: 1.25rem; | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,791 @@ | |||||
| :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); | |||||
| } | |||||
| * { | |||||
| box-sizing: border-box; | |||||
| } | |||||
| html { | |||||
| scroll-behavior: smooth; | |||||
| } | |||||
| body { | |||||
| margin: 0; | |||||
| min-height: 100vh; | |||||
| 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%); | |||||
| } | |||||
| a { | |||||
| color: inherit; | |||||
| } | |||||
| code { | |||||
| font-family: Consolas, "Courier New", monospace; | |||||
| } | |||||
| .page-shell { | |||||
| min-height: 100vh; | |||||
| display: flex; | |||||
| flex-direction: column; | |||||
| } | |||||
| .container { | |||||
| width: min(1120px, calc(100% - 2rem)); | |||||
| margin: 0 auto; | |||||
| } | |||||
| .site-header { | |||||
| 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); | |||||
| } | |||||
| .header-inner { | |||||
| display: flex; | |||||
| align-items: center; | |||||
| justify-content: space-between; | |||||
| gap: 1rem; | |||||
| padding: 1rem 0; | |||||
| } | |||||
| .brand { | |||||
| display: inline-flex; | |||||
| align-items: center; | |||||
| gap: 0.85rem; | |||||
| text-decoration: none; | |||||
| } | |||||
| .brand-mark { | |||||
| display: inline-flex; | |||||
| align-items: center; | |||||
| justify-content: center; | |||||
| width: 2.75rem; | |||||
| height: 2.75rem; | |||||
| border-radius: 0.95rem; | |||||
| background: linear-gradient(135deg, var(--accent), var(--highlight)); | |||||
| color: #fff; | |||||
| font-weight: 700; | |||||
| letter-spacing: 0.08em; | |||||
| box-shadow: var(--shadow-soft); | |||||
| } | |||||
| .brand-copy { | |||||
| display: flex; | |||||
| flex-direction: column; | |||||
| line-height: 1.1; | |||||
| } | |||||
| .brand-copy strong { | |||||
| font-size: 1rem; | |||||
| } | |||||
| .brand-copy small { | |||||
| color: var(--text-secondary); | |||||
| font-size: 0.75rem; | |||||
| text-transform: uppercase; | |||||
| letter-spacing: 0.14em; | |||||
| } | |||||
| .site-nav { | |||||
| display: flex; | |||||
| align-items: center; | |||||
| gap: 0.6rem; | |||||
| flex-wrap: wrap; | |||||
| } | |||||
| .nav-link { | |||||
| text-decoration: none; | |||||
| color: var(--text-secondary); | |||||
| font-weight: 600; | |||||
| padding: 0.7rem 1rem; | |||||
| border-radius: 999px; | |||||
| transition: background-color 160ms ease, color 160ms ease, transform 160ms ease; | |||||
| } | |||||
| .nav-link:hover, | |||||
| .nav-link:focus-visible, | |||||
| .nav-link.is-active { | |||||
| color: var(--accent-strong); | |||||
| background: rgba(29, 122, 109, 0.12); | |||||
| transform: translateY(-1px); | |||||
| } | |||||
| .page-content { | |||||
| flex: 1; | |||||
| padding: 3.5rem 0 4rem; | |||||
| } | |||||
| .content-stack { | |||||
| display: grid; | |||||
| gap: 1.5rem; | |||||
| } | |||||
| .section-heading { | |||||
| max-width: 46rem; | |||||
| } | |||||
| .section-heading h1 { | |||||
| margin: 0.3rem 0 0.8rem; | |||||
| font-size: clamp(2.4rem, 5vw, 4rem); | |||||
| line-height: 1; | |||||
| letter-spacing: -0.04em; | |||||
| } | |||||
| .section-heading p { | |||||
| margin: 0; | |||||
| color: var(--text-secondary); | |||||
| line-height: 1.8; | |||||
| font-size: 1.05rem; | |||||
| } | |||||
| .hero { | |||||
| display: grid; | |||||
| grid-template-columns: minmax(0, 1.5fr) minmax(280px, 0.9fr); | |||||
| gap: 1.5rem; | |||||
| align-items: stretch; | |||||
| } | |||||
| .hero-copy, | |||||
| .hero-panel, | |||||
| .feature-card, | |||||
| .section-panel, | |||||
| .employee-card, | |||||
| .alert, | |||||
| .empty-state { | |||||
| background: var(--surface); | |||||
| border: 1px solid var(--surface-border); | |||||
| box-shadow: var(--shadow-card); | |||||
| } | |||||
| .hero-copy { | |||||
| padding: 3rem; | |||||
| border-radius: 2rem; | |||||
| } | |||||
| .eyebrow { | |||||
| display: inline-block; | |||||
| margin-bottom: 1rem; | |||||
| padding: 0.4rem 0.75rem; | |||||
| border-radius: 999px; | |||||
| background: var(--accent-soft); | |||||
| color: var(--accent-strong); | |||||
| font-size: 0.78rem; | |||||
| font-weight: 700; | |||||
| text-transform: uppercase; | |||||
| letter-spacing: 0.14em; | |||||
| } | |||||
| .hero h1 { | |||||
| margin: 0; | |||||
| font-size: clamp(2.8rem, 6vw, 4.8rem); | |||||
| line-height: 0.98; | |||||
| letter-spacing: -0.04em; | |||||
| } | |||||
| .hero-text { | |||||
| max-width: 44rem; | |||||
| margin: 1.25rem 0 0; | |||||
| font-size: 1.12rem; | |||||
| line-height: 1.8; | |||||
| color: var(--text-secondary); | |||||
| } | |||||
| .hero-actions { | |||||
| display: flex; | |||||
| flex-wrap: wrap; | |||||
| gap: 0.85rem; | |||||
| margin-top: 2rem; | |||||
| } | |||||
| .button { | |||||
| display: inline-flex; | |||||
| align-items: center; | |||||
| justify-content: center; | |||||
| padding: 0.9rem 1.35rem; | |||||
| border-radius: 999px; | |||||
| text-decoration: none; | |||||
| font-weight: 700; | |||||
| } | |||||
| .button-primary { | |||||
| background: linear-gradient(135deg, var(--accent), var(--accent-strong)); | |||||
| color: #fff; | |||||
| box-shadow: 0 18px 30px rgba(19, 92, 82, 0.25); | |||||
| } | |||||
| .button-secondary { | |||||
| background: rgba(29, 122, 109, 0.08); | |||||
| color: var(--accent-strong); | |||||
| } | |||||
| .hero-panel { | |||||
| display: flex; | |||||
| flex-direction: column; | |||||
| justify-content: space-between; | |||||
| padding: 2rem; | |||||
| border-radius: 1.8rem; | |||||
| } | |||||
| .panel-label { | |||||
| margin: 0 0 1rem; | |||||
| font-size: 0.78rem; | |||||
| font-weight: 700; | |||||
| letter-spacing: 0.16em; | |||||
| text-transform: uppercase; | |||||
| color: var(--text-secondary); | |||||
| } | |||||
| .hero-panel code { | |||||
| display: block; | |||||
| padding: 1rem 1.1rem; | |||||
| border-radius: 1.2rem; | |||||
| background: #173d37; | |||||
| color: #eefbf6; | |||||
| line-height: 1.7; | |||||
| white-space: normal; | |||||
| } | |||||
| .route-callout { | |||||
| margin-top: 1.5rem; | |||||
| padding: 1rem 1.1rem; | |||||
| border-radius: 1.2rem; | |||||
| background: var(--surface-strong); | |||||
| } | |||||
| .route-callout span { | |||||
| display: block; | |||||
| margin-bottom: 0.45rem; | |||||
| color: var(--text-secondary); | |||||
| font-size: 0.92rem; | |||||
| } | |||||
| .route-callout a { | |||||
| color: var(--highlight); | |||||
| font-weight: 700; | |||||
| text-decoration: none; | |||||
| } | |||||
| .feature-grid { | |||||
| display: grid; | |||||
| grid-template-columns: repeat(3, minmax(0, 1fr)); | |||||
| gap: 1.25rem; | |||||
| margin-top: 1.5rem; | |||||
| } | |||||
| .feature-card { | |||||
| padding: 1.75rem; | |||||
| border-radius: 1.6rem; | |||||
| } | |||||
| .feature-card h2 { | |||||
| margin-top: 0; | |||||
| margin-bottom: 0.8rem; | |||||
| font-size: 1.25rem; | |||||
| } | |||||
| .feature-card p { | |||||
| margin: 0; | |||||
| color: var(--text-secondary); | |||||
| line-height: 1.7; | |||||
| } | |||||
| .employee-layout { | |||||
| display: grid; | |||||
| grid-template-columns: minmax(320px, 0.9fr) minmax(0, 1.5fr); | |||||
| gap: 1.5rem; | |||||
| align-items: start; | |||||
| } | |||||
| .controls-panel, | |||||
| .table-shell { | |||||
| overflow: hidden; | |||||
| background: | |||||
| linear-gradient(180deg, rgba(255, 255, 255, 0.92), rgba(248, 242, 232, 0.88)), | |||||
| var(--surface); | |||||
| } | |||||
| .controls-header { | |||||
| display: flex; | |||||
| align-items: flex-start; | |||||
| justify-content: space-between; | |||||
| gap: 1rem; | |||||
| } | |||||
| .search-row { | |||||
| display: grid; | |||||
| grid-template-columns: minmax(0, 1fr); | |||||
| } | |||||
| .field-full { | |||||
| width: 100%; | |||||
| } | |||||
| .section-panel { | |||||
| padding: 1.75rem; | |||||
| border-radius: 1.8rem; | |||||
| } | |||||
| .panel-header { | |||||
| margin-bottom: 1.5rem; | |||||
| } | |||||
| .panel-header h2 { | |||||
| margin: 0 0 0.45rem; | |||||
| font-size: 1.45rem; | |||||
| } | |||||
| .panel-header p { | |||||
| margin: 0; | |||||
| color: var(--text-secondary); | |||||
| line-height: 1.7; | |||||
| } | |||||
| .employee-form { | |||||
| display: grid; | |||||
| gap: 1.25rem; | |||||
| } | |||||
| .form-grid { | |||||
| display: grid; | |||||
| grid-template-columns: repeat(2, minmax(0, 1fr)); | |||||
| gap: 1rem; | |||||
| } | |||||
| .field { | |||||
| display: grid; | |||||
| gap: 0.45rem; | |||||
| font-weight: 600; | |||||
| } | |||||
| .field span { | |||||
| font-size: 0.96rem; | |||||
| } | |||||
| .input { | |||||
| width: 100%; | |||||
| padding: 0.95rem 1rem; | |||||
| border: 1px solid rgba(20, 54, 49, 0.16); | |||||
| border-radius: 1rem; | |||||
| background: rgba(255, 255, 255, 0.92); | |||||
| color: var(--text-primary); | |||||
| font: inherit; | |||||
| } | |||||
| .input:focus { | |||||
| outline: 2px solid rgba(29, 122, 109, 0.22); | |||||
| border-color: rgba(29, 122, 109, 0.45); | |||||
| } | |||||
| .field-error { | |||||
| color: #a43d1f; | |||||
| font-size: 0.88rem; | |||||
| font-weight: 600; | |||||
| } | |||||
| .form-actions { | |||||
| display: flex; | |||||
| justify-content: flex-start; | |||||
| align-items: center; | |||||
| gap: 0.85rem; | |||||
| } | |||||
| .button { | |||||
| border: 0; | |||||
| cursor: pointer; | |||||
| } | |||||
| .htmx-indicator { | |||||
| display: none; | |||||
| } | |||||
| .htmx-request .htmx-indicator, | |||||
| .htmx-request.htmx-indicator { | |||||
| display: inline-flex; | |||||
| } | |||||
| .inline-indicator { | |||||
| color: var(--text-secondary); | |||||
| font-size: 0.9rem; | |||||
| font-weight: 600; | |||||
| } | |||||
| .alert, | |||||
| .empty-state { | |||||
| padding: 1rem 1.15rem; | |||||
| border-radius: 1.2rem; | |||||
| } | |||||
| .alert-success { | |||||
| background: rgba(218, 241, 236, 0.92); | |||||
| color: var(--accent-strong); | |||||
| } | |||||
| .alert-error { | |||||
| background: rgba(239, 124, 77, 0.14); | |||||
| color: #8f3518; | |||||
| } | |||||
| .empty-state p { | |||||
| margin: 0; | |||||
| color: var(--text-secondary); | |||||
| line-height: 1.7; | |||||
| } | |||||
| .empty-state p + p { | |||||
| margin-top: 0.45rem; | |||||
| } | |||||
| .employee-cards { | |||||
| display: grid; | |||||
| gap: 1rem; | |||||
| } | |||||
| .employee-card { | |||||
| padding: 1.15rem; | |||||
| border-radius: 1.3rem; | |||||
| } | |||||
| .employee-card-top { | |||||
| display: flex; | |||||
| align-items: flex-start; | |||||
| justify-content: space-between; | |||||
| gap: 1rem; | |||||
| margin-bottom: 0.8rem; | |||||
| } | |||||
| .employee-card-top h3 { | |||||
| margin: 0; | |||||
| font-size: 1.05rem; | |||||
| } | |||||
| .employee-card-top span { | |||||
| padding: 0.4rem 0.7rem; | |||||
| border-radius: 999px; | |||||
| background: rgba(29, 122, 109, 0.09); | |||||
| color: var(--accent-strong); | |||||
| font-size: 0.78rem; | |||||
| font-weight: 700; | |||||
| } | |||||
| .employee-card p { | |||||
| margin: 0 0 1rem; | |||||
| color: var(--text-secondary); | |||||
| } | |||||
| .employee-meta { | |||||
| display: grid; | |||||
| gap: 0.75rem; | |||||
| margin: 0; | |||||
| } | |||||
| .employee-meta div { | |||||
| display: grid; | |||||
| gap: 0.2rem; | |||||
| } | |||||
| .employee-meta dt { | |||||
| color: var(--text-secondary); | |||||
| font-size: 0.82rem; | |||||
| text-transform: uppercase; | |||||
| letter-spacing: 0.08em; | |||||
| } | |||||
| .employee-meta dd { | |||||
| margin: 0; | |||||
| font-weight: 600; | |||||
| } | |||||
| .stats-grid { | |||||
| display: grid; | |||||
| grid-template-columns: repeat(3, minmax(0, 1fr)); | |||||
| gap: 0.9rem; | |||||
| } | |||||
| .stat-card { | |||||
| padding: 1rem; | |||||
| border-radius: 1.3rem; | |||||
| background: rgba(255, 255, 255, 0.72); | |||||
| border: 1px solid rgba(20, 54, 49, 0.08); | |||||
| } | |||||
| .stat-card span { | |||||
| display: block; | |||||
| color: var(--text-secondary); | |||||
| font-size: 0.82rem; | |||||
| text-transform: uppercase; | |||||
| letter-spacing: 0.08em; | |||||
| } | |||||
| .stat-card strong { | |||||
| display: block; | |||||
| margin-top: 0.45rem; | |||||
| font-size: 1.7rem; | |||||
| line-height: 1; | |||||
| } | |||||
| .summary-feature { | |||||
| 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)); | |||||
| } | |||||
| .summary-label { | |||||
| display: block; | |||||
| color: var(--text-secondary); | |||||
| font-size: 0.82rem; | |||||
| text-transform: uppercase; | |||||
| letter-spacing: 0.08em; | |||||
| } | |||||
| .summary-feature h3 { | |||||
| margin: 0.55rem 0 0.3rem; | |||||
| font-size: 1.35rem; | |||||
| } | |||||
| .summary-feature p { | |||||
| margin: 0; | |||||
| color: var(--text-secondary); | |||||
| } | |||||
| .table-toolbar { | |||||
| display: flex; | |||||
| align-items: center; | |||||
| justify-content: space-between; | |||||
| gap: 1rem; | |||||
| margin-bottom: 1rem; | |||||
| flex-wrap: wrap; | |||||
| padding: 0.9rem 1rem; | |||||
| border: 1px solid rgba(20, 54, 49, 0.08); | |||||
| border-radius: 1rem; | |||||
| background: rgba(255, 255, 255, 0.58); | |||||
| } | |||||
| .table-pill { | |||||
| display: inline-flex; | |||||
| align-items: center; | |||||
| padding: 0.5rem 0.8rem; | |||||
| border-radius: 999px; | |||||
| background: rgba(29, 122, 109, 0.12); | |||||
| color: var(--accent-strong); | |||||
| font-size: 0.82rem; | |||||
| font-weight: 700; | |||||
| letter-spacing: 0.04em; | |||||
| } | |||||
| .table-caption { | |||||
| color: var(--text-secondary); | |||||
| font-size: 0.92rem; | |||||
| } | |||||
| .directory-panel .tabulator-host { | |||||
| min-height: 38rem; | |||||
| } | |||||
| .tabulator-host .tabulator { | |||||
| border: 1px solid var(--surface-border); | |||||
| border-radius: 1.35rem; | |||||
| overflow: hidden; | |||||
| 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); | |||||
| } | |||||
| .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)); | |||||
| } | |||||
| .tabulator-host .tabulator-header .tabulator-col { | |||||
| min-height: 3.25rem; | |||||
| background: transparent; | |||||
| border-right: 1px solid rgba(20, 54, 49, 0.06); | |||||
| } | |||||
| .tabulator-host .tabulator-header .tabulator-col:last-child { | |||||
| border-right: 0; | |||||
| } | |||||
| .tabulator-host .tabulator-header .tabulator-col .tabulator-col-content { | |||||
| padding: 0.9rem 0.95rem 0.85rem; | |||||
| } | |||||
| .tabulator-host .tabulator-header .tabulator-col .tabulator-col-title { | |||||
| font-size: 0.78rem; | |||||
| font-weight: 800; | |||||
| letter-spacing: 0.08em; | |||||
| text-transform: uppercase; | |||||
| color: var(--accent-strong); | |||||
| } | |||||
| .tabulator-host .tabulator-col, | |||||
| .tabulator-host .tabulator-cell { | |||||
| border-right: 1px solid rgba(20, 54, 49, 0.06); | |||||
| } | |||||
| .tabulator-host .tabulator-row .tabulator-cell:last-child { | |||||
| border-right: 0; | |||||
| } | |||||
| .tabulator-host .tabulator-row { | |||||
| background: rgba(255, 255, 255, 0.96); | |||||
| border-bottom: 1px solid rgba(20, 54, 49, 0.06); | |||||
| transition: background-color 160ms ease, transform 160ms ease; | |||||
| } | |||||
| .tabulator-host .tabulator-row:nth-child(even) { | |||||
| background: rgba(248, 242, 232, 0.82); | |||||
| } | |||||
| .tabulator-host .tabulator-row:hover { | |||||
| background: rgba(218, 241, 236, 0.72); | |||||
| } | |||||
| .tabulator-host .tabulator-row.tabulator-selected { | |||||
| background: rgba(29, 122, 109, 0.18); | |||||
| } | |||||
| .tabulator-host .tabulator-cell { | |||||
| padding: 0.95rem 0.95rem; | |||||
| font-size: 0.96rem; | |||||
| line-height: 1.4; | |||||
| } | |||||
| .tabulator-host .tabulator-row .tabulator-cell:first-child { | |||||
| font-weight: 700; | |||||
| color: var(--text-primary); | |||||
| } | |||||
| .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); | |||||
| } | |||||
| .tabulator-host .tabulator-footer .tabulator-paginator { | |||||
| font-family: inherit; | |||||
| } | |||||
| .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-radius: 0.8rem; | |||||
| background: rgba(255, 255, 255, 0.9); | |||||
| color: var(--text-secondary); | |||||
| font-weight: 700; | |||||
| } | |||||
| .tabulator-host .tabulator-footer .tabulator-page.active, | |||||
| .tabulator-host .tabulator-footer .tabulator-page:hover { | |||||
| background: linear-gradient(135deg, var(--accent), var(--accent-strong)); | |||||
| border-color: transparent; | |||||
| color: #fff; | |||||
| } | |||||
| .tabulator-host .tabulator-footer .tabulator-page:disabled { | |||||
| opacity: 0.45; | |||||
| } | |||||
| .tabulator-host .tabulator-placeholder { | |||||
| padding: 2.5rem 1rem; | |||||
| color: var(--text-secondary); | |||||
| font-size: 1rem; | |||||
| font-weight: 600; | |||||
| } | |||||
| .site-footer { | |||||
| margin-top: auto; | |||||
| border-top: 1px solid rgba(20, 54, 49, 0.08); | |||||
| background: rgba(255, 252, 247, 0.72); | |||||
| } | |||||
| .footer-inner { | |||||
| display: flex; | |||||
| justify-content: space-between; | |||||
| gap: 1rem; | |||||
| padding: 1.25rem 0 2rem; | |||||
| color: var(--text-secondary); | |||||
| font-size: 0.95rem; | |||||
| } | |||||
| .footer-inner p { | |||||
| margin: 0; | |||||
| } | |||||
| @media (max-width: 860px) { | |||||
| .header-inner, | |||||
| .footer-inner { | |||||
| flex-direction: column; | |||||
| align-items: flex-start; | |||||
| } | |||||
| .hero, | |||||
| .feature-grid, | |||||
| .employee-layout { | |||||
| grid-template-columns: 1fr; | |||||
| } | |||||
| .controls-header, | |||||
| .table-toolbar { | |||||
| flex-direction: column; | |||||
| align-items: flex-start; | |||||
| } | |||||
| .hero-copy, | |||||
| .hero-panel { | |||||
| padding: 2rem; | |||||
| } | |||||
| .form-grid { | |||||
| grid-template-columns: 1fr; | |||||
| } | |||||
| .stats-grid { | |||||
| grid-template-columns: 1fr; | |||||
| } | |||||
| .page-content { | |||||
| padding-top: 2rem; | |||||
| } | |||||
| } | |||||
| @media (max-width: 560px) { | |||||
| .container { | |||||
| width: min(100% - 1.25rem, 1120px); | |||||
| } | |||||
| .site-nav { | |||||
| width: 100%; | |||||
| } | |||||
| .nav-link { | |||||
| width: 100%; | |||||
| text-align: center; | |||||
| } | |||||
| .hero h1 { | |||||
| font-size: 2.5rem; | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,45 @@ | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| require_once __DIR__ . '/../vendor/autoload.php'; | |||||
| // Load .env file if present — sets vars via putenv() so getenv() picks them up | |||||
| (static function (): void { | |||||
| $envFile = __DIR__ . '/../.env'; | |||||
| if (!file_exists($envFile)) { | |||||
| return; | |||||
| } | |||||
| foreach (file($envFile, 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; | |||||
| } | |||||
| } | |||||
| })(); | |||||
| use Core\Dispatcher; | |||||
| use Core\Request; | |||||
| use Core\Router; | |||||
| ensureSessionStarted(); | |||||
| $app = app(); | |||||
| $router = new Router(); | |||||
| require_once __DIR__ . '/../routes/web.php'; | |||||
| $debug = filter_var(getenv('APP_DEBUG'), FILTER_VALIDATE_BOOLEAN); | |||||
| $dispatcher = new Dispatcher($router, $app, $debug); | |||||
| $request = Request::capture(); | |||||
| $app->bind(Request::class, $request); | |||||
| $response = $dispatcher->dispatch($request); | |||||
| $response->send(); | |||||
| @@ -0,0 +1,65 @@ | |||||
| window.employeeDirectory = function () { | |||||
| return { | |||||
| search: '', | |||||
| table: null, | |||||
| init() { | |||||
| this.search = this.$root.querySelector('#employee-search')?.value ?? ''; | |||||
| this.initTable(); | |||||
| document.body.addEventListener('employees-changed', () => { | |||||
| this.reloadTable(); | |||||
| }); | |||||
| }, | |||||
| initTable() { | |||||
| const tableElement = document.getElementById('employee-table'); | |||||
| if (!tableElement || typeof Tabulator === 'undefined') { | |||||
| return; | |||||
| } | |||||
| this.table = new Tabulator(tableElement, { | |||||
| ajaxURL: '/employees/data', | |||||
| ajaxParams: { | |||||
| search: this.search, | |||||
| }, | |||||
| layout: 'fitColumns', | |||||
| responsiveLayout: 'collapse', | |||||
| pagination: true, | |||||
| paginationMode: 'local', | |||||
| paginationSize: 8, | |||||
| movableColumns: true, | |||||
| placeholder: 'No employees found.', | |||||
| columns: [ | |||||
| { title: 'Name', field: 'full_name', minWidth: 180 }, | |||||
| { title: 'Email', field: 'email', minWidth: 220 }, | |||||
| { title: 'Department', field: 'department', minWidth: 140 }, | |||||
| { title: 'Job Title', field: 'job_title', minWidth: 180 }, | |||||
| { title: 'Start Date', field: 'start_date', hozAlign: 'left', minWidth: 130 }, | |||||
| ], | |||||
| }); | |||||
| }, | |||||
| applySearch() { | |||||
| if (!this.table) { | |||||
| return; | |||||
| } | |||||
| this.table.setData('/employees/data', { | |||||
| search: this.search, | |||||
| }); | |||||
| }, | |||||
| reloadTable() { | |||||
| if (!this.table) { | |||||
| this.initTable(); | |||||
| return; | |||||
| } | |||||
| this.table.setData('/employees/data', { | |||||
| search: this.search, | |||||
| }); | |||||
| }, | |||||
| }; | |||||
| }; | |||||
| @@ -0,0 +1,442 @@ | |||||
| /* kanban-board.js - grid rendering and drag-drop between cells */ | |||||
| (function () { | |||||
| 'use strict'; | |||||
| var boardId = KANBAN.boardId; | |||||
| var laneCollapseStorageKey = 'kanban_lane_collapsed_' + String(boardId); | |||||
| var collapsedLaneIds = loadCollapsedLaneIds(); | |||||
| var searchState = { | |||||
| query: '' | |||||
| }; | |||||
| var dragState = { | |||||
| active: false, | |||||
| x: 0, | |||||
| y: 0, | |||||
| rafId: 0 | |||||
| }; | |||||
| function post(url, data, cb) { | |||||
| var params = new URLSearchParams(); | |||||
| Object.keys(data).forEach(function (k) { params.append(k, data[k]); }); | |||||
| fetch(url, { | |||||
| method: 'POST', | |||||
| headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, | |||||
| body: params.toString() | |||||
| }) | |||||
| .then(function (r) { return r.json(); }) | |||||
| .then(cb) | |||||
| .catch(function (e) { console.error(url, e); }); | |||||
| } | |||||
| function esc(s) { | |||||
| return String(s) | |||||
| .replace(/&/g, '&') | |||||
| .replace(/</g, '<') | |||||
| .replace(/>/g, '>') | |||||
| .replace(/"/g, '"'); | |||||
| } | |||||
| function applyGridTemplate() { | |||||
| var grid = document.getElementById('kanban-grid'); | |||||
| var colHs = grid.querySelectorAll('.kanban-col-header'); | |||||
| var cols = '240px'; | |||||
| colHs.forEach(function () { cols += ' 230px'; }); | |||||
| grid.style.gridTemplateColumns = cols; | |||||
| } | |||||
| function loadCollapsedLaneIds() { | |||||
| var laneMap = {}; | |||||
| try { | |||||
| var raw = window.localStorage.getItem(laneCollapseStorageKey); | |||||
| if (!raw) return laneMap; | |||||
| var arr = JSON.parse(raw); | |||||
| if (!Array.isArray(arr)) return laneMap; | |||||
| arr.forEach(function (laneId) { | |||||
| laneMap[String(laneId)] = true; | |||||
| }); | |||||
| } catch (e) { | |||||
| console.warn('Failed to load lane collapse state', e); | |||||
| } | |||||
| return laneMap; | |||||
| } | |||||
| function saveCollapsedLaneIds() { | |||||
| try { | |||||
| window.localStorage.setItem(laneCollapseStorageKey, JSON.stringify(Object.keys(collapsedLaneIds))); | |||||
| } catch (e) { | |||||
| console.warn('Failed to save lane collapse state', e); | |||||
| } | |||||
| } | |||||
| function setLaneCollapsed(laneId, isCollapsed) { | |||||
| var laneKey = String(laneId); | |||||
| var header = document.querySelector('.kanban-lane-header[data-lane-id="' + laneKey + '"]'); | |||||
| if (!header) return; | |||||
| var laneCells = document.querySelectorAll('.kanban-cell[data-lane-id="' + laneKey + '"]'); | |||||
| header.classList.toggle('lane-collapsed', isCollapsed); | |||||
| laneCells.forEach(function (cell) { | |||||
| cell.classList.toggle('lane-collapsed', isCollapsed); | |||||
| }); | |||||
| var toggleBtn = header.querySelector('.lane-toggle'); | |||||
| if (toggleBtn) { | |||||
| toggleBtn.setAttribute('aria-expanded', isCollapsed ? 'false' : 'true'); | |||||
| toggleBtn.title = isCollapsed ? 'Expand swim lane' : 'Collapse swim lane'; | |||||
| toggleBtn.setAttribute('aria-label', toggleBtn.title); | |||||
| } | |||||
| if (isCollapsed) { | |||||
| collapsedLaneIds[laneKey] = true; | |||||
| } else { | |||||
| delete collapsedLaneIds[laneKey]; | |||||
| } | |||||
| saveCollapsedLaneIds(); | |||||
| } | |||||
| function toggleLaneCollapsed(laneId) { | |||||
| var laneKey = String(laneId); | |||||
| setLaneCollapsed(laneKey, !collapsedLaneIds[laneKey]); | |||||
| } | |||||
| function bindLaneHeaderToggle(headerEl) { | |||||
| if (!headerEl) return; | |||||
| var toggleBtn = headerEl.querySelector('.lane-toggle'); | |||||
| if (!toggleBtn) return; | |||||
| if (!toggleBtn.dataset.boundToggle) { | |||||
| toggleBtn.addEventListener('click', function (evt) { | |||||
| evt.preventDefault(); | |||||
| evt.stopPropagation(); | |||||
| toggleLaneCollapsed(headerEl.dataset.laneId); | |||||
| }); | |||||
| toggleBtn.dataset.boundToggle = '1'; | |||||
| } | |||||
| } | |||||
| function initLaneHeaderToggles() { | |||||
| document.querySelectorAll('.kanban-lane-header').forEach(function (headerEl) { | |||||
| bindLaneHeaderToggle(headerEl); | |||||
| if (collapsedLaneIds[String(headerEl.dataset.laneId)]) { | |||||
| setLaneCollapsed(headerEl.dataset.laneId, true); | |||||
| } | |||||
| }); | |||||
| } | |||||
| function cardBodyHtml(card) { | |||||
| var html = '<div class="card-headline">' + | |||||
| '<span class="card-job-number">' + esc(card.job_number || '') + '</span>'; | |||||
| if (card.customer_name) { | |||||
| html += '<span class="card-customer">' + esc(card.customer_name) + '</span>'; | |||||
| } | |||||
| html += '</div>'; | |||||
| return html; | |||||
| } | |||||
| function buildCardSearchText(card) { | |||||
| return [ | |||||
| card.job_number || '', | |||||
| card.job_name || '', | |||||
| card.customer_name || '', | |||||
| card.notes || '' | |||||
| ].join(' ').toLowerCase(); | |||||
| } | |||||
| function buildCardEl(card) { | |||||
| var div = document.createElement('div'); | |||||
| div.className = 'kanban-card'; | |||||
| div.dataset.id = card.id; | |||||
| div.dataset.columnId = card.column_id; | |||||
| div.dataset.laneId = card.swim_lane_id; | |||||
| div.dataset.searchText = buildCardSearchText(card); | |||||
| div.innerHTML = cardBodyHtml(card); | |||||
| div.addEventListener('click', function () { | |||||
| var c = KANBAN.cards.find(function (x) { return String(x.id) === String(div.dataset.id); }); | |||||
| if (!c) return; | |||||
| window.KanbanModal.openEdit(c.id, c.column_id, c.swim_lane_id, c.job_number, c.job_name, c.customer_name, c.delivery_date, c.quantity, c.notes, c.full_note); | |||||
| }); | |||||
| return div; | |||||
| } | |||||
| function renderCards() { | |||||
| KANBAN.cards.forEach(function (card) { | |||||
| var cell = document.querySelector( | |||||
| '.kanban-cell[data-col-id="' + card.column_id + '"][data-lane-id="' + card.swim_lane_id + '"]' | |||||
| ); | |||||
| if (cell) { | |||||
| cell.appendChild(buildCardEl(card)); | |||||
| } | |||||
| }); | |||||
| applyCardFilter(); | |||||
| } | |||||
| function applyCardFilter() { | |||||
| var activeQuery = searchState.query; | |||||
| document.querySelectorAll('.kanban-card').forEach(function (el) { | |||||
| var searchableText = (el.dataset.searchText || '').toLowerCase(); | |||||
| var isMatch = activeQuery === '' || searchableText.indexOf(activeQuery) > -1; | |||||
| el.classList.toggle('kanban-card-hidden', !isMatch); | |||||
| }); | |||||
| } | |||||
| function initJobSearch() { | |||||
| var searchInput = document.getElementById('job-search-input'); | |||||
| if (!searchInput) return; | |||||
| searchInput.addEventListener('input', function () { | |||||
| searchState.query = String(searchInput.value || '').toLowerCase().trim(); | |||||
| applyCardFilter(); | |||||
| }); | |||||
| } | |||||
| function handleDragEnd(evt) { | |||||
| var cardId = evt.item.dataset.id; | |||||
| var newColId = evt.to.dataset.colId; | |||||
| var newLaneId = evt.to.dataset.laneId; | |||||
| var newPos = evt.newIndex; | |||||
| var siblings = []; | |||||
| evt.to.querySelectorAll('.kanban-card').forEach(function (el) { | |||||
| siblings.push(el.dataset.id); | |||||
| }); | |||||
| var card = KANBAN.cards.find(function (c) { return String(c.id) === String(cardId); }); | |||||
| if (card) { | |||||
| card.column_id = parseInt(newColId, 10); | |||||
| card.swim_lane_id = parseInt(newLaneId, 10); | |||||
| card.position = newPos; | |||||
| } | |||||
| evt.item.dataset.columnId = newColId; | |||||
| evt.item.dataset.laneId = newLaneId; | |||||
| post('/cards/' + cardId + '/move', { | |||||
| column_id: newColId, | |||||
| swim_lane_id: newLaneId, | |||||
| position: newPos, | |||||
| sibling_ids: siblings.join(',') | |||||
| }, function (res) { | |||||
| if (!res.ok) console.error('Move failed', res); | |||||
| }); | |||||
| } | |||||
| function createCellSortable(cell) { | |||||
| Sortable.create(cell, { | |||||
| group: 'cards', | |||||
| animation: 150, | |||||
| ghostClass: 'sortable-ghost', | |||||
| chosenClass: 'sortable-chosen', | |||||
| handle: '.kanban-card', | |||||
| delayOnTouchOnly: true, | |||||
| delay: 120, | |||||
| touchStartThreshold: 3, | |||||
| fallbackTolerance: 4, | |||||
| scroll: true, | |||||
| bubbleScroll: true, | |||||
| scrollSensitivity: 140, | |||||
| scrollSpeed: 32, | |||||
| onStart: function () { startEdgeAutoScroll(); }, | |||||
| onEnd: function (evt) { | |||||
| stopEdgeAutoScroll(); | |||||
| handleDragEnd(evt); | |||||
| } | |||||
| }); | |||||
| } | |||||
| function updatePointerFromEvent(evt) { | |||||
| if (!evt) return; | |||||
| if (evt.touches && evt.touches.length > 0) { | |||||
| dragState.x = evt.touches[0].clientX; | |||||
| dragState.y = evt.touches[0].clientY; | |||||
| return; | |||||
| } | |||||
| if (evt.clientX !== undefined && evt.clientY !== undefined) { | |||||
| dragState.x = evt.clientX; | |||||
| dragState.y = evt.clientY; | |||||
| } | |||||
| } | |||||
| function edgeScrollStep() { | |||||
| if (!dragState.active) return; | |||||
| var wrapper = document.querySelector('.kanban-wrapper'); | |||||
| if (wrapper) { | |||||
| var rect = wrapper.getBoundingClientRect(); | |||||
| var edge = 110; | |||||
| var maxStep = 40; | |||||
| var dx = 0; | |||||
| var dy = 0; | |||||
| if (dragState.x > 0 && dragState.x < rect.left + edge) { | |||||
| dx = -Math.min(maxStep, Math.ceil((rect.left + edge - dragState.x) / 3)); | |||||
| } else if (dragState.x > rect.right - edge && dragState.x < rect.right + edge) { | |||||
| dx = Math.min(maxStep, Math.ceil((dragState.x - (rect.right - edge)) / 3)); | |||||
| } | |||||
| if (dragState.y > 0 && dragState.y < rect.top + edge) { | |||||
| dy = -Math.min(maxStep, Math.ceil((rect.top + edge - dragState.y) / 3)); | |||||
| } else if (dragState.y > rect.bottom - edge && dragState.y < rect.bottom + edge) { | |||||
| dy = Math.min(maxStep, Math.ceil((dragState.y - (rect.bottom - edge)) / 3)); | |||||
| } | |||||
| if (dx !== 0) wrapper.scrollLeft += dx; | |||||
| if (dy !== 0) wrapper.scrollTop += dy; | |||||
| } | |||||
| dragState.rafId = window.requestAnimationFrame(edgeScrollStep); | |||||
| } | |||||
| function startEdgeAutoScroll() { | |||||
| if (dragState.active) return; | |||||
| dragState.active = true; | |||||
| document.addEventListener('pointermove', updatePointerFromEvent, { passive: true }); | |||||
| document.addEventListener('touchmove', updatePointerFromEvent, { passive: true }); | |||||
| document.addEventListener('dragover', updatePointerFromEvent, { passive: true }); | |||||
| dragState.rafId = window.requestAnimationFrame(edgeScrollStep); | |||||
| } | |||||
| function stopEdgeAutoScroll() { | |||||
| if (!dragState.active) return; | |||||
| dragState.active = false; | |||||
| if (dragState.rafId) { | |||||
| window.cancelAnimationFrame(dragState.rafId); | |||||
| dragState.rafId = 0; | |||||
| } | |||||
| document.removeEventListener('pointermove', updatePointerFromEvent); | |||||
| document.removeEventListener('touchmove', updatePointerFromEvent); | |||||
| document.removeEventListener('dragover', updatePointerFromEvent); | |||||
| } | |||||
| function initSortables() { | |||||
| document.querySelectorAll('.kanban-cell').forEach(createCellSortable); | |||||
| } | |||||
| document.getElementById('btn-add-card').addEventListener('click', function () { | |||||
| window.KanbanModal.openCreate(boardId, null, null); | |||||
| }); | |||||
| window.KanbanBoard = { | |||||
| onCardCreated: function (card) { | |||||
| KANBAN.cards.push(card); | |||||
| var cell = document.querySelector( | |||||
| '.kanban-cell[data-col-id="' + card.column_id + '"][data-lane-id="' + card.swim_lane_id + '"]' | |||||
| ); | |||||
| if (cell) { | |||||
| cell.appendChild(buildCardEl(card)); | |||||
| } | |||||
| applyCardFilter(); | |||||
| }, | |||||
| onCardUpdated: function (id, data) { | |||||
| var card = KANBAN.cards.find(function (c) { return String(c.id) === String(id); }); | |||||
| if (card) { | |||||
| card.job_number = data.job_number || ''; | |||||
| card.job_name = data.job_name || ''; | |||||
| card.customer_name = data.customer_name || ''; | |||||
| card.delivery_date = data.delivery_date || null; | |||||
| card.quantity = data.quantity || ''; | |||||
| card.notes = data.notes || ''; | |||||
| card.full_note = data.full_note !== undefined ? data.full_note : (card.full_note || ''); | |||||
| } | |||||
| var el = document.querySelector('.kanban-card[data-id="' + id + '"]'); | |||||
| if (el && card) { | |||||
| el.innerHTML = cardBodyHtml(card); | |||||
| el.dataset.searchText = buildCardSearchText(card); | |||||
| } | |||||
| applyCardFilter(); | |||||
| }, | |||||
| onCardDeleted: function (id) { | |||||
| KANBAN.cards = KANBAN.cards.filter(function (c) { return String(c.id) !== String(id); }); | |||||
| var el = document.querySelector('.kanban-card[data-id="' + id + '"]'); | |||||
| if (el) el.remove(); | |||||
| applyCardFilter(); | |||||
| }, | |||||
| addColumn: function (col) { | |||||
| var grid = document.getElementById('kanban-grid'); | |||||
| var headers = grid.querySelectorAll('.kanban-col-header'); | |||||
| var refNode = headers.length ? headers[headers.length - 1].nextSibling : null; | |||||
| var hdr = document.createElement('div'); | |||||
| hdr.className = 'kanban-col-header'; | |||||
| hdr.dataset.colId = col.id; | |||||
| hdr.innerHTML = '<span class="col-label">' + esc(col.name) + '</span>'; | |||||
| grid.insertBefore(hdr, refNode); | |||||
| var laneHeaders = grid.querySelectorAll('.kanban-lane-header'); | |||||
| laneHeaders.forEach(function (lh) { | |||||
| var laneId = lh.dataset.laneId; | |||||
| var cell = document.createElement('div'); | |||||
| cell.className = 'kanban-cell'; | |||||
| cell.dataset.colId = col.id; | |||||
| cell.dataset.laneId = laneId; | |||||
| var row = lh.parentNode; | |||||
| row.appendChild(cell); | |||||
| createCellSortable(cell); | |||||
| }); | |||||
| applyGridTemplate(); | |||||
| }, | |||||
| removeColumn: function (colId) { | |||||
| document.querySelector('.kanban-col-header[data-col-id="' + colId + '"]').remove(); | |||||
| document.querySelectorAll('.kanban-cell[data-col-id="' + colId + '"]').forEach(function (el) { el.remove(); }); | |||||
| KANBAN.cards = KANBAN.cards.filter(function (c) { return String(c.column_id) !== String(colId); }); | |||||
| applyGridTemplate(); | |||||
| }, | |||||
| addLane: function (lane) { | |||||
| var grid = document.getElementById('kanban-grid'); | |||||
| var colHeaders = grid.querySelectorAll('.kanban-col-header'); | |||||
| var lh = document.createElement('div'); | |||||
| lh.className = 'kanban-lane-header'; | |||||
| lh.dataset.laneId = lane.id; | |||||
| lh.innerHTML = | |||||
| '<button type="button" class="lane-toggle" title="Collapse swim lane" aria-label="Collapse swim lane" aria-expanded="true">' + | |||||
| '<i class="bi bi-chevron-down" aria-hidden="true"></i>' + | |||||
| '</button>' + | |||||
| '<span class="lane-label">' + esc(lane.name) + '</span>'; | |||||
| grid.appendChild(lh); | |||||
| bindLaneHeaderToggle(lh); | |||||
| colHeaders.forEach(function (ch) { | |||||
| var cell = document.createElement('div'); | |||||
| cell.className = 'kanban-cell'; | |||||
| cell.dataset.colId = ch.dataset.colId; | |||||
| cell.dataset.laneId = lane.id; | |||||
| grid.appendChild(cell); | |||||
| createCellSortable(cell); | |||||
| }); | |||||
| if (collapsedLaneIds[String(lane.id)]) { | |||||
| setLaneCollapsed(lane.id, true); | |||||
| } | |||||
| applyGridTemplate(); | |||||
| }, | |||||
| removeLane: function (laneId) { | |||||
| document.querySelector('.kanban-lane-header[data-lane-id="' + laneId + '"]').remove(); | |||||
| document.querySelectorAll('.kanban-cell[data-lane-id="' + laneId + '"]').forEach(function (el) { el.remove(); }); | |||||
| KANBAN.cards = KANBAN.cards.filter(function (c) { return String(c.swim_lane_id) !== String(laneId); }); | |||||
| if (collapsedLaneIds[String(laneId)]) { | |||||
| delete collapsedLaneIds[String(laneId)]; | |||||
| saveCollapsedLaneIds(); | |||||
| } | |||||
| }, | |||||
| renameColumn: function (colId, name) { | |||||
| var hdr = document.querySelector('.kanban-col-header[data-col-id="' + colId + '"] .col-label'); | |||||
| if (hdr) hdr.textContent = name; | |||||
| }, | |||||
| renameLane: function (laneId, name) { | |||||
| var hdr = document.querySelector('.kanban-lane-header[data-lane-id="' + laneId + '"] .lane-label'); | |||||
| if (hdr) hdr.textContent = name; | |||||
| } | |||||
| }; | |||||
| applyGridTemplate(); | |||||
| renderCards(); | |||||
| initSortables(); | |||||
| initJobSearch(); | |||||
| initLaneHeaderToggles(); | |||||
| })(); | |||||
| @@ -0,0 +1,200 @@ | |||||
| /* kanban-modal.js — card create/edit modal */ | |||||
| (function () { | |||||
| 'use strict'; | |||||
| var modal = document.getElementById('cardModal'); | |||||
| var bsModal = new bootstrap.Modal(modal); | |||||
| var titleEl = document.getElementById('cardModalLabel'); | |||||
| var cardIdEl = document.getElementById('card-id'); | |||||
| var colIdEl = document.getElementById('card-column-id'); | |||||
| var laneIdEl = document.getElementById('card-lane-id'); | |||||
| var jobNumEl = document.getElementById('card-job-number'); | |||||
| var jobNameEl = document.getElementById('card-job-name'); | |||||
| var errEl = document.getElementById('card-modal-error'); | |||||
| var btnSave = document.getElementById('btn-save-card'); | |||||
| var btnDelete = document.getElementById('btn-delete-card'); | |||||
| var custNameEl = document.getElementById('card-customer-name'); | |||||
| var delivDateEl = document.getElementById('card-delivery-date'); | |||||
| var qtyEl = document.getElementById('card-quantity'); | |||||
| var notesEl = document.getElementById('card-notes'); | |||||
| var fullNoteEl = document.getElementById('card-full-note'); | |||||
| var boardId = KANBAN.boardId; | |||||
| /* ── Helpers ─────────────────────────────────────────────── */ | |||||
| function post(url, data, cb) { | |||||
| var params = new URLSearchParams(); | |||||
| Object.keys(data).forEach(function (k) { params.append(k, data[k]); }); | |||||
| fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: params.toString() }) | |||||
| .then(function (r) { return r.json(); }) | |||||
| .then(cb) | |||||
| .catch(function (e) { showError('Network error: ' + e); }); | |||||
| } | |||||
| function showError(msg) { | |||||
| errEl.textContent = msg; | |||||
| errEl.classList.remove('d-none'); | |||||
| } | |||||
| function clearError() { | |||||
| errEl.textContent = ''; | |||||
| errEl.classList.add('d-none'); | |||||
| } | |||||
| /* ── Open for create ──────────────────────────────────────── */ | |||||
| function openCreate(bId, colId, laneId) { | |||||
| titleEl.textContent = 'Add Card'; | |||||
| cardIdEl.value = ''; | |||||
| colIdEl.value = colId || ''; | |||||
| laneIdEl.value = laneId || ''; | |||||
| jobNumEl.value = ''; | |||||
| jobNameEl.value = ''; | |||||
| custNameEl.value = ''; | |||||
| delivDateEl.value = ''; | |||||
| qtyEl.value = ''; | |||||
| notesEl.value = ''; | |||||
| fullNoteEl.value = ''; | |||||
| btnDelete.classList.add('d-none'); | |||||
| clearError(); | |||||
| bsModal.show(); | |||||
| jobNumEl.focus(); | |||||
| } | |||||
| /* ── Open for edit ───────────────────────────────────────── */ | |||||
| function openEdit(id, colId, laneId, jobNum, jobName, custName, delivDate, qty, notes, fullNote) { | |||||
| titleEl.textContent = 'Edit Card'; | |||||
| cardIdEl.value = id; | |||||
| colIdEl.value = colId; | |||||
| laneIdEl.value = laneId; | |||||
| jobNumEl.value = jobNum || ''; | |||||
| jobNameEl.value = jobName || ''; | |||||
| custNameEl.value = custName || ''; | |||||
| delivDateEl.value = delivDate || ''; | |||||
| qtyEl.value = qty || ''; | |||||
| notesEl.value = notes || ''; | |||||
| fullNoteEl.value = fullNote || ''; | |||||
| btnDelete.classList.remove('d-none'); | |||||
| clearError(); | |||||
| bsModal.show(); | |||||
| jobNumEl.focus(); | |||||
| } | |||||
| /* ── Save ─────────────────────────────────────────────────── */ | |||||
| btnSave.addEventListener('click', function () { | |||||
| clearError(); | |||||
| var id = cardIdEl.value; | |||||
| var colId = colIdEl.value; | |||||
| var laneId = laneIdEl.value; | |||||
| var jNum = jobNumEl.value.trim(); | |||||
| var jName = jobNameEl.value.trim(); | |||||
| var cust = custNameEl.value.trim(); | |||||
| var dDate = delivDateEl.value; | |||||
| var qty = qtyEl.value.trim(); | |||||
| var notes = notesEl.value.trim(); | |||||
| var fullNote = fullNoteEl.value; | |||||
| if (!jNum && !jName) { | |||||
| showError('Enter at least a job number or job name.'); | |||||
| return; | |||||
| } | |||||
| if (id) { | |||||
| // Update existing | |||||
| post('/cards/' + id, { job_number: jNum, job_name: jName, customer_name: cust, delivery_date: dDate, quantity: qty, notes: notes, full_note: fullNote }, function (res) { | |||||
| if (res.ok) { | |||||
| bsModal.hide(); | |||||
| window.KanbanBoard.onCardUpdated(id, res); | |||||
| } else { | |||||
| showError(res.error || 'Save failed.'); | |||||
| } | |||||
| }); | |||||
| } else { | |||||
| // Create new — if no col/lane selected show column/lane picker | |||||
| if (!colId || !laneId) { | |||||
| showError('Please choose a column and swim lane first.'); | |||||
| return; | |||||
| } | |||||
| post('/cards', { | |||||
| board_id: boardId, | |||||
| column_id: colId, | |||||
| swim_lane_id: laneId, | |||||
| job_number: jNum, | |||||
| job_name: jName, | |||||
| customer_name: cust, | |||||
| delivery_date: dDate, | |||||
| quantity: qty, | |||||
| notes: notes, | |||||
| full_note: fullNote | |||||
| }, function (res) { | |||||
| if (res.ok) { | |||||
| bsModal.hide(); | |||||
| window.KanbanBoard.onCardCreated(res); | |||||
| } else { | |||||
| showError(res.error || 'Save failed.'); | |||||
| } | |||||
| }); | |||||
| } | |||||
| }); | |||||
| /* ── Delete ──────────────────────────────────────────────── */ | |||||
| btnDelete.addEventListener('click', function () { | |||||
| if (!confirm('Delete this card?')) return; | |||||
| var id = cardIdEl.value; | |||||
| post('/cards/' + id + '/delete', {}, function (res) { | |||||
| if (res.ok) { | |||||
| bsModal.hide(); | |||||
| window.KanbanBoard.onCardDeleted(id); | |||||
| } else { | |||||
| showError(res.error || 'Delete failed.'); | |||||
| } | |||||
| }); | |||||
| }); | |||||
| /* ── Column/Lane picker when Add Card clicked with no cell ── */ | |||||
| // Populated lazily from board data | |||||
| modal.addEventListener('shown.bs.modal', function () { | |||||
| if (!cardIdEl.value && (!colIdEl.value || !laneIdEl.value)) { | |||||
| injectPicker(); | |||||
| } | |||||
| }); | |||||
| function injectPicker() { | |||||
| if (document.getElementById('card-picker')) return; | |||||
| var picker = document.createElement('div'); | |||||
| picker.id = 'card-picker'; | |||||
| picker.className = 'row g-2 mb-3'; | |||||
| var colSel = '<select class="form-select form-select-sm" id="pick-col"><option value="">-- Column --</option>'; | |||||
| var laneSel = '<select class="form-select form-select-sm" id="pick-lane"><option value="">-- Swim Lane --</option>'; | |||||
| document.querySelectorAll('.kanban-col-header').forEach(function (el) { | |||||
| colSel += '<option value="' + el.dataset.colId + '">' + el.querySelector('.col-label').textContent + '</option>'; | |||||
| }); | |||||
| document.querySelectorAll('.kanban-lane-header').forEach(function (el) { | |||||
| laneSel += '<option value="' + el.dataset.laneId + '">' + el.querySelector('.lane-label').textContent + '</option>'; | |||||
| }); | |||||
| colSel += '</select>'; | |||||
| laneSel += '</select>'; | |||||
| picker.innerHTML = | |||||
| '<div class="col"><label class="form-label small">Column</label>' + colSel + '</div>' + | |||||
| '<div class="col"><label class="form-label small">Swim Lane</label>' + laneSel + '</div>'; | |||||
| var first = document.getElementById('card-job-number').closest('.mb-3'); | |||||
| modal.querySelector('.modal-body').insertBefore(picker, first); | |||||
| document.getElementById('pick-col').addEventListener('change', function () { | |||||
| colIdEl.value = this.value; | |||||
| }); | |||||
| document.getElementById('pick-lane').addEventListener('change', function () { | |||||
| laneIdEl.value = this.value; | |||||
| }); | |||||
| } | |||||
| /* ── Public API ──────────────────────────────────────────── */ | |||||
| window.KanbanModal = { openCreate: openCreate, openEdit: openEdit }; | |||||
| })(); | |||||
| @@ -0,0 +1,235 @@ | |||||
| /* kanban-settings.js — settings panel: add/rename/delete/reorder columns and lanes */ | |||||
| (function () { | |||||
| 'use strict'; | |||||
| var boardId = KANBAN.boardId; | |||||
| var panel = document.getElementById('settings-panel'); | |||||
| var overlay = document.getElementById('settings-overlay'); | |||||
| /* ── Panel open/close ────────────────────────────────────── */ | |||||
| document.getElementById('btn-settings').addEventListener('click', openPanel); | |||||
| document.getElementById('btn-close-settings').addEventListener('click', closePanel); | |||||
| overlay.addEventListener('click', closePanel); | |||||
| function openPanel() { | |||||
| panel.classList.add('open'); | |||||
| overlay.classList.remove('d-none'); | |||||
| } | |||||
| function closePanel() { | |||||
| panel.classList.remove('open'); | |||||
| overlay.classList.add('d-none'); | |||||
| } | |||||
| /* ── Helpers ─────────────────────────────────────────────── */ | |||||
| function post(url, data, cb) { | |||||
| var params = new URLSearchParams(); | |||||
| Object.keys(data).forEach(function (k) { params.append(k, data[k]); }); | |||||
| fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: params.toString() }) | |||||
| .then(function (r) { return r.json(); }) | |||||
| .then(cb) | |||||
| .catch(function (e) { console.error(url, e); }); | |||||
| } | |||||
| function postJson(url, payload, cb) { | |||||
| fetch(url, { | |||||
| method: 'POST', | |||||
| headers: { 'Content-Type': 'application/json' }, | |||||
| body: JSON.stringify(payload) | |||||
| }).then(function (r) { return r.json(); }).then(cb) | |||||
| .catch(function (e) { console.error(url, e); }); | |||||
| } | |||||
| function collectOrder(listId) { | |||||
| return Array.from(document.querySelectorAll('#' + listId + ' li')).map(function (li, idx) { | |||||
| return { id: parseInt(li.dataset.id), position: idx }; | |||||
| }); | |||||
| } | |||||
| function buildListItem(id, name, editClass, deleteClass, labelClass) { | |||||
| var li = document.createElement('li'); | |||||
| li.className = 'list-group-item d-flex align-items-center gap-2 py-2'; | |||||
| li.dataset.id = id; | |||||
| li.innerHTML = | |||||
| '<i class="bi bi-grip-vertical text-muted drag-handle" style="cursor:grab;"></i>' + | |||||
| '<span class="flex-grow-1 ' + labelClass + '">' + esc(name) + '</span>' + | |||||
| '<button class="btn btn-sm btn-link p-0 text-secondary ' + editClass + '" title="Rename"><i class="bi bi-pencil"></i></button>' + | |||||
| '<button class="btn btn-sm btn-link p-0 text-danger ' + deleteClass + '" title="Delete"><i class="bi bi-trash"></i></button>'; | |||||
| return li; | |||||
| } | |||||
| function esc(s) { | |||||
| return String(s) | |||||
| .replace(/&/g, '&').replace(/</g, '<') | |||||
| .replace(/>/g, '>').replace(/"/g, '"'); | |||||
| } | |||||
| /* ── Sortable reorder ─────────────────────────────────────── */ | |||||
| function initSortable(listId, reorderUrl) { | |||||
| var el = document.getElementById(listId); | |||||
| Sortable.create(el, { | |||||
| handle: '.drag-handle', | |||||
| animation: 150, | |||||
| onEnd: function () { | |||||
| postJson(reorderUrl, collectOrder(listId), function (res) { | |||||
| if (!res.ok) console.error('Reorder failed', res); | |||||
| }); | |||||
| } | |||||
| }); | |||||
| } | |||||
| initSortable('col-list', '/columns/reorder'); | |||||
| initSortable('lane-list', '/swimlanes/reorder'); | |||||
| /* ═══════════════════════════════════════════════════════════ | |||||
| COLUMNS | |||||
| ═══════════════════════════════════════════════════════════ */ | |||||
| /* ── Add column ───────────────────────────────────────────── */ | |||||
| document.getElementById('btn-add-column').addEventListener('click', function () { | |||||
| document.getElementById('col-add-form').classList.remove('d-none'); | |||||
| document.getElementById('col-add-input').focus(); | |||||
| }); | |||||
| document.getElementById('btn-col-add-cancel').addEventListener('click', function () { | |||||
| document.getElementById('col-add-form').classList.add('d-none'); | |||||
| document.getElementById('col-add-input').value = ''; | |||||
| }); | |||||
| document.getElementById('btn-col-add-save').addEventListener('click', function () { | |||||
| var name = document.getElementById('col-add-input').value.trim(); | |||||
| if (!name) return; | |||||
| post('/columns', { board_id: boardId, name: name }, function (res) { | |||||
| if (!res.ok) { alert(res.error || 'Failed'); return; } | |||||
| document.getElementById('col-add-form').classList.add('d-none'); | |||||
| document.getElementById('col-add-input').value = ''; | |||||
| var li = buildListItem(res.id, res.name, 'btn-edit-col', 'btn-delete-col', 'col-label-text'); | |||||
| document.getElementById('col-list').appendChild(li); | |||||
| bindColItem(li); | |||||
| window.KanbanBoard.addColumn(res); | |||||
| }); | |||||
| }); | |||||
| /* ── Bind edit/delete on existing column items ─────────────── */ | |||||
| function bindColItem(li) { | |||||
| li.querySelector('.btn-edit-col').addEventListener('click', function () { | |||||
| startRename(li, '.col-label-text', function (newName, done) { | |||||
| post('/columns/' + li.dataset.id, { name: newName }, function (res) { | |||||
| if (res.ok) { | |||||
| done(true); | |||||
| window.KanbanBoard.renameColumn(li.dataset.id, newName); | |||||
| } else { | |||||
| done(false); | |||||
| alert(res.error || 'Rename failed'); | |||||
| } | |||||
| }); | |||||
| }); | |||||
| }); | |||||
| li.querySelector('.btn-delete-col').addEventListener('click', function () { | |||||
| if (!confirm('Delete this column and all its cards?')) return; | |||||
| post('/columns/' + li.dataset.id + '/delete', {}, function (res) { | |||||
| if (res.ok) { | |||||
| window.KanbanBoard.removeColumn(li.dataset.id); | |||||
| li.remove(); | |||||
| } else { | |||||
| alert(res.error || 'Delete failed'); | |||||
| } | |||||
| }); | |||||
| }); | |||||
| } | |||||
| document.querySelectorAll('#col-list li').forEach(bindColItem); | |||||
| /* ═══════════════════════════════════════════════════════════ | |||||
| SWIM LANES | |||||
| ═══════════════════════════════════════════════════════════ */ | |||||
| /* ── Add lane ─────────────────────────────────────────────── */ | |||||
| document.getElementById('btn-add-lane').addEventListener('click', function () { | |||||
| document.getElementById('lane-add-form').classList.remove('d-none'); | |||||
| document.getElementById('lane-add-input').focus(); | |||||
| }); | |||||
| document.getElementById('btn-lane-add-cancel').addEventListener('click', function () { | |||||
| document.getElementById('lane-add-form').classList.add('d-none'); | |||||
| document.getElementById('lane-add-input').value = ''; | |||||
| }); | |||||
| document.getElementById('btn-lane-add-save').addEventListener('click', function () { | |||||
| var name = document.getElementById('lane-add-input').value.trim(); | |||||
| if (!name) return; | |||||
| post('/swimlanes', { board_id: boardId, name: name }, function (res) { | |||||
| if (!res.ok) { alert(res.error || 'Failed'); return; } | |||||
| document.getElementById('lane-add-form').classList.add('d-none'); | |||||
| document.getElementById('lane-add-input').value = ''; | |||||
| var li = buildListItem(res.id, res.name, 'btn-edit-lane', 'btn-delete-lane', 'lane-label-text'); | |||||
| document.getElementById('lane-list').appendChild(li); | |||||
| bindLaneItem(li); | |||||
| window.KanbanBoard.addLane(res); | |||||
| }); | |||||
| }); | |||||
| /* ── Bind edit/delete on existing lane items ──────────────── */ | |||||
| function bindLaneItem(li) { | |||||
| li.querySelector('.btn-edit-lane').addEventListener('click', function () { | |||||
| startRename(li, '.lane-label-text', function (newName, done) { | |||||
| post('/swimlanes/' + li.dataset.id, { name: newName }, function (res) { | |||||
| if (res.ok) { | |||||
| done(true); | |||||
| window.KanbanBoard.renameLane(li.dataset.id, newName); | |||||
| } else { | |||||
| done(false); | |||||
| alert(res.error || 'Rename failed'); | |||||
| } | |||||
| }); | |||||
| }); | |||||
| }); | |||||
| li.querySelector('.btn-delete-lane').addEventListener('click', function () { | |||||
| if (!confirm('Delete this swim lane and all its cards?')) return; | |||||
| post('/swimlanes/' + li.dataset.id + '/delete', {}, function (res) { | |||||
| if (res.ok) { | |||||
| window.KanbanBoard.removeLane(li.dataset.id); | |||||
| li.remove(); | |||||
| } else { | |||||
| alert(res.error || 'Delete failed'); | |||||
| } | |||||
| }); | |||||
| }); | |||||
| } | |||||
| document.querySelectorAll('#lane-list li').forEach(bindLaneItem); | |||||
| /* ── Inline rename helper ─────────────────────────────────── */ | |||||
| function startRename(li, labelSel, saveCb) { | |||||
| var span = li.querySelector(labelSel); | |||||
| var oldName = span.textContent.trim(); | |||||
| var input = document.createElement('input'); | |||||
| input.type = 'text'; | |||||
| input.className = 'form-control form-control-sm inline-rename flex-grow-1'; | |||||
| input.value = oldName; | |||||
| span.replaceWith(input); | |||||
| input.focus(); | |||||
| input.select(); | |||||
| function commit() { | |||||
| var newName = input.value.trim(); | |||||
| if (!newName || newName === oldName) { | |||||
| abort(); | |||||
| return; | |||||
| } | |||||
| saveCb(newName, function (ok) { | |||||
| var replacement = document.createElement('span'); | |||||
| replacement.className = labelSel.replace('.', '') + ' flex-grow-1'; | |||||
| replacement.textContent = ok ? newName : oldName; | |||||
| input.replaceWith(replacement); | |||||
| }); | |||||
| } | |||||
| function abort() { | |||||
| var replacement = document.createElement('span'); | |||||
| replacement.className = labelSel.replace('.', '') + ' flex-grow-1'; | |||||
| replacement.textContent = oldName; | |||||
| input.replaceWith(replacement); | |||||
| } | |||||
| input.addEventListener('blur', commit); | |||||
| input.addEventListener('keydown', function (e) { | |||||
| if (e.key === 'Enter') { e.preventDefault(); commit(); } | |||||
| if (e.key === 'Escape') { e.preventDefault(); abort(); } | |||||
| }); | |||||
| } | |||||
| })(); | |||||
| @@ -0,0 +1,45 @@ | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| use App\Controllers\AuthController; | |||||
| use App\Controllers\BoardsController; | |||||
| use App\Controllers\CardsController; | |||||
| use App\Controllers\ColumnsController; | |||||
| use App\Controllers\HomeController; | |||||
| use App\Controllers\SwimLanesController; | |||||
| // Home — redirects to /boards | |||||
| $router->get('/', [HomeController::class, 'index']); | |||||
| // Boards | |||||
| $router->get('/boards', [BoardsController::class, 'index']); | |||||
| $router->get('/boards/create', [BoardsController::class, 'create']); | |||||
| $router->post('/boards', [BoardsController::class, 'store']); | |||||
| $router->get('/board/{slug}', [BoardsController::class, 'show']); | |||||
| $router->get('/board/{slug}/edit', [BoardsController::class, 'edit']); | |||||
| $router->post('/board/{slug}/update', [BoardsController::class, 'update']); | |||||
| $router->post('/board/{slug}/delete', [BoardsController::class, 'destroy']); | |||||
| // Cards (JSON API) | |||||
| $router->post('/cards', [CardsController::class, 'store']); | |||||
| $router->post('/cards/{id}/move', [CardsController::class, 'move']); | |||||
| $router->post('/cards/{id}/delete', [CardsController::class, 'destroy']); | |||||
| $router->post('/cards/{id}', [CardsController::class, 'update']); | |||||
| // Columns (JSON API) — /columns/reorder MUST be before /columns/{id} | |||||
| $router->post('/columns/reorder', [ColumnsController::class, 'reorder']); | |||||
| $router->post('/columns/{id}/delete', [ColumnsController::class, 'destroy']); | |||||
| $router->post('/columns/{id}', [ColumnsController::class, 'update']); | |||||
| $router->post('/columns', [ColumnsController::class, 'store']); | |||||
| // Swim lanes (JSON API) — /swimlanes/reorder MUST be before /swimlanes/{id} | |||||
| $router->post('/swimlanes/reorder', [SwimLanesController::class, 'reorder']); | |||||
| $router->post('/swimlanes/{id}/delete', [SwimLanesController::class, 'destroy']); | |||||
| $router->post('/swimlanes/{id}', [SwimLanesController::class, 'update']); | |||||
| $router->post('/swimlanes', [SwimLanesController::class, 'store']); | |||||
| // Auth (Keycloak SSO) | |||||
| $router->get('/auth/login', [AuthController::class, 'login']); | |||||
| $router->get('/auth/callback', [AuthController::class, 'callback']); | |||||
| $router->get('/auth/logout', [AuthController::class, 'logout']); | |||||
| @@ -0,0 +1,18 @@ | |||||
| # Scripts | |||||
| This directory holds project PHP scripts that are meant to be run from the command line. | |||||
| Examples: | |||||
| ```bash | |||||
| php scripts/migrate.php up | |||||
| php scripts/migrate.php status | |||||
| php scripts/migrate.php fresh --seed | |||||
| php scripts/seed_employees.php 1000 | |||||
| ``` | |||||
| Guidelines: | |||||
| - Put CLI-only PHP entrypoints here. | |||||
| - Keep reusable logic in `core/`, `app/`, or `database/`. | |||||
| - Let scripts stay thin and call into application classes or helper functions. | |||||
| @@ -0,0 +1,105 @@ | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| require_once __DIR__ . '/../vendor/autoload.php'; | |||||
| $command = $argv[1] ?? 'help'; | |||||
| $options = array_slice($argv, 2); | |||||
| $manager = migration_manager(); | |||||
| try { | |||||
| switch ($command) { | |||||
| case 'up': | |||||
| $ran = $manager->runPending(); | |||||
| if ($ran === []) { | |||||
| echo "No pending migrations." . PHP_EOL; | |||||
| exit(0); | |||||
| } | |||||
| foreach ($ran as $migration) { | |||||
| echo "Migrated: {$migration}" . PHP_EOL; | |||||
| } | |||||
| echo 'Applied ' . count($ran) . ' migration(s).' . PHP_EOL; | |||||
| exit(0); | |||||
| case 'down': | |||||
| $steps = isset($argv[2]) ? max(1, (int) $argv[2]) : 1; | |||||
| $rolledBack = $manager->rollback($steps); | |||||
| if ($rolledBack === []) { | |||||
| echo "No applied migrations to roll back." . PHP_EOL; | |||||
| exit(0); | |||||
| } | |||||
| foreach ($rolledBack as $migration) { | |||||
| echo "Rolled back: {$migration}" . PHP_EOL; | |||||
| } | |||||
| echo 'Rolled back ' . count($rolledBack) . ' migration(s).' . PHP_EOL; | |||||
| exit(0); | |||||
| case 'status': | |||||
| $status = $manager->status(); | |||||
| if ($status === []) { | |||||
| echo "No migration files found." . PHP_EOL; | |||||
| exit(0); | |||||
| } | |||||
| foreach ($status as $row) { | |||||
| $state = $row['ran'] ? 'up' : 'pending'; | |||||
| $ranAt = $row['ran_at'] ?? '-'; | |||||
| echo str_pad($state, 10) . ' ' . $row['migration'] . ' ' . $ranAt . PHP_EOL; | |||||
| } | |||||
| exit(0); | |||||
| case 'make': | |||||
| case 'create': | |||||
| $name = $argv[2] ?? ''; | |||||
| if ($name === '') { | |||||
| throw new InvalidArgumentException('Provide a migration name. Example: php scripts/migrate.php make create_projects_table'); | |||||
| } | |||||
| $path = $manager->make($name); | |||||
| echo "Created migration: {$path}" . PHP_EOL; | |||||
| exit(0); | |||||
| case 'fresh': | |||||
| $result = $manager->fresh(); | |||||
| foreach ($result['rolled_back'] as $migration) { | |||||
| echo "Rolled back: {$migration}" . PHP_EOL; | |||||
| } | |||||
| foreach ($result['migrated'] as $migration) { | |||||
| echo "Migrated: {$migration}" . PHP_EOL; | |||||
| } | |||||
| if (in_array('--seed', $options, true)) { | |||||
| require __DIR__ . '/../database/seed_employees.php'; | |||||
| seed_employees(1000, true); | |||||
| } | |||||
| echo "Fresh migration run complete." . PHP_EOL; | |||||
| exit(0); | |||||
| case 'help': | |||||
| default: | |||||
| echo "Migration CLI" . PHP_EOL; | |||||
| echo "Usage:" . PHP_EOL; | |||||
| echo " php scripts/migrate.php up" . PHP_EOL; | |||||
| echo " php scripts/migrate.php down [steps]" . PHP_EOL; | |||||
| echo " php scripts/migrate.php status" . PHP_EOL; | |||||
| echo " php scripts/migrate.php make <name>" . PHP_EOL; | |||||
| echo " php scripts/migrate.php fresh [--seed]" . PHP_EOL; | |||||
| exit(0); | |||||
| } | |||||
| } catch (Throwable $exception) { | |||||
| fwrite(STDERR, $exception->getMessage() . PHP_EOL); | |||||
| exit(1); | |||||
| } | |||||
| @@ -0,0 +1,8 @@ | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| require_once __DIR__ . '/../database/seed_employees.php'; | |||||
| $targetTotal = isset($argv[1]) ? max(1, (int) $argv[1]) : 1000; | |||||
| seed_employees($targetTotal); | |||||
| @@ -0,0 +1,145 @@ | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| require_once __DIR__ . '/../vendor/autoload.php'; | |||||
| use Core\App; | |||||
| use Core\Database; | |||||
| use Core\Dispatcher; | |||||
| use Core\MigrationManager; | |||||
| use Core\Request; | |||||
| use Core\Router; | |||||
| $tempMigrationPath = sys_get_temp_dir() . '/mvc_migrations_' . uniqid('', true); | |||||
| mkdir($tempMigrationPath, 0777, true); | |||||
| $migrationFile = $tempMigrationPath . '/20260509_120000_create_projects_table.php'; | |||||
| file_put_contents($migrationFile, <<<'PHP' | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| use Core\Database; | |||||
| use Core\Migration; | |||||
| return new class extends Migration | |||||
| { | |||||
| public function up(Database $database): void | |||||
| { | |||||
| $database->execute('CREATE TABLE projects (id INTEGER PRIMARY KEY AUTOINCREMENT, name VARCHAR(100) NOT NULL)'); | |||||
| } | |||||
| public function down(Database $database): void | |||||
| { | |||||
| $database->execute('DROP TABLE IF EXISTS projects'); | |||||
| } | |||||
| }; | |||||
| PHP | |||||
| ); | |||||
| $memoryDatabase = new Database([ | |||||
| 'dsn' => 'sqlite::memory:', | |||||
| 'options' => [ | |||||
| PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, | |||||
| PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, | |||||
| ], | |||||
| ]); | |||||
| $migrationManager = new MigrationManager($memoryDatabase, $tempMigrationPath); | |||||
| $ran = $migrationManager->runPending(); | |||||
| if ($ran !== ['20260509_120000_create_projects_table.php']) { | |||||
| echo "FAIL: migration manager did not apply the expected migration\n"; | |||||
| exit(1); | |||||
| } | |||||
| $projectTable = $memoryDatabase->first("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'projects'"); | |||||
| if ($projectTable === null) { | |||||
| echo "FAIL: migration up() did not create the projects table\n"; | |||||
| exit(1); | |||||
| } | |||||
| $rolledBack = $migrationManager->rollback(); | |||||
| if ($rolledBack !== ['20260509_120000_create_projects_table.php']) { | |||||
| echo "FAIL: migration manager did not roll back the expected migration\n"; | |||||
| exit(1); | |||||
| } | |||||
| $projectTableAfterRollback = $memoryDatabase->first("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'projects'"); | |||||
| if ($projectTableAfterRollback !== null) { | |||||
| echo "FAIL: migration down() did not remove the projects table\n"; | |||||
| exit(1); | |||||
| } | |||||
| $createdMigrationPath = $migrationManager->make('create_tasks_table'); | |||||
| if (!file_exists($createdMigrationPath)) { | |||||
| echo "FAIL: migration manager did not create a migration file\n"; | |||||
| exit(1); | |||||
| } | |||||
| $router = new Router(); | |||||
| $app = new App(); | |||||
| (new MigrationManager(database(), __DIR__ . '/../database/migrations'))->runPending(); | |||||
| require_once __DIR__ . '/../routes/web.php'; | |||||
| $router->get('/hello/{name}', function (string $name) { | |||||
| return 'Hello, ' . $name; | |||||
| }); | |||||
| $request = new Request([], [], [ | |||||
| 'REQUEST_METHOD' => 'GET', | |||||
| 'REQUEST_URI' => '/hello/Daniel', | |||||
| ]); | |||||
| $response = (new Dispatcher($router, $app))->dispatch($request); | |||||
| if ($response->status() !== 200) { | |||||
| echo "FAIL: expected status 200\n"; | |||||
| exit(1); | |||||
| } | |||||
| if ($response->content() !== 'Hello, Daniel') { | |||||
| echo "FAIL: unexpected response content\n"; | |||||
| exit(1); | |||||
| } | |||||
| $employeePage = (new Dispatcher($router, $app))->dispatch(new Request([], [], [ | |||||
| 'REQUEST_METHOD' => 'GET', | |||||
| 'REQUEST_URI' => '/employees', | |||||
| ])); | |||||
| if ($employeePage->status() !== 200) { | |||||
| echo "FAIL: expected employee page status 200\n"; | |||||
| exit(1); | |||||
| } | |||||
| if (strpos($employeePage->content(), 'Add Employee') === false) { | |||||
| echo "FAIL: employee page did not render form content\n"; | |||||
| exit(1); | |||||
| } | |||||
| $employeeData = (new Dispatcher($router, $app))->dispatch(new Request([ | |||||
| 'search' => '', | |||||
| ], [], [ | |||||
| 'REQUEST_METHOD' => 'GET', | |||||
| 'REQUEST_URI' => '/employees/data', | |||||
| ])); | |||||
| if ($employeeData->status() !== 200) { | |||||
| echo "FAIL: expected employee data status 200\n"; | |||||
| exit(1); | |||||
| } | |||||
| if (strpos($employeeData->content(), '[') === false) { | |||||
| echo "FAIL: employee data endpoint did not return JSON array content\n"; | |||||
| exit(1); | |||||
| } | |||||
| echo "PASS: migration manager and route dispatch work\n"; | |||||
Powered by TurnKey Linux.