| @@ -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 | |||
| ``` | |||
| @@ -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; | |||
| } | |||
| } | |||
| @@ -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), | |||
| ]); | |||
| } | |||
| } | |||
| @@ -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; | |||
| } | |||
| } | |||
| @@ -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), | |||
| ]); | |||
| } | |||
| } | |||
| @@ -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 = ''; | |||
| } | |||
| @@ -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 = ''; | |||
| } | |||
| @@ -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] | |||
| ); | |||
| } | |||
| } | |||
| @@ -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 . '%'], | |||
| ]; | |||
| } | |||
| } | |||
| @@ -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'; | |||
| } | |||
| } | |||
| @@ -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] | |||
| ); | |||
| } | |||
| } | |||
| @@ -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] | |||
| ); | |||
| } | |||
| } | |||
| @@ -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; | |||
| } | |||
| @@ -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 = ''; | |||
| } | |||
| @@ -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> | |||
| @@ -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> | |||
| @@ -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; ?> | |||
| @@ -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> | |||
| @@ -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> | |||
| @@ -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> | |||
| @@ -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>© <?= e((string) date('Y')) ?> MindVisionCode</p> | |||
| <p>Project Compass keeps delivery visible across projects, tasks, and team activity.</p> | |||
| <p>© <?= e((string) date('Y')) ?> Project Compass</p> | |||
| </div> | |||
| </footer> | |||
| </div> | |||
| @@ -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> | |||
| @@ -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> | |||
| @@ -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> | |||
| @@ -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> | |||
| @@ -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'; | |||
| @@ -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": {} | |||
| } | |||
| @@ -63,6 +63,6 @@ class View | |||
| return $data['model']->title; | |||
| } | |||
| return 'MindVisionCode PHP'; | |||
| return 'Project Compass'; | |||
| } | |||
| } | |||
| @@ -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', | |||
| }; | |||
| } | |||
| @@ -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'); | |||
| } | |||
| }; | |||
| @@ -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'); | |||
| } | |||
| }; | |||
| @@ -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); | |||
| } | |||
| @@ -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)); | |||
| } | |||
| @@ -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 | |||
| @@ -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; } | |||
| } | |||
| @@ -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; | |||
| @@ -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'; | |||
| }); | |||
| }); | |||
| }, | |||
| }; | |||
| @@ -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> | |||
| @@ -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']); | |||
| @@ -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: | |||
| @@ -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; | |||
| @@ -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); | |||
| @@ -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); | |||
| @@ -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"; | |||
Powered by TurnKey Linux.