소스 검색

init

master
Daniel Covington 1 개월 전
커밋
7fb48edcf1
82개의 변경된 파일9037개의 추가작업 그리고 0개의 파일을 삭제
  1. +333
    -0
      .ai/SKILLS.md
  2. +197
    -0
      .ai/skills/database/SKILL.md
  3. +321
    -0
      .ai/skills/mvc/SKILL.md
  4. +331
    -0
      .ai/skills/php/SKILL.md
  5. +297
    -0
      .ai/skills/security/SKILL.md
  6. +162
    -0
      .ai/skills/testing/SKILL.md
  7. +126
    -0
      .ai/skills/workflow/SKILL.md
  8. +29
    -0
      .claude/settings.local.json
  9. +4
    -0
      .dockerignore
  10. +19
    -0
      .gitignore
  11. +242
    -0
      AGENTS.md
  12. +1
    -0
      CLAUDE.md
  13. +33
    -0
      Dockerfile
  14. +80
    -0
      app/Controllers/AuthController.php
  15. +204
    -0
      app/Controllers/BoardsController.php
  16. +132
    -0
      app/Controllers/CardsController.php
  17. +120
    -0
      app/Controllers/ColumnsController.php
  18. +15
    -0
      app/Controllers/HomeController.php
  19. +120
    -0
      app/Controllers/SwimLanesController.php
  20. +34
    -0
      app/Models/Board.php
  21. +32
    -0
      app/Models/BoardColumn.php
  22. +65
    -0
      app/Models/Card.php
  23. +32
    -0
      app/Models/SwimLane.php
  24. +77
    -0
      app/Repositories/BoardColumnRepository.php
  25. +105
    -0
      app/Repositories/BoardRepository.php
  26. +131
    -0
      app/Repositories/CardRepository.php
  27. +77
    -0
      app/Repositories/SwimLaneRepository.php
  28. +121
    -0
      app/Services/AuthService.php
  29. +5
    -0
      app/Views/auth/callback-error.php
  30. +39
    -0
      app/Views/boards/create.php
  31. +47
    -0
      app/Views/boards/edit.php
  32. +33
    -0
      app/Views/boards/index.php
  33. +99
    -0
      app/Views/boards/show.php
  34. +39
    -0
      app/Views/home/index.php
  35. +14
    -0
      app/Views/layouts/app.php
  36. +54
    -0
      app/Views/partials/card-modal.php
  37. +9
    -0
      app/Views/partials/footer.php
  38. +43
    -0
      app/Views/partials/header.php
  39. +79
    -0
      app/Views/partials/settings-panel.php
  40. +24
    -0
      composer.json
  41. +812
    -0
      composer.lock
  42. +14
    -0
      config/auth.php
  43. +11
    -0
      config/database.php
  44. +6
    -0
      config/view.php
  45. +154
    -0
      core/App.php
  46. +37
    -0
      core/Controller.php
  47. +69
    -0
      core/Database.php
  48. +61
    -0
      core/Dispatcher.php
  49. +12
    -0
      core/Migration.php
  50. +233
    -0
      core/MigrationManager.php
  51. +38
    -0
      core/Repository.php
  52. +80
    -0
      core/Request.php
  53. +64
    -0
      core/Response.php
  54. +52
    -0
      core/Route.php
  55. +54
    -0
      core/Router.php
  56. +114
    -0
      core/Validator.php
  57. +79
    -0
      core/View.php
  58. +134
    -0
      core/helpers.php
  59. +30
    -0
      database/migrations/20260509_000001_create_employees_table.php
  60. +33
    -0
      database/migrations/20260522_000001_create_boards_table.php
  61. +33
    -0
      database/migrations/20260522_000002_create_board_columns_table.php
  62. +33
    -0
      database/migrations/20260522_000003_create_swim_lanes_table.php
  63. +45
    -0
      database/migrations/20260522_000004_create_cards_table.php
  64. +107
    -0
      database/seed_employees.php
  65. +8
    -0
      docker-compose.yml
  66. +17
    -0
      docker/entrypoint.sh
  67. +13
    -0
      docker/vhost.conf
  68. +78
    -0
      docs/README.md
  69. +80
    -0
      docs/REQUEST_FLOW.md
  70. +5
    -0
      public/.htaccess
  71. +507
    -0
      public/css/kanban.css
  72. +791
    -0
      public/css/site.css
  73. +45
    -0
      public/index.php
  74. +65
    -0
      public/js/app.js
  75. +442
    -0
      public/js/kanban-board.js
  76. +200
    -0
      public/js/kanban-modal.js
  77. +235
    -0
      public/js/kanban-settings.js
  78. +45
    -0
      routes/web.php
  79. +18
    -0
      scripts/README.md
  80. +105
    -0
      scripts/migrate.php
  81. +8
    -0
      scripts/seed_employees.php
  82. +145
    -0
      tests/run.php

