Преглед изворни кода

Build Project Compass project management app

master
Daniel Covington пре 2 недеља
родитељ
комит
06cbadef37
44 измењених фајлова са 2281 додато и 1770 уклоњено
  1. +42
    -0
      README.md
  2. +0
    -208
      app/Controllers/EmployeeController.php
  3. +20
    -11
      app/Controllers/HomeController.php
  4. +209
    -0
      app/Controllers/ProjectController.php
  5. +160
    -0
      app/Controllers/TaskController.php
  6. +0
    -16
      app/Models/Employee.php
  7. +0
    -12
      app/Models/User.php
  8. +64
    -0
      app/Repositories/ActivityRepository.php
  9. +0
    -122
      app/Repositories/EmployeeRepository.php
  10. +383
    -0
      app/Repositories/ProjectRepository.php
  11. +153
    -0
      app/Repositories/TaskRepository.php
  12. +0
    -21
      app/Repositories/UserRepository.php
  13. +0
    -50
      app/ViewModels/EmployeeFormViewModel.php
  14. +0
    -13
      app/ViewModels/HomeIndexViewModel.php
  15. +0
    -54
      app/Views/employees/create.php
  16. +0
    -83
      app/Views/employees/partials/form.php
  17. +0
    -34
      app/Views/employees/partials/summary.php
  18. +52
    -0
      app/Views/home/activity.php
  19. +122
    -26
      app/Views/home/index.php
  20. +1
    -1
      app/Views/layouts/app.php
  21. +2
    -2
      app/Views/partials/footer.php
  22. +11
    -15
      app/Views/partials/header.php
  23. +88
    -0
      app/Views/projects/create.php
  24. +66
    -0
      app/Views/projects/index.php
  25. +220
    -0
      app/Views/projects/show.php
  26. +27
    -0
      autoload.php
  27. +4
    -2
      composer.json
  28. +1
    -1
      core/View.php
  29. +108
    -5
      core/helpers.php
  30. +0
    -30
      database/migrations/20260509_000001_create_employees_table.php
  31. +93
    -0
      database/migrations/20260509_000001_create_project_management_tables.php
  32. +0
    -107
      database/seed_employees.php
  33. +138
    -0
      database/seed_projects.php
  34. +8
    -29
      docs/README.md
  35. +169
    -764
      public/css/site.css
  36. +1
    -1
      public/index.php
  37. +9
    -56
      public/js/app.js
  38. +24
    -0
      public/web.config
  39. +10
    -7
      routes/web.php
  40. +1
    -1
      scripts/README.md
  41. +4
    -4
      scripts/migrate.php
  42. +0
    -8
      scripts/seed_employees.php
  43. +11
    -0
      scripts/seed_projects.php
  44. +80
    -87
      tests/run.php

+ 42
- 0
README.md Прегледај датотеку

@@ -0,0 +1,42 @@
# Project Compass

Project Compass is a project-management app built on a small PHP MVC framework.

## Features

- portfolio dashboard
- project cards with progress, budget, and risk status
- Kanban-style project boards
- task creation and status changes
- activity feed
- SQLite storage with migrations and seed data

## Run locally

```bash
php scripts/migrate.php up
php scripts/seed_projects.php --reset
php -S localhost:8000 -t public
```

Then open:

```text
http://localhost:8000/
```

## Useful routes

- `/` — dashboard
- `/projects` — project index
- `/projects/create` — new project form
- `/projects/{id}` — project board
- `/activity` — activity feed

## CLI helpers

```bash
php scripts/migrate.php status
php scripts/migrate.php fresh --seed
php scripts/seed_projects.php 6
```

+ 0
- 208
app/Controllers/EmployeeController.php Прегледај датотеку

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

declare(strict_types=1);

namespace App\Controllers;

use App\Models\Employee;
use App\Repositories\EmployeeRepository;
use App\ViewModels\EmployeeFormViewModel;
use Core\Controller;
use Core\Request;
use Core\Validator;

class EmployeeController extends Controller
{
public function index()
{
$request = Request::capture();
$viewModel = $this->buildViewModel((string) $request->input('search', ''));
$viewModel->saved = $request->input('saved') === '1';

return $this->view('employees.create', [
'model' => $viewModel,
'pageTitle' => $viewModel->title,
]);
}

public function store()
{
$request = Request::capture();
$form = $this->sanitizeFormData($request);
$errors = $this->validateForm($form, $request);

if (empty($errors) && $this->employees()->findByEmail($form['email']) !== null) {
$errors['email'][] = 'That email address is already in use.';
}

if (!empty($errors)) {
$viewModel = $this->buildViewModel();
$viewModel->form = $form;
$viewModel->errors = $errors;

if ($this->isHtmxRequest($request)) {
return $this->fragment('employees.partials.form', [
'model' => $viewModel,
]);
}

return $this->view('employees.create', [
'model' => $viewModel,
'pageTitle' => $viewModel->title,
]);
}

$employee = new Employee();
$employee->firstName = $form['first_name'];
$employee->lastName = $form['last_name'];
$employee->email = $form['email'];
$employee->department = $form['department'];
$employee->jobTitle = $form['job_title'];
$employee->startDate = $form['start_date'];

$this->employees()->create($employee);

if ($this->isHtmxRequest($request)) {
$viewModel = $this->buildViewModel();
$viewModel->saved = true;

return $this->fragment('employees.partials.form', [
'model' => $viewModel,
], 200, [
'HX-Trigger' => json_encode(['employees-changed' => true]),
]);
}

return $this->redirect('/employees?saved=1');
}

public function create()
{
return $this->redirect('/employees');
}

public function summary()
{
$request = Request::capture();
$viewModel = $this->buildViewModel((string) $request->input('search', ''));

return $this->fragment('employees.partials.summary', [
'model' => $viewModel,
]);
}

public function data()
{
$request = Request::capture();
$search = trim((string) $request->input('search', ''));
$rows = $this->employees()->search($search);

$data = array_map(
static function (array $row): array {
return [
'id' => (int) $row['id'],
'full_name' => trim($row['first_name'] . ' ' . $row['last_name']),
'first_name' => (string) $row['first_name'],
'last_name' => (string) $row['last_name'],
'email' => (string) $row['email'],
'department' => (string) $row['department'],
'job_title' => (string) $row['job_title'],
'start_date' => (string) $row['start_date'],
'created_at' => (string) $row['created_at'],
];
},
$rows
);

return $this->json($data);
}

/**
* @return array<string, string>
*/
private function sanitizeFormData(Request $request): array
{
return [
'first_name' => trim((string) $request->input('first_name', '')),
'last_name' => trim((string) $request->input('last_name', '')),
'email' => trim((string) $request->input('email', '')),
'department' => trim((string) $request->input('department', '')),
'job_title' => trim((string) $request->input('job_title', '')),
'start_date' => trim((string) $request->input('start_date', '')),
];
}

/**
* @param array<string, string> $form
* @return array<string, list<string>>
*/
private function validateForm(array $form, Request $request): array
{
$validator = new Validator();

$validator
->required('first_name', $form['first_name'], 'First name is required.')
->maxLength('first_name', $form['first_name'], 100, 'First name must be 100 characters or fewer.')
->required('last_name', $form['last_name'], 'Last name is required.')
->maxLength('last_name', $form['last_name'], 100, 'Last name must be 100 characters or fewer.')
->required('email', $form['email'], 'Email is required.')
->maxLength('email', $form['email'], 255, 'Email must be 255 characters or fewer.')
->required('department', $form['department'], 'Department is required.')
->maxLength('department', $form['department'], 100, 'Department must be 100 characters or fewer.')
->required('job_title', $form['job_title'], 'Job title is required.')
->maxLength('job_title', $form['job_title'], 150, 'Job title must be 150 characters or fewer.')
->required('start_date', $form['start_date'], 'Start date is required.');

$errors = $validator->errors();

if (!verify_csrf_token((string) $request->input('_token', ''))) {
$errors['_token'][] = 'Your form session expired. Please refresh the page and try again.';
}

if ($form['email'] !== '' && filter_var($form['email'], FILTER_VALIDATE_EMAIL) === false) {
$errors['email'][] = 'Enter a valid email address.';
}

if ($form['start_date'] !== '' && !$this->isValidDate($form['start_date'])) {
$errors['start_date'][] = 'Enter a valid start date.';
}

return $errors;
}

private function isValidDate(string $value): bool
{
$date = \DateTimeImmutable::createFromFormat('Y-m-d', $value);

return $date !== false && $date->format('Y-m-d') === $value;
}

private function employees(): EmployeeRepository
{
return new EmployeeRepository(database());
}

private function isHtmxRequest(Request $request): bool
{
return strtolower((string) $request->server('HTTP_HX_REQUEST', '')) === 'true';
}

private function buildViewModel(string $search = ''): EmployeeFormViewModel
{
$viewModel = new EmployeeFormViewModel();
$viewModel->search = trim($search);

$employees = $this->employees()->search($viewModel->search);
$newestEmployee = $this->employees()->newestMatching($viewModel->search);

$viewModel->employees = array_slice($employees, 0, 5);
$viewModel->newestEmployee = $newestEmployee;
$viewModel->summary = [
'employee_count' => $this->employees()->countMatching($viewModel->search),
'department_count' => $this->employees()->countDepartments($viewModel->search),
'latest_start_date' => $newestEmployee['start_date'] ?? 'N/A',
];

return $viewModel;
}
}

+ 20
- 11
app/Controllers/HomeController.php Прегледај датотеку

@@ -4,29 +4,38 @@ declare(strict_types=1);

namespace App\Controllers;

use App\ViewModels\HomeIndexViewModel;
use App\Repositories\ActivityRepository;
use App\Repositories\ProjectRepository;
use App\Repositories\TaskRepository;
use Core\Controller;

