From 65bdb4282539fdf3994ba410ac4646436c89ead2 Mon Sep 17 00:00:00 2001 From: Darko Gjorgjijoski Date: Tue, 15 Sep 2026 07:47:03 +0200 Subject: [PATCH 1/4] feat(api): let the project and task lists choose their order Both list endpoints take `sort_by` and `sort_order`, validated against the keys the service publishes so an unknown column is a 422 rather than a silently ignored parameter. Projects order by name, status, due date, created date or rate; tasks by number, name, priority, due date or created date. The sort runs in PHP over the collection the service already holds, not in the query. Priority ranks LOW, NORMAL, HIGH, URGENT, which is not its alphabetical order, and the three supported databases disagree about where a NULL lands, so ordering here gives one answer everywhere without a CASE expression or a `NULLS LAST` clause SQLite would refuse. It costs nothing extra: every list endpoint already loads the whole collection and cuts the page from it. A row with no value sorts last in both directions, and ties break on the id in the direction of the sort, so a page boundary never drops or repeats a row. Naming a column reads it ascending unless the caller says otherwise. The projects list now opens newest first rather than alphabetically, which is how the host's own lists open; two existing order assertions move with it. --- app/Application/Concerns/SortsLists.php | 108 ++++++++++++++++++++ app/Application/ProjectService.php | 43 +++++++- app/Application/TaskService.php | 50 ++++++++- app/Http/Controllers/ProjectsController.php | 2 + app/Http/Controllers/TasksController.php | 2 + app/Http/Requests/ListProjectsRequest.php | 3 + app/Http/Requests/ListTasksRequest.php | 4 + tests/Feature/ProjectsApiTest.php | 99 +++++++++++++++++- tests/Feature/TasksApiTest.php | 85 +++++++++++++++ tests/Unit/ProjectServiceTest.php | 3 +- 10 files changed, 393 insertions(+), 6 deletions(-) create mode 100644 app/Application/Concerns/SortsLists.php diff --git a/app/Application/Concerns/SortsLists.php b/app/Application/Concerns/SortsLists.php new file mode 100644 index 0000000..fe30dfb --- /dev/null +++ b/app/Application/Concerns/SortsLists.php @@ -0,0 +1,108 @@ + $items + * @param callable(TModel): (int|string|null) $value the comparable value of one row + * @param string $order `asc` or `desc`; anything else reads as `asc` + * @return Collection + */ + protected function sortList(Collection $items, callable $value, string $order): Collection + { + $descending = $order === 'desc'; + + return $items + ->sort(function (Model $left, Model $right) use ($value, $descending): int { + /** @var callable(Model): (int|string|null) $value */ + $first = $value($left); + $second = $value($right); + + if ($first === null || $second === null) { + return $first === $second + ? $this->compareIds($left, $right, $descending) + : ($first === null ? 1 : -1); + } + + $comparison = is_int($first) && is_int($second) + ? $first <=> $second + : $this->compareText((string) $first, (string) $second); + + if ($comparison === 0) { + return $this->compareIds($left, $right, $descending); + } + + return $descending ? -$comparison : $comparison; + }) + ->values(); + } + + /** + * The key and the direction to sort by, given what the caller asked for. + * + * A caller who names no column gets the list's own opening order, which is + * newest first for projects and by number for tasks. A caller who does + * name one gets it ascending unless they say otherwise, because that is + * the direction "sort by name" means to the person asking. The form + * request has already rejected an unknown key, so the fallback here is + * only ever reached by an internal caller. + * + * @param array $filters + * @param array $supported sort key => value reader + * @return array{0: string, 1: string} + */ + protected function sortFor(array $filters, array $supported, string $defaultKey, string $defaultOrder): array + { + $key = $filters['sort_by'] ?? null; + $order = $filters['sort_order'] ?? null; + + if (! is_string($key) || ! isset($supported[$key])) { + return [$defaultKey, $order === 'asc' || $order === 'desc' ? $order : $defaultOrder]; + } + + return [$key, $order === 'desc' ? 'desc' : 'asc']; + } + + /** + * Names read the way a person reads them, so "apple" sits beside "Apple" + * rather than in a separate uppercase block the way a byte comparison + * would put it. Equal-but-for-case values fall through to the id. + */ + private function compareText(string $first, string $second): int + { + return strcasecmp($first, $second) <=> 0; + } + + private function compareIds(Model $left, Model $right, bool $descending): int + { + $comparison = (int) $left->getKey() <=> (int) $right->getKey(); + + return $descending ? -$comparison : $comparison; + } +} diff --git a/app/Application/ProjectService.php b/app/Application/ProjectService.php index d48f0ce..61f0418 100644 --- a/app/Application/ProjectService.php +++ b/app/Application/ProjectService.php @@ -9,6 +9,7 @@ use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Support\Facades\DB; use InvoiceShelf\Modules\Contracts\Host\CompanyDataReader; +use Modules\TasksProjects\Application\Concerns\SortsLists; use Modules\TasksProjects\Application\Exceptions\ProjectInUse; use Modules\TasksProjects\Models\Project; use Modules\TasksProjects\Models\ProjectMember; @@ -18,6 +19,21 @@ /** Project CRUD, archiving and the totals the project detail screen shows. */ final class ProjectService { + use SortsLists; + + /** + * The columns the list may be ordered by. The request rule reads this, so + * a new key is added here and nowhere else. + * + * @var list + */ + public const SORT_KEYS = ['name', 'status', 'due_date', 'created_at', 'default_rate']; + + /** Newest first, the way the host's own lists open. */ + public const DEFAULT_SORT_KEY = 'created_at'; + + public const DEFAULT_SORT_ORDER = 'desc'; + /** @var list */ private const FIELDS = [ 'customer_id', 'name', 'identifier', 'description', 'colour', 'status', @@ -27,7 +43,7 @@ final class ProjectService public function __construct(private readonly CompanyDataReader $companyData) {} /** - * @param array{status?: string, customer_id?: int, user_id?: int, search?: string} $filters + * @param array{status?: string, customer_id?: int, user_id?: int, search?: string, sort_by?: string, sort_order?: string} $filters * @return Collection */ public function listFor(int $companyId, array $filters = []): Collection @@ -57,7 +73,30 @@ public function listFor(int $companyId, array $filters = []): Collection }); } - return $query->orderBy('name')->orderBy('id')->get(); + $sorts = $this->sorts(); + [$key, $order] = $this->sortFor($filters, $sorts, self::DEFAULT_SORT_KEY, self::DEFAULT_SORT_ORDER); + + return $this->sortList($query->get(), $sorts[$key], $order); + } + + /** + * How each sortable column compares, as a value the sorter can order. + * + * Dates become timestamps rather than Carbon instances so the comparison + * is a plain integer one, and a missing date or rate answers null, which + * the sorter always puts last. + * + * @return array + */ + private function sorts(): array + { + return [ + 'name' => static fn (Project $project): string => (string) $project->name, + 'status' => static fn (Project $project): string => (string) $project->status, + 'due_date' => static fn (Project $project): ?int => $project->due_date?->getTimestamp(), + 'created_at' => static fn (Project $project): ?int => $project->created_at?->getTimestamp(), + 'default_rate' => static fn (Project $project): ?int => $project->default_rate, + ]; } public function findForCompany(int $companyId, int $id): Project diff --git a/app/Application/TaskService.php b/app/Application/TaskService.php index 04c9831..602b01e 100644 --- a/app/Application/TaskService.php +++ b/app/Application/TaskService.php @@ -10,6 +10,7 @@ use Illuminate\Support\Carbon; use Illuminate\Support\Facades\DB; use Modules\TasksProjects\Application\Concerns\DetectsUniqueViolations; +use Modules\TasksProjects\Application\Concerns\SortsLists; use Modules\TasksProjects\Application\Exceptions\EntriesAlreadyInvoiced; use Modules\TasksProjects\Models\Project; use Modules\TasksProjects\Models\Task; @@ -20,6 +21,20 @@ final class TaskService { use DetectsUniqueViolations; + use SortsLists; + + /** + * The columns the list may be ordered by. The request rule reads this, so + * a new key is added here and nowhere else. + * + * @var list + */ + public const SORT_KEYS = ['number', 'name', 'priority', 'due_date', 'created_at']; + + /** The per-company sequence, which is the order people refer to tasks in. */ + public const DEFAULT_SORT_KEY = 'number'; + + public const DEFAULT_SORT_ORDER = 'asc'; /** @var list */ private const FIELDS = [ @@ -35,7 +50,7 @@ public function __construct( ) {} /** - * @param array{project_id?: int, assignee_id?: int, task_status_id?: int, customer_id?: int, due_before?: string, due_after?: string, search?: string} $filters + * @param array{project_id?: int, assignee_id?: int, task_status_id?: int, customer_id?: int, due_before?: string, due_after?: string, search?: string, sort_by?: string, sort_order?: string} $filters * @return Collection */ public function listFor(int $companyId, array $filters = []): Collection @@ -60,7 +75,38 @@ public function listFor(int $companyId, array $filters = []): Collection $query->where('name', 'like', '%'.$filters['search'].'%'); } - return $query->orderBy('number')->get(); + $sorts = $this->sorts(); + [$key, $order] = $this->sortFor($filters, $sorts, self::DEFAULT_SORT_KEY, self::DEFAULT_SORT_ORDER); + + return $this->sortList($query->get(), $sorts[$key], $order); + } + + /** + * How each sortable column compares, as a value the sorter can order. + * + * Priority sorts by its rank rather than by its name, so URGENT sits above + * HIGH instead of below it, and a task with no priority set answers null, + * which the sorter always puts last. + * + * @return array + */ + private function sorts(): array + { + return [ + 'number' => static fn (Task $task): int => (int) $task->number, + 'name' => static fn (Task $task): string => (string) $task->name, + 'priority' => static fn (Task $task): ?int => self::priorityRank($task), + 'due_date' => static fn (Task $task): ?int => $task->due_date?->getTimestamp(), + 'created_at' => static fn (Task $task): ?int => $task->created_at?->getTimestamp(), + ]; + } + + /** LOW, NORMAL, HIGH, URGENT as 1 to 4; an unset or unknown value as null. */ + private static function priorityRank(Task $task): ?int + { + $rank = array_search($task->priority, Task::PRIORITIES, true); + + return $rank === false ? null : $rank + 1; } public function findForCompany(int $companyId, int $id): Task diff --git a/app/Http/Controllers/ProjectsController.php b/app/Http/Controllers/ProjectsController.php index e9ebe91..9edd6ec 100644 --- a/app/Http/Controllers/ProjectsController.php +++ b/app/Http/Controllers/ProjectsController.php @@ -33,6 +33,8 @@ public function index(ListProjectsRequest $request): AnonymousResourceCollection 'customer_id' => isset($filters['customer_id']) ? (int) $filters['customer_id'] : null, 'user_id' => isset($filters['member_id']) ? (int) $filters['member_id'] : null, 'search' => $filters['search'] ?? null, + 'sort_by' => $filters['sort_by'] ?? null, + 'sort_order' => $filters['sort_order'] ?? null, ], static fn (mixed $value): bool => $value !== null)); return ProjectResource::collection($this->paginate($projects, $request)); diff --git a/app/Http/Controllers/TasksController.php b/app/Http/Controllers/TasksController.php index bdc5014..9c66404 100644 --- a/app/Http/Controllers/TasksController.php +++ b/app/Http/Controllers/TasksController.php @@ -37,6 +37,8 @@ public function index(ListTasksRequest $request): AnonymousResourceCollection 'due_before' => $filters['due_before'] ?? null, 'due_after' => $filters['due_after'] ?? null, 'search' => $filters['search'] ?? null, + 'sort_by' => $filters['sort_by'] ?? null, + 'sort_order' => $filters['sort_order'] ?? null, ], static fn (mixed $value): bool => $value !== null)); return TaskResource::collection($this->paginate($tasks, $request)); diff --git a/app/Http/Requests/ListProjectsRequest.php b/app/Http/Requests/ListProjectsRequest.php index ed1a5dc..ee4b46b 100644 --- a/app/Http/Requests/ListProjectsRequest.php +++ b/app/Http/Requests/ListProjectsRequest.php @@ -4,6 +4,7 @@ namespace Modules\TasksProjects\Http\Requests; +use Modules\TasksProjects\Application\ProjectService; use Modules\TasksProjects\Models\Project; final class ListProjectsRequest extends ModuleRequest @@ -16,6 +17,8 @@ public function rules(): array 'customer_id' => ['sometimes', 'integer', 'min:1'], 'member_id' => ['sometimes', 'integer', 'min:1'], 'search' => ['sometimes', 'string', 'max:255'], + 'sort_by' => ['sometimes', 'string', 'in:'.implode(',', ProjectService::SORT_KEYS)], + 'sort_order' => ['sometimes', 'string', 'in:asc,desc'], ]; } } diff --git a/app/Http/Requests/ListTasksRequest.php b/app/Http/Requests/ListTasksRequest.php index a8031a1..a2b7017 100644 --- a/app/Http/Requests/ListTasksRequest.php +++ b/app/Http/Requests/ListTasksRequest.php @@ -4,6 +4,8 @@ namespace Modules\TasksProjects\Http\Requests; +use Modules\TasksProjects\Application\TaskService; + final class ListTasksRequest extends ModuleRequest { /** @return array> */ @@ -17,6 +19,8 @@ public function rules(): array 'due_before' => ['sometimes', 'date'], 'due_after' => ['sometimes', 'date'], 'search' => ['sometimes', 'string', 'max:255'], + 'sort_by' => ['sometimes', 'string', 'in:'.implode(',', TaskService::SORT_KEYS)], + 'sort_order' => ['sometimes', 'string', 'in:asc,desc'], ]; } } diff --git a/tests/Feature/ProjectsApiTest.php b/tests/Feature/ProjectsApiTest.php index 8d91684..eed3afe 100644 --- a/tests/Feature/ProjectsApiTest.php +++ b/tests/Feature/ProjectsApiTest.php @@ -4,6 +4,7 @@ namespace Modules\TasksProjects\Tests\Feature; +use Illuminate\Support\Carbon; use Modules\TasksProjects\Models\Project; use Modules\TasksProjects\Models\ProjectMember; use Modules\TasksProjects\Models\TimeEntry; @@ -31,7 +32,8 @@ public function test_it_lists_only_the_companys_projects_and_pages_them(): void $response->assertJsonPath('meta.total', 2); $response->assertJsonPath('meta.per_page', 1); $response->assertJsonCount(1, 'data'); - $response->assertJsonPath('data.0.name', 'Alpha'); + // The list opens newest first, so the second project leads the page. + $response->assertJsonPath('data.0.name', 'Beta'); } public function test_the_list_filters_by_status_customer_member_and_text(): void @@ -62,6 +64,56 @@ public function test_the_list_filters_by_status_customer_member_and_text(): void ->assertJsonPath('data.0.name', 'Internal tooling'); } + public function test_the_list_opens_newest_first_and_sorts_by_every_supported_key(): void + { + $this->threeProjects(); + + self::assertSame(['Gamma', 'Beta', 'alpha'], $this->names('')); + self::assertSame(['alpha', 'Beta', 'Gamma'], $this->names('sort_by=created_at')); + self::assertSame(['alpha', 'Beta', 'Gamma'], $this->names('sort_by=name')); + self::assertSame(['Gamma', 'Beta', 'alpha'], $this->names('sort_by=name&sort_order=desc')); + self::assertSame(['alpha', 'Gamma', 'Beta'], $this->names('sort_by=status')); + self::assertSame(['Beta', 'alpha', 'Gamma'], $this->names('sort_by=due_date')); + self::assertSame(['alpha', 'Beta', 'Gamma'], $this->names('sort_by=due_date&sort_order=desc')); + self::assertSame(['alpha', 'Beta', 'Gamma'], $this->names('sort_by=default_rate')); + self::assertSame(['Beta', 'alpha', 'Gamma'], $this->names('sort_by=default_rate&sort_order=desc')); + } + + public function test_a_project_without_the_sorted_value_lands_last_whichever_way_the_list_runs(): void + { + $this->threeProjects(); + + // Gamma has neither a due date nor a rate, so it never leads the page. + self::assertSame('Gamma', $this->names('sort_by=due_date')[2]); + self::assertSame('Gamma', $this->names('sort_by=due_date&sort_order=desc')[2]); + self::assertSame('Gamma', $this->names('sort_by=default_rate')[2]); + self::assertSame('Gamma', $this->names('sort_by=default_rate&sort_order=desc')[2]); + } + + public function test_the_sort_survives_paging(): void + { + $this->threeProjects(); + + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/projects?sort_by=name&limit=2&page=2') + ->assertOk() + ->assertJsonCount(1, 'data') + ->assertJsonPath('data.0.name', 'Gamma'); + } + + public function test_the_list_refuses_a_sort_key_or_direction_it_does_not_know(): void + { + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/projects?sort_by=colour') + ->assertStatus(422) + ->assertJsonValidationErrors(['sort_by']); + + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/projects?sort_by=name&sort_order=sideways') + ->assertStatus(422) + ->assertJsonValidationErrors(['sort_order']); + } + public function test_it_creates_a_project_for_the_header_company_and_stamps_the_creator(): void { $response = $this->asCompany(self::COMPANY)->postJson('/api/v1/tasks-projects/projects', [ @@ -263,4 +315,49 @@ public function test_the_ability_is_checked_for_the_header_company_and_the_authe 'resource' => null, ], $this->authorization->checks[0]); } + + /** + * Three projects that differ in every sortable column, created a day + * apart so `created_at` orders them without relying on the clock. + * + * The lowercase name is deliberate: a byte comparison would file it after + * the capitalised ones, and a person reading the list would not. + */ + private function threeProjects(): void + { + Carbon::setTestNow('2026-09-01 09:00:00'); + $this->makeProject(self::COMPANY, [ + 'name' => 'alpha', + 'due_date' => '2026-12-31', + 'default_rate' => 9000, + ]); + + Carbon::setTestNow('2026-09-02 09:00:00'); + $this->makeProject(self::COMPANY, [ + 'name' => 'Beta', + 'status' => Project::STATUS_ARCHIVED, + 'due_date' => '2026-01-31', + 'default_rate' => 12000, + ]); + + Carbon::setTestNow('2026-09-03 09:00:00'); + $this->makeProject(self::COMPANY, ['name' => 'Gamma']); + + Carbon::setTestNow(); + } + + /** + * The names the list answers with, in the order it answered them. + * + * @return list + */ + private function names(string $query): array + { + $response = $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/projects?'.$query); + + $response->assertOk(); + + return array_column((array) $response->json('data'), 'name'); + } } diff --git a/tests/Feature/TasksApiTest.php b/tests/Feature/TasksApiTest.php index 8928d8e..66d7c39 100644 --- a/tests/Feature/TasksApiTest.php +++ b/tests/Feature/TasksApiTest.php @@ -4,6 +4,7 @@ namespace Modules\TasksProjects\Tests\Feature; +use Illuminate\Support\Carbon; use Modules\TasksProjects\Models\Task; use Modules\TasksProjects\Models\TaskStatus; use Modules\TasksProjects\Support\Abilities; @@ -106,6 +107,44 @@ public function test_the_list_filters_by_project_assignee_status_due_date_and_te $this->assertListReturns([$pricing->id], '?search=Pricing'); } + public function test_the_list_opens_by_number_and_sorts_by_every_supported_key(): void + { + $this->fourTasks(); + + self::assertSame(['zebra', 'apple', 'Mango', 'berry'], $this->names('')); + self::assertSame(['berry', 'Mango', 'apple', 'zebra'], $this->names('sort_by=number&sort_order=desc')); + self::assertSame(['apple', 'berry', 'Mango', 'zebra'], $this->names('sort_by=name')); + self::assertSame(['zebra', 'Mango', 'berry', 'apple'], $this->names('sort_by=name&sort_order=desc')); + self::assertSame(['apple', 'berry', 'zebra', 'Mango'], $this->names('sort_by=due_date')); + self::assertSame(['zebra', 'berry', 'apple', 'Mango'], $this->names('sort_by=due_date&sort_order=desc')); + self::assertSame(['zebra', 'apple', 'Mango', 'berry'], $this->names('sort_by=created_at')); + self::assertSame(['berry', 'Mango', 'apple', 'zebra'], $this->names('sort_by=created_at&sort_order=desc')); + } + + public function test_priority_sorts_by_rank_rather_than_by_name(): void + { + $this->fourTasks(); + + // Alphabetically these run HIGH, LOW, NORMAL, URGENT, which is not an + // order anyone means; the rank runs LOW, NORMAL, HIGH, URGENT, and the + // task with no priority set trails both ways. + self::assertSame(['zebra', 'Mango', 'apple', 'berry'], $this->names('sort_by=priority')); + self::assertSame(['apple', 'Mango', 'zebra', 'berry'], $this->names('sort_by=priority&sort_order=desc')); + } + + public function test_the_task_sort_refuses_a_key_or_direction_it_does_not_know(): void + { + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/tasks?sort_by=board_position') + ->assertStatus(422) + ->assertJsonValidationErrors(['sort_by']); + + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/tasks?sort_by=number&sort_order=up') + ->assertStatus(422) + ->assertJsonValidationErrors(['sort_order']); + } + public function test_the_list_is_paged_and_never_leaves_the_company(): void { $this->makeTask(self::COMPANY, ['name' => 'Mine']); @@ -281,4 +320,50 @@ private function assertListReturns(array $expected, string $query): void self::assertSame(array_map(intval(...), $expected), $ids); } + + /** + * Four tasks that differ in every sortable column, created a day apart so + * `created_at` orders them without relying on the clock. + * + * One carries no priority and one no due date, which is what proves a + * missing value lands last rather than first. + */ + private function fourTasks(): void + { + $status = $this->makeStatus(self::COMPANY); + + $rows = [ + ['2026-09-01 09:00:00', 'zebra', Task::PRIORITY_LOW, '2026-03-01'], + ['2026-09-02 09:00:00', 'apple', Task::PRIORITY_URGENT, '2026-01-01'], + ['2026-09-03 09:00:00', 'Mango', Task::PRIORITY_NORMAL, null], + ['2026-09-04 09:00:00', 'berry', null, '2026-02-01'], + ]; + + foreach ($rows as [$day, $name, $priority, $due]) { + Carbon::setTestNow($day); + $this->makeTask(self::COMPANY, [ + 'task_status_id' => $status->id, + 'name' => $name, + 'priority' => $priority, + 'due_date' => $due, + ]); + } + + Carbon::setTestNow(); + } + + /** + * The names the list answers with, in the order it answered them. + * + * @return list + */ + private function names(string $query): array + { + $response = $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/tasks?'.$query); + + $response->assertOk(); + + return array_column((array) $response->json('data'), 'name'); + } } diff --git a/tests/Unit/ProjectServiceTest.php b/tests/Unit/ProjectServiceTest.php index 3ce8750..156fbd3 100644 --- a/tests/Unit/ProjectServiceTest.php +++ b/tests/Unit/ProjectServiceTest.php @@ -62,7 +62,8 @@ public function test_listing_is_scoped_to_the_company_and_filtered(): void $this->projects->archive(self::COMPANY, (int) $archived->id); $this->projects->create(10, ['name' => 'Elsewhere']); - self::assertSame(['Alpha', 'Beta'], $this->projects->listFor(self::COMPANY)->pluck('name')->all()); + // Newest first by default, and two rows of the same second break on the id. + self::assertSame(['Beta', 'Alpha'], $this->projects->listFor(self::COMPANY)->pluck('name')->all()); self::assertSame(['Alpha'], $this->projects->listFor(self::COMPANY, ['status' => Project::STATUS_ACTIVE])->pluck('name')->all()); self::assertSame(['Beta'], $this->projects->listFor(self::COMPANY, ['customer_id' => 43])->pluck('name')->all()); } From a6d5464c2666c03820202e7a052876def066abf6 Mon Sep 17 00:00:00 2001 From: Darko Gjorgjijoski Date: Tue, 15 Sep 2026 07:55:24 +0200 Subject: [PATCH 2/4] feat(ui): add the reports page A range, four figures per currency, a billable split and three breakdowns of the same range: by project, by member and by customer. Everything comes from one call to `reports/summary`, which already aggregates per currency and never converts between them, so the page never adds two currencies together either. The range offers this week, this month, last month, this quarter and this year, and switches to Custom the moment a date is picked by hand. "This week" follows the company's week-start setting, so the report and the timesheet agree about where a week begins. The page asks only for `view-own-time`. The endpoint narrows the report to the caller's own time rather than refusing them, so gating on `view-all-time` would hide a screen that works. The split bar charts minutes rather than money, because minutes are the one figure that adds up across currencies, and it is drawn with two tokened divs rather than a chart library the bundle would have to carry. The payload is read defensively throughout: a missing figure becomes zero and a missing list an empty one, which renders as an empty table rather than as a blank screen. --- resources/js/api/reports.ts | 94 ++++ .../js/components/ReportBreakdownTable.vue | 80 ++++ resources/js/init.ts | 2 + resources/js/messages/reports.ts | 60 +++ resources/js/pages/ReportsPage.vue | 402 ++++++++++++++++++ resources/js/registrations/reports.ts | 39 ++ resources/js/support/reports.ts | 72 ++++ resources/js/types/reports.ts | 64 +++ 8 files changed, 813 insertions(+) create mode 100644 resources/js/api/reports.ts create mode 100644 resources/js/components/ReportBreakdownTable.vue create mode 100644 resources/js/messages/reports.ts create mode 100644 resources/js/pages/ReportsPage.vue create mode 100644 resources/js/registrations/reports.ts create mode 100644 resources/js/support/reports.ts create mode 100644 resources/js/types/reports.ts diff --git a/resources/js/api/reports.ts b/resources/js/api/reports.ts new file mode 100644 index 0000000..1732138 --- /dev/null +++ b/resources/js/api/reports.ts @@ -0,0 +1,94 @@ +import type { AxiosInstance } from 'axios' +import { BASE } from '@/api' +import type { + ReportBillableRow, + ReportCustomerRow, + ReportMemberRow, + ReportParams, + ReportProjectRow, + ReportSummary, + ReportTotals, +} from '@/types/reports' + +/** The one endpoint the reports page reads. */ +export const REPORTS_API = { + summary: `${BASE}/reports/summary`, +} as const + +/** + * The aggregates for one range, shaped the way the page renders them. + * + * The payload crosses a module boundary and the page cannot be checked in a + * browser from the module's own tree, so every field is read defensively: a + * missing figure becomes zero and a missing list becomes an empty one, which + * renders as an empty table rather than as a blank screen. + */ +export async function fetchReportSummary( + client: AxiosInstance, + params: ReportParams, +): Promise { + const { data } = await client.get<{ data?: unknown }>(REPORTS_API.summary, { params }) + + return normalise(data?.data, params) +} + +function normalise(payload: unknown, params: ReportParams): ReportSummary { + const body = isRecord(payload) ? payload : {} + + return { + from: textOr(body.from, params.from ?? ''), + to: textOr(body.to, params.to ?? ''), + totals: rowsOf(body.totals).map(totalsOf), + by_project: rowsOf(body.by_project).map( + (row): ReportProjectRow => ({ + ...totalsOf(row), + project_id: idOr(row.project_id), + label: textOr(row.label, ''), + }), + ), + by_member: rowsOf(body.by_member).map( + (row): ReportMemberRow => ({ + ...totalsOf(row), + user_id: idOr(row.user_id), + label: textOr(row.label, ''), + }), + ), + by_customer: rowsOf(body.by_customer).map( + (row): ReportCustomerRow => ({ ...totalsOf(row), customer_id: idOr(row.customer_id) }), + ), + by_billable: rowsOf(body.by_billable).map( + (row): ReportBillableRow => ({ ...totalsOf(row), billable: row.billable === true }), + ), + } +} + +function totalsOf(row: Record): ReportTotals { + return { + currency_id: idOr(row.currency_id), + minutes: numberOr(row.minutes), + amount: numberOr(row.amount), + billable_minutes: numberOr(row.billable_minutes), + billable_amount: numberOr(row.billable_amount), + unbilled_amount: numberOr(row.unbilled_amount), + } +} + +function rowsOf(value: unknown): Record[] { + return Array.isArray(value) ? value.filter(isRecord) : [] +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + +function numberOr(value: unknown): number { + return typeof value === 'number' && Number.isFinite(value) ? value : 0 +} + +function idOr(value: unknown): number | null { + return typeof value === 'number' && Number.isFinite(value) ? value : null +} + +function textOr(value: unknown, fallback: string): string { + return typeof value === 'string' && value.trim() !== '' ? value : fallback +} diff --git a/resources/js/components/ReportBreakdownTable.vue b/resources/js/components/ReportBreakdownTable.vue new file mode 100644 index 0000000..29896cd --- /dev/null +++ b/resources/js/components/ReportBreakdownTable.vue @@ -0,0 +1,80 @@ + + + diff --git a/resources/js/init.ts b/resources/js/init.ts index 6ae8eed..360f625 100644 --- a/resources/js/init.ts +++ b/resources/js/init.ts @@ -6,6 +6,7 @@ import { messages } from './messages' import ProjectsIndexPage from './pages/ProjectsIndexPage.vue' import { registerTimeTracking } from './registrations/time' import { registerBoardPages } from './registrations/board' +import { registerReportPages } from './registrations/reports' const MODULE = 'tasks-projects' @@ -25,6 +26,7 @@ window.InvoiceShelf.booting((_app, _router, extensions) => { registerTimeTracking(extensions) registerBoardPages(extensions) + registerReportPages(extensions) }) /** diff --git a/resources/js/messages/reports.ts b/resources/js/messages/reports.ts new file mode 100644 index 0000000..9c62c1a --- /dev/null +++ b/resources/js/messages/reports.ts @@ -0,0 +1,60 @@ +/** + * Strings for the reports page. + * + * These live beside the slice that owns them rather than in `messages.ts`, so + * two slices of the module never edit the same catalogue. The host merges + * every bundle recursively, so `tasks_projects.reports` here and + * `tasks_projects.projects` there end up in one namespace. + */ +export const reportMessages = { + en: { + tasks_projects: { + reports: { + title: 'Reports', + load_failed: 'Unable to load the report.', + empty_title: 'Nothing logged in this range', + empty_description: 'Pick a wider range, or log some time against a task.', + range: { + this_week: 'This week', + this_month: 'This month', + last_month: 'Last month', + this_quarter: 'This quarter', + this_year: 'This year', + custom: 'Custom', + from: 'From', + to: 'To', + }, + summary: { + logged: 'Logged', + billable: 'Billable', + amount: 'Amount', + unbilled: 'Unbilled', + currency: 'Currency #{id}', + base_currency: 'Company currency', + }, + split: { + title: 'Billable against the rest', + billable: 'Billable', + non_billable: 'Not billable', + nothing: 'No time logged in this range.', + }, + tables: { + by_project: 'By project', + by_member: 'By member', + by_customer: 'By customer', + project: 'Project', + member: 'Member', + customer: 'Customer', + no_project: 'No project', + no_customer: 'Internal', + unknown_member: 'Removed member', + currency: 'Currency', + logged: 'Logged', + billable: 'Billable', + amount: 'Amount', + unbilled: 'Unbilled', + }, + }, + }, + }, +} diff --git a/resources/js/pages/ReportsPage.vue b/resources/js/pages/ReportsPage.vue new file mode 100644 index 0000000..5fd046f --- /dev/null +++ b/resources/js/pages/ReportsPage.vue @@ -0,0 +1,402 @@ + + + diff --git a/resources/js/registrations/reports.ts b/resources/js/registrations/reports.ts new file mode 100644 index 0000000..9b076be --- /dev/null +++ b/resources/js/registrations/reports.ts @@ -0,0 +1,39 @@ +import type { InvoiceShelfExtensionApi } from '@invoiceshelf/modules/frontend' +import { reportMessages } from '@/messages/reports' +import ReportsPage from '@/pages/ReportsPage.vue' +import { resetCustomers } from '@/stores/customers' +import { injectedPage } from '@/support/page' + +const MODULE = 'tasks-projects' + +/** + * The reports page, and the one piece of lifecycle it owns. + * + * Registered from here rather than from `init.ts` so that a slice of the + * module owns one file: adding a screen never means editing the same lines + * another slice is editing. The strings come along for the ride, because the + * host merges message bundles recursively. + * + * The page asks only for `view-own-time`. A caller without `view-all-time` + * still gets a report; the endpoint narrows it to their own time rather than + * refusing them, so gating on the wider ability would hide a screen that works. + */ +export function registerReportPages(extensions: InvoiceShelfExtensionApi): void { + extensions.addMessages(reportMessages) + + extensions.registerPage({ + id: 'reports', + module: MODULE, + path: 'reports', + component: injectedPage(extensions, ReportsPage), + meta: { + ability: `${MODULE}:view-own-time`, + title: 'tasks_projects.reports.title', + }, + }) + + // Contact ids belong to one company, so the map goes with the company. + extensions.on('company:changing', () => { + resetCustomers() + }) +} diff --git a/resources/js/support/reports.ts b/resources/js/support/reports.ts new file mode 100644 index 0000000..d9e3329 --- /dev/null +++ b/resources/js/support/reports.ts @@ -0,0 +1,72 @@ +import { addDays, formatLocalDate, startOfWeek } from '@/support/time' + +/** + * The ranges the reports page offers, and the dates each one covers. + * + * Every boundary is a local calendar date, because a report is read in the + * viewer's own days rather than in UTC, and is handed to the API as the + * `Y-m-d` it takes. "This week" follows the company's week-start setting, so + * the report and the timesheet agree about where a week begins. + */ + +export const RANGE_PRESETS = [ + 'THIS_WEEK', + 'THIS_MONTH', + 'LAST_MONTH', + 'THIS_QUARTER', + 'THIS_YEAR', + 'CUSTOM', +] as const + +export type RangePreset = (typeof RANGE_PRESETS)[number] + +export interface DateRange { + from: string + to: string +} + +const MONTHS_PER_QUARTER = 3 + +/** + * The dates a preset covers, as of `today`. + * + * `CUSTOM` has no dates of its own: it is what the page switches to when + * someone picks a date by hand, so it answers the current month and the caller + * leaves the pickers alone. + */ +export function rangeFor(preset: RangePreset, weekStart: number, today: Date = new Date()): DateRange { + const year = today.getFullYear() + const month = today.getMonth() + + switch (preset) { + case 'THIS_WEEK': { + const start = startOfWeek(today, weekStart) + + return range(start, addDays(start, 6)) + } + case 'LAST_MONTH': + return range(new Date(year, month - 1, 1), new Date(year, month, 0)) + case 'THIS_QUARTER': { + const first = Math.floor(month / MONTHS_PER_QUARTER) * MONTHS_PER_QUARTER + + return range(new Date(year, first, 1), new Date(year, first + MONTHS_PER_QUARTER, 0)) + } + case 'THIS_YEAR': + return range(new Date(year, 0, 1), new Date(year, 12, 0)) + default: + return range(new Date(year, month, 1), new Date(year, month + 1, 0)) + } +} + +/** Minutes as a percentage of a total, clamped and never dividing by zero. */ +export function shareOf(minutes: number, total: number): number { + if (total <= 0) { + return 0 + } + + return Math.min(100, Math.max(0, Math.round((minutes / total) * 100))) +} + +function range(from: Date, to: Date): DateRange { + return { from: formatLocalDate(from), to: formatLocalDate(to) } +} diff --git a/resources/js/types/reports.ts b/resources/js/types/reports.ts new file mode 100644 index 0000000..b85b025 --- /dev/null +++ b/resources/js/types/reports.ts @@ -0,0 +1,64 @@ +/** + * The read-only aggregates `GET reports/summary` answers with. + * + * Every row is reported per `currency_id` and nothing is converted: a project + * inherits its customer's currency and an internal project has none, so a row + * never adds two currencies together. Durations are whole minutes and amounts + * are integer minor units, the same convention the rest of the module uses. + */ + +/** The figures every breakdown row carries, whatever it is broken down by. */ +export interface ReportTotals { + currency_id: number | null + minutes: number + amount: number + billable_minutes: number + billable_amount: number + /** Billable and not yet stamped with an invoice. */ + unbilled_amount: number +} + +export interface ReportProjectRow extends ReportTotals { + /** Null for time logged against a task that belongs to no project. */ + project_id: number | null + label: string +} + +export interface ReportMemberRow extends ReportTotals { + user_id: number | null + /** The server's own label, which reads "Removed member" for a stale id. */ + label: string +} + +export interface ReportCustomerRow extends ReportTotals { + /** Null for internal work, which never reaches the billing screen. */ + customer_id: number | null +} + +export interface ReportBillableRow extends ReportTotals { + billable: boolean +} + +export interface ReportSummary { + /** `Y-m-d`, inclusive, echoed back so the page shows the range it charted. */ + from: string + to: string + totals: ReportTotals[] + by_project: ReportProjectRow[] + by_member: ReportMemberRow[] + by_customer: ReportCustomerRow[] + by_billable: ReportBillableRow[] +} + +export interface ReportParams { + /** `Y-m-d`, inclusive. Both default to the current month on the server. */ + from?: string + to?: string +} + +/** One breakdown row with its name already resolved, ready for a table. */ +export interface BreakdownRow extends ReportTotals { + /** Unique within its table, so the table can key its rows. */ + id: string + label: string +} From 664d7dfc8c39cfe4b55375b0caa658f5eed16702 Mon Sep 17 00:00:00 2001 From: Darko Gjorgjijoski Date: Tue, 15 Sep 2026 07:55:36 +0200 Subject: [PATCH 3/4] feat(ui): sort the project and task lists, and name their contacts The project columns name, status, rate and due date, and the task columns number, name, priority and due date, are now sortable headers. `BaseTable` reports the column key it is sorting on and the empty order it starts in, so `sortParams` maps that to the `sort_by` and `sort_order` the endpoints take and adds nothing at all until a header is clicked. Contacts stop reading as `#42`. A small store holds one map of contact id to display name per company session, filled the first time a screen renders a row that has one and dropped on `company:changing`. The projects table, the board card tooltip and the project detail header all read it, which also retires the detail page's single-contact fetch. The map is capped at 200 contacts on purpose: it exists to label ids the module already holds, not to browse the address book, and forms keep their search-based picker for choosing one. Anything past the cap falls back to `#id`, as does a contact the caller may not read. The two project pickers ask for their list by name, because a picker reads alphabetically whatever order the endpoint itself opens in. --- resources/js/api.ts | 54 +++++++++++- resources/js/api/board.ts | 24 ++--- resources/js/components/TaskList.vue | 31 +++++-- resources/js/pages/BoardPage.vue | 23 ++++- resources/js/pages/ProjectDetailPage.vue | 37 +++----- resources/js/pages/ProjectsIndexPage.vue | 41 +++++++-- resources/js/stores/customers.ts | 108 +++++++++++++++++++++++ 7 files changed, 262 insertions(+), 56 deletions(-) create mode 100644 resources/js/stores/customers.ts diff --git a/resources/js/api.ts b/resources/js/api.ts index ff103b0..0f52b22 100644 --- a/resources/js/api.ts +++ b/resources/js/api.ts @@ -22,9 +22,61 @@ export const HOST_API = { customers: '/api/v1/customers', } as const +export type SortOrder = 'asc' | 'desc' + +/** The columns `GET projects` orders by. Mirrors `ProjectService::SORT_KEYS`. */ +export const PROJECT_SORT_KEYS = [ + 'name', + 'status', + 'due_date', + 'created_at', + 'default_rate', +] as const + +export type ProjectSortKey = (typeof PROJECT_SORT_KEYS)[number] + +/** The ordering half of a list request, which every list endpoint accepts. */ +export interface SortParams { + sort_by?: TKey + sort_order?: SortOrder +} + +/** + * What `BaseTable` hands a server-side fetcher. + * + * `fieldName` is the key of the column whose header was clicked, and `order` + * is empty until one has been, which is the unsorted state the table starts + * in. + */ +export interface TableSort { + fieldName: string + order: SortOrder | '' +} + +/** + * The list parameters a table sort asks for, or none at all. + * + * The table reports its own column key, so the caller passes the map from + * those to the keys the endpoint takes. A column the endpoint cannot order by, + * and a table nobody has sorted yet, add nothing and leave the endpoint on its + * own opening order. + */ +export function sortParams( + sort: TableSort | undefined, + keys: Record, +): SortParams { + if (sort === undefined || sort.order === '') { + return {} + } + + const key = keys[sort.fieldName] + + return key === undefined ? {} : { sort_by: key, sort_order: sort.order } +} + export async function listProjects( client: AxiosInstance, - params: ProjectListParams, + params: ProjectListParams & SortParams, ): Promise> { const { data } = await client.get>(TASKS_PROJECTS_API.projects, { params }) diff --git a/resources/js/api/board.ts b/resources/js/api/board.ts index 97bf852..573f84e 100644 --- a/resources/js/api/board.ts +++ b/resources/js/api/board.ts @@ -1,6 +1,7 @@ import type { AxiosInstance } from 'axios' -import { BASE, HOST_API, TASKS_PROJECTS_API } from '@/api' -import type { Customer, Paginated, Wrapped } from '@/types/api' +import { BASE, TASKS_PROJECTS_API } from '@/api' +import type { SortParams } from '@/api' +import type { Paginated, Wrapped } from '@/types/api' import type { BoardColumn, BoardParams } from '@/types/board' import type { Project } from '@/types/project' import type { ProjectMember, ProjectMemberInput } from '@/types/project-member' @@ -8,6 +9,11 @@ import type { Task, TaskInput, TaskListParams, TaskMoveInput } from '@/types/tas import type { TaskStatus } from '@/types/task-status' import type { TimeEntry, TimeEntryListParams } from '@/types/time-entry' +/** The columns `GET tasks` orders by. Mirrors `TaskService::SORT_KEYS`. */ +export const TASK_SORT_KEYS = ['number', 'name', 'priority', 'due_date', 'created_at'] as const + +export type TaskSortKey = (typeof TASK_SORT_KEYS)[number] + /** The endpoints the board, the task lists and the project detail read. */ export const BOARD_API = { board: `${BASE}/board`, @@ -40,7 +46,7 @@ export async function listTaskStatuses(client: AxiosInstance): Promise, ): Promise> { const { data } = await client.get>(BOARD_API.tasks, { params }) @@ -129,15 +135,3 @@ export async function listProjectTime( return data } - -/** - * One host contact, for the name the project header shows. - * - * A project detail only ever knows the customer id, and the contact may have - * been deleted since, so the caller falls back to `#id` on failure. - */ -export async function fetchCustomer(client: AxiosInstance, id: number): Promise { - const { data } = await client.get>(`${HOST_API.customers}/${id}`) - - return data.data -} diff --git a/resources/js/components/TaskList.vue b/resources/js/components/TaskList.vue index 28d3618..5ae6b7f 100644 --- a/resources/js/components/TaskList.vue +++ b/resources/js/components/TaskList.vue @@ -1,8 +1,10 @@