+ 333
- 0
.ai/SKILLS.md 파일 보기

@@ -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.

+ 197
- 0
.ai/skills/database/SKILL.md 파일 보기

@@ -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
```

+ 321
- 0
.ai/skills/mvc/SKILL.md 파일 보기

@@ -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.

+ 331
- 0
.ai/skills/php/SKILL.md 파일 보기

@@ -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.

+ 297
- 0
.ai/skills/security/SKILL.md 파일 보기

@@ -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`.

+ 162
- 0
.ai/skills/testing/SKILL.md 파일 보기

@@ -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.

+ 126
- 0
.ai/skills/workflow/SKILL.md 파일 보기

@@ -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

+ 29
- 0
.claude/settings.local.json 파일 보기

@@ -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)"
]
}
}

+ 4
- 0
.dockerignore 파일 보기

@@ -0,0 +1,4 @@
.git
.ai
vendor
database/app.sqlite

+ 19
- 0
.gitignore 파일 보기

@@ -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


+ 242
- 0
AGENTS.md 파일 보기

@@ -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: ...
```

+ 1
- 0
CLAUDE.md 파일 보기

@@ -0,0 +1 @@
AGENTS.md

+ 33
- 0
Dockerfile 파일 보기

@@ -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"]

+ 80
- 0
app/Controllers/AuthController.php 파일 보기

@@ -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());
}
}

+ 204
- 0
app/Controllers/BoardsController.php 파일 보기

@@ -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, '-');
}
}

+ 132
- 0
app/Controllers/CardsController.php 파일 보기

@@ -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]);
}
}

+ 120
- 0
app/Controllers/ColumnsController.php 파일 보기

@@ -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]);
}
}

+ 15
- 0
app/Controllers/HomeController.php 파일 보기

@@ -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');
}
}

+ 120
- 0
app/Controllers/SwimLanesController.php 파일 보기

@@ -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]);
}
}

+ 34
- 0
app/Models/Board.php 파일 보기

@@ -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;
}
}

+ 32
- 0
app/Models/BoardColumn.php 파일 보기

@@ -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;
}
}

+ 65
- 0
app/Models/Card.php 파일 보기

@@ -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,
];
}
}

+ 32
- 0
app/Models/SwimLane.php 파일 보기

@@ -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;
}
}

+ 77
- 0
app/Repositories/BoardColumnRepository.php 파일 보기

@@ -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]);
}
}

+ 105
- 0
app/Repositories/BoardRepository.php 파일 보기

@@ -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,
]
);
}
}

+ 131
- 0
app/Repositories/CardRepository.php 파일 보기

@@ -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]);
}
}

+ 77
- 0
app/Repositories/SwimLaneRepository.php 파일 보기

@@ -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]);
}
}

+ 121
- 0
app/Services/AuthService.php 파일 보기

@@ -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}";
}
}

+ 5
- 0
app/Views/auth/callback-error.php 파일 보기

@@ -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>

+ 39
- 0
app/Views/boards/create.php 파일 보기

@@ -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>

+ 47
- 0
app/Views/boards/edit.php 파일 보기

@@ -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>

+ 33
- 0
app/Views/boards/index.php 파일 보기

@@ -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; ?>

+ 99
- 0
app/Views/boards/show.php 파일 보기

@@ -0,0 +1,99 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title><?= e($board->name) ?> &mdash; 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>

+ 39
- 0
app/Views/home/index.php 파일 보기

@@ -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>

+ 14
- 0
app/Views/layouts/app.php 파일 보기

@@ -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'; ?>

+ 54
- 0
app/Views/partials/card-modal.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>

+ 9
- 0
app/Views/partials/footer.php 파일 보기

@@ -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>&copy; <?= e((string) date('Y')) ?> MindVisionCode</p>
</div>
</footer>
</div>
</body>
</html>

+ 43
- 0
app/Views/partials/header.php 파일 보기

@@ -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>

+ 79
- 0
app/Views/partials/settings-panel.php 파일 보기

@@ -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>

+ 24
- 0
composer.json 파일 보기

@@ -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"
}
}

+ 812
- 0
composer.lock 파일 보기

@@ -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"
}

+ 14
- 0
config/auth.php 파일 보기

@@ -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',
],
];

+ 11
- 0
config/database.php 파일 보기

@@ -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,
],
];

+ 6
- 0
config/view.php 파일 보기

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

return [
'views_path' => __DIR__ . '/../app/Views',
'layout_path' => __DIR__ . '/../app/Views/layouts/app.php',
];

+ 154
- 0
core/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);
}
}

+ 37
- 0
core/Controller.php 파일 보기

@@ -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;
}
}

+ 69
- 0
core/Database.php 파일 보기

@@ -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;
}
}
}

+ 61
- 0
core/Dispatcher.php 파일 보기

@@ -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);
}
}

+ 12
- 0
core/Migration.php 파일 보기

@@ -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;
}

+ 233
- 0
core/MigrationManager.php 파일 보기

@@ -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.');
}
}

+ 38
- 0
core/Repository.php 파일 보기

@@ -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]
);
}
}

+ 80
- 0
core/Request.php 파일 보기

@@ -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);
}
}

+ 64
- 0
core/Response.php 파일 보기

@@ -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;
}
}

+ 52
- 0
core/Route.php 파일 보기

@@ -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);
}
}

+ 54
- 0
core/Router.php 파일 보기

@@ -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;
}
}

+ 114
- 0
core/Validator.php 파일 보기

@@ -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;
}
}

+ 79
- 0
core/View.php 파일 보기

@@ -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';
}
}

+ 134
- 0
core/helpers.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);
}

+ 30
- 0
database/migrations/20260509_000001_create_employees_table.php 파일 보기

@@ -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');
}
};

+ 33
- 0
database/migrations/20260522_000001_create_boards_table.php 파일 보기

@@ -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');
}
};

+ 33
- 0
database/migrations/20260522_000002_create_board_columns_table.php 파일 보기

@@ -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');
}
};

+ 33
- 0
database/migrations/20260522_000003_create_swim_lanes_table.php 파일 보기

@@ -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');
}
};

+ 45
- 0
database/migrations/20260522_000004_create_cards_table.php 파일 보기

@@ -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');
}
};

+ 107
- 0
database/seed_employees.php 파일 보기

@@ -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);
}

+ 8
- 0
docker-compose.yml 파일 보기

@@ -0,0 +1,8 @@
services:
app:
build: .
ports:
- "8080:80"
volumes:
- .:/var/www/html
env_file: .env

+ 17
- 0
docker/entrypoint.sh 파일 보기

@@ -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

+ 13
- 0
docker/vhost.conf 파일 보기

@@ -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>

+ 78
- 0
docs/README.md 파일 보기

@@ -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.

+ 80
- 0
docs/REQUEST_FLOW.md 파일 보기

@@ -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

+ 5
- 0
public/.htaccess 파일 보기

@@ -0,0 +1,5 @@
RewriteEngine On

RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^ index.php [QSA,L]

+ 507
- 0
public/css/kanban.css 파일 보기

@@ -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;
}
}

+ 791
- 0
public/css/site.css 파일 보기

@@ -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;
}
}

+ 45
- 0
public/index.php 파일 보기

@@ -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();

+ 65
- 0
public/js/app.js 파일 보기

@@ -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,
});
},
};
};

+ 442
- 0
public/js/kanban-board.js 파일 보기

@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}

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();
})();

+ 200
- 0
public/js/kanban-modal.js 파일 보기

@@ -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 };

})();

+ 235
- 0
public/js/kanban-settings.js 파일 보기

@@ -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, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}

/* ── 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(); }
});
}

})();

+ 45
- 0
routes/web.php 파일 보기

@@ -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']);

+ 18
- 0
scripts/README.md 파일 보기

@@ -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.

+ 105
- 0
scripts/migrate.php 파일 보기

@@ -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);
}

+ 8
- 0
scripts/seed_employees.php 파일 보기

@@ -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);

+ 145
- 0
tests/run.php 파일 보기

@@ -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.