class HomeController extends Controller
{
public function index()
{
$model = new HomeIndexViewModel();
$model->title = 'MindVisionCode PHP';
$model->eyebrow = 'Small MVC framework';
$model->message = 'A lightweight PHP MVC starter with a central dispatcher, clean controllers, SQLite-backed repositories, and readable conventions.';
$model->routeExample = '/employees';
$projects = new ProjectRepository();
$tasks = new TaskRepository();
$activities = new ActivityRepository();

return $this->view('home.index', [
'model' => $model,
'pageTitle' => $model->title,
'pageTitle' => 'Project Compass',
'summary' => $projects->dashboardSummary(),
'featuredProjects' => $projects->recent(5),
'dueSoon' => $tasks->dueSoon(6),
'overdue' => $tasks->overdue(4),
'activity' => $activities->recent(8),
]);
}

public function user(string $id)
public function activity()
{
return $this->json([
'userId' => $id,
$activities = new ActivityRepository();
$projects = new ProjectRepository();

return $this->view('home.activity', [
'pageTitle' => 'Activity feed',
'activity' => $activities->recent(30),
'projects' => $projects->recent(8),
]);
}
}

+ 209
- 0
app/Controllers/ProjectController.php Прегледај датотеку

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

declare(strict_types=1);

namespace App\Controllers;

use App\Repositories\ActivityRepository;
use App\Repositories\ProjectRepository;
use Core\Controller;
use Core\Validator;

class ProjectController extends Controller
{
private const STATUS_OPTIONS = ['planned', 'active', 'at-risk', 'paused', 'done'];
private const TASK_STATUS_OPTIONS = ['backlog', 'in-progress', 'review', 'blocked', 'done'];
private const PRIORITY_OPTIONS = ['low', 'normal', 'high', 'urgent'];

public function index()
{
$search = trim((string) request()->input('search', ''));
$status = trim((string) request()->input('status', ''));

$repository = new ProjectRepository();

return $this->view('projects.index', [
'pageTitle' => 'Projects',
'projects' => $repository->all($search, $status),
'summary' => $repository->dashboardSummary(),
'search' => $search,
'status' => $status,
'statusOptions' => self::STATUS_OPTIONS,
]);
}

public function create()
{
return $this->view('projects.create', [
'pageTitle' => 'Create project',
'statusOptions' => self::STATUS_OPTIONS,
'old' => $this->defaultProjectForm(),
'errors' => [],
]);
}

public function store()
{
$this->requirePost(request());

if (!verify_csrf_token((string) request()->input('_token'))) {
return $this->renderCreateWithErrors([
'_token' => ['The security token expired. Refresh the page and try again.'],
], $this->defaultProjectForm(request()->all()));
}

$data = $this->projectDataFromRequest();
$errors = $this->validateProject($data);

if ($errors !== []) {
return $this->renderCreateWithErrors($errors, $data);
}

$projectId = (new ProjectRepository())->create($data);

return $this->redirect('/projects/' . $projectId);
}

public function show(string $id)
{
$projectId = (int) $id;
$repository = new ProjectRepository();
$project = $repository->findBoard($projectId);

if ($project === null) {
return \Core\Response::notFound('Project not found.');
}

return $this->view('projects.show', [
'pageTitle' => $project['name'],
'project' => $project,
'taskStatusOptions' => self::TASK_STATUS_OPTIONS,
'priorityOptions' => self::PRIORITY_OPTIONS,
'activity' => (new ActivityRepository())->forProject($projectId, 12),
'taskErrors' => [],
'taskOld' => $this->defaultTaskForm(),
]);
}

public function updateStatus(string $id)
{
$this->requirePost(request());

if (!verify_csrf_token((string) request()->input('_token'))) {
return $this->redirect('/projects/' . (int) $id . '?error=token');
}

$status = trim((string) request()->input('status', ''));

if (!in_array($status, self::STATUS_OPTIONS, true)) {
return $this->redirect('/projects/' . (int) $id . '?error=status');
}

(new ProjectRepository())->updateStatus((int) $id, $status);

return $this->redirect('/projects/' . (int) $id . '?updated=1');
}

private function renderCreateWithErrors(array $errors, array $old)
{
return $this->view('projects.create', [
'pageTitle' => 'Create project',
'statusOptions' => self::STATUS_OPTIONS,
'errors' => $errors,
'old' => array_merge($this->defaultProjectForm(), $old),
]);
}

private function defaultProjectForm(array $input = []): array
{
return [
'name' => (string) ($input['name'] ?? ''),
'code' => (string) ($input['code'] ?? ''),
'client_name' => (string) ($input['client_name'] ?? ''),
'description' => (string) ($input['description'] ?? ''),
'status' => (string) ($input['status'] ?? 'planned'),
'start_date' => (string) ($input['start_date'] ?? date('Y-m-d')),
'due_date' => (string) ($input['due_date'] ?? ''),
'budget_cents' => (string) ($input['budget_cents'] ?? '0'),
'owner_name' => (string) ($input['owner_name'] ?? ''),
'color_token' => (string) ($input['color_token'] ?? 'teal'),
'members_text' => (string) ($input['members_text'] ?? ''),
];
}

private function defaultTaskForm(array $input = []): array
{
return [
'title' => (string) ($input['title'] ?? ''),
'description' => (string) ($input['description'] ?? ''),
'priority' => (string) ($input['priority'] ?? 'normal'),
'status' => (string) ($input['status'] ?? 'backlog'),
'assignee' => (string) ($input['assignee'] ?? ''),
'estimate_hours' => (string) ($input['estimate_hours'] ?? ''),
'due_date' => (string) ($input['due_date'] ?? ''),
];
}

private function projectDataFromRequest(): array
{
$input = request()->all();

return [
'name' => trim((string) ($input['name'] ?? '')),
'code' => trim((string) ($input['code'] ?? '')),
'client_name' => trim((string) ($input['client_name'] ?? '')),
'description' => trim((string) ($input['description'] ?? '')),
'status' => trim((string) ($input['status'] ?? 'planned')),
'start_date' => trim((string) ($input['start_date'] ?? '')),
'due_date' => trim((string) ($input['due_date'] ?? '')),
'budget_cents' => max(0, (int) preg_replace('/[^0-9]/', '', (string) ($input['budget_cents'] ?? '0'))),
'owner_name' => trim((string) ($input['owner_name'] ?? '')),
'color_token' => trim((string) ($input['color_token'] ?? 'teal')),
'members' => $this->parseMembers((string) ($input['members_text'] ?? '')),
'members_text' => (string) ($input['members_text'] ?? ''),
];
}

private function validateProject(array $data): array
{
$validator = new Validator();

$validator->required('name', $data['name'], 'Project name is required.')->maxLength('name', $data['name'], 120);
$validator->required('client_name', $data['client_name'], 'Client name is required.')->maxLength('client_name', $data['client_name'], 120);
$validator->required('owner_name', $data['owner_name'], 'Project owner is required.')->maxLength('owner_name', $data['owner_name'], 120);
$validator->required('description', $data['description'], 'Project summary is required.')->maxLength('description', $data['description'], 800);
$validator->required('status', $data['status'], 'Choose a project status.');
$validator->required('start_date', $data['start_date'], 'Start date is required.');

if ($data['due_date'] !== '' && !preg_match('/^\d{4}-\d{2}-\d{2}$/', $data['due_date'])) {
$validator->required('due_date', null, 'Use a valid due date.');
}

if (!in_array($data['status'], self::STATUS_OPTIONS, true)) {
$validator->required('status', null, 'Choose a valid project status.');
}

return $validator->errors();
}

private function parseMembers(string $value): array
{
$lines = preg_split('/[\r\n,]+/', $value) ?: [];
$members = [];

foreach ($lines as $index => $line) {
$name = trim($line);
if ($name === '') {
continue;
}

$members[] = [
'full_name' => $name,
'role' => $index === 0 ? 'Project Lead' : 'Contributor',
'allocation_percent' => $index === 0 ? 40 : 20,
];
}

return $members;
}
}

+ 160
- 0
app/Controllers/TaskController.php Прегледај датотеку

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

declare(strict_types=1);

namespace App\Controllers;

use App\Repositories\ActivityRepository;
use App\Repositories\ProjectRepository;
use App\Repositories\TaskRepository;
use Core\Controller;
use Core\Validator;

class TaskController extends Controller
{
private const TASK_STATUS_OPTIONS = ['backlog', 'in-progress', 'review', 'blocked', 'done'];
private const PRIORITY_OPTIONS = ['low', 'normal', 'high', 'urgent'];

public function store(string $projectId)
{
$this->requirePost(request());

if (!verify_csrf_token((string) request()->input('_token'))) {
return $this->renderProjectWithTaskErrors((int) $projectId, ['_token' => ['The security token expired. Refresh the page and try again.']], $this->taskFormFromRequest());
}

$projectRepository = new ProjectRepository();
$project = $projectRepository->findBoard((int) $projectId);

if ($project === null) {
return \Core\Response::notFound('Project not found.');
}

$data = $this->taskDataFromRequest();
$errors = $this->validateTask($data);

if ($errors !== []) {
return $this->renderProjectWithTaskErrors((int) $projectId, $errors, $data);
}

$taskId = (new TaskRepository())->create((int) $projectId, $data);
(new ActivityRepository())->record(
'task_created',
'Added task ' . $data['title'],
'Task created on the project board.',
(int) $projectId,
$taskId
);

return $this->redirect('/projects/' . (int) $projectId . '?task=created#task-' . $taskId);
}

public function updateStatus(string $id)
{
$this->requirePost(request());

if (!verify_csrf_token((string) request()->input('_token'))) {
return $this->redirect('/projects');
}

$status = trim((string) request()->input('status', ''));
if (!in_array($status, self::TASK_STATUS_OPTIONS, true)) {
return $this->redirect('/projects');
}

$projectId = (new TaskRepository())->updateStatus((int) $id, $status);

if ($projectId === null) {
return \Core\Response::notFound('Task not found.');
}

(new ActivityRepository())->record(
'task_status_changed',
'Task updated to ' . task_status_label($status),
'Task status changed on the project board.',
$projectId,
(int) $id
);

return $this->redirect('/projects/' . $projectId . '?task-updated=1#task-' . (int) $id);
}

private function taskFormFromRequest(): array
{
$input = request()->all();

return [
'title' => (string) ($input['title'] ?? ''),
'description' => (string) ($input['description'] ?? ''),
'priority' => (string) ($input['priority'] ?? 'normal'),
'status' => (string) ($input['status'] ?? 'backlog'),
'assignee' => (string) ($input['assignee'] ?? ''),
'estimate_hours' => (string) ($input['estimate_hours'] ?? ''),
'due_date' => (string) ($input['due_date'] ?? ''),
];
}

private function taskDataFromRequest(): array
{
$input = request()->all();
$estimateHours = trim((string) ($input['estimate_hours'] ?? ''));
$dueDate = trim((string) ($input['due_date'] ?? ''));

return [
'title' => trim((string) ($input['title'] ?? '')),
'description' => trim((string) ($input['description'] ?? '')),
'priority' => trim((string) ($input['priority'] ?? 'normal')),
'status' => trim((string) ($input['status'] ?? 'backlog')),
'assignee' => trim((string) ($input['assignee'] ?? '')),
'estimate_hours' => $estimateHours !== '' ? (float) $estimateHours : null,
'due_date' => $dueDate !== '' ? $dueDate : null,
];
}

private function validateTask(array $data): array
{
$validator = new Validator();

$validator->required('title', $data['title'], 'Task title is required.')->maxLength('title', $data['title'], 140);
$validator->required('description', $data['description'], 'Task description is required.')->maxLength('description', $data['description'], 800);
$validator->required('assignee', $data['assignee'], 'Assign the task to someone.')->maxLength('assignee', $data['assignee'], 100);

if (!in_array($data['priority'], self::PRIORITY_OPTIONS, true)) {
$validator->required('priority', null, 'Choose a valid task priority.');
}

if (!in_array($data['status'], self::TASK_STATUS_OPTIONS, true)) {
$validator->required('status', null, 'Choose a valid task status.');
}

if ($data['due_date'] !== null && !preg_match('/^\d{4}-\d{2}-\d{2}$/', $data['due_date'])) {
$validator->required('due_date', null, 'Use a valid due date.');
}

if ($data['estimate_hours'] !== null && $data['estimate_hours'] < 0) {
$validator->required('estimate_hours', null, 'Estimate hours must be 0 or more.');
}

return $validator->errors();
}

private function renderProjectWithTaskErrors(int $projectId, array $errors, array $old)
{
$projectRepository = new ProjectRepository();
$project = $projectRepository->findBoard($projectId);

if ($project === null) {
return \Core\Response::notFound('Project not found.');
}

return $this->view('projects.show', [
'pageTitle' => $project['name'],
'project' => $project,
'taskStatusOptions' => self::TASK_STATUS_OPTIONS,
'priorityOptions' => self::PRIORITY_OPTIONS,
'activity' => (new ActivityRepository())->forProject($projectId, 12),
'taskErrors' => $errors,
'taskOld' => array_merge($this->taskFormFromRequest(), $old),
]);
}
}

+ 0
- 16
app/Models/Employee.php Прегледај датотеку

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

declare(strict_types=1);

namespace App\Models;

class Employee
{
public ?int $id = null;
public string $firstName = '';
public string $lastName = '';
public string $email = '';
public string $department = '';
public string $jobTitle = '';
public string $startDate = '';
}

+ 0
- 12
app/Models/User.php Прегледај датотеку

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

declare(strict_types=1);

namespace App\Models;

class User
{
public int|string|null $id = null;
public string $name = '';
public string $email = '';
}

+ 64
- 0
app/Repositories/ActivityRepository.php Прегледај датотеку

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

declare(strict_types=1);

namespace App\Repositories;

use Core\Database;

class ActivityRepository
{
public function __construct(protected ?Database $database = null)
{
$this->database ??= database();
}

public function record(
string $eventType,
string $headline,
string $detail = '',
?int $projectId = null,
?int $taskId = null
): void {
$this->database->execute(
'INSERT INTO activity_log (project_id, task_id, event_type, headline, detail)
VALUES (:project_id, :task_id, :event_type, :headline, :detail)',
[
'project_id' => $projectId,
'task_id' => $taskId,
'event_type' => $eventType,
'headline' => $headline,
'detail' => $detail,
]
);
}

public function recent(int $limit = 10): array
{
$limit = max(1, min(50, $limit));

return $this->database->query(
'SELECT a.*, p.name AS project_name, p.code AS project_code, t.title AS task_title
FROM activity_log a
LEFT JOIN projects p ON p.id = a.project_id
LEFT JOIN tasks t ON t.id = a.task_id
ORDER BY a.created_at DESC, a.id DESC
LIMIT ' . $limit
);
}

public function forProject(int $projectId, int $limit = 12): array
{
$limit = max(1, min(50, $limit));

return $this->database->query(
'SELECT a.*, t.title AS task_title
FROM activity_log a
LEFT JOIN tasks t ON t.id = a.task_id
WHERE a.project_id = :project_id
ORDER BY a.created_at DESC, a.id DESC
LIMIT ' . $limit,
['project_id' => $projectId]
);
}
}

+ 0
- 122
app/Repositories/EmployeeRepository.php Прегледај датотеку

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

declare(strict_types=1);

namespace App\Repositories;

use App\Models\Employee;
use Core\Repository;

class EmployeeRepository extends Repository
{
protected string $table = 'employees';
protected string $primaryKey = 'id';

public function create(Employee $employee): bool
{
return $this->database->execute(
'INSERT INTO employees (first_name, last_name, email, department, job_title, start_date)
VALUES (:first_name, :last_name, :email, :department, :job_title, :start_date)',
[
'first_name' => $employee->firstName,
'last_name' => $employee->lastName,
'email' => $employee->email,
'department' => $employee->department,
'job_title' => $employee->jobTitle,
'start_date' => $employee->startDate,
]
);
}

public function findByEmail(string $email): ?array
{
return $this->database->first(
'SELECT * FROM employees WHERE email = :email',
['email' => $email]
);
}

/**
* @return list<array<string, mixed>>
*/
public function latest(int $limit = 8): array
{
$limit = max(1, $limit);

return $this->database->query(
"SELECT * FROM employees ORDER BY created_at DESC, id DESC LIMIT {$limit}"
);
}

/**
* @return list<array<string, mixed>>
*/
public function search(string $search = ''): array
{
[$whereClause, $parameters] = $this->buildSearchClause($search);

return $this->database->query(
'SELECT id, first_name, last_name, email, department, job_title, start_date, created_at
FROM employees' . $whereClause . '
ORDER BY created_at DESC, id DESC',
$parameters
);
}

public function countMatching(string $search = ''): int
{
[$whereClause, $parameters] = $this->buildSearchClause($search);
$row = $this->database->first(
'SELECT COUNT(*) AS total FROM employees' . $whereClause,
$parameters
);

return (int) ($row['total'] ?? 0);
}

public function countDepartments(string $search = ''): int
{
[$whereClause, $parameters] = $this->buildSearchClause($search);
$row = $this->database->first(
'SELECT COUNT(DISTINCT department) AS total FROM employees' . $whereClause,
$parameters
);

return (int) ($row['total'] ?? 0);
}

public function newestMatching(string $search = ''): ?array
{
[$whereClause, $parameters] = $this->buildSearchClause($search);

return $this->database->first(
'SELECT id, first_name, last_name, department, job_title, start_date
FROM employees' . $whereClause . '
ORDER BY created_at DESC, id DESC
LIMIT 1',
$parameters
);
}

/**
* @return array{0:string,1:array<string,string>}
*/
private function buildSearchClause(string $search): array
{
$search = trim($search);

if ($search === '') {
return ['', []];
}

return [
' WHERE first_name LIKE :search
OR last_name LIKE :search
OR email LIKE :search
OR department LIKE :search
OR job_title LIKE :search
OR start_date LIKE :search',
['search' => '%' . $search . '%'],
];
}
}

+ 383
- 0
app/Repositories/ProjectRepository.php Прегледај датотеку

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

declare(strict_types=1);

namespace App\Repositories;

use Core\Database;

class ProjectRepository
{
public function __construct(protected ?Database $database = null)
{
$this->database ??= database();
}

public function dashboardSummary(): array
{
$projectCount = (int) ($this->database->first('SELECT COUNT(*) AS total FROM projects')['total'] ?? 0);
$activeProjects = (int) ($this->database->first(
'SELECT COUNT(*) AS total FROM projects WHERE status IN ("active", "at-risk")'
)['total'] ?? 0);
$openTasks = (int) ($this->database->first(
'SELECT COUNT(*) AS total FROM tasks WHERE status != "done"'
)['total'] ?? 0);
$blockedTasks = (int) ($this->database->first(
'SELECT COUNT(*) AS total FROM tasks WHERE status = "blocked"'
)['total'] ?? 0);
$dueSoonTasks = (int) ($this->database->first(
'SELECT COUNT(*) AS total FROM tasks WHERE status != "done" AND due_date IS NOT NULL AND date(due_date) BETWEEN date("now") AND date("now", "+7 day")'
)['total'] ?? 0);
$overdueTasks = (int) ($this->database->first(
'SELECT COUNT(*) AS total FROM tasks WHERE status != "done" AND due_date IS NOT NULL AND date(due_date) < date("now")'
)['total'] ?? 0);

return [
'project_count' => $projectCount,
'active_projects' => $activeProjects,
'open_tasks' => $openTasks,
'blocked_tasks' => $blockedTasks,
'due_soon_tasks' => $dueSoonTasks,
'overdue_tasks' => $overdueTasks,
];
}

public function recent(int $limit = 6): array
{
$limit = max(1, min(25, $limit));

$rows = $this->database->query(
'SELECT p.*,
COUNT(t.id) AS task_count,
SUM(CASE WHEN t.status = "done" THEN 1 ELSE 0 END) AS done_task_count,
SUM(CASE WHEN t.status = "blocked" THEN 1 ELSE 0 END) AS blocked_task_count,
SUM(CASE WHEN t.status != "done" AND t.due_date IS NOT NULL AND date(t.due_date) < date("now") THEN 1 ELSE 0 END) AS overdue_task_count,
MAX(t.updated_at) AS latest_task_update
FROM projects p
LEFT JOIN tasks t ON t.project_id = p.id
GROUP BY p.id
ORDER BY CASE p.status WHEN "active" THEN 0 WHEN "at-risk" THEN 1 WHEN "planned" THEN 2 WHEN "paused" THEN 3 WHEN "done" THEN 4 ELSE 5 END,
p.updated_at DESC,
p.id DESC
LIMIT ' . $limit
);

return array_map([$this, 'decorateProjectRow'], $rows);
}

public function all(string $search = '', string $status = ''): array
{
$where = [];
$params = [];

if ($search !== '') {
$where[] = '(p.name LIKE :search OR p.code LIKE :search OR p.client_name LIKE :search OR p.description LIKE :search)';
$params['search'] = '%' . $search . '%';
}

if ($status !== '') {
$where[] = 'p.status = :status';
$params['status'] = $status;
}

$sql = 'SELECT p.*,
COUNT(t.id) AS task_count,
SUM(CASE WHEN t.status = "done" THEN 1 ELSE 0 END) AS done_task_count,
SUM(CASE WHEN t.status = "blocked" THEN 1 ELSE 0 END) AS blocked_task_count,
SUM(CASE WHEN t.status != "done" AND t.due_date IS NOT NULL AND date(t.due_date) < date("now") THEN 1 ELSE 0 END) AS overdue_task_count,
MAX(t.updated_at) AS latest_task_update
FROM projects p
LEFT JOIN tasks t ON t.project_id = p.id';

if ($where !== []) {
$sql .= ' WHERE ' . implode(' AND ', $where);
}

$sql .= ' GROUP BY p.id
ORDER BY CASE p.status WHEN "active" THEN 0 WHEN "at-risk" THEN 1 WHEN "planned" THEN 2 WHEN "paused" THEN 3 WHEN "done" THEN 4 ELSE 5 END,
COALESCE(p.due_date, "9999-12-31") ASC,
p.updated_at DESC,
p.id DESC';

return array_map(
[$this, 'decorateProjectRow'],
$this->database->query($sql, $params)
);
}

public function find(int $projectId): ?array
{
$project = $this->database->first(
'SELECT p.*,
COUNT(t.id) AS task_count,
SUM(CASE WHEN t.status = "done" THEN 1 ELSE 0 END) AS done_task_count,
SUM(CASE WHEN t.status = "blocked" THEN 1 ELSE 0 END) AS blocked_task_count,
SUM(CASE WHEN t.status != "done" AND t.due_date IS NOT NULL AND date(t.due_date) < date("now") THEN 1 ELSE 0 END) AS overdue_task_count,
MAX(t.updated_at) AS latest_task_update
FROM projects p
LEFT JOIN tasks t ON t.project_id = p.id
WHERE p.id = :id
GROUP BY p.id',
['id' => $projectId]
);

if ($project === null) {
return null;
}

return $this->decorateProjectDetail($project);
}

public function findBoard(int $projectId): ?array
{
$project = $this->find($projectId);

if ($project === null) {
return null;
}

$tasks = $this->database->query(
'SELECT * FROM tasks WHERE project_id = :project_id ORDER BY
CASE status
WHEN "backlog" THEN 0
WHEN "in-progress" THEN 1
WHEN "review" THEN 2
WHEN "blocked" THEN 3
WHEN "done" THEN 4
ELSE 5
END,
CASE priority
WHEN "urgent" THEN 0
WHEN "high" THEN 1
WHEN "normal" THEN 2
ELSE 3
END,
due_date IS NULL,
due_date ASC,
position ASC,
id ASC',
['project_id' => $projectId]
);

$buckets = [
'backlog' => [],
'in-progress' => [],
'review' => [],
'blocked' => [],
'done' => [],
];

foreach ($tasks as $task) {
$task['is_overdue'] = $task['status'] !== 'done' && !empty($task['due_date']) && strtotime((string) $task['due_date']) < strtotime(date('Y-m-d'));
$task['priority_label'] = ucfirst(str_replace('-', ' ', $task['priority']));
$task['status_label'] = task_status_label($task['status']);
$task['status_class'] = status_class($task['status']);
$bucketKey = array_key_exists($task['status'], $buckets) ? $task['status'] : 'backlog';
$buckets[$bucketKey][] = $task;
}

$members = $this->database->query(
'SELECT * FROM project_members WHERE project_id = :project_id ORDER BY is_primary DESC, id ASC',
['project_id' => $projectId]
);

$project['members'] = $members;
$project['task_buckets'] = $buckets;
$project['tasks'] = $tasks;
$project['progress_percent'] = $this->progressPercentage($projectId, (int) $project['task_count'], (int) $project['done_task_count']);
$project['throughput'] = $this->throughputLabel((int) $project['done_task_count'], (int) $project['task_count']);
$project['health_class'] = $this->healthClass((string) $project['status'], (int) $project['overdue_task_count']);
$project['latest_activity'] = $this->database->first(
'SELECT created_at FROM activity_log WHERE project_id = :project_id ORDER BY created_at DESC, id DESC LIMIT 1',
['project_id' => $projectId]
)['created_at'] ?? null;

return $project;
}

public function create(array $data): int
{
$code = $data['code'] !== '' ? strtoupper($data['code']) : $this->generateProjectCode($data['name']);
$code = $this->ensureUniqueCode($code);

$this->database->execute(
'INSERT INTO projects (
code, name, client_name, description, status, start_date, due_date, budget_cents, owner_name, color_token
) VALUES (
:code, :name, :client_name, :description, :status, :start_date, :due_date, :budget_cents, :owner_name, :color_token
)',
[
'code' => $code,
'name' => $data['name'],
'client_name' => $data['client_name'] ?? '',
'description' => $data['description'] ?? '',
'status' => $data['status'] ?? 'planned',
'start_date' => $data['start_date'] ?? null,
'due_date' => $data['due_date'] ?? null,
'budget_cents' => $data['budget_cents'] ?? 0,
'owner_name' => $data['owner_name'] ?? '',
'color_token' => $data['color_token'] ?? 'teal',
]
);

$projectId = (int) $this->database->pdo()->lastInsertId();

if (!empty($data['members']) && is_array($data['members'])) {
$this->saveMembers($projectId, $data['members']);
}

(new ActivityRepository($this->database))->record(
'project_created',
'Created project ' . $data['name'],
trim((($data['client_name'] ?? '') !== '' ? 'Client: ' . $data['client_name'] : '') . (($data['description'] ?? '') !== '' ? ' ' . ($data['description'] ?? '') : '')),
$projectId,
null
);

return $projectId;
}

public function updateStatus(int $projectId, string $status): bool
{
$project = $this->database->first('SELECT id, name, status FROM projects WHERE id = :id', ['id' => $projectId]);

if ($project === null) {
return false;
}

$this->database->execute(
'UPDATE projects SET status = :status, updated_at = CURRENT_TIMESTAMP WHERE id = :id',
[
'status' => $status,
'id' => $projectId,
]
);

(new ActivityRepository($this->database))->record(
'project_status_changed',
'Project moved to ' . project_status_label($status),
'Project status updated from ' . project_status_label($project['status']) . ' to ' . project_status_label($status) . '.',
$projectId,
null
);

return true;
}

protected function decorateProjectRow(array $row): array
{
$row['task_count'] = (int) ($row['task_count'] ?? 0);
$row['done_task_count'] = (int) ($row['done_task_count'] ?? 0);
$row['blocked_task_count'] = (int) ($row['blocked_task_count'] ?? 0);
$row['overdue_task_count'] = (int) ($row['overdue_task_count'] ?? 0);
$row['progress_percent'] = $this->progressPercentage((int) $row['id'], $row['task_count'], $row['done_task_count']);
$row['health_class'] = $this->healthClass((string) $row['status'], $row['overdue_task_count']);
$row['status_label'] = project_status_label((string) $row['status']);
$row['budget_label'] = money_cents((int) ($row['budget_cents'] ?? 0));
$row['task_traffic'] = $this->throughputLabel($row['done_task_count'], $row['task_count']);
$row['due_label'] = format_date($row['due_date'] ?? null);
$row['start_label'] = format_date($row['start_date'] ?? null);

return $row;
}

protected function decorateProjectDetail(array $project): array
{
$project = $this->decorateProjectRow($project);
$project['member_count'] = (int) ($this->database->first(
'SELECT COUNT(*) AS total FROM project_members WHERE project_id = :project_id',
['project_id' => $project['id']]
)['total'] ?? 0);

return $project;
}

protected function saveMembers(int $projectId, array $members): void
{
$statement = $this->database->pdo()->prepare(
'INSERT INTO project_members (project_id, full_name, role, allocation_percent, is_primary) VALUES (:project_id, :full_name, :role, :allocation_percent, :is_primary)'
);

foreach ($members as $index => $member) {
$name = trim((string) ($member['full_name'] ?? ''));
if ($name === '') {
continue;
}

$statement->execute([
'project_id' => $projectId,
'full_name' => $name,
'role' => trim((string) ($member['role'] ?? 'Contributor')),
'allocation_percent' => max(0, min(100, (int) ($member['allocation_percent'] ?? 0))),
'is_primary' => $index === 0 ? 1 : 0,
]);
}
}

protected function generateProjectCode(string $name): string
{
$slug = strtoupper(preg_replace('/[^A-Za-z0-9]+/', '', $name) ?? 'PROJECT');
$slug = substr($slug, 0, 6) ?: 'PROJECT';

return 'PRJ-' . $slug . '-' . date('ym');
}

protected function ensureUniqueCode(string $code): string
{
$candidate = $code;
$suffix = 2;

while ($this->database->first('SELECT id FROM projects WHERE code = :code', ['code' => $candidate]) !== null) {
$candidate = $code . '-' . $suffix;
$suffix++;
}

return $candidate;
}

protected function progressPercentage(int $projectId, ?int $taskCount = null, ?int $doneCount = null): int
{
$taskCount ??= (int) ($this->database->first('SELECT COUNT(*) AS total FROM tasks WHERE project_id = :project_id', ['project_id' => $projectId])['total'] ?? 0);
$doneCount ??= (int) ($this->database->first('SELECT COUNT(*) AS total FROM tasks WHERE project_id = :project_id AND status = "done"', ['project_id' => $projectId])['total'] ?? 0);

if ($taskCount <= 0) {
return 0;
}

return (int) round(($doneCount / $taskCount) * 100);
}

protected function throughputLabel(int $doneCount, int $taskCount): string
{
if ($taskCount === 0) {
return 'No tasks yet';
}

if ($doneCount === 0) {
return 'Starting up';
}

if ($doneCount >= $taskCount) {
return 'Ready to ship';
}

return $doneCount . '/' . $taskCount . ' completed';
}

protected function healthClass(string $status, int $overdueTasks): string
{
if ($status === 'done') {
return 'is-green';
}

if ($overdueTasks > 0 || $status === 'at-risk') {
return 'is-red';
}

if ($status === 'paused') {
return 'is-slate';
}

return 'is-blue';
}
}

+ 153
- 0
app/Repositories/TaskRepository.php Прегледај датотеку

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

declare(strict_types=1);

namespace App\Repositories;

use Core\Database;

class TaskRepository
{
public function __construct(protected ?Database $database = null)
{
$this->database ??= database();
}

public function create(int $projectId, array $data): int
{
$position = (int) ($this->database->first(
'SELECT COALESCE(MAX(position), 0) + 1 AS next_position FROM tasks WHERE project_id = :project_id AND status = :status',
[
'project_id' => $projectId,
'status' => $data['status'] ?? 'backlog',
]
)['next_position'] ?? 1);

$this->database->execute(
'INSERT INTO tasks (
project_id, title, description, status, priority, assignee, estimate_hours, due_date, position, completed_at
) VALUES (
:project_id, :title, :description, :status, :priority, :assignee, :estimate_hours, :due_date, :position, :completed_at
)',
[
'project_id' => $projectId,
'title' => $data['title'],
'description' => $data['description'] ?? '',
'status' => $data['status'] ?? 'backlog',
'priority' => $data['priority'] ?? 'normal',
'assignee' => $data['assignee'] ?? '',
'estimate_hours' => $data['estimate_hours'] ?? null,
'due_date' => $data['due_date'] ?? null,
'position' => $position,
'completed_at' => ($data['status'] ?? 'backlog') === 'done' ? date('Y-m-d H:i:s') : null,
]
);

return (int) $this->database->pdo()->lastInsertId();
}

public function updateStatus(int $taskId, string $status): ?int
{
$task = $this->database->first('SELECT id, project_id, title, status FROM tasks WHERE id = :id', ['id' => $taskId]);

if ($task === null) {
return null;
}

$completedAt = $status === 'done' ? date('Y-m-d H:i:s') : null;

$this->database->execute(
'UPDATE tasks
SET status = :status,
completed_at = :completed_at,
updated_at = CURRENT_TIMESTAMP
WHERE id = :id',
[
'status' => $status,
'completed_at' => $completedAt,
'id' => $taskId,
]
);

return (int) $task['project_id'];
}

public function dueSoon(int $limit = 6): array
{
$limit = max(1, min(25, $limit));

return $this->database->query(
'SELECT t.*, p.name AS project_name, p.code AS project_code
FROM tasks t
INNER JOIN projects p ON p.id = t.project_id
WHERE t.status != "done"
AND t.due_date IS NOT NULL
AND date(t.due_date) BETWEEN date("now") AND date("now", "+7 day")
ORDER BY date(t.due_date) ASC, CASE t.priority WHEN "urgent" THEN 0 WHEN "high" THEN 1 WHEN "normal" THEN 2 ELSE 3 END, t.id DESC
LIMIT ' . $limit
);
}

public function overdue(int $limit = 6): array
{
$limit = max(1, min(25, $limit));

return $this->database->query(
'SELECT t.*, p.name AS project_name, p.code AS project_code
FROM tasks t
INNER JOIN projects p ON p.id = t.project_id
WHERE t.status != "done"
AND t.due_date IS NOT NULL
AND date(t.due_date) < date("now")
ORDER BY date(t.due_date) ASC, t.id DESC
LIMIT ' . $limit
);
}

public function countsByStatus(int $projectId): array
{
$rows = $this->database->query(
'SELECT status, COUNT(*) AS total
FROM tasks
WHERE project_id = :project_id
GROUP BY status',
['project_id' => $projectId]
);

$counts = [];
foreach ($rows as $row) {
$counts[$row['status']] = (int) $row['total'];
}

return $counts;
}

public function forProject(int $projectId): array
{
return $this->database->query(
'SELECT *
FROM tasks
WHERE project_id = :project_id
ORDER BY
CASE status
WHEN "backlog" THEN 0
WHEN "in-progress" THEN 1
WHEN "review" THEN 2
WHEN "blocked" THEN 3
WHEN "done" THEN 4
ELSE 5
END,
CASE priority
WHEN "urgent" THEN 0
WHEN "high" THEN 1
WHEN "normal" THEN 2
ELSE 3
END,
due_date IS NULL,
due_date ASC,
position ASC,
id ASC',
['project_id' => $projectId]
);
}
}

