A project management app derived from Mind-Vision-Code
25'ten fazla konu seçemezsiniz Konular bir harf veya rakamla başlamalı, kısa çizgiler ('-') içerebilir ve en fazla 35 karakter uzunluğunda olabilir.

384 satır
15KB

  1. <?php
  2. declare(strict_types=1);
  3. namespace App\Repositories;
  4. use Core\Database;
  5. class ProjectRepository
  6. {
  7. public function __construct(protected ?Database $database = null)
  8. {
  9. $this->database ??= database();
  10. }
  11. public function dashboardSummary(): array
  12. {
  13. $projectCount = (int) ($this->database->first('SELECT COUNT(*) AS total FROM projects')['total'] ?? 0);
  14. $activeProjects = (int) ($this->database->first(
  15. 'SELECT COUNT(*) AS total FROM projects WHERE status IN ("active", "at-risk")'
  16. )['total'] ?? 0);
  17. $openTasks = (int) ($this->database->first(
  18. 'SELECT COUNT(*) AS total FROM tasks WHERE status != "done"'
  19. )['total'] ?? 0);
  20. $blockedTasks = (int) ($this->database->first(
  21. 'SELECT COUNT(*) AS total FROM tasks WHERE status = "blocked"'
  22. )['total'] ?? 0);
  23. $dueSoonTasks = (int) ($this->database->first(
  24. '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")'
  25. )['total'] ?? 0);
  26. $overdueTasks = (int) ($this->database->first(
  27. 'SELECT COUNT(*) AS total FROM tasks WHERE status != "done" AND due_date IS NOT NULL AND date(due_date) < date("now")'
  28. )['total'] ?? 0);
  29. return [
  30. 'project_count' => $projectCount,
  31. 'active_projects' => $activeProjects,
  32. 'open_tasks' => $openTasks,
  33. 'blocked_tasks' => $blockedTasks,
  34. 'due_soon_tasks' => $dueSoonTasks,
  35. 'overdue_tasks' => $overdueTasks,
  36. ];
  37. }
  38. public function recent(int $limit = 6): array
  39. {
  40. $limit = max(1, min(25, $limit));
  41. $rows = $this->database->query(
  42. 'SELECT p.*,
  43. COUNT(t.id) AS task_count,
  44. SUM(CASE WHEN t.status = "done" THEN 1 ELSE 0 END) AS done_task_count,
  45. SUM(CASE WHEN t.status = "blocked" THEN 1 ELSE 0 END) AS blocked_task_count,
  46. 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,
  47. MAX(t.updated_at) AS latest_task_update
  48. FROM projects p
  49. LEFT JOIN tasks t ON t.project_id = p.id
  50. GROUP BY p.id
  51. 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,
  52. p.updated_at DESC,
  53. p.id DESC
  54. LIMIT ' . $limit
  55. );
  56. return array_map([$this, 'decorateProjectRow'], $rows);
  57. }
  58. public function all(string $search = '', string $status = ''): array
  59. {
  60. $where = [];
  61. $params = [];
  62. if ($search !== '') {
  63. $where[] = '(p.name LIKE :search OR p.code LIKE :search OR p.client_name LIKE :search OR p.description LIKE :search)';
  64. $params['search'] = '%' . $search . '%';
  65. }
  66. if ($status !== '') {
  67. $where[] = 'p.status = :status';
  68. $params['status'] = $status;
  69. }
  70. $sql = 'SELECT p.*,
  71. COUNT(t.id) AS task_count,
  72. SUM(CASE WHEN t.status = "done" THEN 1 ELSE 0 END) AS done_task_count,
  73. SUM(CASE WHEN t.status = "blocked" THEN 1 ELSE 0 END) AS blocked_task_count,
  74. 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,
  75. MAX(t.updated_at) AS latest_task_update
  76. FROM projects p
  77. LEFT JOIN tasks t ON t.project_id = p.id';
  78. if ($where !== []) {
  79. $sql .= ' WHERE ' . implode(' AND ', $where);
  80. }
  81. $sql .= ' GROUP BY p.id
  82. 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,
  83. COALESCE(p.due_date, "9999-12-31") ASC,
  84. p.updated_at DESC,
  85. p.id DESC';
  86. return array_map(
  87. [$this, 'decorateProjectRow'],
  88. $this->database->query($sql, $params)
  89. );
  90. }
  91. public function find(int $projectId): ?array
  92. {
  93. $project = $this->database->first(
  94. 'SELECT p.*,
  95. COUNT(t.id) AS task_count,
  96. SUM(CASE WHEN t.status = "done" THEN 1 ELSE 0 END) AS done_task_count,
  97. SUM(CASE WHEN t.status = "blocked" THEN 1 ELSE 0 END) AS blocked_task_count,
  98. 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,
  99. MAX(t.updated_at) AS latest_task_update
  100. FROM projects p
  101. LEFT JOIN tasks t ON t.project_id = p.id
  102. WHERE p.id = :id
  103. GROUP BY p.id',
  104. ['id' => $projectId]
  105. );
  106. if ($project === null) {
  107. return null;
  108. }
  109. return $this->decorateProjectDetail($project);
  110. }
  111. public function findBoard(int $projectId): ?array
  112. {
  113. $project = $this->find($projectId);
  114. if ($project === null) {
  115. return null;
  116. }
  117. $tasks = $this->database->query(
  118. 'SELECT * FROM tasks WHERE project_id = :project_id ORDER BY
  119. CASE status
  120. WHEN "backlog" THEN 0
  121. WHEN "in-progress" THEN 1
  122. WHEN "review" THEN 2
  123. WHEN "blocked" THEN 3
  124. WHEN "done" THEN 4
  125. ELSE 5
  126. END,
  127. CASE priority
  128. WHEN "urgent" THEN 0
  129. WHEN "high" THEN 1
  130. WHEN "normal" THEN 2
  131. ELSE 3
  132. END,
  133. due_date IS NULL,
  134. due_date ASC,
  135. position ASC,
  136. id ASC',
  137. ['project_id' => $projectId]
  138. );
  139. $buckets = [
  140. 'backlog' => [],
  141. 'in-progress' => [],
  142. 'review' => [],
  143. 'blocked' => [],
  144. 'done' => [],
  145. ];
  146. foreach ($tasks as $task) {
  147. $task['is_overdue'] = $task['status'] !== 'done' && !empty($task['due_date']) && strtotime((string) $task['due_date']) < strtotime(date('Y-m-d'));
  148. $task['priority_label'] = ucfirst(str_replace('-', ' ', $task['priority']));
  149. $task['status_label'] = task_status_label($task['status']);
  150. $task['status_class'] = status_class($task['status']);
  151. $bucketKey = array_key_exists($task['status'], $buckets) ? $task['status'] : 'backlog';
  152. $buckets[$bucketKey][] = $task;
  153. }
  154. $members = $this->database->query(
  155. 'SELECT * FROM project_members WHERE project_id = :project_id ORDER BY is_primary DESC, id ASC',
  156. ['project_id' => $projectId]
  157. );
  158. $project['members'] = $members;
  159. $project['task_buckets'] = $buckets;
  160. $project['tasks'] = $tasks;
  161. $project['progress_percent'] = $this->progressPercentage($projectId, (int) $project['task_count'], (int) $project['done_task_count']);
  162. $project['throughput'] = $this->throughputLabel((int) $project['done_task_count'], (int) $project['task_count']);
  163. $project['health_class'] = $this->healthClass((string) $project['status'], (int) $project['overdue_task_count']);
  164. $project['latest_activity'] = $this->database->first(
  165. 'SELECT created_at FROM activity_log WHERE project_id = :project_id ORDER BY created_at DESC, id DESC LIMIT 1',
  166. ['project_id' => $projectId]
  167. )['created_at'] ?? null;
  168. return $project;
  169. }
  170. public function create(array $data): int
  171. {
  172. $code = $data['code'] !== '' ? strtoupper($data['code']) : $this->generateProjectCode($data['name']);
  173. $code = $this->ensureUniqueCode($code);
  174. $this->database->execute(
  175. 'INSERT INTO projects (
  176. code, name, client_name, description, status, start_date, due_date, budget_cents, owner_name, color_token
  177. ) VALUES (
  178. :code, :name, :client_name, :description, :status, :start_date, :due_date, :budget_cents, :owner_name, :color_token
  179. )',
  180. [
  181. 'code' => $code,
  182. 'name' => $data['name'],
  183. 'client_name' => $data['client_name'] ?? '',
  184. 'description' => $data['description'] ?? '',
  185. 'status' => $data['status'] ?? 'planned',
  186. 'start_date' => $data['start_date'] ?? null,
  187. 'due_date' => $data['due_date'] ?? null,
  188. 'budget_cents' => $data['budget_cents'] ?? 0,
  189. 'owner_name' => $data['owner_name'] ?? '',
  190. 'color_token' => $data['color_token'] ?? 'teal',
  191. ]
  192. );
  193. $projectId = (int) $this->database->pdo()->lastInsertId();
  194. if (!empty($data['members']) && is_array($data['members'])) {
  195. $this->saveMembers($projectId, $data['members']);
  196. }
  197. (new ActivityRepository($this->database))->record(
  198. 'project_created',
  199. 'Created project ' . $data['name'],
  200. trim((($data['client_name'] ?? '') !== '' ? 'Client: ' . $data['client_name'] : '') . (($data['description'] ?? '') !== '' ? ' ' . ($data['description'] ?? '') : '')),
  201. $projectId,
  202. null
  203. );
  204. return $projectId;
  205. }
  206. public function updateStatus(int $projectId, string $status): bool
  207. {
  208. $project = $this->database->first('SELECT id, name, status FROM projects WHERE id = :id', ['id' => $projectId]);
  209. if ($project === null) {
  210. return false;
  211. }
  212. $this->database->execute(
  213. 'UPDATE projects SET status = :status, updated_at = CURRENT_TIMESTAMP WHERE id = :id',
  214. [
  215. 'status' => $status,
  216. 'id' => $projectId,
  217. ]
  218. );
  219. (new ActivityRepository($this->database))->record(
  220. 'project_status_changed',
  221. 'Project moved to ' . project_status_label($status),
  222. 'Project status updated from ' . project_status_label($project['status']) . ' to ' . project_status_label($status) . '.',
  223. $projectId,
  224. null
  225. );
  226. return true;
  227. }
  228. protected function decorateProjectRow(array $row): array
  229. {
  230. $row['task_count'] = (int) ($row['task_count'] ?? 0);
  231. $row['done_task_count'] = (int) ($row['done_task_count'] ?? 0);
  232. $row['blocked_task_count'] = (int) ($row['blocked_task_count'] ?? 0);
  233. $row['overdue_task_count'] = (int) ($row['overdue_task_count'] ?? 0);
  234. $row['progress_percent'] = $this->progressPercentage((int) $row['id'], $row['task_count'], $row['done_task_count']);
  235. $row['health_class'] = $this->healthClass((string) $row['status'], $row['overdue_task_count']);
  236. $row['status_label'] = project_status_label((string) $row['status']);
  237. $row['budget_label'] = money_cents((int) ($row['budget_cents'] ?? 0));
  238. $row['task_traffic'] = $this->throughputLabel($row['done_task_count'], $row['task_count']);
  239. $row['due_label'] = format_date($row['due_date'] ?? null);
  240. $row['start_label'] = format_date($row['start_date'] ?? null);
  241. return $row;
  242. }
  243. protected function decorateProjectDetail(array $project): array
  244. {
  245. $project = $this->decorateProjectRow($project);
  246. $project['member_count'] = (int) ($this->database->first(
  247. 'SELECT COUNT(*) AS total FROM project_members WHERE project_id = :project_id',
  248. ['project_id' => $project['id']]
  249. )['total'] ?? 0);
  250. return $project;
  251. }
  252. protected function saveMembers(int $projectId, array $members): void
  253. {
  254. $statement = $this->database->pdo()->prepare(
  255. 'INSERT INTO project_members (project_id, full_name, role, allocation_percent, is_primary) VALUES (:project_id, :full_name, :role, :allocation_percent, :is_primary)'
  256. );
  257. foreach ($members as $index => $member) {
  258. $name = trim((string) ($member['full_name'] ?? ''));
  259. if ($name === '') {
  260. continue;
  261. }
  262. $statement->execute([
  263. 'project_id' => $projectId,
  264. 'full_name' => $name,
  265. 'role' => trim((string) ($member['role'] ?? 'Contributor')),
  266. 'allocation_percent' => max(0, min(100, (int) ($member['allocation_percent'] ?? 0))),
  267. 'is_primary' => $index === 0 ? 1 : 0,
  268. ]);
  269. }
  270. }
  271. protected function generateProjectCode(string $name): string
  272. {
  273. $slug = strtoupper(preg_replace('/[^A-Za-z0-9]+/', '', $name) ?? 'PROJECT');
  274. $slug = substr($slug, 0, 6) ?: 'PROJECT';
  275. return 'PRJ-' . $slug . '-' . date('ym');
  276. }
  277. protected function ensureUniqueCode(string $code): string
  278. {
  279. $candidate = $code;
  280. $suffix = 2;
  281. while ($this->database->first('SELECT id FROM projects WHERE code = :code', ['code' => $candidate]) !== null) {
  282. $candidate = $code . '-' . $suffix;
  283. $suffix++;
  284. }
  285. return $candidate;
  286. }
  287. protected function progressPercentage(int $projectId, ?int $taskCount = null, ?int $doneCount = null): int
  288. {
  289. $taskCount ??= (int) ($this->database->first('SELECT COUNT(*) AS total FROM tasks WHERE project_id = :project_id', ['project_id' => $projectId])['total'] ?? 0);
  290. $doneCount ??= (int) ($this->database->first('SELECT COUNT(*) AS total FROM tasks WHERE project_id = :project_id AND status = "done"', ['project_id' => $projectId])['total'] ?? 0);
  291. if ($taskCount <= 0) {
  292. return 0;
  293. }
  294. return (int) round(($doneCount / $taskCount) * 100);
  295. }
  296. protected function throughputLabel(int $doneCount, int $taskCount): string
  297. {
  298. if ($taskCount === 0) {
  299. return 'No tasks yet';
  300. }
  301. if ($doneCount === 0) {
  302. return 'Starting up';
  303. }
  304. if ($doneCount >= $taskCount) {
  305. return 'Ready to ship';
  306. }
  307. return $doneCount . '/' . $taskCount . ' completed';
  308. }
  309. protected function healthClass(string $status, int $overdueTasks): string
  310. {
  311. if ($status === 'done') {
  312. return 'is-green';
  313. }
  314. if ($overdueTasks > 0 || $status === 'at-risk') {
  315. return 'is-red';
  316. }
  317. if ($status === 'paused') {
  318. return 'is-slate';
  319. }
  320. return 'is-blue';
  321. }
  322. }

Powered by TurnKey Linux.