diff --git a/README.md b/README.md new file mode 100644 index 0000000..8f233d7 --- /dev/null +++ b/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 +``` diff --git a/app/Controllers/EmployeeController.php b/app/Controllers/EmployeeController.php deleted file mode 100644 index 6ff7eae..0000000 --- a/app/Controllers/EmployeeController.php +++ /dev/null @@ -1,208 +0,0 @@ -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 - */ - 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 $form - * @return array> - */ - 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; - } -} diff --git a/app/Controllers/HomeController.php b/app/Controllers/HomeController.php index 230df0d..d6624b6 100644 --- a/app/Controllers/HomeController.php +++ b/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), ]); } } diff --git a/app/Controllers/ProjectController.php b/app/Controllers/ProjectController.php new file mode 100644 index 0000000..30df031 --- /dev/null +++ b/app/Controllers/ProjectController.php @@ -0,0 +1,209 @@ +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; + } +} diff --git a/app/Controllers/TaskController.php b/app/Controllers/TaskController.php new file mode 100644 index 0000000..2287615 --- /dev/null +++ b/app/Controllers/TaskController.php @@ -0,0 +1,160 @@ +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), + ]); + } +} diff --git a/app/Models/Employee.php b/app/Models/Employee.php deleted file mode 100644 index 6418db5..0000000 --- a/app/Models/Employee.php +++ /dev/null @@ -1,16 +0,0 @@ -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] + ); + } +} diff --git a/app/Repositories/EmployeeRepository.php b/app/Repositories/EmployeeRepository.php deleted file mode 100644 index 6de1b25..0000000 --- a/app/Repositories/EmployeeRepository.php +++ /dev/null @@ -1,122 +0,0 @@ -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> - */ - 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> - */ - 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} - */ - 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 . '%'], - ]; - } -} diff --git a/app/Repositories/ProjectRepository.php b/app/Repositories/ProjectRepository.php new file mode 100644 index 0000000..e9f7b15 --- /dev/null +++ b/app/Repositories/ProjectRepository.php @@ -0,0 +1,383 @@ +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'; + } +} diff --git a/app/Repositories/TaskRepository.php b/app/Repositories/TaskRepository.php new file mode 100644 index 0000000..3393637 --- /dev/null +++ b/app/Repositories/TaskRepository.php @@ -0,0 +1,153 @@ +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] + ); + } +} diff --git a/app/Repositories/UserRepository.php b/app/Repositories/UserRepository.php deleted file mode 100644 index a2e0d70..0000000 --- a/app/Repositories/UserRepository.php +++ /dev/null @@ -1,21 +0,0 @@ -database->first( - 'SELECT * FROM users WHERE email = :email', - ['email' => $email] - ); - } -} diff --git a/app/ViewModels/EmployeeFormViewModel.php b/app/ViewModels/EmployeeFormViewModel.php deleted file mode 100644 index 18800e2..0000000 --- a/app/ViewModels/EmployeeFormViewModel.php +++ /dev/null @@ -1,50 +0,0 @@ - - */ - public array $form = [ - 'first_name' => '', - 'last_name' => '', - 'email' => '', - 'department' => '', - 'job_title' => '', - 'start_date' => '', - ]; - - /** - * @var array> - */ - public array $errors = []; - - /** - * @var list> - */ - public array $employees = []; - - /** - * @var array - */ - public array $summary = [ - 'employee_count' => 0, - 'department_count' => 0, - 'latest_start_date' => 'N/A', - ]; - - /** - * @var array|null - */ - public ?array $newestEmployee = null; -} diff --git a/app/ViewModels/HomeIndexViewModel.php b/app/ViewModels/HomeIndexViewModel.php deleted file mode 100644 index 458b11f..0000000 --- a/app/ViewModels/HomeIndexViewModel.php +++ /dev/null @@ -1,13 +0,0 @@ - -
- eyebrow) ?> -

title) ?>

-

intro) ?>

-
- -
-
-
-

Employee Workspace

-

Use htmx for server updates, Alpine for page state, and Tabulator for a richer table experience.

-
- - -
- -
- -
-
- -
-
- -
- -
-
-

Employee Directory Table

-

Browse, search, and sort all employees in one place. The table refreshes after new employee records are saved.

-
- -
- HTMX + Alpine + Tabulator - Live data endpoint: /employees/data -
- -
-
-
- diff --git a/app/Views/employees/partials/form.php b/app/Views/employees/partials/form.php deleted file mode 100644 index 00d2b50..0000000 --- a/app/Views/employees/partials/form.php +++ /dev/null @@ -1,83 +0,0 @@ -
-
-

Add Employee

-

Store a clean employee record with basic contact and role details.

-
- - saved): ?> -
- Employee information was saved to SQLite successfully. -
- - - errors['_token'])): ?> -
errors['_token'][0]) ?>
- - -
- - -
- - - - - - - - - - - -
- -
- - Saving employee... -
-
-
diff --git a/app/Views/employees/partials/summary.php b/app/Views/employees/partials/summary.php deleted file mode 100644 index 57b4999..0000000 --- a/app/Views/employees/partials/summary.php +++ /dev/null @@ -1,34 +0,0 @@ -
-

Live Summary

-

Server-rendered fragments refresh here with htmx whenever employees change or search terms update.

-
- -
-
- Total Employees - summary['employee_count']) ?> -
- -
- Departments - summary['department_count']) ?> -
- -
- Latest Start Date - summary['latest_start_date']) ?> -
-
- -newestEmployee !== null): ?> -
- Newest matching record -

newestEmployee['first_name'] . ' ' . $model->newestEmployee['last_name']) ?>

-

newestEmployee['job_title']) ?> in newestEmployee['department']) ?>

-
- -
-

No matching employees yet.

-

Try a broader search or add a new employee record.

-
- diff --git a/app/Views/home/activity.php b/app/Views/home/activity.php new file mode 100644 index 0000000..bf08b46 --- /dev/null +++ b/app/Views/home/activity.php @@ -0,0 +1,52 @@ +
+
+ Activity feed +

What changed across the portfolio

+

A chronological log of project creation, status changes, and task movement.

+
+ +
+
+ +
+
+
+ +

+
+ +
+

+
+ +
+ + +
+
diff --git a/app/Views/home/index.php b/app/Views/home/index.php index 9cc963f..2322704 100644 --- a/app/Views/home/index.php +++ b/app/Views/home/index.php @@ -1,39 +1,135 @@
- eyebrow) ?> -

title) ?>

-

message) ?>

+ Command center +

See every project, task, and risk in one view.

+

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.

+ +
+
Projects
+
Active
+
Open tasks
+
Blocked
-
-
-
-

Readable by design

-

Small files, explicit routing, and plain PHP views keep the framework approachable for day-to-day work.

-
- -
-

Classic MVC feel

-

Controllers, repositories, and view models stay separate so request handling remains predictable and easy to follow.

-
- -
-

SQLite ready

-

Typed PHP 8.2 code, Composer autoloading, PDO access, and auto-run migrations make the project feel current without becoming heavyweight.

-
+
+
+
+
+ Featured projects +

Most active workstreams

+
+ Browse all +
+ +
+ +
+
+
+ +

+
+ +
+

+
+
+ tasks + + Budget +
+
+ +
+
+ +
+
+
+
+ Due soon +

Tasks needing attention

+
+
+
+ +
+
+ + +
+

·

+
+ +
+
+ +
+
+
+ Overdue +

Risk items

+
+
+
+ +
+
+ + +
+

·

+
+ +
+
+ +
+
+
+ Activity feed +

Recent changes

+
+ Open full feed +
+
+ +
+
+ + +
+

+
+ +
+
+
diff --git a/app/Views/layouts/app.php b/app/Views/layouts/app.php index 5796585..c030b68 100644 --- a/app/Views/layouts/app.php +++ b/app/Views/layouts/app.php @@ -5,7 +5,7 @@ declare(strict_types=1); require __DIR__ . '/../partials/header.php'; ?> -
+
diff --git a/app/Views/partials/footer.php b/app/Views/partials/footer.php index d2a38dd..edb57f2 100644 --- a/app/Views/partials/footer.php +++ b/app/Views/partials/footer.php @@ -1,7 +1,7 @@
diff --git a/app/Views/partials/header.php b/app/Views/partials/header.php index 7844f3a..8cd68fa 100644 --- a/app/Views/partials/header.php +++ b/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 : ' - <?= e($pageTitle ?? 'MindVisionCode PHP') ?> - + <?= e($pageTitle ?? 'Project Compass') ?> - - - - +>
+