+ 0
- 21
app/Repositories/UserRepository.php Прегледај датотеку

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

declare(strict_types=1);

namespace App\Repositories;

use Core\Repository;

class UserRepository extends Repository
{
protected string $table = 'users';
protected string $primaryKey = 'id';

public function findByEmail(string $email): ?array
{
return $this->database->first(
'SELECT * FROM users WHERE email = :email',
['email' => $email]
);
}
}

+ 0
- 50
app/ViewModels/EmployeeFormViewModel.php Прегледај датотеку

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

declare(strict_types=1);

namespace App\ViewModels;

class EmployeeFormViewModel
{
public string $title = 'Employee Directory';
public string $eyebrow = 'SQLite employee form';
public string $intro = 'Capture employee details in a lightweight SQLite-backed page that fits the framework style.';
public bool $saved = false;
public string $search = '';

/**
* @var array<string, string>
*/
public array $form = [
'first_name' => '',
'last_name' => '',
'email' => '',
'department' => '',
'job_title' => '',
'start_date' => '',
];

/**
* @var array<string, list<string>>
*/
public array $errors = [];

/**
* @var list<array<string, mixed>>
*/
public array $employees = [];

/**
* @var array<string, int|string>
*/
public array $summary = [
'employee_count' => 0,
'department_count' => 0,
'latest_start_date' => 'N/A',
];

/**
* @var array<string, mixed>|null
*/
public ?array $newestEmployee = null;
}

