Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion app/Application/ProjectService.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace Modules\TasksProjects\Application;

use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Support\Facades\DB;
Expand All @@ -23,7 +24,7 @@ final class ProjectService
];

/**
* @param array{status?: string, customer_id?: int, user_id?: int} $filters
* @param array{status?: string, customer_id?: int, user_id?: int, search?: string} $filters
* @return Collection<int, Project>
*/
public function listFor(int $companyId, array $filters = []): Collection
Expand All @@ -45,6 +46,14 @@ public function listFor(int $companyId, array $filters = []): Collection
->select('project_id'));
}

if (isset($filters['search']) && $filters['search'] !== '') {
$search = '%'.$filters['search'].'%';

$query->where(static function (Builder $inner) use ($search): void {
$inner->where('name', 'like', $search)->orWhere('identifier', 'like', $search);
});
}

return $query->orderBy('name')->orderBy('id')->get();
}

Expand Down
10 changes: 9 additions & 1 deletion app/Application/TaskService.php
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ public function __construct(
) {}

/**
* @param array{project_id?: int, assignee_id?: int, task_status_id?: int, customer_id?: int, 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} $filters
* @return Collection<int, Task>
*/
public function listFor(int $companyId, array $filters = []): Collection
Expand All @@ -48,6 +48,14 @@ public function listFor(int $companyId, array $filters = []): Collection
}
}

if (isset($filters['due_after'])) {
$query->where('due_date', '>=', Carbon::parse($filters['due_after'])->toDateString());
}

if (isset($filters['due_before'])) {
$query->where('due_date', '<=', Carbon::parse($filters['due_before'])->toDateString());
}

if (isset($filters['search']) && $filters['search'] !== '') {
$query->where('name', 'like', '%'.$filters['search'].'%');
}
Expand Down
85 changes: 85 additions & 0 deletions app/Http/Controllers/BillingController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
<?php

declare(strict_types=1);

namespace Modules\TasksProjects\Http\Controllers;

use Illuminate\Http\JsonResponse;
use Modules\TasksProjects\Application\BillingService;
use Modules\TasksProjects\Http\Requests\ConfirmInvoiceRequest;
use Modules\TasksProjects\Http\Requests\PrepareInvoiceRequest;
use Modules\TasksProjects\Http\Requests\UnbilledTimeRequest;
use Modules\TasksProjects\Support\Abilities;
use Modules\TasksProjects\Support\Authorizes;

/**
* Turning unbilled time into invoice lines, in two steps.
*
* `prepare` returns the body the host invoice endpoint expects; the browser
* posts it with the session's own client, so the module never writes to the
* host invoice tables, and hands the ids back to `confirm`, which stamps the
* entries and is safe to repeat.
*/
final class BillingController extends Controller
{
public function __construct(Authorizes $authorizes, private readonly BillingService $billing)
{
parent::__construct($authorizes);
}

public function unbilled(UnbilledTimeRequest $request): JsonResponse
{
$context = $this->context($request);
$this->authorize($context, Abilities::INVOICE_TASKS);

$filters = $request->validated();

return response()->json(['data' => $this->billing->unbilled(
$context->companyId,
(int) $filters['customer_id'],
$filters['from'] ?? null,
$filters['to'] ?? null,
)]);
}

/**
* The body the browser posts to the host invoice endpoint.
*
* Zero fractions are preserved so `quantity` stays the two-decimal number
* of hours the preview showed, rather than collapsing to an integer on the
* way out.
*/
public function prepare(PrepareInvoiceRequest $request): JsonResponse
{
$context = $this->context($request);
$this->authorize($context, Abilities::INVOICE_TASKS);

$validated = $request->validated();

$payload = $this->billing->prepare(
$context->companyId,
array_map(intval(...), $validated['entry_ids']),
$validated['grouping'],
);

return response()->json(['data' => $payload], 200, [], JSON_PRESERVE_ZERO_FRACTION);
}

/** Stamp the entries with the invoice and line ids the host handed back. */
public function confirm(ConfirmInvoiceRequest $request): JsonResponse
{
$context = $this->context($request);
$this->authorize($context, Abilities::INVOICE_TASKS);

$validated = $request->validated();

$items = array_map(static fn (array $item): array => [
'invoice_item_id' => (int) $item['invoice_item_id'],
'entry_ids' => array_map(intval(...), $item['entry_ids']),
], $validated['items']);

return response()->json([
'stamped' => $this->billing->confirm($context->companyId, (int) $validated['invoice_id'], $items),
]);
}
}
52 changes: 52 additions & 0 deletions app/Http/Controllers/BoardController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
<?php

declare(strict_types=1);

namespace Modules\TasksProjects\Http\Controllers;

use Illuminate\Http\JsonResponse;
use Modules\TasksProjects\Application\BoardQuery;
use Modules\TasksProjects\Application\TaskStatusService;
use Modules\TasksProjects\Http\Requests\BoardRequest;
use Modules\TasksProjects\Http\Resources\TaskResource;
use Modules\TasksProjects\Http\Resources\TaskStatusResource;
use Modules\TasksProjects\Support\Abilities;
use Modules\TasksProjects\Support\Authorizes;

/**
* The whole board in one response: every column of the company, each with its
* tasks in board order.
*
* A company that has never opened the board has no columns yet, so the four
* defaults are created before the first read.
*/
final class BoardController extends Controller
{
public function __construct(
Authorizes $authorizes,
private readonly BoardQuery $board,
private readonly TaskStatusService $statuses,
) {
parent::__construct($authorizes);
}

public function __invoke(BoardRequest $request): JsonResponse
{
$context = $this->context($request);
$this->authorize($context, Abilities::VIEW_TASK);

$this->statuses->ensureDefaults($context->companyId);

$filters = $request->validated();
$columns = $this->board->columns(
$context->companyId,
isset($filters['project_id']) ? (int) $filters['project_id'] : null,
isset($filters['assignee_id']) ? (int) $filters['assignee_id'] : null,
);

return response()->json(['data' => array_map(static fn (array $column): array => [
'status' => TaskStatusResource::make($column['status'])->resolve($request),
'tasks' => TaskResource::collection($column['tasks'])->resolve($request),
], $columns)]);
}
}
94 changes: 94 additions & 0 deletions app/Http/Controllers/Controller.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
<?php

