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