+ 0
- 13
app/ViewModels/HomeIndexViewModel.php Прегледај датотеку

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

declare(strict_types=1);

namespace App\ViewModels;

class HomeIndexViewModel
{
public string $title = '';
public string $eyebrow = '';
public string $message = '';
public string $routeExample = '';
}

+ 0
- 54
app/Views/employees/create.php Прегледај датотеку

@@ -1,54 +0,0 @@
<section class="content-stack" x-data="employeeDirectory()">
<div class="section-heading">
<span class="eyebrow"><?= e($model->eyebrow) ?></span>
<h1><?= e($model->title) ?></h1>
<p><?= e($model->intro) ?></p>
</div>

<section class="section-panel controls-panel">
<div class="panel-header controls-header">
<div>
<h2>Employee Workspace</h2>
<p>Use htmx for server updates, Alpine for page state, and Tabulator for a richer table experience.</p>
</div>

<button class="button button-secondary" type="button" x-on:click="reloadTable()">Refresh Table</button>
</div>

<div class="search-row">
<label class="field field-full">
<span>Search employees</span>
<input
id="employee-search"
class="input"
type="search"
name="search"
placeholder="Search by name, email, department, title, or date"
x-model.debounce.300ms="search"
x-on:input.debounce.300ms="applySearch()"
value="<?= e($model->search) ?>"
>
</label>
</div>
</section>

<div class="employee-layout">
<div id="employee-form-panel">
<?php require __DIR__ . '/partials/form.php'; ?>
</div>

<section class="section-panel table-shell directory-panel">
<div class="panel-header">
<h2>Employee Directory Table</h2>
<p>Browse, search, and sort all employees in one place. The table refreshes after new employee records are saved.</p>
</div>

<div class="table-toolbar">
<span class="table-pill">HTMX + Alpine + Tabulator</span>
<span class="table-caption">Live data endpoint: <code>/employees/data</code></span>
</div>

<div id="employee-table" class="tabulator-host"></div>
</section>
</div>
</section>

+ 0
- 83
app/Views/employees/partials/form.php Прегледај датотеку

@@ -1,83 +0,0 @@
<section class="section-panel">
<div class="panel-header">
<h2>Add Employee</h2>
<p>Store a clean employee record with basic contact and role details.</p>
</div>

<?php if ($model->saved): ?>
<div class="alert alert-success" x-data="{ open: true }" x-show="open" x-transition.opacity x-init="setTimeout(() => open = false, 3500)">
Employee information was saved to SQLite successfully.
</div>
<?php endif; ?>

<?php if (isset($model->errors['_token'])): ?>
<div class="alert alert-error"><?= e($model->errors['_token'][0]) ?></div>
<?php endif; ?>

<form
method="post"
action="/employees"
class="employee-form"
novalidate
hx-post="/employees"
hx-target="#employee-form-panel"
hx-swap="outerHTML"
>
<?= csrf_field() ?>

<div class="form-grid">
<label class="field">
<span>First name</span>
<input class="input" type="text" name="first_name" maxlength="100" value="<?= e($model->form['first_name']) ?>" required>
<?php if (isset($model->errors['first_name'])): ?>
<small class="field-error"><?= e($model->errors['first_name'][0]) ?></small>
<?php endif; ?>
</label>

<label class="field">
<span>Last name</span>
<input class="input" type="text" name="last_name" maxlength="100" value="<?= e($model->form['last_name']) ?>" required>
<?php if (isset($model->errors['last_name'])): ?>
<small class="field-error"><?= e($model->errors['last_name'][0]) ?></small>
<?php endif; ?>
</label>

<label class="field">
<span>Email</span>
<input class="input" type="email" name="email" maxlength="255" value="<?= e($model->form['email']) ?>" required>
<?php if (isset($model->errors['email'])): ?>
<small class="field-error"><?= e($model->errors['email'][0]) ?></small>
<?php endif; ?>
</label>

<label class="field">
<span>Department</span>
<input class="input" type="text" name="department" maxlength="100" value="<?= e($model->form['department']) ?>" required>
<?php if (isset($model->errors['department'])): ?>
<small class="field-error"><?= e($model->errors['department'][0]) ?></small>
<?php endif; ?>
</label>

<label class="field">
<span>Job title</span>
<input class="input" type="text" name="job_title" maxlength="150" value="<?= e($model->form['job_title']) ?>" required>
<?php if (isset($model->errors['job_title'])): ?>
<small class="field-error"><?= e($model->errors['job_title'][0]) ?></small>
<?php endif; ?>
</label>

<label class="field">
<span>Start date</span>
<input class="input" type="date" name="start_date" value="<?= e($model->form['start_date']) ?>" required>
<?php if (isset($model->errors['start_date'])): ?>
<small class="field-error"><?= e($model->errors['start_date'][0]) ?></small>
<?php endif; ?>
</label>
</div>

<div class="form-actions">
<button class="button button-primary" type="submit">Save Employee</button>
<span class="inline-indicator htmx-indicator">Saving employee...</span>
</div>
</form>
</section>

+ 0
- 34
app/Views/employees/partials/summary.php Прегледај датотеку

@@ -1,34 +0,0 @@
<div class="panel-header">
<h2>Live Summary</h2>
<p>Server-rendered fragments refresh here with htmx whenever employees change or search terms update.</p>
</div>

<div class="stats-grid">
<article class="stat-card">
<span>Total Employees</span>
<strong><?= e((string) $model->summary['employee_count']) ?></strong>
</article>

<article class="stat-card">
<span>Departments</span>
<strong><?= e((string) $model->summary['department_count']) ?></strong>
</article>

<article class="stat-card">
<span>Latest Start Date</span>
<strong><?= e((string) $model->summary['latest_start_date']) ?></strong>
</article>
</div>

<?php if ($model->newestEmployee !== null): ?>
<div class="summary-feature">
<span class="summary-label">Newest matching record</span>
<h3><?= e($model->newestEmployee['first_name'] . ' ' . $model->newestEmployee['last_name']) ?></h3>
<p><?= e((string) $model->newestEmployee['job_title']) ?> in <?= e((string) $model->newestEmployee['department']) ?></p>
</div>
<?php else: ?>
<div class="empty-state">
<p>No matching employees yet.</p>
<p>Try a broader search or add a new employee record.</p>
</div>
<?php endif; ?>

+ 52
- 0
app/Views/home/activity.php Прегледај датотеку

@@ -0,0 +1,52 @@
<section class="stack">
<div class="section-title">
<span class="eyebrow">Activity feed</span>
<h1>What changed across the portfolio</h1>
<p>A chronological log of project creation, status changes, and task movement.</p>
</div>

<div class="layout-two">
<div class="stack">
<?php foreach ($activity as $item): ?>
<article class="feed-item" data-filter-item>
<header>
<div>
<strong><?= e($item['headline']) ?></strong>
<p class="fineprint"><?= e($item['project_name'] ? $item['project_name'] . ' · ' . $item['project_code'] : 'Portfolio event') ?></p>
</div>
<span class="kicker"><?= e(format_date($item['created_at'], 'M j · H:i')) ?></span>
</header>
<p><?= e($item['detail']) ?></p>
</article>
<?php endforeach; ?>
</div>

<aside class="stack">
<section class="panel">
<span class="eyebrow">Snapshot</span>
<h2><?= e((string) count($projects)) ?> projects in rotation</h2>
<p>Use this feed to see what is changing right now, then jump into the project board to move work forward.</p>
</section>

<section class="panel">
<div class="block-title">
<div>
<span class="eyebrow">Short list</span>
<h2>Latest projects</h2>
</div>
</div>
<div class="list">
<?php foreach ($projects as $project): ?>
<div class="feed-item">
<header>
<strong><a href="/projects/<?= e((string) $project['id']) ?>"><?= e($project['name']) ?></a></strong>
<span class="status-pill <?= e($project['health_class']) ?>"><?= e($project['status_label']) ?></span>
</header>
<p><?= e($project['task_traffic']) ?> · <?= e($project['code']) ?></p>
</div>
<?php endforeach; ?>
</div>
</section>
</aside>
</div>
</section>

+ 122
- 26
app/Views/home/index.php Прегледај датотеку

@@ -1,39 +1,135 @@
<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>
<span class="eyebrow">Command center</span>
<h1>See every project, task, and risk in one view.</h1>
<p class="hero-text">Project Compass is a focused delivery system for keeping portfolios organized. Track budgets, deadlines, blocked work, and team momentum without losing the narrative of what is happening next.</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>
<a class="button button-primary" href="/projects">Open projects</a>
<a class="button button-secondary" href="/projects/create">Create a project</a>
<a class="button button-ghost" href="/activity">View activity</a>
</div>

<div class="metrics-grid">
<div class="metric-card"><span class="meta-label">Projects</span><strong><?= e((string) $summary['project_count']) ?></strong></div>
<div class="metric-card"><span class="meta-label">Active</span><strong><?= e((string) $summary['active_projects']) ?></strong></div>
<div class="metric-card"><span class="meta-label">Open tasks</span><strong><?= e((string) $summary['open_tasks']) ?></strong></div>
<div class="metric-card"><span class="meta-label">Blocked</span><strong><?= e((string) $summary['blocked_tasks']) ?></strong></div>
</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>
<aside class="hero-panel">
<div>
<p class="panel-label">Delivery snapshot</p>
<h2><?= e((string) $summary['due_soon_tasks']) ?> tasks due soon</h2>
<p><?= e((string) $summary['overdue_tasks']) ?> tasks are already overdue, so the board is watching deadline risk closely.</p>
</div>

<div class="route-callout">
<span>Employee entry page</span>
<a href="<?= e($model->routeExample) ?>"><?= e($model->routeExample) ?></a>
<div class="summary-chips">
<span class="tag is-amber">Due in 7 days: <?= e((string) $summary['due_soon_tasks']) ?></span>
<span class="tag is-red">Overdue: <?= e((string) $summary['overdue_tasks']) ?></span>
<span class="tag is-blue">In motion: <?= e((string) $summary['active_projects']) ?></span>
</div>

<div class="notice">
<p class="kicker">How the app works</p>
<p>Projects carry budgets, team members, status, and a live Kanban board. Every status change creates activity so the project story stays visible.</p>
</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 class="layout-two" style="margin-top: 1.2rem;">
<div class="stack">
<div class="block-title">
<div>
<span class="eyebrow">Featured projects</span>
<h2>Most active workstreams</h2>
</div>
<a class="button button-secondary" href="/projects">Browse all</a>
</div>

<div class="card-grid">
<?php foreach ($featuredProjects as $project): ?>
<article class="project-card card" data-filter-item>
<div class="project-top">
<div>
<span class="kicker"><?= e($project['code']) ?></span>
<h3><a href="/projects/<?= e((string) $project['id']) ?>"><?= e($project['name']) ?></a></h3>
</div>
<span class="status-pill <?= e($project['health_class']) ?>"><?= e($project['status_label']) ?></span>
</div>
<p><?= e($project['description']) ?></p>
<div class="progress" aria-label="Project completion progress"><span style="width: <?= e((string) $project['progress_percent']) ?>%"></span></div>
<div class="project-bottom">
<span class="mini-pill is-neutral"><?= e((string) $project['task_count']) ?> tasks</span>
<span class="mini-pill is-blue"><?= e($project['task_traffic']) ?></span>
<span class="mini-pill is-amber">Budget <?= e($project['budget_label']) ?></span>
</div>
</article>
<?php endforeach; ?>
</div>
</div>