declare(strict_types=1);

namespace Modules\TasksProjects\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Routing\Controller as BaseController;
use Illuminate\Support\Collection;
use Modules\TasksProjects\Support\Abilities;
use Modules\TasksProjects\Support\Authorizes;
use Modules\TasksProjects\Support\CompanyContext;
use Modules\TasksProjects\Support\ModuleSettings;

/**
* What every controller in the module shares: the company and user of the
* request, the ability check, and the page it should return.
*
* Controllers stay thin. They authorize, validate through a form request, call
* a service and hand the result to a resource; the rules themselves live in the
* services, and company scoping lives in the services' findForCompany.
*/
abstract class Controller extends BaseController
{
public const DEFAULT_LIMIT = 15;

public const MAX_LIMIT = 100;

public function __construct(protected readonly Authorizes $authorizes) {}

protected function context(Request $request): CompanyContext
{
return CompanyContext::fromRequest($request);
}

protected function authorize(CompanyContext $context, string $ability): void
{
$this->authorizes->require($context, $ability);
}

protected function allows(CompanyContext $context, string $ability): bool
{
return $this->authorizes->allows($context, $ability);
}

/**
* Whether the caller sees other members' time: either the ability, or the
* company setting that opens the timesheet to everyone.
*/
protected function canSeeAllTime(CompanyContext $context, ModuleSettings $settings): bool
{
return $this->allows($context, Abilities::VIEW_ALL_TIME)
|| $settings->membersSeeAllTime($context->companyId);
}

/** `?limit=`, defaulted and clamped rather than rejected. */
protected function limit(Request $request): int
{
$limit = $request->integer('limit');

return $limit <= 0 ? self::DEFAULT_LIMIT : min($limit, self::MAX_LIMIT);
}

/**
* Page an already-loaded collection.
*
* The services own every query and hand back whole collections, so the page
* is cut here rather than in a second query. The lists this module serves
* are one company's projects, tasks and time entries, which is the scale
* that fits in memory comfortably.
*
* @template TValue
*
* @param Collection<int, TValue> $items
* @return LengthAwarePaginator<int, TValue>
*/
protected function paginate(Collection $items, Request $request): LengthAwarePaginator
{
$perPage = $this->limit($request);
$page = LengthAwarePaginator::resolveCurrentPage();

return new LengthAwarePaginator(
$items->forPage($page, $perPage)->values(),
$items->count(),
$perPage,
$page,
[
'path' => LengthAwarePaginator::resolveCurrentPath(),
'query' => $request->query(),
],
);
}
}
33 changes: 33 additions & 0 deletions app/Http/Controllers/MembersController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php

declare(strict_types=1);

namespace Modules\TasksProjects\Http\Controllers;

use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use InvoiceShelf\Modules\Contracts\Host\CompanyDataReader;
use Modules\TasksProjects\Support\Abilities;
use Modules\TasksProjects\Support\Authorizes;

/**
* The company's members, for the assignee and project member pickers.
*
* This is host data, so it comes from the company data contract rather than
* from a host model or a pivot table read by name.
*/
final class MembersController extends Controller
{
public function __construct(Authorizes $authorizes, private readonly CompanyDataReader $companyData)
{
parent::__construct($authorizes);
}

public function __invoke(Request $request): JsonResponse
{
$context = $this->context($request);
$this->authorize($context, Abilities::VIEW_PROJECT);

return response()->json(['data' => $this->companyData->companyMembers($context->companyId)]);
}
}
61 changes: 61 additions & 0 deletions app/Http/Controllers/ProjectMembersController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
<?php

declare(strict_types=1);

namespace Modules\TasksProjects\Http\Controllers;

use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Modules\TasksProjects\Application\ProjectMemberService;
use Modules\TasksProjects\Http\Requests\AttachProjectMemberRequest;
use Modules\TasksProjects\Http\Resources\ProjectMemberResource;
use Modules\TasksProjects\Support\Abilities;
use Modules\TasksProjects\Support\Authorizes;

/**
* Who works on a project, and at what rate.
*
* Detaching a member never touches their time entries: the history of the work
* survives the leaver.
*/
final class ProjectMembersController extends Controller
{
public function __construct(Authorizes $authorizes, private readonly ProjectMemberService $members)
{
parent::__construct($authorizes);
}

public function index(Request $request, int $id): AnonymousResourceCollection
{
$context = $this->context($request);
$this->authorize($context, Abilities::EDIT_PROJECT);

return ProjectMemberResource::collection($this->members->listFor($context->companyId, $id));
}

public function store(AttachProjectMemberRequest $request, int $id): ProjectMemberResource
{
$context = $this->context($request);
$this->authorize($context, Abilities::EDIT_PROJECT);

$validated = $request->validated();

return new ProjectMemberResource($this->members->attach(
$context->companyId,
$id,
(int) $validated['user_id'],
isset($validated['rate']) ? (int) $validated['rate'] : null,
));
}

public function destroy(Request $request, int $id, int $userId): JsonResponse
{
$context = $this->context($request);
$this->authorize($context, Abilities::EDIT_PROJECT);

$this->members->detach($context->companyId, $id, $userId);

return response()->json(['success' => true]);
}
}
Loading
Loading