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