<div class="side-stack">
<section class="panel">
<div class="block-title">
<div>
<span class="eyebrow">Due soon</span>
<h2>Tasks needing attention</h2>
</div>
</div>
<div class="list">
<?php foreach ($dueSoon as $task): ?>
<div class="feed-item">
<header>
<strong><?= e($task['title']) ?></strong>
<span class="status-pill is-amber"><?= e(format_date($task['due_date'])) ?></span>
</header>
<p><?= e($task['project_name']) ?> · <?= e($task['assignee']) ?></p>
</div>
<?php endforeach; ?>
</div>
</section>

<section class="panel">
<div class="block-title">
<div>
<span class="eyebrow">Overdue</span>
<h2>Risk items</h2>
</div>
</div>
<div class="list">
<?php foreach ($overdue as $task): ?>
<div class="feed-item">
<header>
<strong><?= e($task['title']) ?></strong>
<span class="status-pill is-red"><?= e(format_date($task['due_date'])) ?></span>
</header>
<p><?= e($task['project_name']) ?> · <?= e($task['assignee']) ?></p>
</div>
<?php endforeach; ?>
</div>
</section>

<section class="panel">
<div class="block-title">
<div>
<span class="eyebrow">Activity feed</span>
<h2>Recent changes</h2>
</div>
<a class="button button-ghost" href="/activity">Open full feed</a>
</div>
<div class="list">
<?php foreach ($activity as $item): ?>
<div class="feed-item">
<header>
<strong><?= e($item['headline']) ?></strong>
<span class="kicker"><?= e(format_date($item['created_at'], 'M j · H:i')) ?></span>
</header>
<p><?= e($item['detail'] !== '' ? $item['detail'] : ($item['project_name'] ?? '')) ?></p>
</div>
<?php endforeach; ?>
</div>
</section>
</div>
</section>

+ 1
- 1
app/Views/layouts/app.php Прегледај датотеку

@@ -5,7 +5,7 @@ declare(strict_types=1);
require __DIR__ . '/../partials/header.php';
?>

<main class="page-content">
<main id="main-content" class="page-content">
<div class="container">
<?= $content ?>
</div>


+ 2
- 2
app/Views/partials/footer.php Прегледај датотеку

@@ -1,7 +1,7 @@
<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>
<p>Project Compass keeps delivery visible across projects, tasks, and team activity.</p>
<p>&copy; <?= e((string) date('Y')) ?> Project Compass</p>
</div>
</footer>
</div>


+ 11
- 15
app/Views/partials/header.php Прегледај датотеку

@@ -3,9 +3,10 @@
declare(strict_types=1);

$navigationItems = [
['label' => 'Home', 'href' => '/'],
['label' => 'Employees', 'href' => '/employees'],
['label' => 'Example JSON', 'href' => '/users/123'],
['label' => 'Dashboard', 'href' => '/'],
['label' => 'Projects', 'href' => '/projects'],
['label' => 'Activity', 'href' => '/activity'],
['label' => 'New project', 'href' => '/projects/create'],
];

$currentPath = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH);
@@ -16,32 +17,27 @@ $currentPath = is_string($currentPath) && $currentPath !== '' ? $currentPath : '
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= e($pageTitle ?? 'MindVisionCode PHP') ?></title>
<link rel="stylesheet" href="https://unpkg.com/tabulator-tables@6.3.1/dist/css/tabulator.min.css">
<title><?= e($pageTitle ?? 'Project Compass') ?></title>
<link rel="stylesheet" href="<?= e(asset('css/site.css')) ?>">
<script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.10/dist/htmx.min.js" integrity="sha384-H5SrcfygHmAuTDZphMHqBJLc3FhssKjG7w/CeCpFReSfwBWDTKpkzPP8c+cLsK+V" crossorigin="anonymous" defer></script>
<script src="https://unpkg.com/tabulator-tables@6.3.1/dist/js/tabulator.min.js" defer></script>
<script src="<?= e(asset('js/app.js')) ?>" defer></script>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
</head>
<body>
<body <?= isset($bodyClass) ? 'class="' . e($bodyClass) . '"' : '' ?>>
<div class="page-shell">
<a class="skip-link" href="#main-content">Skip to content</a>
<header class="site-header">
<div class="container header-inner">
<a class="brand" href="/">
<span class="brand-mark">MV</span>
<span class="brand-mark">PC</span>
<span class="brand-copy">
<strong>MindVisionCode</strong>
<small>PHP MVC</small>
<strong>Project Compass</strong>
<small>Portfolio command center</small>
</span>
</a>

<nav class="site-nav" aria-label="Primary navigation">
<?php foreach ($navigationItems as $item): ?>
<?php $isActive = $currentPath === $item['href']; ?>
<a class="nav-link<?= $isActive ? ' is-active' : '' ?>" href="<?= e($item['href']) ?>">
<?= e($item['label']) ?>
</a>
<a class="nav-link<?= $isActive ? ' is-active' : '' ?>" href="<?= e($item['href']) ?>"><?= e($item['label']) ?></a>
<?php endforeach; ?>
</nav>
</div>


+ 88
- 0
app/Views/projects/create.php Прегледај датотеку

@@ -0,0 +1,88 @@
<section class="stack">
<div class="section-title">
<span class="eyebrow">New project</span>
<h1>Create a project</h1>
<p>Set up a new workstream with budget, team members, and delivery milestones in one pass.</p>
</div>

<?php if (!empty($errors['_token'])): ?>
<div class="alert alert-error"><?= e($errors['_token'][0]) ?></div>
<?php endif; ?>

<form class="project-form panel" method="post" action="/projects">
<?= csrf_field() ?>
<div class="form-grid">
<label class="field">
<span>Project name</span>
<input class="input" type="text" name="name" maxlength="120" value="<?= e($old['name']) ?>" required>
<?php if (!empty($errors['name'])): ?><small class="field-error"><?= e($errors['name'][0]) ?></small><?php endif; ?>
</label>

<label class="field">
<span>Code</span>
<input class="input" type="text" name="code" maxlength="32" value="<?= e($old['code']) ?>" placeholder="Leave blank for auto-generation">
</label>

<label class="field">
<span>Client</span>
<input class="input" type="text" name="client_name" maxlength="120" value="<?= e($old['client_name']) ?>" required>
<?php if (!empty($errors['client_name'])): ?><small class="field-error"><?= e($errors['client_name'][0]) ?></small><?php endif; ?>
</label>

<label class="field">
<span>Owner</span>
<input class="input" type="text" name="owner_name" maxlength="120" value="<?= e($old['owner_name']) ?>" required>
<?php if (!empty($errors['owner_name'])): ?><small class="field-error"><?= e($errors['owner_name'][0]) ?></small><?php endif; ?>
</label>

<label class="field">
<span>Status</span>
<select class="select" name="status" required>
<?php foreach ($statusOptions as $option): ?>
<option value="<?= e($option) ?>" <?= $old['status'] === $option ? 'selected' : '' ?>><?= e(project_status_label($option)) ?></option>
<?php endforeach; ?>
</select>
<?php if (!empty($errors['status'])): ?><small class="field-error"><?= e($errors['status'][0]) ?></small><?php endif; ?>
</label>

<label class="field">
<span>Color token</span>
<input class="input" type="text" name="color_token" maxlength="32" value="<?= e($old['color_token']) ?>" placeholder="teal, amber, violet...">
</label>

<label class="field">
<span>Start date</span>
<input class="input" type="date" name="start_date" value="<?= e($old['start_date']) ?>" required>
<?php if (!empty($errors['start_date'])): ?><small class="field-error"><?= e($errors['start_date'][0]) ?></small><?php endif; ?>
</label>

<label class="field">
<span>Due date</span>
<input class="input" type="date" name="due_date" value="<?= e($old['due_date']) ?>">
<?php if (!empty($errors['due_date'])): ?><small class="field-error"><?= e($errors['due_date'][0]) ?></small><?php endif; ?>
</label>

<label class="field">
<span>Budget (cents)</span>
<input class="input" type="number" min="0" name="budget_cents" value="<?= e($old['budget_cents']) ?>">
</label>
</div>

<label class="field">
<span>Project summary</span>
<textarea class="textarea" name="description" maxlength="800" required><?= e($old['description']) ?></textarea>
<?php if (!empty($errors['description'])): ?><small class="field-error"><?= e($errors['description'][0]) ?></small><?php endif; ?>
</label>

<label class="field">
<span>Team members</span>
<textarea class="textarea" name="members_text" placeholder="One person per line. The first person becomes the lead."><?= e($old['members_text']) ?></textarea>
<small class="helper">Write each team member on a new line. The first person becomes the project lead.</small>
</label>

<div class="row-actions">
<button class="button button-primary" type="submit">Create project</button>
<a class="button button-ghost" href="/projects">Cancel</a>
</div>
</form>
</section>

+ 66
- 0
app/Views/projects/index.php Прегледај датотеку

@@ -0,0 +1,66 @@
<section class="stack">
<div class="layout-top">
<div class="section-title">
<span class="eyebrow">Portfolio view</span>
<h1>Projects</h1>
<p>Filter every project by status, workstream, and delivery risk.</p>
</div>
<div class="board-actions">
<a class="button button-primary" href="/projects/create">Create project</a>
<a class="button button-secondary" href="/activity">View activity</a>
</div>
</div>

<div class="stats-grid">
<div class="stat-card"><span class="meta-label">Projects</span><strong><?= e((string) $summary['project_count']) ?></strong></div>
<div class="stat-card"><span class="meta-label">Open tasks</span><strong><?= e((string) $summary['open_tasks']) ?></strong></div>
<div class="stat-card"><span class="meta-label">Overdue</span><strong><?= e((string) $summary['overdue_tasks']) ?></strong></div>
</div>

<form class="filter-bar" method="get" action="/projects">
<label class="field">
<span>Search</span>
<input class="input" type="search" name="search" placeholder="Search name, client, or code" value="<?= e($search) ?>">
</label>

<label class="field">
<span>Status</span>
<select class="select" name="status">
<option value="">All statuses</option>
<?php foreach ($statusOptions as $option): ?>
<option value="<?= e($option) ?>" <?= $status === $option ? 'selected' : '' ?>><?= e(project_status_label($option)) ?></option>
<?php endforeach; ?>
</select>
</label>

<div class="board-actions">
<button class="button button-primary" type="submit">Apply filters</button>
<a class="button button-ghost" href="/projects">Reset</a>
</div>
</form>

<div class="card-grid">
<?php foreach ($projects as $project): ?>
<article class="project-card card" data-filter-item>
<div class="project-top">
<div>
<span class="kicker"><?= e($project['code']) ?></span>
<h3><a href="/projects/<?= e((string) $project['id']) ?>"><?= e($project['name']) ?></a></h3>
</div>
<span class="status-pill <?= e($project['health_class']) ?>"><?= e($project['status_label']) ?></span>
</div>
<p><?= e($project['description']) ?></p>
<div class="progress"><span style="width: <?= e((string) $project['progress_percent']) ?>%"></span></div>
<div class="project-bottom">
<span class="mini-pill is-neutral"><?= e((string) $project['task_count']) ?> tasks</span>
<span class="mini-pill is-blue"><?= e($project['task_traffic']) ?></span>
<span class="mini-pill is-amber">Due <?= e($project['due_label']) ?></span>
</div>
<div class="project-bottom">
<span class="mini-pill is-slate">Owner: <?= e($project['owner_name']) ?></span>
<span class="mini-pill is-green"><?= e($project['budget_label']) ?></span>
</div>
</article>
<?php endforeach; ?>
</div>
</section>

+ 220
- 0
app/Views/projects/show.php Прегледај датотеку

@@ -0,0 +1,220 @@
<?php
$taskColumns = [
'backlog' => 'Backlog',
'in-progress' => 'In progress',
'review' => 'In review',
'blocked' => 'Blocked',
'done' => 'Done',
];
?>

<section class="stack">
<div class="project-header">
<div class="project-hero">
<div class="project-top">
<div>
<span class="eyebrow"><?= e($project['code']) ?></span>
<h1><?= e($project['name']) ?></h1>
<p><?= e($project['description']) ?></p>
</div>
<div class="board-actions">
<a class="button button-secondary" href="/projects">Back to projects</a>
<a class="button button-ghost" href="/activity">Activity feed</a>
</div>
</div>

<div class="badge-row">
<span class="status-pill <?= e($project['health_class']) ?>"><?= e($project['status_label']) ?></span>
<span class="status-pill is-blue"><?= e($project['throughput']) ?></span>
<span class="status-pill is-amber">Owner: <?= e($project['owner_name']) ?></span>
<span class="status-pill is-neutral">Last update: <?= e(format_date($project['latest_activity'], 'M j, Y · H:i')) ?></span>
</div>

<div class="progress"><span style="width: <?= e((string) $project['progress_percent']) ?>%"></span></div>

<div class="project-meta-grid">
<div class="meta-card"><span>Client</span><strong><?= e($project['client_name']) ?></strong></div>
<div class="meta-card"><span>Budget</span><strong><?= e($project['budget_label']) ?></strong></div>
<div class="meta-card"><span>Due date</span><strong><?= e($project['due_label']) ?></strong></div>
<div class="meta-card"><span>Tasks</span><strong><?= e((string) $project['task_count']) ?> total / <?= e((string) $project['done_task_count']) ?> done</strong></div>
</div>

<div class="project-actions">
<form method="post" action="/projects/<?= e((string) $project['id']) ?>/status" class="board-actions">
<?= csrf_field() ?>
<label class="field">
<span>Project status</span>
<select class="select" name="status">
<?php foreach (['planned','active','at-risk','paused','done'] as $status): ?>
<option value="<?= e($status) ?>" <?= $project['status'] === $status ? 'selected' : '' ?>><?= e(project_status_label($status)) ?></option>
<?php endforeach; ?>
</select>
</label>
<button class="button button-primary" type="submit">Update status</button>
</form>
</div>
</div>
</div>

<div class="shell">
<div class="board-shell">
<div class="board-header">
<div>
<span class="eyebrow">Kanban board</span>
<h2>Task flow</h2>
</div>
<div class="board-actions">
<span class="tag is-neutral"><?= e((string) count($project['members'])) ?> team members</span>
<span class="tag is-amber"><?= e((string) $project['blocked_task_count']) ?> blocked</span>
<span class="tag is-red"><?= e((string) $project['overdue_task_count']) ?> overdue</span>
</div>
</div>

