| @@ -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; | namespace App\Controllers; | ||||
| use App\ViewModels\HomeIndexViewModel; | |||||
| use App\Repositories\ActivityRepository; | |||||
| use App\Repositories\ProjectRepository; | |||||
| use App\Repositories\TaskRepository; | |||||
| use Core\Controller; | use Core\Controller; | ||||
| class HomeController extends Controller | class HomeController extends Controller | ||||
| { | { | ||||
| public function index() | 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', [ | 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"> | <section class="hero"> | ||||
| <div class="hero-copy"> | <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"> | <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> | ||||
| </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> | </div> | ||||
| </aside> | </aside> | ||||
| </section> | </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> | </section> | ||||
| @@ -5,7 +5,7 @@ declare(strict_types=1); | |||||
| require __DIR__ . '/../partials/header.php'; | require __DIR__ . '/../partials/header.php'; | ||||
| ?> | ?> | ||||
| <main class="page-content"> | |||||
| <main id="main-content" class="page-content"> | |||||
| <div class="container"> | <div class="container"> | ||||
| <?= $content ?> | <?= $content ?> | ||||
| </div> | </div> | ||||
| @@ -1,7 +1,7 @@ | |||||
| <footer class="site-footer"> | <footer class="site-footer"> | ||||
| <div class="container footer-inner"> | <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> | </div> | ||||
| </footer> | </footer> | ||||
| </div> | </div> | ||||
| @@ -3,9 +3,10 @@ | |||||
| declare(strict_types=1); | declare(strict_types=1); | ||||
| $navigationItems = [ | $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); | $currentPath = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH); | ||||
| @@ -16,32 +17,27 @@ $currentPath = is_string($currentPath) && $currentPath !== '' ? $currentPath : ' | |||||
| <head> | <head> | ||||
| <meta charset="UTF-8"> | <meta charset="UTF-8"> | ||||
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | <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')) ?>"> | <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 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> | </head> | ||||
| <body> | |||||
| <body <?= isset($bodyClass) ? 'class="' . e($bodyClass) . '"' : '' ?>> | |||||
| <div class="page-shell"> | <div class="page-shell"> | ||||
| <a class="skip-link" href="#main-content">Skip to content</a> | |||||
| <header class="site-header"> | <header class="site-header"> | ||||
| <div class="container header-inner"> | <div class="container header-inner"> | ||||
| <a class="brand" href="/"> | <a class="brand" href="/"> | ||||
| <span class="brand-mark">MV</span> | |||||
| <span class="brand-mark">PC</span> | |||||
| <span class="brand-copy"> | <span class="brand-copy"> | ||||
| <strong>MindVisionCode</strong> | |||||
| <small>PHP MVC</small> | |||||
| <strong>Project Compass</strong> | |||||
| <small>Portfolio command center</small> | |||||
| </span> | </span> | ||||
| </a> | </a> | ||||
| <nav class="site-nav" aria-label="Primary navigation"> | <nav class="site-nav" aria-label="Primary navigation"> | ||||
| <?php foreach ($navigationItems as $item): ?> | <?php foreach ($navigationItems as $item): ?> | ||||
| <?php $isActive = $currentPath === $item['href']; ?> | <?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; ?> | <?php endforeach; ?> | ||||
| </nav> | </nav> | ||||
| </div> | </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", | "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", | "type": "project", | ||||
| "autoload": { | "autoload": { | ||||
| "psr-4": { | "psr-4": { | ||||
| @@ -16,7 +16,9 @@ | |||||
| "migrate:down": "php scripts/migrate.php down", | "migrate:down": "php scripts/migrate.php down", | ||||
| "migrate:status": "php scripts/migrate.php status", | "migrate:status": "php scripts/migrate.php status", | ||||
| "migrate:fresh": "php scripts/migrate.php fresh", | "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": {} | "require": {} | ||||
| } | } | ||||
| @@ -63,6 +63,6 @@ class View | |||||
| return $data['model']->title; | return $data['model']->title; | ||||
| } | } | ||||
| return 'MindVisionCode PHP'; | |||||
| return 'Project Compass'; | |||||
| } | } | ||||
| } | } | ||||
| @@ -5,6 +5,7 @@ declare(strict_types=1); | |||||
| use Core\App; | use Core\App; | ||||
| use Core\Database; | use Core\Database; | ||||
| use Core\MigrationManager; | use Core\MigrationManager; | ||||
| use Core\Request; | |||||
| use Core\Response; | use Core\Response; | ||||
| use Core\View; | use Core\View; | ||||
| @@ -29,22 +30,49 @@ function redirect(string $url): Response | |||||
| return Response::redirect($url); | return Response::redirect($url); | ||||
| } | } | ||||
| function request(): Request | |||||
| { | |||||
| return Request::capture(); | |||||
| } | |||||
| function database(): Database | function database(): Database | ||||
| { | { | ||||
| static $database = null; | 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; | return $database; | ||||
| } | } | ||||
| function set_database(Database $database): void | |||||
| { | |||||
| global $databaseOverride; | |||||
| $databaseOverride = $database; | |||||
| } | |||||
| function reset_database(): void | |||||
| { | |||||
| global $databaseOverride; | |||||
| $databaseOverride = null; | |||||
| } | |||||
| function migration_manager(): MigrationManager | function migration_manager(): MigrationManager | ||||
| { | { | ||||
| static $migrationManager = null; | static $migrationManager = null; | ||||
| @@ -132,3 +160,78 @@ function verify_csrf_token(?string $token): bool | |||||
| return is_string($sessionToken) && hash_equals($sessionToken, $token); | 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 | ## Run | ||||
| ```bash | ```bash | ||||
| composer install | composer install | ||||
| php scripts/migrate.php up | php scripts/migrate.php up | ||||
| php scripts/seed_projects.php --reset | |||||
| php -S localhost:8000 -t public | php -S localhost:8000 -t public | ||||
| ``` | ``` | ||||
| Open: | Open: | ||||
| ```text | ```text | ||||
| http://localhost:8000 | |||||
| ``` | |||||
| Try: | |||||
| ```text | |||||
| http://localhost:8000/users/123 | |||||
| ``` | |||||
| Employee form: | |||||
| ```text | |||||
| http://localhost:8000/employees | |||||
| http://localhost:8000/ | |||||
| ``` | ``` | ||||
| ## Request Flow | ## 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 | ## Main Folders | ||||
| - `core/` framework classes | - `core/` framework classes | ||||
| - `app/Controllers/` application controllers | - `app/Controllers/` application controllers | ||||
| - `app/ViewModels/` view model classes | |||||
| - `app/Repositories/` data access classes | - `app/Repositories/` data access classes | ||||
| - `app/Views/` PHP templates | - `app/Views/` PHP templates | ||||
| - `routes/web.php` route definitions | - `routes/web.php` route definitions | ||||
| - `database/migrations/` migrations | - `database/migrations/` migrations | ||||
| - `database/seed_projects.php` sample data loader | |||||
| - `scripts/` runnable PHP CLI scripts | - `scripts/` runnable PHP CLI scripts | ||||
| ## SQLite | ## SQLite | ||||
| @@ -51,24 +40,14 @@ The default database is SQLite and points to: | |||||
| database/app.sqlite | database/app.sqlite | ||||
| ``` | ``` | ||||
| The database file is created automatically when the app first needs it. | |||||
| Run migrations from the PHP CLI: | Run migrations from the PHP CLI: | ||||
| ```bash | ```bash | ||||
| php scripts/migrate.php up | php scripts/migrate.php up | ||||
| php scripts/migrate.php down | php scripts/migrate.php down | ||||
| php scripts/migrate.php status | 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 | ||||
| php scripts/migrate.php fresh --seed | 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 { | :root { | ||||
| --page-background: #f4efe7; | |||||
| --bg: #f4efe8; | |||||
| --surface: rgba(255, 252, 247, 0.88); | --surface: rgba(255, 252, 247, 0.88); | ||||
| --surface-strong: #fffdf8; | --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 { | body { | ||||
| margin: 0; | margin: 0; | ||||
| min-height: 100vh; | 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: | 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 { | .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 { | .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 { | .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 { | .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 { | .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 { | .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; | 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; | 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: 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); | declare(strict_types=1); | ||||
| require_once __DIR__ . '/../vendor/autoload.php'; | |||||
| require_once __DIR__ . '/../autoload.php'; | |||||
| use Core\App; | use Core\App; | ||||
| use Core\Dispatcher; | use Core\Dispatcher; | ||||
| @@ -1,64 +1,17 @@ | |||||
| window.employeeDirectory = function () { | |||||
| window.projectCompass = function () { | |||||
| return { | return { | ||||
| search: '', | |||||
| table: null, | |||||
| init() { | 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; | 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); | declare(strict_types=1); | ||||
| use App\Controllers\HomeController; | use App\Controllers\HomeController; | ||||
| use App\Controllers\EmployeeController; | |||||
| use App\Controllers\ProjectController; | |||||
| use App\Controllers\TaskController; | |||||
| $router->get('/', [HomeController::class, 'index']); | $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 up | ||||
| php scripts/migrate.php status | php scripts/migrate.php status | ||||
| php scripts/migrate.php fresh --seed | php scripts/migrate.php fresh --seed | ||||
| php scripts/seed_employees.php 1000 | |||||
| php scripts/seed_projects.php --reset | |||||
| ``` | ``` | ||||
| Guidelines: | Guidelines: | ||||
| @@ -2,7 +2,7 @@ | |||||
| declare(strict_types=1); | declare(strict_types=1); | ||||
| require_once __DIR__ . '/../vendor/autoload.php'; | |||||
| require_once __DIR__ . '/../autoload.php'; | |||||
| $command = $argv[1] ?? 'help'; | $command = $argv[1] ?? 'help'; | ||||
| $options = array_slice($argv, 2); | $options = array_slice($argv, 2); | ||||
| @@ -62,7 +62,7 @@ try { | |||||
| $name = $argv[2] ?? ''; | $name = $argv[2] ?? ''; | ||||
| if ($name === '') { | 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); | $path = $manager->make($name); | ||||
| @@ -81,8 +81,8 @@ try { | |||||
| } | } | ||||
| if (in_array('--seed', $options, true)) { | 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; | 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); | declare(strict_types=1); | ||||
| require_once __DIR__ . '/../vendor/autoload.php'; | |||||
| require_once __DIR__ . '/../autoload.php'; | |||||
| use Core\App; | use Core\App; | ||||
| use Core\Database; | use Core\Database; | ||||
| @@ -11,32 +11,10 @@ use Core\MigrationManager; | |||||
| use Core\Request; | use Core\Request; | ||||
| use Core\Router; | 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); | 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([ | $memoryDatabase = new Database([ | ||||
| 'dsn' => 'sqlite::memory:', | '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); | 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); | 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); | 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); | 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); | 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); | 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_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); | 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.