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