<div class="board">
<?php foreach ($taskColumns as $status => $label): ?>
<section class="column">
<header>
<h3><?= e($label) ?></h3>
<span class="count"><?= e((string) count($project['task_buckets'][$status])) ?></span>
</header>
<?php foreach ($project['task_buckets'][$status] as $task): ?>
<article class="task-card" id="task-<?= e((string) $task['id']) ?>">
<header>
<div>
<span class="mini-pill <?= e($task['status_class']) ?>"><?= e($task['priority']) ?></span>
<h4><?= e($task['title']) ?></h4>
</div>
<span class="kicker"><?= e(format_date($task['due_date'])) ?></span>
</header>
<div class="task-body">
<p><?= e($task['description']) ?></p>
<div class="task-meta">
<span class="mini-pill is-neutral">Assigned to <?= e($task['assignee']) ?></span>
<span class="mini-pill is-blue"><?= e($task['estimate_hours'] !== null ? (string) $task['estimate_hours'] . 'h' : 'No estimate') ?></span>
</div>
</div>
<footer>
<form method="post" action="/tasks/<?= e((string) $task['id']) ?>/status" class="task-actions">
<?= csrf_field() ?>
<select class="select" name="status">
<?php foreach ($taskStatusOptions as $option): ?>
<option value="<?= e($option) ?>" <?= $task['status'] === $option ? 'selected' : '' ?>><?= e(task_status_label($option)) ?></option>
<?php endforeach; ?>
</select>
<button class="button button-ghost" type="submit">Move</button>
</form>
</footer>
</article>
<?php endforeach; ?>
</section>
<?php endforeach; ?>
</div>
</div>

<aside class="side-stack">
<section class="panel">
<div class="block-title">
<div>
<span class="eyebrow">Team</span>
<h2>Project members</h2>
</div>
</div>
<div class="members-list">
<?php foreach ($project['members'] as $member): ?>
<div class="member-chip">
<div>
<strong><?= e($member['full_name']) ?></strong>
<small><?= e($member['role']) ?></small>
</div>
<span class="tag is-neutral"><?= e((string) $member['allocation_percent']) ?>%</span>
</div>
<?php endforeach; ?>
</div>
</section>

<section class="panel">
<div class="block-title">
<div>
<span class="eyebrow">Add task</span>
<h2>New work item</h2>
</div>
</div>

<?php if (!empty($taskErrors['_token'])): ?>
<div class="alert alert-error"><?= e($taskErrors['_token'][0]) ?></div>
<?php endif; ?>

<form class="task-form" method="post" action="/projects/<?= e((string) $project['id']) ?>/tasks">
<?= csrf_field() ?>
<label class="field">
<span>Task title</span>
<input class="input" type="text" name="title" maxlength="140" value="<?= e($taskOld['title']) ?>" required>
<?php if (!empty($taskErrors['title'])): ?><small class="field-error"><?= e($taskErrors['title'][0]) ?></small><?php endif; ?>
</label>

<label class="field">
<span>Description</span>
<textarea class="textarea" name="description" maxlength="800" required><?= e($taskOld['description']) ?></textarea>
</label>

<div class="form-grid">
<label class="field">
<span>Priority</span>
<select class="select" name="priority">
<?php foreach ($priorityOptions as $option): ?>
<option value="<?= e($option) ?>" <?= $taskOld['priority'] === $option ? 'selected' : '' ?>><?= e(ucfirst($option)) ?></option>
<?php endforeach; ?>
</select>
</label>

<label class="field">
<span>Status</span>
<select class="select" name="status">
<?php foreach ($taskStatusOptions as $option): ?>
<option value="<?= e($option) ?>" <?= $taskOld['status'] === $option ? 'selected' : '' ?>><?= e(task_status_label($option)) ?></option>
<?php endforeach; ?>
</select>
</label>

<label class="field">
<span>Assignee</span>
<input class="input" type="text" name="assignee" maxlength="100" value="<?= e($taskOld['assignee']) ?>" required>
</label>

<label class="field">
<span>Estimate hours</span>
<input class="input" type="number" step="0.5" min="0" name="estimate_hours" value="<?= e($taskOld['estimate_hours']) ?>">
</label>
</div>

<label class="field">
<span>Due date</span>
<input class="input" type="date" name="due_date" value="<?= e($taskOld['due_date']) ?>">
</label>

<button class="button button-primary" type="submit">Add task</button>
</form>
</section>

<section class="panel">
<div class="block-title">
<div>
<span class="eyebrow">Timeline</span>
<h2>Recent activity</h2>
</div>
</div>
<div class="list">
<?php foreach ($activity as $item): ?>
<div class="feed-item">
<header>
<strong><?= e($item['headline']) ?></strong>
<span class="kicker"><?= e(format_date($item['created_at'], 'M j · H:i')) ?></span>
</header>
<p><?= e($item['detail']) ?></p>
</div>
<?php endforeach; ?>
</div>
</section>
</aside>
</div>
</section>

+ 27
- 0
autoload.php Прегледај датотеку

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

declare(strict_types=1);

spl_autoload_register(static function (string $class): void {
$prefixes = [
'App\\' => __DIR__ . '/app/',
'Core\\' => __DIR__ . '/core/',
];

foreach ($prefixes as $prefix => $baseDir) {
if (!str_starts_with($class, $prefix)) {
continue;
}

$relative = substr($class, strlen($prefix));
$file = $baseDir . str_replace('\\', DIRECTORY_SEPARATOR, $relative) . '.php';

if (is_file($file)) {
require_once $file;
}

return;
}
});

require_once __DIR__ . '/core/helpers.php';

+ 4
- 2
composer.json Прегледај датотеку

@@ -1,6 +1,6 @@
{
"name": "kci/mindvisioncode",
"description": "A small PHP MVC framework inspired by a Classic ASP MVC framework.",
"description": "A project management app built on a small PHP MVC framework.",
"type": "project",
"autoload": {
"psr-4": {
@@ -16,7 +16,9 @@
"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"
"migrate:fresh-seed": "php scripts/migrate.php fresh --seed",
"seed": "php scripts/seed_projects.php",
"seed:fresh": "php scripts/seed_projects.php --reset"
},
"require": {}
}

+ 1
- 1
core/View.php Прегледај датотеку

@@ -63,6 +63,6 @@ class View
return $data['model']->title;
}

return 'MindVisionCode PHP';
return 'Project Compass';
}
}

+ 108
- 5
core/helpers.php Прегледај датотеку

@@ -5,6 +5,7 @@ declare(strict_types=1);
use Core\App;
use Core\Database;
use Core\MigrationManager;
use Core\Request;
use Core\Response;
use Core\View;

@@ -29,22 +30,49 @@ function redirect(string $url): Response
return Response::redirect($url);
}

function request(): Request
{
return Request::capture();
}

function database(): Database
{
static $database = null;

if ($database === null) {
/** @var array<string, mixed> $config */
$config = require __DIR__ . '/../config/database.php';
global $databaseOverride;

prepareSqliteDatabase($config['dsn'] ?? '');
if ($databaseOverride instanceof Database) {
return $databaseOverride;
}

$database = new Database($config);
if ($database instanceof Database) {
return $database;
}

/** @var array<string, mixed> $config */
$config = require __DIR__ . '/../config/database.php';

prepareSqliteDatabase($config['dsn'] ?? '');

$database = new Database($config);

return $database;
}

function set_database(Database $database): void
{
global $databaseOverride;

$databaseOverride = $database;
}

function reset_database(): void
{
global $databaseOverride;

$databaseOverride = null;
}

function migration_manager(): MigrationManager
{
static $migrationManager = null;
@@ -132,3 +160,78 @@ function verify_csrf_token(?string $token): bool

return is_string($sessionToken) && hash_equals($sessionToken, $token);
}

function h(string $value): string
{
return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}

function format_date(?string $value, string $format = 'M j, Y'): string
{
if (!is_string($value) || trim($value) === '') {
return '—';
}

try {
$date = new DateTimeImmutable($value);
} catch (Throwable) {
return $value;
}

return $date->format($format);
}

function format_number(int|float|null $value): string
{
if ($value === null) {
return '0';
}

return number_format((float) $value);
}

function money_cents(int|null $value): string
{
if ($value === null) {
return '$0';
}

return '$' . number_format($value / 100, 0);
}

function project_status_label(string $status): string
{
return match ($status) {
'planned' => 'Planned',
'active' => 'Active',
'at-risk' => 'At risk',
'paused' => 'Paused',
'done' => 'Done',
default => ucfirst($status),
};
}

function task_status_label(string $status): string
{
return match ($status) {
'backlog', 'todo' => 'Backlog',
'in-progress', 'doing' => 'In progress',
'blocked' => 'Blocked',
'review' => 'In review',
'done' => 'Done',
default => ucfirst($status),
};
}

function status_class(string $status): string
{
return match ($status) {
'planned', 'backlog', 'todo' => 'is-neutral',
'active', 'in-progress', 'doing' => 'is-blue',
'blocked', 'at-risk' => 'is-red',
'review' => 'is-amber',
'done' => 'is-green',
'paused' => 'is-slate',
default => 'is-neutral',
};
}

+ 0
- 30
database/migrations/20260509_000001_create_employees_table.php Прегледај датотеку

@@ -1,30 +0,0 @@
<?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');
}
};

+ 93
- 0
database/migrations/20260509_000001_create_project_management_tables.php Прегледај датотеку

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

declare(strict_types=1);

use Core\Database;
use Core\Migration;

return new class extends Migration
{
public function up(Database $database): void
{
$database->execute('DROP TABLE IF EXISTS project_members');
$database->execute('DROP TABLE IF EXISTS tasks');
$database->execute('DROP TABLE IF EXISTS activity_log');
$database->execute('DROP TABLE IF EXISTS projects');

$database->execute(
'CREATE TABLE IF NOT EXISTS projects (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code VARCHAR(32) NOT NULL UNIQUE,
name VARCHAR(120) NOT NULL,
client_name VARCHAR(120) NOT NULL,
description TEXT NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT "planned",
start_date DATE NOT NULL,
due_date DATE NULL,
budget_cents INTEGER NOT NULL DEFAULT 0,
owner_name VARCHAR(120) NOT NULL,
color_token VARCHAR(32) NOT NULL DEFAULT "teal",
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
)'
);

$database->execute(
'CREATE TABLE IF NOT EXISTS project_members (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id INTEGER NOT NULL,
full_name VARCHAR(120) NOT NULL,
role VARCHAR(80) NOT NULL,
allocation_percent INTEGER NOT NULL DEFAULT 0,
is_primary INTEGER NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE
)'
);

$database->execute(
'CREATE TABLE IF NOT EXISTS tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id INTEGER NOT NULL,
title VARCHAR(140) NOT NULL,
description TEXT NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT "backlog",
priority VARCHAR(20) NOT NULL DEFAULT "normal",
assignee VARCHAR(100) NOT NULL,
estimate_hours REAL NULL,
due_date DATE NULL,
position INTEGER NOT NULL DEFAULT 0,
completed_at DATETIME NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE
)'
);

$database->execute(
'CREATE TABLE IF NOT EXISTS activity_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id INTEGER NULL,
task_id INTEGER NULL,
event_type VARCHAR(40) NOT NULL,
headline VARCHAR(180) NOT NULL,
detail TEXT NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE,
FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE
)'
);

$database->execute('CREATE INDEX IF NOT EXISTS idx_tasks_project_status ON tasks (project_id, status, due_date)');
$database->execute('CREATE INDEX IF NOT EXISTS idx_activity_project_created ON activity_log (project_id, created_at DESC)');
$database->execute('CREATE INDEX IF NOT EXISTS idx_project_members_project_primary ON project_members (project_id, is_primary DESC)');
}

public function down(Database $database): void
{
$database->execute('DROP TABLE IF EXISTS activity_log');
$database->execute('DROP TABLE IF EXISTS tasks');
$database->execute('DROP TABLE IF EXISTS project_members');
$database->execute('DROP TABLE IF EXISTS projects');
}
};

+ 0
- 107
database/seed_employees.php Прегледај датотеку

@@ -1,107 +0,0 @@
<?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);
}

+ 138
- 0
database/seed_projects.php Прегледај датотеку

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

declare(strict_types=1);

use App\Repositories\ActivityRepository;
use App\Repositories\ProjectRepository;
use App\Repositories\TaskRepository;

function seed_projects(int $projectTotal = 6, bool $resetExisting = false): void
{
$projectTotal = max(1, $projectTotal);

migration_manager()->runPending();
$database = database();

if ($resetExisting) {
$database->execute('DELETE FROM activity_log');
$database->execute('DELETE FROM tasks');
$database->execute('DELETE FROM project_members');
$database->execute('DELETE FROM projects');
}

$projects = [
[
'name' => 'Atlas Portfolio Rollout',
'client_name' => 'Atlas Financial',
'description' => 'A multi-quarter portfolio site refresh with content migration, editorial workflow, and KPI reporting.',
'status' => 'active',
'start_date' => '2026-02-10',
'due_date' => '2026-06-30',
'budget_cents' => 1450000,
'owner_name' => 'Maya Chen',
'color_token' => 'teal',
'members_text' => "Maya Chen\nJordan Ellis\nPriya Singh",
'tasks' => [
['title' => 'Finalize content inventory', 'description' => 'Audit all portfolio assets and map old URLs to the new structure.', 'status' => 'done', 'priority' => 'high', 'assignee' => 'Maya Chen', 'estimate_hours' => 12, 'due_date' => '2026-03-05'],
['title' => 'Build editorial review queue', 'description' => 'Create approvals and publish scheduling for content editors.', 'status' => 'in-progress', 'priority' => 'urgent', 'assignee' => 'Jordan Ellis', 'estimate_hours' => 28, 'due_date' => '2026-05-18'],
['title' => 'Launch executive dashboard', 'description' => 'Surface conversion, retention, and campaign metrics for leadership.', 'status' => 'review', 'priority' => 'high', 'assignee' => 'Priya Singh', 'estimate_hours' => 24, 'due_date' => '2026-05-22'],
['title' => 'Prepare stakeholder training', 'description' => 'Document the content workflow and publish timeline for the business team.', 'status' => 'backlog', 'priority' => 'normal', 'assignee' => 'Maya Chen', 'estimate_hours' => 10, 'due_date' => '2026-06-05'],
],
],
[
'name' => 'Northstar Launch Pad',
'client_name' => 'Northstar Logistics',
'description' => 'A cross-department launch program covering operations, onboarding, and service readiness.',
'status' => 'at-risk',
'start_date' => '2026-03-01',
'due_date' => '2026-05-28',
'budget_cents' => 980000,
'owner_name' => 'Diego Flores',
'color_token' => 'amber',
'members_text' => "Diego Flores\nAvery Brooks\nNina Patel",
'tasks' => [
['title' => 'Resolve deployment blocker', 'description' => 'Investigate the shipping API timeout and document the fix.', 'status' => 'blocked', 'priority' => 'urgent', 'assignee' => 'Avery Brooks', 'estimate_hours' => 16, 'due_date' => '2026-05-10'],
['title' => 'Coordinate ops handoff', 'description' => 'Align training, support, and fulfillment teams before launch.', 'status' => 'in-progress', 'priority' => 'high', 'assignee' => 'Diego Flores', 'estimate_hours' => 18, 'due_date' => '2026-05-19'],
['title' => 'Validate onboarding scripts', 'description' => 'Check each script against the latest workflow and signoff notes.', 'status' => 'backlog', 'priority' => 'normal', 'assignee' => 'Nina Patel', 'estimate_hours' => 8, 'due_date' => '2026-05-21'],
],
],
[
'name' => 'Studio Systems Upgrade',
'client_name' => 'Bluebird Studios',
'description' => 'An internal tooling rebuild that unifies scheduling, budgeting, and show reporting.',
'status' => 'planned',
'start_date' => '2026-04-15',
'due_date' => '2026-08-01',
'budget_cents' => 650000,
'owner_name' => 'Elena Rossi',
'color_token' => 'violet',
'members_text' => "Elena Rossi\nSam Walker",
'tasks' => [
['title' => 'Map legacy workflows', 'description' => 'Document existing scheduling and finance handoffs.', 'status' => 'backlog', 'priority' => 'normal', 'assignee' => 'Elena Rossi', 'estimate_hours' => 9, 'due_date' => '2026-05-30'],
['title' => 'Define reporting schema', 'description' => 'Outline the data that finance and production teams need.', 'status' => 'backlog', 'priority' => 'high', 'assignee' => 'Sam Walker', 'estimate_hours' => 14, 'due_date' => '2026-06-10'],
],
],
[
'name' => 'Summit Care Portal',
'client_name' => 'Summit Health',
'description' => 'A patient-facing portal modernization with secure messaging and appointment flow improvements.',
'status' => 'paused',
'start_date' => '2026-01-12',
'due_date' => '2026-07-15',
'budget_cents' => 2100000,
'owner_name' => 'Ari Johnson',
'color_token' => 'blue',
'members_text' => "Ari Johnson\nTaylor Nguyen\nRita Gomez",
'tasks' => [
['title' => 'Stabilize auth workflow', 'description' => 'Review the sign-in edge cases before resuming development.', 'status' => 'done', 'priority' => 'high', 'assignee' => 'Ari Johnson', 'estimate_hours' => 20, 'due_date' => '2026-03-28'],
['title' => 'Resume design review', 'description' => 'Pick up the paused UX feedback from the accessibility team.', 'status' => 'blocked', 'priority' => 'normal', 'assignee' => 'Taylor Nguyen', 'estimate_hours' => 6, 'due_date' => '2026-06-01'],
],
],
];

$projectRepo = new ProjectRepository($database);
$taskRepo = new TaskRepository($database);
$activityRepo = new ActivityRepository($database);

foreach (array_slice($projects, 0, $projectTotal) as $project) {
$projectId = $projectRepo->create([
'name' => $project['name'],
'code' => '',
'client_name' => $project['client_name'],
'description' => $project['description'],
'status' => $project['status'],
'start_date' => $project['start_date'],
'due_date' => $project['due_date'],
'budget_cents' => $project['budget_cents'],
'owner_name' => $project['owner_name'],
'color_token' => $project['color_token'],
'members' => array_map(
static function (string $name, int $index): array {
return [
'full_name' => $name,
'role' => $index === 0 ? 'Project Lead' : 'Contributor',
'allocation_percent' => $index === 0 ? 40 : 20,
];
},
preg_split('/[\r\n]+/', $project['members_text']) ?: [],
array_keys(preg_split('/[\r\n]+/', $project['members_text']) ?: [])
),
]);

foreach ($project['tasks'] as $task) {
$taskId = $taskRepo->create($projectId, $task);
$activityRepo->record(
'task_seeded',
'Seeded task ' . $task['title'],
'Sample project data loaded for the dashboard.',
$projectId,
$taskId
);
}
}
}

if (PHP_SAPI === 'cli' && realpath($_SERVER['SCRIPT_FILENAME'] ?? '') === __FILE__) {
$count = isset($argv[1]) ? max(1, (int) $argv[1]) : 6;
seed_projects($count, in_array('--reset', $argv, true));
}

+ 8
- 29
docs/README.md Прегледај датотеку

@@ -1,46 +1,35 @@
# MindVisionCode PHP
# Project Compass PHP

A small PHP MVC framework inspired by a Classic ASP MVC framework.
A small PHP MVC app for managing projects, tasks, and activity.

## Run

```bash
composer install
php scripts/migrate.php up
php scripts/seed_projects.php --reset
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
http://localhost:8000/
```

## Request Flow

Browser → public/index.php → Request → Dispatcher → Router → Route → Controller → ViewModel/Repository → View → Response
Browser → public/index.php → Request → Dispatcher → Router → Route → Controller → 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
- `database/seed_projects.php` sample data loader
- `scripts/` runnable PHP CLI scripts

## SQLite
@@ -51,24 +40,14 @@ The default database is SQLite and points to:
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 make create_project_management_tables
php scripts/migrate.php fresh
php scripts/migrate.php fresh --seed
php scripts/seed_employees.php 1000
php scripts/seed_projects.php 6
```

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

+ 169
- 764
public/css/site.css Прегледај датотеку

@@ -1,791 +1,196 @@
:root {
--page-background: #f4efe7;
--bg: #f4efe8;
--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;
}

--surface-border: rgba(19, 54, 49, 0.1);
--text: #153632;
--muted: #5d706b;
--line: rgba(19, 54, 49, 0.08);
--accent: #0f766e;
--accent-deep: #115e59;
--accent-soft: #dbf2ef;
--warning: #cb6b2c;
--danger: #be2c2c;
--success: #188a51;
--slate: #5a6b79;
--shadow-lg: 0 24px 60px rgba(19, 54, 49, 0.12);
--shadow-md: 0 16px 30px rgba(19, 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);
font-family: Inter, "Segoe UI", system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
color: var(--text);
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;
radial-gradient(circle at top left, rgba(15, 118, 110, 0.12), transparent 25%),
radial-gradient(circle at top right, rgba(203, 107, 44, 0.12), transparent 30%),
linear-gradient(180deg, #faf5ee 0%, var(--bg) 50%, #efe6db 100%);
}

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;
}
a { color: inherit; }
img { max-width: 100%; display: block; }
code, pre, kbd { font-family: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace; }

.page-shell { min-height: 100vh; display: flex; flex-direction: column; }
.container { width: min(1180px, calc(100% - 2rem)); margin: 0 auto; }
.page-content { flex: 1; padding: 2rem 0 4rem; }
.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;
position: sticky; top: 0; z-index: 20;
background: rgba(250, 245, 238, 0.84);
backdrop-filter: blur(16px);
border-bottom: 1px solid var(--line);
}

.brand {
display: inline-flex;
align-items: center;
gap: 0.85rem;
text-decoration: none;
}

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

width: 2.75rem; height: 2.75rem; border-radius: 0.95rem;
display: inline-flex; align-items: center; justify-content: center;
background: linear-gradient(135deg, var(--accent), #2dd4bf);
color: #fff; font-weight: 800; letter-spacing: 0.08em; box-shadow: var(--shadow-md);
}
.brand-copy { display: flex; flex-direction: column; line-height: 1.1; }
.brand-copy strong { font-size: 1rem; }
.brand-copy small { color: var(--muted); text-transform: uppercase; letter-spacing: 0.14em; font-size: 0.74rem; }
.site-nav { display: flex; flex-wrap: wrap; gap: 0.5rem; }
.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;
text-decoration: none; color: var(--muted); font-weight: 700;
padding: 0.7rem 1rem; border-radius: 999px; transition: transform 160ms ease, background 160ms ease, color 160ms ease;
}
.nav-link:hover, .nav-link:focus-visible, .nav-link.is-active { background: rgba(15, 118, 110, 0.1); color: var(--accent-deep); transform: translateY(-1px); }

.hero {
display: grid;
grid-template-columns: minmax(0, 1.5fr) minmax(280px, 0.9fr);
gap: 1.5rem;
align-items: stretch;
display: grid; grid-template-columns: minmax(0, 1.5fr) minmax(280px, 0.85fr); gap: 1.4rem;
}

.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;
.hero-copy, .hero-panel, .card, .panel, .stat-card, .metric-card, .task-card, .feed-item, .notice, .empty-state {
background: var(--surface); border: 1px solid var(--surface-border); box-shadow: var(--shadow-md);
}

.hero-copy { border-radius: 2rem; padding: 2.6rem; }
.hero h1, .section-title h1 { margin: 0; line-height: 0.98; letter-spacing: -0.05em; }
.hero h1 { font-size: clamp(2.8rem, 6vw, 4.9rem); }
.section-title h1 { font-size: clamp(2.1rem, 4vw, 3.6rem); }
.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;
}

display: inline-flex; align-items: center; gap: 0.4rem; margin-bottom: 1rem;
padding: 0.42rem 0.75rem; border-radius: 999px; background: var(--accent-soft); color: var(--accent-deep);
font-size: 0.78rem; font-weight: 800; text-transform: uppercase; letter-spacing: 0.16em;
}
.hero-text, .section-title p, .panel p, .card p, .feed-item p, .empty-state p { color: var(--muted); line-height: 1.7; }
.hero-text { font-size: 1.08rem; max-width: 52rem; margin: 1.2rem 0 0; }
.hero-actions, .toolbar, .summary-chips, .row-actions, .stack-actions { display: flex; flex-wrap: wrap; gap: 0.75rem; }
.hero-actions { margin-top: 1.8rem; }
.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: inline-flex; align-items: center; justify-content: center; gap: 0.5rem;
border: 0; text-decoration: none; font-weight: 800; padding: 0.9rem 1.2rem; border-radius: 999px; cursor: pointer;
}
.button-primary { background: linear-gradient(135deg, var(--accent), var(--accent-deep)); color: #fff; }
.button-secondary { background: rgba(15, 118, 110, 0.08); color: var(--accent-deep); }
.button-ghost { background: transparent; color: var(--text); border: 1px solid var(--surface-border); }
.button:hover, .button:focus-visible { transform: translateY(-1px); }

.hero-panel { border-radius: 1.8rem; padding: 1.8rem; display: grid; gap: 1rem; }
.panel, .card, .stat-card, .metric-card, .task-card, .feed-item, .notice, .empty-state { border-radius: 1.5rem; }
.panel, .card, .stat-card, .metric-card, .notice, .empty-state { padding: 1.25rem; }
.panel h2, .card h2, .metric-card strong { margin: 0; }
.panel h2 { font-size: 1.15rem; }
.panel-label, .meta-label, .kicker { text-transform: uppercase; letter-spacing: 0.14em; font-size: 0.74rem; color: var(--muted); font-weight: 800; }
.project-grid, .metrics-grid, .stats-grid, .feed-grid { display: grid; gap: 1rem; }
.metrics-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); margin-top: 1.2rem; }
.stats-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.project-list { display: grid; gap: 1rem; margin-top: 1rem; }
.card-grid { display: grid; gap: 1rem; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); }
.project-card { display: grid; gap: 0.9rem; padding: 1.2rem; }
.project-card .project-top, .project-card .project-bottom, .project-card .project-meta, .project-card .project-status { display: flex; align-items: center; justify-content: space-between; gap: 0.75rem; flex-wrap: wrap; }
.project-card h3 { margin: 0; font-size: 1.1rem; }
.progress { width: 100%; height: 0.75rem; background: rgba(19, 54, 49, 0.08); border-radius: 999px; overflow: hidden; }
.progress > span { display: block; height: 100%; border-radius: inherit; background: linear-gradient(90deg, var(--accent), #34d399); }
.tag, .status-pill, .mini-pill {
display: inline-flex; align-items: center; gap: 0.35rem; padding: 0.35rem 0.68rem; border-radius: 999px; font-size: 0.76rem; font-weight: 800;
}
.is-neutral { background: rgba(90, 107, 121, 0.12); color: var(--slate); }
.is-blue { background: rgba(15, 118, 110, 0.12); color: var(--accent-deep); }
.is-amber { background: rgba(203, 107, 44, 0.14); color: var(--warning); }
.is-red { background: rgba(190, 44, 44, 0.12); color: var(--danger); }
.is-green { background: rgba(24, 138, 81, 0.12); color: var(--success); }
.is-slate { background: rgba(90, 107, 121, 0.12); color: var(--slate); }

.shell {
display: grid;
grid-template-columns: minmax(320px, 0.9fr) minmax(0, 1.5fr);
gap: 1.5rem;
grid-template-columns: minmax(0, 1.2fr) minmax(320px, 0.7fr);
gap: 1.1rem;
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 {
.board-shell { display: grid; gap: 1rem; }
.board { display: grid; gap: 1rem; grid-template-columns: repeat(5, minmax(0, 1fr)); }
.column { display: grid; gap: 0.85rem; align-content: start; background: rgba(255,255,255,0.4); border: 1px solid var(--surface-border); border-radius: 1.4rem; padding: 1rem; }
.column header { display: flex; justify-content: space-between; align-items: center; gap: 0.5rem; }
.column h3 { margin: 0; font-size: 0.98rem; }
.column .count { color: var(--muted); font-size: 0.84rem; font-weight: 700; }
.task-card { padding: 1rem; display: grid; gap: 0.75rem; }
.task-card header, .task-card footer, .task-card .task-meta { display: flex; justify-content: space-between; align-items: center; gap: 0.6rem; flex-wrap: wrap; }
.task-card h4 { margin: 0; font-size: 1rem; }
.task-card p { margin: 0; font-size: 0.95rem; }
.task-card .task-body { display: grid; gap: 0.5rem; }
.task-card .task-footnote { font-size: 0.82rem; color: var(--muted); }
.task-form, .project-form, .filter-bar { display: grid; gap: 1rem; }
.form-grid { display: grid; gap: 1rem; grid-template-columns: repeat(2, minmax(0, 1fr)); }
.field { display: grid; gap: 0.45rem; }
.field span { font-size: 0.86rem; font-weight: 700; color: var(--muted); }
.input, .select, .textarea {
width: 100%; border-radius: 1rem; border: 1px solid var(--surface-border); background: rgba(255,255,255,0.85);
padding: 0.9rem 1rem; color: var(--text); font: inherit; outline: none;
}
.textarea { min-height: 10rem; resize: vertical; }
.input:focus, .select:focus, .textarea:focus { border-color: rgba(15,118,110,0.4); box-shadow: 0 0 0 4px rgba(15,118,110,0.12); }
.helper, .field-error, .fineprint { color: var(--muted); font-size: 0.84rem; line-height: 1.5; }
.field-error { color: var(--danger); }
.alert { border-radius: 1rem; padding: 0.95rem 1rem; }
.alert-success { background: rgba(24, 138, 81, 0.12); color: var(--success); }
.alert-error { background: rgba(190, 44, 44, 0.12); color: var(--danger); }
.stack { display: grid; gap: 1rem; }
.layout-two { display: grid; gap: 1rem; grid-template-columns: minmax(0, 1.3fr) minmax(320px, 0.7fr); }
.side-stack { display: grid; gap: 1rem; }
.kpi { display: flex; flex-direction: column; gap: 0.35rem; }
.kpi strong { font-size: 1.6rem; }
.list { display: grid; gap: 0.8rem; }
.list-item, .feed-item { padding: 1rem; }
.feed-item { display: grid; gap: 0.45rem; }
.feed-item header { display: flex; justify-content: space-between; gap: 0.75rem; flex-wrap: wrap; }
.board-actions, .project-actions, .task-actions { display: flex; flex-wrap: wrap; gap: 0.6rem; }
.badge-row { display: flex; flex-wrap: wrap; gap: 0.5rem; }
.board-header { display: flex; justify-content: space-between; align-items: center; gap: 0.75rem; flex-wrap: wrap; }
.project-header { display: grid; gap: 1rem; }
.project-hero {
display: grid; gap: 1rem; padding: 1.4rem; border-radius: 1.8rem; background: linear-gradient(135deg, rgba(15,118,110,0.12), rgba(255,255,255,0.66));
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;
}
.project-meta-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 1rem; }
.meta-card { padding: 0.95rem 1rem; border-radius: 1.2rem; background: rgba(255,255,255,0.8); border: 1px solid var(--surface-border); }
.meta-card span { display: block; color: var(--muted); font-size: 0.8rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.12em; }
.meta-card strong { display: block; margin-top: 0.4rem; font-size: 1rem; }
.members-list { display: grid; gap: 0.65rem; }
.member-chip { display: flex; justify-content: space-between; gap: 0.75rem; align-items: center; padding: 0.75rem 0.9rem; border-radius: 1rem; background: rgba(255,255,255,0.72); border: 1px solid var(--surface-border); }
.member-chip strong { font-size: 0.95rem; }
.member-chip small { color: var(--muted); }
.block-title { display: flex; justify-content: space-between; align-items: center; gap: 0.75rem; flex-wrap: wrap; }
.board-toolbar, .filter-bar { display: flex; flex-wrap: wrap; gap: 0.75rem; align-items: end; }
.layout-top { display: flex; justify-content: space-between; align-items: end; gap: 1rem; flex-wrap: wrap; }
.empty-state { text-align: center; padding: 2rem; }
.site-footer { border-top: 1px solid var(--line); padding: 1.8rem 0 2.4rem; color: var(--muted); }
.footer-inner { display: flex; justify-content: space-between; gap: 1rem; flex-wrap: wrap; }
.skip-link { position: absolute; left: -9999px; top: 1rem; }
.skip-link:focus { left: 1rem; z-index: 30; background: #fff; padding: 0.75rem 1rem; border-radius: 0.8rem; }

@media (max-width: 1100px) {
.hero, .shell, .layout-two, .project-meta-grid, .metrics-grid, .stats-grid, .board { grid-template-columns: 1fr 1fr; }
.board { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}

@media (max-width: 820px) {
.header-inner, .layout-top, .board-header, .project-meta-grid, .stats-grid, .metrics-grid, .board, .shell, .layout-two { grid-template-columns: 1fr; }
.header-inner, .footer-inner { flex-direction: column; align-items: flex-start; }
.hero { grid-template-columns: 1fr; }
.form-grid { grid-template-columns: 1fr; }
.board { grid-template-columns: 1fr; }
}

+ 1
- 1
public/index.php Прегледај датотеку

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

declare(strict_types=1);

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

use Core\App;
use Core\Dispatcher;


+ 9
- 56
public/js/app.js Прегледај датотеку

@@ -1,64 +1,17 @@
window.employeeDirectory = function () {
window.projectCompass = 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();
const filter = document.querySelector('[data-live-filter]');
if (!filter) {
return;
}

this.table.setData('/employees/data', {
search: this.search,
filter.addEventListener('input', () => {
const query = filter.value.trim().toLowerCase();
document.querySelectorAll('[data-filter-item]').forEach((item) => {
const text = item.textContent?.toLowerCase() ?? '';
item.style.display = text.includes(query) ? '' : 'none';
});
});
},
};


+ 24
- 0
public/web.config Прегледај датотеку

@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<system.webServer>
<defaultDocument>
<files>
<clear />
<add value="index.php" />
</files>
</defaultDocument>

<rewrite>
<rules>
<rule name="Front Controller" stopProcessing="true">
<match url=".*" />
<conditions logicalGrouping="MatchAll">
<add input="{REQUEST_FILENAME}" matchType="IsFile" negate="true" />
<add input="{REQUEST_FILENAME}" matchType="IsDirectory" negate="true" />
</conditions>
<action type="Rewrite" url="index.php" />
</rule>
</rules>
</rewrite>
</system.webServer>
</configuration>

+ 10
- 7
routes/web.php Прегледај датотеку

@@ -3,12 +3,15 @@
declare(strict_types=1);

use App\Controllers\HomeController;
use App\Controllers\EmployeeController;
use App\Controllers\ProjectController;
use App\Controllers\TaskController;

$router->get('/', [HomeController::class, 'index']);
$router->get('/users/{id}', [HomeController::class, 'user']);
$router->get('/employees', [EmployeeController::class, 'index']);
$router->get('/employees/create', [EmployeeController::class, 'create']);
$router->get('/employees/summary', [EmployeeController::class, 'summary']);
$router->get('/employees/data', [EmployeeController::class, 'data']);
$router->post('/employees', [EmployeeController::class, 'store']);
$router->get('/projects', [ProjectController::class, 'index']);
$router->get('/projects/create', [ProjectController::class, 'create']);
$router->post('/projects', [ProjectController::class, 'store']);
$router->get('/projects/{id}', [ProjectController::class, 'show']);
$router->post('/projects/{id}/status', [ProjectController::class, 'updateStatus']);
$router->post('/projects/{id}/tasks', [TaskController::class, 'store']);
$router->post('/tasks/{id}/status', [TaskController::class, 'updateStatus']);
$router->get('/activity', [HomeController::class, 'activity']);

+ 1
- 1
scripts/README.md Прегледај датотеку

@@ -8,7 +8,7 @@ Examples:
php scripts/migrate.php up
php scripts/migrate.php status
php scripts/migrate.php fresh --seed
php scripts/seed_employees.php 1000
php scripts/seed_projects.php --reset
```

Guidelines:


+ 4
- 4
scripts/migrate.php Прегледај датотеку

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

declare(strict_types=1);

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

$command = $argv[1] ?? 'help';
$options = array_slice($argv, 2);
@@ -62,7 +62,7 @@ try {
$name = $argv[2] ?? '';

if ($name === '') {
throw new InvalidArgumentException('Provide a migration name. Example: php scripts/migrate.php make create_projects_table');
throw new InvalidArgumentException('Provide a migration name. Example: php scripts/migrate.php make create_project_management_tables');
}

$path = $manager->make($name);
@@ -81,8 +81,8 @@ try {
}

if (in_array('--seed', $options, true)) {
require __DIR__ . '/../database/seed_employees.php';
seed_employees(1000, true);
require __DIR__ . '/../database/seed_projects.php';
seed_projects(6, true);
}

echo "Fresh migration run complete." . PHP_EOL;


+ 0
- 8
scripts/seed_employees.php Прегледај датотеку

@@ -1,8 +0,0 @@
<?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);

+ 11
- 0
scripts/seed_projects.php Прегледај датотеку

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

declare(strict_types=1);

require_once __DIR__ . '/../autoload.php';
require_once __DIR__ . '/../database/seed_projects.php';

$count = isset($argv[1]) ? max(1, (int) $argv[1]) : 6;
$reset = in_array('--reset', $argv, true);

seed_projects($count, $reset);

+ 80
- 87
tests/run.php Прегледај датотеку

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

declare(strict_types=1);

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

use Core\App;
use Core\Database;
@@ -11,32 +11,10 @@ use Core\MigrationManager;
use Core\Request;
use Core\Router;

$tempMigrationPath = sys_get_temp_dir() . '/mvc_migrations_' . uniqid('', true);
$tempMigrationPath = sys_get_temp_dir() . '/project_compass_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
);
copy(__DIR__ . '/../database/migrations/20260509_000001_create_project_management_tables.php', $tempMigrationPath . '/20260509_000001_create_projects_tables.php');

$memoryDatabase = new Database([
'dsn' => 'sqlite::memory:',
@@ -46,100 +24,115 @@ $memoryDatabase = new Database([
],
]);

$migrationManager = new MigrationManager($memoryDatabase, $tempMigrationPath);
$ran = $migrationManager->runPending();
set_database($memoryDatabase);
(new MigrationManager($memoryDatabase, $tempMigrationPath))->runPending();
require_once __DIR__ . '/../database/seed_projects.php';
seed_projects(4, true);

if ($ran !== ['20260509_120000_create_projects_table.php']) {
echo "FAIL: migration manager did not apply the expected migration\n";
exit(1);
}
$router = new Router();
$app = new App();
require_once __DIR__ . '/../routes/web.php';

$projectTable = $memoryDatabase->first("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'projects'");
$dispatcher = new Dispatcher($router, $app);

if ($projectTable === null) {
echo "FAIL: migration up() did not create the projects table\n";
$home = $dispatcher->dispatch(new Request([], [], [
'REQUEST_METHOD' => 'GET',
'REQUEST_URI' => '/',
]));

if ($home->status() !== 200 || strpos($home->content(), 'Project Compass') === false) {
echo "FAIL: dashboard did not render\n";
exit(1);
}

$rolledBack = $migrationManager->rollback();
$projectsPage = $dispatcher->dispatch(new Request([], [], [
'REQUEST_METHOD' => 'GET',
'REQUEST_URI' => '/projects',
]));

if ($rolledBack !== ['20260509_120000_create_projects_table.php']) {
echo "FAIL: migration manager did not roll back the expected migration\n";
if ($projectsPage->status() !== 200 || strpos($projectsPage->content(), 'Projects') === false) {
echo "FAIL: projects list did not render\n";
exit(1);
}

$projectTableAfterRollback = $memoryDatabase->first("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'projects'");
$createPage = $dispatcher->dispatch(new Request([], [], [
'REQUEST_METHOD' => 'GET',
'REQUEST_URI' => '/projects/create',
]));

if ($projectTableAfterRollback !== null) {
echo "FAIL: migration down() did not remove the projects table\n";
if ($createPage->status() !== 200 || strpos($createPage->content(), 'Create project') === false) {
echo "FAIL: create project page did not render\n";
exit(1);
}

$createdMigrationPath = $migrationManager->make('create_tasks_table');
$projectPage = $dispatcher->dispatch(new Request([], [], [
'REQUEST_METHOD' => 'GET',
'REQUEST_URI' => '/projects/1',
]));

if (!file_exists($createdMigrationPath)) {
echo "FAIL: migration manager did not create a migration file\n";
if ($projectPage->status() !== 200 || strpos($projectPage->content(), 'Kanban board') === false) {
echo "FAIL: project board did not render\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',
$newProjectRequest = new Request([
'_token' => csrf_token(),
], [
'_token' => csrf_token(),
'name' => 'Orbit Mobile Relaunch',
'code' => '',
'client_name' => 'Orbit Labs',
'description' => 'A fresh mobile experience with project tracking and delivery visibility.',
'status' => 'planned',
'start_date' => '2026-05-09',
'due_date' => '2026-08-09',
'budget_cents' => '750000',
'owner_name' => 'Jordan Ellis',
'color_token' => 'emerald',
'members_text' => "Jordan Ellis\nRiley Kim",
], [
'REQUEST_METHOD' => 'POST',
'REQUEST_URI' => '/projects',
]);

$response = (new Dispatcher($router, $app))->dispatch($request);
$created = $dispatcher->dispatch($newProjectRequest);

if ($response->status() !== 200) {
echo "FAIL: expected status 200\n";
if ($created->status() !== 302) {
echo "FAIL: project creation did not redirect\n";
exit(1);
}

if ($response->content() !== 'Hello, Daniel') {
echo "FAIL: unexpected response content\n";
exit(1);
}
$taskRequest = new Request([
'_token' => csrf_token(),
], [
'_token' => csrf_token(),
'title' => 'Design the intake flow',
'description' => 'Create a simple intake flow for requests and approvals.',
'priority' => 'high',
'status' => 'backlog',
'assignee' => 'Jordan Ellis',
'estimate_hours' => '10',
'due_date' => '2026-05-20',
], [
'REQUEST_METHOD' => 'POST',
'REQUEST_URI' => '/projects/5/tasks',
]);

$employeePage = (new Dispatcher($router, $app))->dispatch(new Request([], [], [
'REQUEST_METHOD' => 'GET',
'REQUEST_URI' => '/employees',
]));
$taskCreated = $dispatcher->dispatch($taskRequest);

if ($employeePage->status() !== 200) {
echo "FAIL: expected employee page status 200\n";
if ($taskCreated->status() !== 302) {
echo "FAIL: task creation did not redirect\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' => '',
], [], [
$activity = $dispatcher->dispatch(new Request([], [], [
'REQUEST_METHOD' => 'GET',
'REQUEST_URI' => '/employees/data',
'REQUEST_URI' => '/activity',
]));

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";
if ($activity->status() !== 200 || strpos($activity->content(), 'Activity feed') === false) {
echo "FAIL: activity feed did not render\n";
exit(1);
}

echo "PASS: migration manager and route dispatch work\n";
echo "PASS: project management app routes and data flow work\n";

Loading…
Откажи
Сачувај

Powered by TurnKey Linux.