From 643f48084db255c4d9f63d3b688e525318b640eb Mon Sep 17 00:00:00 2001 From: Darko Gjorgjijoski Date: Wed, 16 Sep 2026 02:05:36 +0200 Subject: [PATCH 1/4] feat: data model, services and HTTP API Projects, tasks, time entries and task statuses scoped to the company, the timer service (start, stop with rounding, discard, one running entry per user, idempotent start on the running task), task locking once invoiced, billing preparation that turns selected tasks or a whole project into host invoice lines, settings returned in their declared types, abilities registered through the SDK, and the module's sidebar entries. Feature and unit tests cover the API, the timer, the rounding and the invoice line composition. --- app/Application/BillingSelection.php | 83 ++ app/Application/BillingService.php | 778 +++++++++++++++++ app/Application/BoardOrderingService.php | 109 +++ app/Application/BoardQuery.php | 55 ++ .../Concerns/DetectsUniqueViolations.php | 28 + app/Application/Concerns/SortsLists.php | 108 +++ .../Exceptions/EntriesAlreadyInvoiced.php | 37 + .../Exceptions/MixedBillingSelection.php | 33 + app/Application/Exceptions/NotBillable.php | 25 + .../Exceptions/NothingToInvoice.php | 21 + app/Application/Exceptions/ProjectInUse.php | 14 + app/Application/Exceptions/StatusInUse.php | 24 + app/Application/Exceptions/TaskLocked.php | 20 + .../Exceptions/TasksProjectsException.php | 38 + .../Exceptions/TimerAlreadyRunning.php | 14 + app/Application/Exceptions/TimerMismatch.php | 25 + .../Exceptions/UnknownTimeEntries.php | 15 + app/Application/InvoiceLineComposer.php | 168 ++++ app/Application/ProjectMemberService.php | 73 ++ app/Application/ProjectService.php | 299 +++++++ app/Application/RateResolver.php | 47 + app/Application/ReportService.php | 184 ++++ app/Application/Rounding.php | 65 ++ app/Application/TaskLock.php | 36 + app/Application/TaskNumberSequence.php | 31 + app/Application/TaskService.php | 337 ++++++++ app/Application/TaskStatusService.php | 207 +++++ app/Application/TaskTimeSummary.php | 215 +++++ app/Application/TimeEntryService.php | 310 +++++++ app/Application/TimerService.php | 219 +++++ app/Http/Controllers/BillingController.php | 132 +++ app/Http/Controllers/BoardController.php | 61 ++ app/Http/Controllers/BulkTasksController.php | 77 ++ app/Http/Controllers/Controller.php | 94 ++ app/Http/Controllers/MembersController.php | 33 + .../Controllers/ProjectMembersController.php | 61 ++ app/Http/Controllers/ProjectsController.php | 97 +++ app/Http/Controllers/ReportsController.php | 48 ++ app/Http/Controllers/SettingsController.php | 48 ++ .../Controllers/TaskStatusesController.php | 78 ++ .../Controllers/TaskTimeLogController.php | 55 ++ app/Http/Controllers/TasksController.php | 168 ++++ .../Controllers/TimeEntriesController.php | 124 +++ app/Http/Controllers/TimerController.php | 134 +++ app/Http/DomainExceptionRenderer.php | 59 ++ .../Requests/AttachProjectMemberRequest.php | 48 ++ app/Http/Requests/BoardRequest.php | 17 + app/Http/Requests/BulkTasksRequest.php | 35 + app/Http/Requests/ConfirmInvoiceRequest.php | 27 + app/Http/Requests/ListProjectsRequest.php | 24 + app/Http/Requests/ListTasksRequest.php | 27 + app/Http/Requests/ListTimeEntriesRequest.php | 22 + app/Http/Requests/ModuleRequest.php | 23 + app/Http/Requests/MoveTaskRequest.php | 39 + app/Http/Requests/PrepareInvoiceRequest.php | 32 + .../Requests/ReorderTaskStatusesRequest.php | 17 + app/Http/Requests/ReportSummaryRequest.php | 17 + app/Http/Requests/StartTaskTimerRequest.php | 21 + app/Http/Requests/StartTimerRequest.php | 18 + app/Http/Requests/StopTimerRequest.php | 23 + app/Http/Requests/StoreProjectRequest.php | 27 + app/Http/Requests/StoreTaskRequest.php | 28 + app/Http/Requests/StoreTaskStatusRequest.php | 20 + app/Http/Requests/StoreTimeEntryRequest.php | 23 + .../Requests/UnbilledCustomersRequest.php | 23 + app/Http/Requests/UnbilledTimeRequest.php | 18 + app/Http/Requests/UpdateProjectRequest.php | 27 + app/Http/Requests/UpdateTaskRequest.php | 28 + app/Http/Requests/UpdateTaskStatusRequest.php | 20 + app/Http/Requests/UpdateTimeEntryRequest.php | 22 + app/Http/Resources/ProjectMemberResource.php | 34 + app/Http/Resources/ProjectResource.php | 64 ++ app/Http/Resources/TaskResource.php | 55 ++ app/Http/Resources/TaskStatusResource.php | 35 + app/Http/Resources/TimeEntryResource.php | 46 + app/Lifecycle/DataCleanup.php | 25 +- app/Models/Project.php | 77 ++ app/Models/ProjectMember.php | 54 ++ app/Models/Task.php | 109 +++ app/Models/TaskStatus.php | 52 ++ app/Models/TimeEntry.php | 95 ++ .../TasksProjectsServiceProvider.php | 20 + app/Support/Abilities.php | 48 +- app/Support/Authorizes.php | 41 + app/Support/CompanyContext.php | 28 + app/Support/ModuleRegistration.php | 189 +++- app/Support/ModuleSettings.php | 137 +++ composer.json | 36 +- ..._09_15_000001_create_tp_projects_table.php | 47 + ...000002_create_tp_project_members_table.php | 35 + ...5_000003_create_tp_task_statuses_table.php | 37 + ...026_09_15_000004_create_tp_tasks_table.php | 51 ++ ...15_000005_create_tp_time_entries_table.php | 54 ++ lang/en/menu.php | 4 + lang/en/settings.php | 15 + routes/api.php | 74 +- tests/Feature/BillingApiTest.php | 486 +++++++++++ tests/Feature/MembersApiTest.php | 49 ++ tests/Feature/ModuleRegistrationTest.php | 205 ++++- tests/Feature/ProjectsApiTest.php | 363 ++++++++ tests/Feature/ReportsApiTest.php | 188 ++++ tests/Feature/SettingsApiTest.php | 120 +++ tests/Feature/TaskStatusesApiTest.php | 175 ++++ tests/Feature/TaskTimeLogApiTest.php | 164 ++++ tests/Feature/TasksApiTest.php | 759 ++++++++++++++++ tests/Feature/TimeEntriesApiTest.php | 348 ++++++++ tests/Feature/TimerApiTest.php | 442 ++++++++++ tests/Support/MemoryCompanyDataReader.php | 153 ++++ tests/Support/MemorySettingsStore.php | 54 ++ tests/Support/RecordingAuthorization.php | 42 + tests/TestCase.php | 200 ++++- tests/Unit/BillingServiceTest.php | 815 ++++++++++++++++++ tests/Unit/BoardOrderingServiceTest.php | 128 +++ tests/Unit/BoardQueryTest.php | 71 ++ tests/Unit/MigrationRollbackTest.php | 62 ++ tests/Unit/ModuleRoutesTest.php | 130 +++ tests/Unit/ProjectServiceTest.php | 253 ++++++ tests/Unit/RateResolverTest.php | 86 ++ tests/Unit/ReportServiceTest.php | 132 +++ tests/Unit/RoundingTest.php | 86 ++ tests/Unit/TaskNumberSequenceTest.php | 34 + tests/Unit/TaskServiceTest.php | 187 ++++ tests/Unit/TaskStatusServiceTest.php | 156 ++++ tests/Unit/TimeEntryServiceTest.php | 205 +++++ tests/Unit/TimerServiceTest.php | 271 ++++++ 125 files changed, 13262 insertions(+), 77 deletions(-) create mode 100644 app/Application/BillingSelection.php create mode 100644 app/Application/BillingService.php create mode 100644 app/Application/BoardOrderingService.php create mode 100644 app/Application/BoardQuery.php create mode 100644 app/Application/Concerns/DetectsUniqueViolations.php create mode 100644 app/Application/Concerns/SortsLists.php create mode 100644 app/Application/Exceptions/EntriesAlreadyInvoiced.php create mode 100644 app/Application/Exceptions/MixedBillingSelection.php create mode 100644 app/Application/Exceptions/NotBillable.php create mode 100644 app/Application/Exceptions/NothingToInvoice.php create mode 100644 app/Application/Exceptions/ProjectInUse.php create mode 100644 app/Application/Exceptions/StatusInUse.php create mode 100644 app/Application/Exceptions/TaskLocked.php create mode 100644 app/Application/Exceptions/TasksProjectsException.php create mode 100644 app/Application/Exceptions/TimerAlreadyRunning.php create mode 100644 app/Application/Exceptions/TimerMismatch.php create mode 100644 app/Application/Exceptions/UnknownTimeEntries.php create mode 100644 app/Application/InvoiceLineComposer.php create mode 100644 app/Application/ProjectMemberService.php create mode 100644 app/Application/ProjectService.php create mode 100644 app/Application/RateResolver.php create mode 100644 app/Application/ReportService.php create mode 100644 app/Application/Rounding.php create mode 100644 app/Application/TaskLock.php create mode 100644 app/Application/TaskNumberSequence.php create mode 100644 app/Application/TaskService.php create mode 100644 app/Application/TaskStatusService.php create mode 100644 app/Application/TaskTimeSummary.php create mode 100644 app/Application/TimeEntryService.php create mode 100644 app/Application/TimerService.php create mode 100644 app/Http/Controllers/BillingController.php create mode 100644 app/Http/Controllers/BoardController.php create mode 100644 app/Http/Controllers/BulkTasksController.php create mode 100644 app/Http/Controllers/Controller.php create mode 100644 app/Http/Controllers/MembersController.php create mode 100644 app/Http/Controllers/ProjectMembersController.php create mode 100644 app/Http/Controllers/ProjectsController.php create mode 100644 app/Http/Controllers/ReportsController.php create mode 100644 app/Http/Controllers/SettingsController.php create mode 100644 app/Http/Controllers/TaskStatusesController.php create mode 100644 app/Http/Controllers/TaskTimeLogController.php create mode 100644 app/Http/Controllers/TasksController.php create mode 100644 app/Http/Controllers/TimeEntriesController.php create mode 100644 app/Http/Controllers/TimerController.php create mode 100644 app/Http/DomainExceptionRenderer.php create mode 100644 app/Http/Requests/AttachProjectMemberRequest.php create mode 100644 app/Http/Requests/BoardRequest.php create mode 100644 app/Http/Requests/BulkTasksRequest.php create mode 100644 app/Http/Requests/ConfirmInvoiceRequest.php create mode 100644 app/Http/Requests/ListProjectsRequest.php create mode 100644 app/Http/Requests/ListTasksRequest.php create mode 100644 app/Http/Requests/ListTimeEntriesRequest.php create mode 100644 app/Http/Requests/ModuleRequest.php create mode 100644 app/Http/Requests/MoveTaskRequest.php create mode 100644 app/Http/Requests/PrepareInvoiceRequest.php create mode 100644 app/Http/Requests/ReorderTaskStatusesRequest.php create mode 100644 app/Http/Requests/ReportSummaryRequest.php create mode 100644 app/Http/Requests/StartTaskTimerRequest.php create mode 100644 app/Http/Requests/StartTimerRequest.php create mode 100644 app/Http/Requests/StopTimerRequest.php create mode 100644 app/Http/Requests/StoreProjectRequest.php create mode 100644 app/Http/Requests/StoreTaskRequest.php create mode 100644 app/Http/Requests/StoreTaskStatusRequest.php create mode 100644 app/Http/Requests/StoreTimeEntryRequest.php create mode 100644 app/Http/Requests/UnbilledCustomersRequest.php create mode 100644 app/Http/Requests/UnbilledTimeRequest.php create mode 100644 app/Http/Requests/UpdateProjectRequest.php create mode 100644 app/Http/Requests/UpdateTaskRequest.php create mode 100644 app/Http/Requests/UpdateTaskStatusRequest.php create mode 100644 app/Http/Requests/UpdateTimeEntryRequest.php create mode 100644 app/Http/Resources/ProjectMemberResource.php create mode 100644 app/Http/Resources/ProjectResource.php create mode 100644 app/Http/Resources/TaskResource.php create mode 100644 app/Http/Resources/TaskStatusResource.php create mode 100644 app/Http/Resources/TimeEntryResource.php create mode 100644 app/Models/Project.php create mode 100644 app/Models/ProjectMember.php create mode 100644 app/Models/Task.php create mode 100644 app/Models/TaskStatus.php create mode 100644 app/Models/TimeEntry.php create mode 100644 app/Support/Authorizes.php create mode 100644 app/Support/CompanyContext.php create mode 100644 app/Support/ModuleSettings.php create mode 100644 database/migrations/2026_09_15_000001_create_tp_projects_table.php create mode 100644 database/migrations/2026_09_15_000002_create_tp_project_members_table.php create mode 100644 database/migrations/2026_09_15_000003_create_tp_task_statuses_table.php create mode 100644 database/migrations/2026_09_15_000004_create_tp_tasks_table.php create mode 100644 database/migrations/2026_09_15_000005_create_tp_time_entries_table.php create mode 100644 tests/Feature/BillingApiTest.php create mode 100644 tests/Feature/MembersApiTest.php create mode 100644 tests/Feature/ProjectsApiTest.php create mode 100644 tests/Feature/ReportsApiTest.php create mode 100644 tests/Feature/SettingsApiTest.php create mode 100644 tests/Feature/TaskStatusesApiTest.php create mode 100644 tests/Feature/TaskTimeLogApiTest.php create mode 100644 tests/Feature/TasksApiTest.php create mode 100644 tests/Feature/TimeEntriesApiTest.php create mode 100644 tests/Feature/TimerApiTest.php create mode 100644 tests/Support/MemoryCompanyDataReader.php create mode 100644 tests/Support/MemorySettingsStore.php create mode 100644 tests/Support/RecordingAuthorization.php create mode 100644 tests/Unit/BillingServiceTest.php create mode 100644 tests/Unit/BoardOrderingServiceTest.php create mode 100644 tests/Unit/BoardQueryTest.php create mode 100644 tests/Unit/MigrationRollbackTest.php create mode 100644 tests/Unit/ModuleRoutesTest.php create mode 100644 tests/Unit/ProjectServiceTest.php create mode 100644 tests/Unit/RateResolverTest.php create mode 100644 tests/Unit/ReportServiceTest.php create mode 100644 tests/Unit/RoundingTest.php create mode 100644 tests/Unit/TaskNumberSequenceTest.php create mode 100644 tests/Unit/TaskServiceTest.php create mode 100644 tests/Unit/TaskStatusServiceTest.php create mode 100644 tests/Unit/TimeEntryServiceTest.php create mode 100644 tests/Unit/TimerServiceTest.php diff --git a/app/Application/BillingSelection.php b/app/Application/BillingSelection.php new file mode 100644 index 0000000..0aec108 --- /dev/null +++ b/app/Application/BillingSelection.php @@ -0,0 +1,83 @@ + */ + public const KINDS = [self::ENTRIES, self::TASKS, self::PROJECT]; + + /** @param list $ids */ + private function __construct( + public readonly string $kind, + public readonly array $ids, + ) {} + + /** @param list $entryIds */ + public static function fromEntryIds(array $entryIds): self + { + return new self(self::ENTRIES, self::normalise($entryIds)); + } + + /** @param list $taskIds */ + public static function fromTaskIds(array $taskIds): self + { + return new self(self::TASKS, self::normalise($taskIds)); + } + + public static function fromProject(int $projectId): self + { + return new self(self::PROJECT, [$projectId]); + } + + /** The project this selection names, for the project shape only. */ + public function projectId(): int + { + if ($this->kind !== self::PROJECT) { + throw new InvalidArgumentException("A {$this->kind} selection does not name a project."); + } + + return $this->ids[0]; + } + + /** + * Ids as integers, de-duplicated and in ascending order. + * + * The order the browser sent is never meaningful: entries come back sorted + * by their start, so sorting here only makes the resolution deterministic + * and the "which ids are missing" message stable. + * + * @param list $ids + * @return list + */ + private static function normalise(array $ids): array + { + $ids = array_values(array_unique(array_map(intval(...), $ids))); + sort($ids); + + return $ids; + } +} diff --git a/app/Application/BillingService.php b/app/Application/BillingService.php new file mode 100644 index 0000000..a3dddf2 --- /dev/null +++ b/app/Application/BillingService.php @@ -0,0 +1,778 @@ +, minutes: int, currencies: list, entries: list>, groups: array{task: list>, project: list>, member: list>, summary: list>}} + */ + public function unbilled(int $companyId, int $customerId, ?string $from = null, ?string $to = null): array + { + $entries = $this->unbilledEntries($companyId, $customerId, $from, $to); + + $currencies = []; + $minutes = 0; + foreach ($entries as $entry) { + $key = $entry->currency_id === null ? 'null' : (string) $entry->currency_id; + $currencies[$key] ??= ['currency_id' => $entry->currency_id, 'minutes' => 0, 'amount' => 0]; + $currencies[$key]['minutes'] += (int) $entry->duration_minutes; + $currencies[$key]['amount'] += (int) $entry->amount; + $minutes += (int) $entry->duration_minutes; + } + + $labels = $this->labelsFor($companyId, $entries); + + return [ + 'customer_id' => $customerId, + 'from' => $from, + 'to' => $to, + 'entry_ids' => $entries->map(static fn (TimeEntry $entry): int => (int) $entry->id)->all(), + 'minutes' => $minutes, + 'currencies' => array_values($currencies), + 'entries' => $this->rows($entries, $labels), + 'groups' => [ + 'task' => $this->group($entries, 'task', $labels, true), + 'project' => $this->group($entries, 'project', $labels, true), + 'member' => $this->group($entries, 'member', $labels, true), + 'summary' => $this->group($entries, 'summary', $labels, true), + ], + ]; + } + + /** + * Which customers have unbilled billable time, and how much of it. + * + * The same rule `unbilled()` applies to one customer, applied to all of + * them at once: the unbilled time page needs to know who is worth opening + * before it asks for anyone's entries. A customer whose work spans two + * currencies gets a row per currency, because money in two denominations + * cannot be added up and `prepare()` refuses such a selection anyway. + * + * @param string|null $from inclusive start date, Y-m-d + * @param string|null $to inclusive end date, Y-m-d + * @return list + */ + public function customers(int $companyId, ?string $from = null, ?string $to = null): array + { + $customerByTask = Task::query() + ->forCompany($companyId) + ->whereNotNull('customer_id') + ->pluck('customer_id', 'id'); + + $entries = $this->unbilledEntriesForTasks( + $companyId, + array_map(intval(...), $customerByTask->keys()->all()), + $from, + $to, + ); + + $rows = []; + foreach ($entries as $entry) { + $customerId = (int) $customerByTask->get((int) $entry->task_id); + $currencyId = $entry->currency_id === null ? null : (int) $entry->currency_id; + + $bucket = $customerId.'|'.($currencyId ?? 'null'); + $rows[$bucket] ??= [ + 'customer_id' => $customerId, + 'entries' => 0, + 'minutes' => 0, + 'amount' => 0, + 'currency_id' => $currencyId, + ]; + + $rows[$bucket]['entries']++; + $rows[$bucket]['minutes'] += (int) $entry->duration_minutes; + $rows[$bucket]['amount'] += (int) $entry->amount; + } + + $rows = array_values($rows); + usort($rows, static fn (array $left, array $right): int => [$left['customer_id'], $left['currency_id'] ?? 0] + <=> [$right['customer_id'], $right['currency_id'] ?? 0]); + + return $rows; + } + + /** + * The invoice body for a selection of entries, plus the entry ids behind + * each line. + * + * `groups[i]` lists the entries that produced `items[i]`, so the caller can + * hand `confirm()` the line ids the host gave back without re-deriving the + * grouping. A line's `price` is the shared rate of its entries, or the + * blended rate when they differ, and its `total` is `quantity * price` so + * the invoice the host builds matches the preview exactly. + * + * Every key the host's invoice writer reads is present, including the ones + * this module never sets: a line carries its zeroed discount and tax fields + * so `DocumentItemService::createItems` never reaches for a missing index, + * and `notes` and `template_name` are placeholders the browser fills in from + * the company's own defaults before it posts. + * + * @param 'task'|'project'|'member'|'summary' $grouping + * @return array{invoice_date: string, customer_id: int, currency_id: int|null, discount: int, discount_type: string, discount_val: int, tax: int, sub_total: int, total: int, notes: string|null, template_name: string|null, taxes: list>, items: list>, total: int}>, groups: list}>} + */ + public function prepare(int $companyId, BillingSelection $selection, string $grouping = self::DEFAULT_GROUPING): array + { + if (! in_array($grouping, self::GROUPINGS, true)) { + throw new InvalidArgumentException( + "Grouping '{$grouping}' is not one of ".implode(', ', self::GROUPINGS).'.', + ); + } + + $entries = $this->resolveEntries($companyId, $selection); + $tasks = $this->tasksFor($companyId, $entries); + $customerId = $this->singleCustomerFor($tasks, $entries); + $currencyId = $this->singleCurrencyFor($entries); + + $labels = $this->labelsFor($companyId, $entries, $tasks); + $options = $this->settings->invoiceLineOptions($companyId); + $byId = $entries->keyBy(static fn (TimeEntry $entry): int => (int) $entry->id); + + $items = []; + $groups = []; + $subTotal = 0; + + foreach ($this->group($entries, $grouping, $labels, false) as $row) { + /** @var list $rowEntries */ + $rowEntries = array_values(array_map( + static fn (int $entryId): TimeEntry => $byId->get($entryId), + $row['entry_ids'], + )); + $task = $grouping === 'task' ? $tasks->get($row['key']) : null; + + $quantity = round($row['minutes'] / 60, 2); + $price = $row['rate'] ?? ($quantity > 0.0 ? (int) round($row['amount'] / $quantity) : 0); + $total = (int) round($quantity * $price); + + $items[] = [ + 'name' => $this->composer->name((string) $row['label'], $task), + 'description' => $this->composer->description( + $rowEntries, + $options, + $this->projectNameFor($grouping, $row, $rowEntries, $task, $labels), + $task?->description === null ? null : (string) $task->description, + ), + 'quantity' => $quantity, + 'price' => $price, + 'discount_type' => 'fixed', + 'discount' => 0, + 'discount_val' => 0, + 'tax' => 0, + 'taxes' => [], + 'total' => $total, + ]; + $groups[] = ['entry_ids' => $row['entry_ids']]; + $subTotal += $total; + } + + return [ + 'invoice_date' => Carbon::now()->toDateString(), + 'customer_id' => $customerId, + 'currency_id' => $currencyId, + 'discount' => 0, + 'discount_type' => 'fixed', + 'discount_val' => 0, + 'tax' => 0, + 'sub_total' => $subTotal, + 'total' => $subTotal, + 'notes' => null, + 'template_name' => null, + 'taxes' => [], + 'items' => $items, + 'groups' => $groups, + ]; + } + + /** + * The entries a selection stands for, ordered by when the work started. + * + * An explicit list of entry ids is validated to the letter, because the + * caller ticked those boxes itself and a silently dropped row would be a + * silently dropped invoice line. A task or a project instead asks for + * "whatever is still unbilled here", so the same rule the unbilled list + * uses applies: stopped, billable, off an internal project, and free of a + * live invoice. Nothing left to bill is a refusal of its own rather than an + * empty invoice. + * + * @return Collection + */ + public function resolveEntries(int $companyId, BillingSelection $selection): Collection + { + return match ($selection->kind) { + BillingSelection::ENTRIES => $this->selectionFor($companyId, $selection->ids), + BillingSelection::TASKS => $this->unbilledSelection($companyId, $this->ownTaskIds($companyId, $selection->ids)), + BillingSelection::PROJECT => $this->unbilledSelection($companyId, $this->projectTaskIds($companyId, $selection->projectId())), + default => throw new InvalidArgumentException( + "Billing selection '{$selection->kind}' is not one of ".implode(', ', BillingSelection::KINDS).'.', + ), + }; + } + + /** + * Stamp entries with the invoice and line ids the host handed back. + * + * Re-running the same call is harmless: an entry already stamped with this + * invoice and this line is left alone, so the returned count is the number + * of entries this call actually wrote and a repeat returns zero. An entry + * belonging to another invoice, or to another company, is refused. + * + * @param list}> $items + */ + public function confirm(int $companyId, int $invoiceId, array $items): int + { + $wanted = []; + foreach ($items as $item) { + foreach ($item['entry_ids'] as $entryId) { + $wanted[(int) $entryId] = (int) $item['invoice_item_id']; + } + } + + if ($wanted === []) { + return 0; + } + + return DB::transaction(function () use ($companyId, $invoiceId, $wanted): int { + $entries = TimeEntry::query() + ->forCompany($companyId) + ->whereIn('id', array_keys($wanted)) + ->get() + ->keyBy('id'); + + $missing = array_values(array_diff(array_keys($wanted), $entries->keys()->map(intval(...))->all())); + if ($missing !== []) { + throw UnknownTimeEntries::forIds($missing); + } + + $now = Carbon::now(); + $stamped = 0; + + foreach ($wanted as $entryId => $invoiceItemId) { + /** @var TimeEntry $entry */ + $entry = $entries->get($entryId); + + if ($entry->invoice_id !== null && (int) $entry->invoice_id !== $invoiceId) { + throw EntriesAlreadyInvoiced::forOtherInvoice((int) $entry->id, (int) $entry->invoice_id); + } + + if ((int) $entry->invoice_id === $invoiceId && (int) $entry->invoice_item_id === $invoiceItemId) { + continue; + } + + $entry->invoice_id = $invoiceId; + $entry->invoice_item_id = $invoiceItemId; + $entry->invoiced_at ??= $now; + $entry->save(); + $stamped++; + } + + return $stamped; + }); + } + + /** @return Collection */ + private function unbilledEntries(int $companyId, int $customerId, ?string $from, ?string $to): Collection + { + $taskIds = Task::query() + ->forCompany($companyId) + ->where('customer_id', $customerId) + ->pluck('id') + ->all(); + + return $this->unbilledEntriesForTasks($companyId, array_map(intval(...), $taskIds), $from, $to); + } + + /** + * The billable, stopped, not-yet-invoiced time logged against these tasks. + * + * One customer's list and the whole company's list differ only in which + * tasks go in, so both ask this: the internal-project exclusion, the date + * range and the vanished-invoice rule are written once. + * + * @param list $taskIds + * @return Collection + */ + private function unbilledEntriesForTasks(int $companyId, array $taskIds, ?string $from, ?string $to): Collection + { + if ($taskIds === []) { + /** @var Collection $none */ + $none = new Collection; + + return $none; + } + + $internalProjectIds = Project::query() + ->forCompany($companyId) + ->whereNull('customer_id') + ->pluck('id') + ->all(); + + $query = TimeEntry::query() + ->forCompany($companyId) + ->where('billable', true) + ->whereNull('running_user_id') + ->whereIn('task_id', $taskIds); + + if ($internalProjectIds !== []) { + $query->where(static function (Builder $inner) use ($internalProjectIds): void { + $inner->whereNull('project_id')->orWhereNotIn('project_id', $internalProjectIds); + }); + } + + if ($from !== null) { + $query->where('started_at', '>=', Carbon::parse($from)->startOfDay()); + } + + if ($to !== null) { + $query->where('started_at', '<=', Carbon::parse($to)->endOfDay()); + } + + $entries = $query->orderBy('started_at')->orderBy('id')->get(); + $live = $this->liveInvoiceIds($companyId, $entries); + + return $entries + ->reject(static fn (TimeEntry $entry): bool => $entry->invoice_id !== null && in_array((int) $entry->invoice_id, $live, true)) + ->values(); + } + + /** + * The subset of the invoices these entries point at that still exists. + * + * The module has no delete hook, so a stamped entry whose invoice was + * removed in the host counts as unbilled again rather than as money that + * quietly vanished. + * + * @param Collection $entries + * @return list + */ + private function liveInvoiceIds(int $companyId, Collection $entries): array + { + $stamped = $entries + ->pluck('invoice_id') + ->filter(static fn (?int $id): bool => $id !== null) + ->map(intval(...)) + ->unique() + ->values() + ->all(); + + if ($stamped === []) { + return []; + } + + return array_values(array_map(intval(...), $this->companyData->existingInvoiceIds($companyId, $stamped))); + } + + /** + * Load and validate an explicit selection: every id has to exist in the + * company, be billable, be stopped and be free of a live invoice. + * + * @param list $entryIds + * @return Collection + */ + private function selectionFor(int $companyId, array $entryIds): Collection + { + $ids = array_values(array_unique(array_map(intval(...), $entryIds))); + sort($ids); + + if ($ids === []) { + throw MixedBillingSelection::empty(); + } + + $entries = TimeEntry::query() + ->forCompany($companyId) + ->whereIn('id', $ids) + ->orderBy('started_at') + ->orderBy('id') + ->get(); + + $missing = array_values(array_diff($ids, $entries->map(static fn (TimeEntry $entry): int => (int) $entry->id)->all())); + if ($missing !== []) { + throw UnknownTimeEntries::forIds($missing); + } + + $running = $entries->first(static fn (TimeEntry $entry): bool => $entry->running_user_id !== null); + if ($running !== null) { + throw NotBillable::running((int) $running->id); + } + + $notBillable = $entries + ->reject(static fn (TimeEntry $entry): bool => (bool) $entry->billable) + ->map(static fn (TimeEntry $entry): int => (int) $entry->id) + ->values() + ->all(); + if ($notBillable !== []) { + throw NotBillable::forEntries($notBillable); + } + + $live = $this->liveInvoiceIds($companyId, $entries); + $invoiced = $entries + ->filter(static fn (TimeEntry $entry): bool => $entry->invoice_id !== null && in_array((int) $entry->invoice_id, $live, true)) + ->map(static fn (TimeEntry $entry): int => (int) $entry->id) + ->values() + ->all(); + if ($invoiced !== []) { + throw EntriesAlreadyInvoiced::forEntries($invoiced); + } + + return $entries; + } + + /** + * Everything still unbilled on these tasks, or a refusal if that is + * nothing. + * + * @param list $taskIds + * @return Collection + */ + private function unbilledSelection(int $companyId, array $taskIds): Collection + { + $entries = $this->unbilledEntriesForTasks($companyId, $taskIds, null, null); + + if ($entries->isEmpty()) { + throw NothingToInvoice::forSelection(); + } + + return $entries; + } + + /** + * The named tasks, refusing any the company does not own. + * + * A task id from another company is a 404 rather than a quietly shorter + * invoice, which is the same answer `tasks/{id}` gives. + * + * @param list $taskIds + * @return list + */ + private function ownTaskIds(int $companyId, array $taskIds): array + { + $found = array_map(intval(...), Task::query() + ->forCompany($companyId) + ->whereIn('id', $taskIds) + ->pluck('id') + ->all()); + + $missing = array_values(array_diff($taskIds, $found)); + if ($missing !== []) { + throw (new ModelNotFoundException)->setModel(Task::class, $missing); + } + + return array_values($found); + } + + /** + * Every task filed under one of the company's projects. + * + * @return list + */ + private function projectTaskIds(int $companyId, int $projectId): array + { + $project = Project::query()->forCompany($companyId)->find($projectId); + + if ($project === null) { + throw (new ModelNotFoundException)->setModel(Project::class, [$projectId]); + } + + return array_values(array_map(intval(...), Task::query() + ->forCompany($companyId) + ->where('project_id', $project->id) + ->pluck('id') + ->all())); + } + + /** + * The tasks these entries were logged against, keyed by id. + * + * @param Collection $entries + * @return Collection + */ + private function tasksFor(int $companyId, Collection $entries): Collection + { + /** @var Collection $tasks */ + $tasks = Task::query() + ->forCompany($companyId) + ->whereIn('id', $entries->pluck('task_id')->unique()->all()) + ->get() + ->keyBy('id'); + + return $tasks; + } + + /** + * The project a line's note may head itself with. + * + * A task line follows its own task, a project line is the project, and a + * line that collapses several projects only gets a heading when all of its + * work happens to sit in one of them. + * + * @param array{key: int|null, label: string, currency_id: int|null, rate: int|null, entry_ids: list, minutes: int, amount: int, description: string|null} $row + * @param list $entries + * @param array{task: array, project: array, member: array} $labels + */ + private function projectNameFor(string $grouping, array $row, array $entries, ?Task $task, array $labels): ?string + { + $projectId = match ($grouping) { + 'task' => $task?->project_id === null ? null : (int) $task->project_id, + 'project' => $row['key'], + default => self::singleProjectId($entries), + }; + + return $projectId === null ? null : ($labels['project'][$projectId] ?? null); + } + + /** + * The one project these entries share, or null when they span several. + * + * @param list $entries + */ + private static function singleProjectId(array $entries): ?int + { + $projectIds = []; + foreach ($entries as $entry) { + if ($entry->project_id === null) { + return null; + } + + $projectIds[(int) $entry->project_id] = true; + } + + return count($projectIds) === 1 ? (int) array_key_first($projectIds) : null; + } + + /** + * @param Collection $tasks keyed by id + * @param Collection $entries + */ + private function singleCustomerFor(Collection $tasks, Collection $entries): int + { + $customerIds = []; + foreach ($entries as $entry) { + $customerId = $tasks->get((int) $entry->task_id)?->customer_id; + + if ($customerId === null) { + throw NotBillable::withoutCustomer((int) $entry->id); + } + + $customerIds[(int) $customerId] = true; + } + + if (count($customerIds) > 1) { + throw MixedBillingSelection::customers(array_keys($customerIds)); + } + + return (int) array_key_first($customerIds); + } + + /** @param Collection $entries */ + private function singleCurrencyFor(Collection $entries): ?int + { + $currencies = []; + foreach ($entries as $entry) { + $currencies[$entry->currency_id === null ? 'null' : (string) $entry->currency_id] = $entry->currency_id; + } + + if (count($currencies) > 1) { + throw MixedBillingSelection::currencies(array_keys($currencies)); + } + + $currencyId = reset($currencies); + + return $currencyId === null ? null : (int) $currencyId; + } + + /** + * One row per entry, with the names the review step shows. + * + * The grouped views answer "how much"; this answers "which work", so the + * step that ticks entries off can render the task, the project, the member + * and the day without a second round trip per row. + * + * @param Collection $entries + * @param array{task: array, project: array, member: array} $labels + * @return list + */ + private function rows(Collection $entries, array $labels): array + { + return $entries->map(static fn (TimeEntry $entry): array => [ + 'id' => (int) $entry->id, + 'task_id' => (int) $entry->task_id, + 'task_name' => $labels['task'][(int) $entry->task_id] ?? "Task {$entry->task_id}", + 'project_id' => $entry->project_id === null ? null : (int) $entry->project_id, + 'project_name' => $entry->project_id === null + ? null + : ($labels['project'][(int) $entry->project_id] ?? "Project {$entry->project_id}"), + 'user_id' => (int) $entry->user_id, + 'user_name' => $labels['member'][(int) $entry->user_id] ?? 'Removed member', + 'date' => $entry->started_at?->toDateString(), + 'minutes' => (int) $entry->duration_minutes, + 'amount' => (int) $entry->amount, + 'rate' => (int) $entry->rate, + 'currency_id' => $entry->currency_id === null ? null : (int) $entry->currency_id, + 'description' => $entry->description, + ])->values()->all(); + } + + /** + * Human labels for every grouping key the entries touch. + * + * Member names come from the host reader, so a user who has left the + * company renders as a removed member rather than as a bare id. + * + * @param Collection $entries + * @param Collection|null $tasks already loaded and keyed by id, when the caller has them + * @return array{task: array, project: array, member: array} + */ + private function labelsFor(int $companyId, Collection $entries, ?Collection $tasks = null): array + { + $tasks ??= $this->tasksFor($companyId, $entries); + + $projectIds = $entries + ->pluck('project_id') + ->merge($tasks->pluck('project_id')) + ->filter(static fn (?int $id): bool => $id !== null) + ->unique() + ->all(); + /** @var Collection $projects */ + $projects = $projectIds === [] + ? new Collection + : Project::query()->forCompany($companyId)->whereIn('id', $projectIds)->get(); + + $members = []; + foreach ($this->companyData->companyMembers($companyId) as $member) { + $members[(int) $member['id']] = (string) $member['name']; + } + + return [ + 'task' => $tasks->mapWithKeys(static fn (Task $task): array => [(int) $task->id => (string) $task->name])->all(), + 'project' => $projects->mapWithKeys(static fn (Project $project): array => [(int) $project->id => (string) $project->name])->all(), + 'member' => $members, + ]; + } + + /** + * Collapse entries into one row per grouping key, in the order the entries + * were read. + * + * `$splitByCurrency` keeps the browsing view honest about multi-currency + * work; `prepare()` has already refused a mixed selection, so it groups on + * the key alone. + * + * @param Collection $entries + * @param array{task: array, project: array, member: array} $labels + * @return list, minutes: int, amount: int, description: string|null}> + */ + private function group(Collection $entries, string $grouping, array $labels, bool $splitByCurrency): array + { + $rows = []; + + foreach ($entries as $entry) { + [$key, $label] = $this->keyFor($entry, $grouping, $labels); + $bucket = ($key === null ? '~' : (string) $key).($splitByCurrency ? '|'.($entry->currency_id ?? 'null') : ''); + + $rows[$bucket] ??= [ + 'key' => $key, + 'label' => $label, + 'currency_id' => $entry->currency_id === null ? null : (int) $entry->currency_id, + 'rate' => (int) $entry->rate, + 'entry_ids' => [], + 'minutes' => 0, + 'amount' => 0, + 'descriptions' => [], + ]; + + $rows[$bucket]['entry_ids'][] = (int) $entry->id; + $rows[$bucket]['minutes'] += (int) $entry->duration_minutes; + $rows[$bucket]['amount'] += (int) $entry->amount; + + if ($rows[$bucket]['rate'] !== (int) $entry->rate) { + $rows[$bucket]['rate'] = null; + } + + $description = trim((string) ($entry->description ?? '')); + if ($description !== '' && ! in_array($description, $rows[$bucket]['descriptions'], true)) { + $rows[$bucket]['descriptions'][] = $description; + } + } + + return array_values(array_map(static function (array $row): array { + $descriptions = $row['descriptions']; + unset($row['descriptions']); + $row['description'] = $descriptions === [] ? null : implode("\n", $descriptions); + + return $row; + }, $rows)); + } + + /** + * @param array{task: array, project: array, member: array} $labels + * @return array{0: int|null, 1: string} + */ + private function keyFor(TimeEntry $entry, string $grouping, array $labels): array + { + return match ($grouping) { + 'task' => [(int) $entry->task_id, $labels['task'][(int) $entry->task_id] ?? "Task {$entry->task_id}"], + 'project' => $entry->project_id === null + ? [null, 'No project'] + : [(int) $entry->project_id, $labels['project'][(int) $entry->project_id] ?? "Project {$entry->project_id}"], + 'member' => [(int) $entry->user_id, $labels['member'][(int) $entry->user_id] ?? 'Removed member'], + default => [null, self::SUMMARY_LABEL], + }; + } +} diff --git a/app/Application/BoardOrderingService.php b/app/Application/BoardOrderingService.php new file mode 100644 index 0000000..423fe1c --- /dev/null +++ b/app/Application/BoardOrderingService.php @@ -0,0 +1,109 @@ +neighbourPosition($companyId, $statusId, $beforeTaskId); + $after = $this->neighbourPosition($companyId, $statusId, $afterTaskId); + + if ($after !== null && ($after - ($before ?? 0.0)) < self::MIN_GAP) { + $this->renormalise($companyId, $statusId); + + $before = $this->neighbourPosition($companyId, $statusId, $beforeTaskId); + $after = $this->neighbourPosition($companyId, $statusId, $afterTaskId); + } + + return match (true) { + $before !== null && $after !== null => self::format(($before + $after) / 2), + $before !== null => self::format($before + self::STEP), + $after !== null => self::format($after / 2), + default => self::format($this->lastPosition($companyId, $statusId) + self::STEP), + }; + } + + /** + * Rewrite a column to whole steps (1024, 2048, ...), keeping the current order. + * + * @return int the number of tasks renumbered + */ + public function renormalise(int $companyId, int $statusId): int + { + return DB::transaction(static function () use ($companyId, $statusId): int { + $tasks = Task::query() + ->forCompany($companyId) + ->where('task_status_id', $statusId) + ->orderBy('board_position') + ->orderBy('id') + ->get(); + + foreach ($tasks as $index => $task) { + $task->board_position = self::format(($index + 1) * self::STEP); + $task->save(); + } + + return $tasks->count(); + }); + } + + /** @throws InvalidArgumentException when the neighbour is outside the company or the column */ + private function neighbourPosition(int $companyId, int $statusId, ?int $taskId): ?float + { + if ($taskId === null) { + return null; + } + + $task = Task::query()->forCompany($companyId)->find($taskId); + + if ($task === null) { + throw new InvalidArgumentException("Task {$taskId} does not belong to company {$companyId}."); + } + + if ((int) $task->task_status_id !== $statusId) { + throw new InvalidArgumentException("Task {$taskId} is not in task status {$statusId}."); + } + + return (float) $task->board_position; + } + + private function lastPosition(int $companyId, int $statusId): float + { + return (float) Task::query() + ->forCompany($companyId) + ->where('task_status_id', $statusId) + ->max('board_position'); + } + + private static function format(float $position): string + { + return sprintf('%.10F', $position); + } +} diff --git a/app/Application/BoardQuery.php b/app/Application/BoardQuery.php new file mode 100644 index 0000000..ed454ab --- /dev/null +++ b/app/Application/BoardQuery.php @@ -0,0 +1,55 @@ +}> */ + public function columns(int $companyId, ?int $projectId = null, ?int $assigneeId = null): array + { + $statuses = TaskStatus::query() + ->forCompany($companyId) + ->orderBy('position') + ->orderBy('id') + ->get(); + + $query = Task::query() + ->forCompany($companyId) + ->orderBy('task_status_id') + ->orderBy('board_position') + ->orderBy('id'); + + if ($projectId !== null) { + $query->where('project_id', $projectId); + } + + if ($assigneeId !== null) { + $query->where('assignee_id', $assigneeId); + } + + $tasks = $query->get()->groupBy('task_status_id'); + + $columns = []; + foreach ($statuses as $status) { + $columns[] = [ + 'status' => $status, + 'tasks' => array_values($tasks->get($status->id, collect())->all()), + ]; + } + + return $columns; + } +} diff --git a/app/Application/Concerns/DetectsUniqueViolations.php b/app/Application/Concerns/DetectsUniqueViolations.php new file mode 100644 index 0000000..c0e61de --- /dev/null +++ b/app/Application/Concerns/DetectsUniqueViolations.php @@ -0,0 +1,28 @@ +getCode(), ['23000', '23505'], true)) { + return false; + } + + $message = strtolower($exception->getMessage()); + + return str_contains($message, 'unique') || str_contains($message, 'duplicate entry'); + } +} 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/Exceptions/EntriesAlreadyInvoiced.php b/app/Application/Exceptions/EntriesAlreadyInvoiced.php new file mode 100644 index 0000000..c4308f2 --- /dev/null +++ b/app/Application/Exceptions/EntriesAlreadyInvoiced.php @@ -0,0 +1,37 @@ + $entryIds */ + public static function forEntries(array $entryIds): self + { + return new self('Time entries '.implode(', ', $entryIds).' are already on an invoice.'); + } + + public static function forEntry(int $entryId): self + { + return self::forEntries([$entryId]); + } + + /** + * An edit that would move time an invoice was already raised for. + * + * @param list $fields + */ + public static function forLockedFields(int $entryId, array $fields): self + { + return new self( + "Time entry {$entryId} is already on an invoice: ".implode(', ', $fields).' cannot be changed.', + ); + } + + public static function forOtherInvoice(int $entryId, int $invoiceId): self + { + return new self("Time entry {$entryId} is already stamped with invoice {$invoiceId}."); + } +} diff --git a/app/Application/Exceptions/MixedBillingSelection.php b/app/Application/Exceptions/MixedBillingSelection.php new file mode 100644 index 0000000..17ee713 --- /dev/null +++ b/app/Application/Exceptions/MixedBillingSelection.php @@ -0,0 +1,33 @@ + $customerIds + */ + public static function customers(array $customerIds): self + { + return (new self('The selected time entries belong to more than one customer: '.implode(', ', $customerIds).'.')) + ->withContext(['customer_ids' => array_values(array_map(intval(...), $customerIds))]); + } + + /** @param list $currencyIds */ + public static function currencies(array $currencyIds): self + { + return new self('The selected time entries use more than one currency: '.implode(', ', $currencyIds).'.'); + } + + public static function empty(): self + { + return new self('No time entries were selected.'); + } +} diff --git a/app/Application/Exceptions/NotBillable.php b/app/Application/Exceptions/NotBillable.php new file mode 100644 index 0000000..18ac3ad --- /dev/null +++ b/app/Application/Exceptions/NotBillable.php @@ -0,0 +1,25 @@ + $entryIds */ + public static function forEntries(array $entryIds): self + { + return new self('Time entries '.implode(', ', $entryIds).' are not billable.'); + } + + public static function withoutCustomer(int $entryId): self + { + return new self("Time entry {$entryId} is on an internal project and has no customer to bill."); + } + + public static function running(int $entryId): self + { + return new self("Time entry {$entryId} is still running and cannot be invoiced until the timer stops."); + } +} diff --git a/app/Application/Exceptions/NothingToInvoice.php b/app/Application/Exceptions/NothingToInvoice.php new file mode 100644 index 0000000..3c0a247 --- /dev/null +++ b/app/Application/Exceptions/NothingToInvoice.php @@ -0,0 +1,21 @@ + */ + private array $context = []; + + /** + * @param array $context + * @return $this + */ + public function withContext(array $context): static + { + $this->context = $context; + + return $this; + } + + /** @return array */ + public function context(): array + { + return $this->context; + } +} diff --git a/app/Application/Exceptions/TimerAlreadyRunning.php b/app/Application/Exceptions/TimerAlreadyRunning.php new file mode 100644 index 0000000..5a5afa0 --- /dev/null +++ b/app/Application/Exceptions/TimerAlreadyRunning.php @@ -0,0 +1,14 @@ + $entryIds */ + public static function forIds(array $entryIds): self + { + return new self('Time entries '.implode(', ', $entryIds).' do not belong to this company.'); + } +} diff --git a/app/Application/InvoiceLineComposer.php b/app/Application/InvoiceLineComposer.php new file mode 100644 index 0000000..d6734c4 --- /dev/null +++ b/app/Application/InvoiceLineComposer.php @@ -0,0 +1,168 @@ +number.' '.(string) $task->name; + } + + /** + * The line's note, or null when every part is switched off. + * + * Entries are read in the order they started. An entry that would render + * as an empty line, because the only parts switched on are ones it does + * not have, is left out rather than printed as a blank row. + * + * @param list $entries + * @param array{project_heading: bool, task_description: bool, entry_dates: bool, entry_times: bool, entry_hours: bool, entry_descriptions: bool} $options + */ + public function description( + array $entries, + array $options, + ?string $projectName = null, + ?string $taskDescription = null, + ): ?string { + $heading = []; + + if ($options['project_heading'] && trim((string) $projectName) !== '') { + $heading[] = self::HEADING_PREFIX.trim((string) $projectName); + } + + if ($options['task_description'] && trim((string) $taskDescription) !== '') { + $heading[] = trim((string) $taskDescription); + } + + $lines = []; + foreach ($this->inStartOrder($entries) as $entry) { + $line = $this->entryLine($entry, $options); + + if ($line !== null) { + $lines[] = $line; + } + } + + $note = $this->capped($heading, $lines); + + return $note === '' ? null : $note; + } + + /** + * One entry, as the parts the company asked for. + * + * @param array{project_heading: bool, task_description: bool, entry_dates: bool, entry_times: bool, entry_hours: bool, entry_descriptions: bool} $options + */ + private function entryLine(TimeEntry $entry, array $options): ?string + { + $parts = []; + + // The company's own date format lives in the host and is not reachable + // from a module, so the note states the ISO date, which reads the same + // in every locale. + if ($options['entry_dates'] && $entry->started_at !== null) { + $parts[] = $entry->started_at->format('Y-m-d'); + } + + if ($options['entry_times'] && $entry->started_at !== null && $entry->ended_at !== null) { + $parts[] = $entry->started_at->format('H:i').'-'.$entry->ended_at->format('H:i'); + } + + if ($options['entry_hours']) { + $parts[] = number_format((int) $entry->duration_minutes / 60, 2, '.', '').' h'; + } + + if ($options['entry_descriptions'] && trim((string) $entry->description) !== '') { + $parts[] = trim((string) $entry->description); + } + + return $parts === [] ? null : implode(self::PART_SEPARATOR, $parts); + } + + /** + * Keep the note inside the cap, dropping whole entry lines from the end. + * + * A truncated note says how many entries it stopped listing, so a client + * reading a month of ten-minute entries sees a readable summary instead of + * a wall of text cut mid-sentence. The heading lines are never counted as + * entries, and the summary line itself has to fit inside the cap too. + * + * @param list $heading + * @param list $lines + */ + private function capped(array $heading, array $lines): string + { + $whole = implode("\n", [...$heading, ...$lines]); + + if (mb_strlen($whole) <= self::MAX_LENGTH) { + return $whole; + } + + $kept = $lines; + + while ($kept !== []) { + array_pop($kept); + $dropped = count($lines) - count($kept); + $summary = 'and '.$dropped.' more '.($dropped === 1 ? 'entry' : 'entries'); + $note = implode("\n", [...$heading, ...$kept, $summary]); + + if (mb_strlen($note) <= self::MAX_LENGTH) { + return $note; + } + } + + return mb_substr(implode("\n", $heading), 0, self::MAX_LENGTH); + } + + /** + * @param list $entries + * @return list + */ + private function inStartOrder(array $entries): array + { + usort($entries, static fn (TimeEntry $left, TimeEntry $right): int => [ + $left->started_at?->getTimestamp() ?? 0, (int) $left->id, + ] <=> [ + $right->started_at?->getTimestamp() ?? 0, (int) $right->id, + ]); + + return $entries; + } +} diff --git a/app/Application/ProjectMemberService.php b/app/Application/ProjectMemberService.php new file mode 100644 index 0000000..fc47e33 --- /dev/null +++ b/app/Application/ProjectMemberService.php @@ -0,0 +1,73 @@ + */ + public function listFor(int $companyId, int $projectId): Collection + { + $this->projects->findForCompany($companyId, $projectId); + + return ProjectMember::query() + ->forCompany($companyId) + ->where('project_id', $projectId) + ->orderBy('user_id') + ->get(); + } + + /** Attach a member, or update the rate of one already attached. */ + public function attach(int $companyId, int $projectId, int $userId, ?int $rate = null): ProjectMember + { + $this->projects->findForCompany($companyId, $projectId); + + $member = ProjectMember::query() + ->forCompany($companyId) + ->where('project_id', $projectId) + ->where('user_id', $userId) + ->first(); + + if ($member === null) { + return ProjectMember::query()->create([ + 'company_id' => $companyId, + 'project_id' => $projectId, + 'user_id' => $userId, + 'rate' => $rate, + ]); + } + + $member->rate = $rate; + $member->save(); + + return $member; + } + + public function detach(int $companyId, int $projectId, int $userId): void + { + $member = ProjectMember::query() + ->forCompany($companyId) + ->where('project_id', $projectId) + ->where('user_id', $userId) + ->first(); + + if ($member === null) { + throw (new ModelNotFoundException)->setModel(ProjectMember::class, [$userId]); + } + + $member->delete(); + } +} diff --git a/app/Application/ProjectService.php b/app/Application/ProjectService.php new file mode 100644 index 0000000..61f0418 --- /dev/null +++ b/app/Application/ProjectService.php @@ -0,0 +1,299 @@ + + */ + 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', + 'currency_id', 'default_rate', 'budget_minutes', 'due_date', 'creator_id', + ]; + + public function __construct(private readonly CompanyDataReader $companyData) {} + + /** + * @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 + { + $query = Project::query()->forCompany($companyId); + + if (isset($filters['status'])) { + $query->where('status', $filters['status']); + } + + if (array_key_exists('customer_id', $filters)) { + $query->where('customer_id', $filters['customer_id']); + } + + if (isset($filters['user_id'])) { + $query->whereIn('id', ProjectMember::query() + ->forCompany($companyId) + ->where('user_id', $filters['user_id']) + ->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); + }); + } + + $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 + { + $project = Project::query()->forCompany($companyId)->find($id); + + if ($project === null) { + throw (new ModelNotFoundException)->setModel(Project::class, [$id]); + } + + return $project; + } + + /** + * A project's currency follows the customer it was filed under, unless the + * caller named one itself. + * + * @param array $attributes + */ + public function create(int $companyId, array $attributes): Project + { + $values = ['company_id' => $companyId, 'status' => Project::STATUS_ACTIVE]; + + foreach (self::FIELDS as $field) { + if (array_key_exists($field, $attributes)) { + $values[$field] = $attributes[$field]; + } + } + + if (! array_key_exists('currency_id', $attributes)) { + $currencyId = $this->customerCurrency($companyId, $values['customer_id'] ?? null); + + if ($currencyId !== null) { + $values['currency_id'] = $currencyId; + } + } + + return Project::query()->create($values); + } + + /** + * A project's customer is denormalised onto its tasks, so changing it + * rewrites the tasks that follow the project, and moves the project to + * that customer's currency unless the caller named one itself. + * + * @param array $attributes + */ + public function update(int $companyId, int $id, array $attributes): Project + { + return DB::transaction(function () use ($companyId, $id, $attributes): Project { + $project = $this->findForCompany($companyId, $id); + $customerChanged = array_key_exists('customer_id', $attributes) + && (int) $attributes['customer_id'] !== (int) $project->customer_id; + + foreach (self::FIELDS as $field) { + if (array_key_exists($field, $attributes)) { + $project->{$field} = $attributes[$field]; + } + } + + if (! array_key_exists('currency_id', $attributes) && array_key_exists('customer_id', $attributes)) { + $currencyId = $this->customerCurrency($companyId, $project->customer_id); + + if ($currencyId !== null) { + $project->currency_id = $currencyId; + } + } + + $project->save(); + + if ($customerChanged) { + Task::query() + ->forCompany($companyId) + ->where('project_id', $project->id) + ->get() + ->each(function (Task $task) use ($project): void { + $task->customer_id = $project->customer_id; + $task->save(); + }); + } + + return $project; + }); + } + + public function archive(int $companyId, int $id): Project + { + return $this->setStatus($companyId, $id, Project::STATUS_ARCHIVED); + } + + public function unarchive(int $companyId, int $id): Project + { + return $this->setStatus($companyId, $id, Project::STATUS_ACTIVE); + } + + /** + * Delete a project with its members, tasks and time entries. + * + * Invoiced time is history and never disappears, so a project that carries + * any stamped entry is refused: archive it instead. + */ + public function delete(int $companyId, int $id): void + { + DB::transaction(function () use ($companyId, $id): void { + $project = $this->findForCompany($companyId, $id); + + $invoiced = TimeEntry::query() + ->forCompany($companyId) + ->where('project_id', $project->id) + ->whereNotNull('invoice_id') + ->exists(); + + if ($invoiced) { + throw ProjectInUse::hasInvoicedTime((int) $project->id); + } + + TimeEntry::query()->forCompany($companyId)->where('project_id', $project->id)->delete(); + Task::query()->forCompany($companyId)->where('project_id', $project->id)->delete(); + ProjectMember::query()->forCompany($companyId)->where('project_id', $project->id)->delete(); + + $project->delete(); + }); + } + + /** + * Task counts and logged, billable and unbilled totals for one project. + * + * Amounts stay in minor units and are not converted between currencies: a + * project carries a single currency, inherited from its customer. + * + * @return array{tasks: array{total: int, open: int, closed: int}, logged_minutes: int, billable_minutes: int, billable_amount: int, unbilled_amount: int, currency_id: int|null} + */ + public function totals(Project $project): array + { + $companyId = (int) $project->company_id; + + $total = Task::query()->forCompany($companyId)->where('project_id', $project->id)->count(); + $closed = Task::query()->forCompany($companyId)->where('project_id', $project->id)->whereNotNull('closed_at')->count(); + + $entries = TimeEntry::query() + ->forCompany($companyId) + ->where('project_id', $project->id) + ->get(['duration_minutes', 'billable', 'amount', 'invoice_id']); + + $loggedMinutes = 0; + $billableMinutes = 0; + $billableAmount = 0; + $unbilledAmount = 0; + + foreach ($entries as $entry) { + $loggedMinutes += (int) $entry->duration_minutes; + + if (! $entry->billable) { + continue; + } + + $billableMinutes += (int) $entry->duration_minutes; + $billableAmount += (int) $entry->amount; + + if ($entry->invoice_id === null) { + $unbilledAmount += (int) $entry->amount; + } + } + + return [ + 'tasks' => ['total' => $total, 'open' => $total - $closed, 'closed' => $closed], + 'logged_minutes' => $loggedMinutes, + 'billable_minutes' => $billableMinutes, + 'billable_amount' => $billableAmount, + 'unbilled_amount' => $unbilledAmount, + 'currency_id' => $project->currency_id === null ? null : (int) $project->currency_id, + ]; + } + + /** + * The currency of one customer, read through the host contract. + * + * An internal project has no customer and so no currency to inherit, and a + * customer without one leaves the project's currency alone. + */ + private function customerCurrency(int $companyId, mixed $customerId): ?int + { + if ($customerId === null) { + return null; + } + + $currencyId = $this->companyData->findCustomer($companyId, (int) $customerId)['currency_id'] ?? null; + + return $currencyId === null ? null : (int) $currencyId; + } + + private function setStatus(int $companyId, int $id, string $status): Project + { + $project = $this->findForCompany($companyId, $id); + $project->status = $status; + $project->save(); + + return $project; + } +} diff --git a/app/Application/RateResolver.php b/app/Application/RateResolver.php new file mode 100644 index 0000000..6c72aa7 --- /dev/null +++ b/app/Application/RateResolver.php @@ -0,0 +1,47 @@ +rate !== null) { + return (int) $task->rate; + } + + $project = $task->project_id === null ? null : $task->project()->first(); + + if ($project !== null && $userId !== null) { + $member = ProjectMember::query() + ->forCompany((int) $task->company_id) + ->where('project_id', $project->id) + ->where('user_id', $userId) + ->first(); + + if ($member !== null && $member->rate !== null) { + return (int) $member->rate; + } + } + + if ($project !== null && $project->default_rate !== null) { + return (int) $project->default_rate; + } + + return $settings->defaultRate((int) $task->company_id); + } +} diff --git a/app/Application/ReportService.php b/app/Application/ReportService.php new file mode 100644 index 0000000..ad08c3c --- /dev/null +++ b/app/Application/ReportService.php @@ -0,0 +1,184 @@ +, by_project: list>, by_member: list>, by_customer: list>, by_billable: list>} + */ + public function summary(int $companyId, string $from, string $to, ?int $viewerUserId, bool $canSeeAll): array + { + $query = TimeEntry::query() + ->forCompany($companyId) + ->whereNull('running_user_id') + ->where('started_at', '>=', Carbon::parse($from)->startOfDay()) + ->where('started_at', '<=', Carbon::parse($to)->endOfDay()); + + if (! $canSeeAll) { + $query->where('user_id', $viewerUserId); + } + + $entries = $query->orderBy('started_at')->orderBy('id')->get(); + + $projects = $this->projectNames($companyId, $entries); + $customers = $this->taskCustomers($companyId, $entries); + $members = $this->memberNames($companyId); + + return [ + 'from' => $from, + 'to' => $to, + 'totals' => $this->rowsFor($entries, static fn (TimeEntry $entry): array => ['currency' => $entry->currency_id]), + 'by_project' => $this->rowsFor($entries, static fn (TimeEntry $entry): array => [ + 'currency' => $entry->currency_id, + 'project_id' => $entry->project_id === null ? null : (int) $entry->project_id, + 'label' => $entry->project_id === null ? 'No project' : ($projects[(int) $entry->project_id] ?? "Project {$entry->project_id}"), + ]), + 'by_member' => $this->rowsFor($entries, static fn (TimeEntry $entry): array => [ + 'currency' => $entry->currency_id, + 'user_id' => (int) $entry->user_id, + 'label' => $members[(int) $entry->user_id] ?? 'Removed member', + ]), + 'by_customer' => $this->rowsFor($entries, static fn (TimeEntry $entry): array => [ + 'currency' => $entry->currency_id, + 'customer_id' => $customers[(int) $entry->task_id] ?? null, + ]), + 'by_billable' => $this->rowsFor($entries, static fn (TimeEntry $entry): array => [ + 'currency' => $entry->currency_id, + 'billable' => (bool) $entry->billable, + ]), + ]; + } + + /** + * Sum the entries into one row per distinct set of dimensions, in the order + * the dimensions first appear. + * + * The `currency` dimension is always present and is rendered as + * `currency_id`, so no row ever adds two currencies together. + * + * @param Collection $entries + * @param callable(TimeEntry): array $dimensions + * @return list> + */ + private function rowsFor(Collection $entries, callable $dimensions): array + { + $rows = []; + + foreach ($entries as $entry) { + $values = $dimensions($entry); + $bucket = implode('|', array_map(static fn (mixed $value): string => match (true) { + $value === null => '~', + is_bool($value) => $value ? '1' : '0', + default => (string) $value, + }, $values)); + + if (! isset($rows[$bucket])) { + $row = $values; + $row['currency_id'] = $values['currency'] === null ? null : (int) $values['currency']; + unset($row['currency']); + + $rows[$bucket] = $row + [ + 'minutes' => 0, + 'amount' => 0, + 'billable_minutes' => 0, + 'billable_amount' => 0, + 'unbilled_amount' => 0, + ]; + } + + $rows[$bucket]['minutes'] += (int) $entry->duration_minutes; + $rows[$bucket]['amount'] += (int) $entry->amount; + + if (! $entry->billable) { + continue; + } + + $rows[$bucket]['billable_minutes'] += (int) $entry->duration_minutes; + $rows[$bucket]['billable_amount'] += (int) $entry->amount; + + if ($entry->invoice_id === null) { + $rows[$bucket]['unbilled_amount'] += (int) $entry->amount; + } + } + + return array_values($rows); + } + + /** + * @param Collection $entries + * @return array + */ + private function projectNames(int $companyId, Collection $entries): array + { + $ids = $entries->pluck('project_id')->filter(static fn (?int $id): bool => $id !== null)->unique()->all(); + + if ($ids === []) { + return []; + } + + return Project::query() + ->forCompany($companyId) + ->whereIn('id', $ids) + ->pluck('name', 'id') + ->map(static fn (string $name): string => $name) + ->all(); + } + + /** + * @param Collection $entries + * @return array + */ + private function taskCustomers(int $companyId, Collection $entries): array + { + $ids = $entries->pluck('task_id')->unique()->all(); + + if ($ids === []) { + return []; + } + + return Task::query() + ->forCompany($companyId) + ->whereIn('id', $ids) + ->get(['id', 'customer_id']) + ->mapWithKeys(static fn (Task $task): array => [ + (int) $task->id => $task->customer_id === null ? null : (int) $task->customer_id, + ]) + ->all(); + } + + /** @return array */ + private function memberNames(int $companyId): array + { + $names = []; + + foreach ($this->companyData->companyMembers($companyId) as $member) { + $names[(int) $member['id']] = (string) $member['name']; + } + + return $names; + } +} diff --git a/app/Application/Rounding.php b/app/Application/Rounding.php new file mode 100644 index 0000000..409d992 --- /dev/null +++ b/app/Application/Rounding.php @@ -0,0 +1,65 @@ + */ + public const DIRECTIONS = [self::NEAREST, self::UP, self::DOWN]; + + /** + * Round a duration to a multiple of the increment. + * + * Zero stays zero whichever way the company rounds, because an entry with + * no time is not worth an increment. Rounding `nearest` then treats a spell + * shorter than one increment as a whole one: a two minute call on a fifteen + * minute increment bills a quarter of an hour, never nothing. Rounding + * `down` is the one direction that may answer zero for real work, which is + * exactly what a firm asking to round down is asking for. + */ + public static function roundMinutes(int $minutes, int $increment, string $direction = self::NEAREST): int + { + if (! in_array($increment, ModuleSettings::ROUNDING_INCREMENTS, true)) { + throw new InvalidArgumentException( + "Rounding increment {$increment} is not one of ".implode(', ', ModuleSettings::ROUNDING_INCREMENTS).'.', + ); + } + + if (! in_array($direction, self::DIRECTIONS, true)) { + throw new InvalidArgumentException( + "Rounding direction {$direction} is not one of ".implode(', ', self::DIRECTIONS).'.', + ); + } + + if ($minutes <= 0) { + return 0; + } + + return match ($direction) { + self::UP => (int) ceil($minutes / $increment) * $increment, + self::DOWN => intdiv($minutes, $increment) * $increment, + default => $minutes < $increment + ? $increment + : (int) round($minutes / $increment) * $increment, + }; + } +} diff --git a/app/Application/TaskLock.php b/app/Application/TaskLock.php new file mode 100644 index 0000000..e7e2672 --- /dev/null +++ b/app/Application/TaskLock.php @@ -0,0 +1,36 @@ +settings->lockInvoicedTasks($companyId)) { + return; + } + + if ($this->summary->forTask($companyId, $taskId)['invoiced'] === TaskTimeSummary::INVOICED) { + throw TaskLocked::forTask($taskId); + } + } +} diff --git a/app/Application/TaskNumberSequence.php b/app/Application/TaskNumberSequence.php new file mode 100644 index 0000000..d9315ac --- /dev/null +++ b/app/Application/TaskNumberSequence.php @@ -0,0 +1,31 @@ +forCompany($companyId) + ->lockForUpdate() + ->max('number'); + + return (int) $highest + 1; + }); + } +} diff --git a/app/Application/TaskService.php b/app/Application/TaskService.php new file mode 100644 index 0000000..27d0e05 --- /dev/null +++ b/app/Application/TaskService.php @@ -0,0 +1,337 @@ + + */ + 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 = [ + 'name', 'description', 'assignee_id', 'priority', 'due_date', + 'estimated_minutes', 'billable', 'rate', 'creator_id', + ]; + + public function __construct( + private readonly TaskNumberSequence $numbers, + private readonly BoardOrderingService $board, + private readonly TaskStatusService $statuses, + private readonly ProjectService $projects, + private readonly TaskLock $lock, + ) {} + + /** + * @param array{project_id?: int, assignee_id?: int, task_status_id?: int, customer_id?: int, invoiced?: bool, 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 + { + $query = Task::query()->forCompany($companyId); + + foreach (['project_id', 'assignee_id', 'task_status_id', 'customer_id'] as $field) { + if (array_key_exists($field, $filters)) { + $query->where($field, $filters[$field]); + } + } + + 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'].'%'); + } + + if (isset($filters['invoiced'])) { + $this->filterByInvoiced($query, $companyId, (bool) $filters['invoiced']); + } + + $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 + { + $task = Task::query()->forCompany($companyId)->find($id); + + if ($task === null) { + throw (new ModelNotFoundException)->setModel(Task::class, [$id]); + } + + return $task; + } + + /** + * Create a task, denormalising the customer from its project. + * + * The number comes from the per-company sequence; because two writers can + * pick the same one, the unique index catches the loser and the write is + * retried once with a fresh number. + * + * @param array $attributes + */ + public function create(int $companyId, array $attributes): Task + { + $project = isset($attributes['project_id']) && $attributes['project_id'] !== null + ? $this->projects->findForCompany($companyId, (int) $attributes['project_id']) + : null; + + $status = isset($attributes['task_status_id']) && $attributes['task_status_id'] !== null + ? $this->statuses->findForCompany($companyId, (int) $attributes['task_status_id']) + : $this->statuses->defaultFor($companyId); + + $values = [ + 'company_id' => $companyId, + 'project_id' => $project?->id, + 'customer_id' => $this->customerFor($project, $attributes), + 'task_status_id' => $status->id, + 'billable' => (bool) ($attributes['billable'] ?? true), + 'closed_at' => $status->is_closed ? Carbon::now() : null, + ]; + + foreach (self::FIELDS as $field) { + if (array_key_exists($field, $attributes)) { + $values[$field] = $attributes[$field]; + } + } + + return $this->withRetry(fn (): Task => Task::query()->create($values + [ + 'number' => $this->numbers->next($companyId), + 'board_position' => $this->board->positionFor($companyId, (int) $status->id), + ])); + } + + /** @param array $attributes */ + public function update(int $companyId, int $id, array $attributes): Task + { + return DB::transaction(function () use ($companyId, $id, $attributes): Task { + $task = $this->findForCompany($companyId, $id); + $this->lock->guard($companyId, (int) $task->id); + + if (array_key_exists('project_id', $attributes)) { + $project = $attributes['project_id'] === null + ? null + : $this->projects->findForCompany($companyId, (int) $attributes['project_id']); + + $task->project_id = $project?->id; + $task->customer_id = $this->customerFor($project, $attributes); + } elseif (array_key_exists('customer_id', $attributes) && $task->project_id === null) { + $task->customer_id = $attributes['customer_id']; + } + + if (array_key_exists('task_status_id', $attributes) + && (int) $attributes['task_status_id'] !== (int) $task->task_status_id) { + $status = $this->statuses->findForCompany($companyId, (int) $attributes['task_status_id']); + $this->applyStatus($task, $status); + $task->board_position = $this->board->positionFor($companyId, (int) $status->id); + } + + foreach (self::FIELDS as $field) { + if (array_key_exists($field, $attributes)) { + $task->{$field} = $attributes[$field]; + } + } + + $task->save(); + + return $task; + }); + } + + /** Deleting a task takes its time entries with it, unless any of them are invoiced. */ + public function delete(int $companyId, int $id): void + { + DB::transaction(function () use ($companyId, $id): void { + $task = $this->findForCompany($companyId, $id); + $this->lock->guard($companyId, (int) $task->id); + + $invoiced = TimeEntry::query() + ->forCompany($companyId) + ->where('task_id', $task->id) + ->whereNotNull('invoice_id') + ->pluck('id') + ->all(); + + if ($invoiced !== []) { + throw EntriesAlreadyInvoiced::forEntries(array_map(intval(...), $invoiced)); + } + + TimeEntry::query()->forCompany($companyId)->where('task_id', $task->id)->delete(); + + $task->delete(); + }); + } + + /** Drop a task between two neighbours of the target column. */ + public function move(int $companyId, int $taskId, int $statusId, ?int $beforeId = null, ?int $afterId = null): Task + { + return DB::transaction(function () use ($companyId, $taskId, $statusId, $beforeId, $afterId): Task { + $task = $this->findForCompany($companyId, $taskId); + $this->lock->guard($companyId, (int) $task->id); + $status = $this->statuses->findForCompany($companyId, $statusId); + + $position = $this->board->positionFor($companyId, (int) $status->id, $beforeId, $afterId); + + $this->applyStatus($task, $status); + $task->board_position = $position; + $task->save(); + + return $task; + }); + } + + /** Entering a closed status stamps closed_at; leaving one clears it. */ + private function applyStatus(Task $task, TaskStatus $status): void + { + $task->task_status_id = $status->id; + + if ($status->is_closed) { + $task->closed_at ??= Carbon::now(); + + return; + } + + $task->closed_at = null; + } + + /** + * Narrow the list to tasks whose billable time has, or has not, reached an + * invoice. + * + * The state belongs to the entries, so it is asked of them rather than + * cached on the task: uninvoiced means at least one billable entry is still + * unbilled, and invoiced means a stamped entry exists and no unbilled one + * does. Both are `exists` subqueries, which MySQL, PostgreSQL and SQLite + * all plan off the `(company_id, task_id)` index and all spell the same. + * + * @param Builder $query + */ + private function filterByInvoiced(Builder $query, int $companyId, bool $invoiced): void + { + if (! $invoiced) { + $query->whereExists($this->billableEntries($companyId, stamped: false)); + + return; + } + + $query->whereExists($this->billableEntries($companyId, stamped: true)) + ->whereNotExists($this->billableEntries($companyId, stamped: false)); + } + + /** A correlated subquery over the stopped, billable entries of the task. */ + private function billableEntries(int $companyId, bool $stamped): callable + { + $entries = (new TimeEntry)->getTable(); + $tasks = (new Task)->getTable(); + + return static function (QueryBuilder $sub) use ($companyId, $stamped, $entries, $tasks): void { + $sub->selectRaw('1') + ->from($entries) + ->whereColumn($entries.'.task_id', $tasks.'.id') + ->where($entries.'.company_id', $companyId) + ->whereNull($entries.'.running_user_id') + ->where($entries.'.billable', true); + + $stamped + ? $sub->whereNotNull($entries.'.invoice_id') + : $sub->whereNull($entries.'.invoice_id'); + }; + } + + /** @param array $attributes */ + private function customerFor(?Project $project, array $attributes): ?int + { + if ($project !== null) { + return $project->customer_id === null ? null : (int) $project->customer_id; + } + + return isset($attributes['customer_id']) ? (int) $attributes['customer_id'] : null; + } + + /** + * Run a write once more when the per-company number collided. + * + * @template T + * + * @param callable(): T $write + * @return T + */ + private function withRetry(callable $write): mixed + { + try { + return $write(); + } catch (QueryException $exception) { + if (! $this->isUniqueViolation($exception)) { + throw $exception; + } + + return $write(); + } + } +} diff --git a/app/Application/TaskStatusService.php b/app/Application/TaskStatusService.php new file mode 100644 index 0000000..27009af --- /dev/null +++ b/app/Application/TaskStatusService.php @@ -0,0 +1,207 @@ + */ + public const DEFAULTS = [ + ['name' => 'Backlog', 'colour' => '#94a3b8', 'is_default' => true, 'is_closed' => false], + ['name' => 'In Progress', 'colour' => '#3b82f6', 'is_default' => false, 'is_closed' => false], + ['name' => 'Review', 'colour' => '#f59e0b', 'is_default' => false, 'is_closed' => false], + ['name' => 'Done', 'colour' => '#22c55e', 'is_default' => false, 'is_closed' => true], + ]; + + /** Create Backlog / In Progress / Review / Done, but only for a company that has no statuses yet. */ + public function ensureDefaults(int $companyId): void + { + DB::transaction(function () use ($companyId): void { + if (TaskStatus::query()->forCompany($companyId)->exists()) { + return; + } + + foreach (self::DEFAULTS as $position => $status) { + TaskStatus::query()->create($status + [ + 'company_id' => $companyId, + 'position' => $position + 1, + ]); + } + }); + } + + /** @return Collection */ + public function listFor(int $companyId): Collection + { + return TaskStatus::query() + ->forCompany($companyId) + ->orderBy('position') + ->orderBy('id') + ->get(); + } + + public function findForCompany(int $companyId, int $id): TaskStatus + { + $status = TaskStatus::query()->forCompany($companyId)->find($id); + + if ($status === null) { + throw (new ModelNotFoundException)->setModel(TaskStatus::class, [$id]); + } + + return $status; + } + + /** The status new tasks land in, creating the defaults when the company has none. */ + public function defaultFor(int $companyId): TaskStatus + { + $this->ensureDefaults($companyId); + + $status = TaskStatus::query() + ->forCompany($companyId) + ->orderByDesc('is_default') + ->orderBy('position') + ->orderBy('id') + ->first(); + + if ($status === null) { + throw (new ModelNotFoundException)->setModel(TaskStatus::class); + } + + return $status; + } + + /** @param array $attributes */ + public function create(int $companyId, array $attributes): TaskStatus + { + return DB::transaction(function () use ($companyId, $attributes): TaskStatus { + $status = TaskStatus::query()->create([ + 'company_id' => $companyId, + 'name' => $attributes['name'], + 'colour' => $attributes['colour'] ?? null, + 'position' => (int) ($attributes['position'] ?? $this->nextPosition($companyId)), + 'is_default' => (bool) ($attributes['is_default'] ?? false), + 'is_closed' => (bool) ($attributes['is_closed'] ?? false), + ]); + + $this->keepSingleDefault($companyId, $status); + + return $status; + }); + } + + /** @param array $attributes */ + public function update(int $companyId, int $id, array $attributes): TaskStatus + { + return DB::transaction(function () use ($companyId, $id, $attributes): TaskStatus { + $status = $this->findForCompany($companyId, $id); + + foreach (['name', 'colour', 'position', 'is_default', 'is_closed'] as $field) { + if (array_key_exists($field, $attributes)) { + $status->{$field} = $attributes[$field]; + } + } + + $status->save(); + $this->keepSingleDefault($companyId, $status); + + return $status; + }); + } + + /** + * Apply the wanted order. Statuses the caller left out keep their relative + * order and follow the listed ones. + * + * @param list $ids + */ + public function reorder(int $companyId, array $ids): void + { + DB::transaction(function () use ($companyId, $ids): void { + $statuses = $this->listFor($companyId)->keyBy('id'); + $position = 0; + + foreach ($ids as $id) { + $status = $statuses->get($id); + + if ($status === null) { + throw (new ModelNotFoundException)->setModel(TaskStatus::class, [$id]); + } + + $status->position = ++$position; + $status->save(); + $statuses->forget($id); + } + + foreach ($statuses as $status) { + $status->position = ++$position; + $status->save(); + } + }); + } + + public function delete(int $companyId, int $id): void + { + DB::transaction(function () use ($companyId, $id): void { + $status = $this->findForCompany($companyId, $id); + + $tasks = Task::query()->forCompany($companyId)->where('task_status_id', $status->id)->count(); + if ($tasks > 0) { + throw StatusInUse::hasTasks((int) $status->id, $tasks); + } + + if (TaskStatus::query()->forCompany($companyId)->count() <= 1) { + throw StatusInUse::isLast((int) $status->id); + } + + $others = TaskStatus::query() + ->forCompany($companyId) + ->where('id', '!=', $status->id) + ->where('is_default', true) + ->exists(); + + if ($status->is_default && ! $others) { + throw StatusInUse::isDefault((int) $status->id); + } + + $status->delete(); + }); + } + + private function nextPosition(int $companyId): int + { + return (int) TaskStatus::query()->forCompany($companyId)->max('position') + 1; + } + + /** Exactly one status per company carries is_default. */ + private function keepSingleDefault(int $companyId, TaskStatus $status): void + { + if (! $status->is_default) { + return; + } + + TaskStatus::query() + ->forCompany($companyId) + ->where('id', '!=', $status->id) + ->where('is_default', true) + ->get() + ->each(static function (TaskStatus $other): void { + $other->is_default = false; + $other->save(); + }); + } +} diff --git a/app/Application/TaskTimeSummary.php b/app/Application/TaskTimeSummary.php new file mode 100644 index 0000000..2fb1974 --- /dev/null +++ b/app/Application/TaskTimeSummary.php @@ -0,0 +1,215 @@ + $tasks + */ + public function attach(int $companyId, iterable $tasks): void + { + $tasks = is_array($tasks) ? $tasks : iterator_to_array($tasks); + + if ($tasks === []) { + return; + } + + $summaries = $this->forTasks($companyId, array_map( + static fn (Task $task): int => (int) $task->id, + array_values($tasks), + )); + + foreach ($tasks as $task) { + $task->timeSummary = $summaries[(int) $task->id] ?? self::empty(); + } + } + + /** + * The summary of one task, for the rules that only need the state. + * + * @return array + */ + public function forTask(int $companyId, int $taskId): array + { + return $this->forTasks($companyId, [$taskId])[$taskId] ?? self::empty(); + } + + /** + * Three grouped reads, keyed by task id. + * + * @param list $taskIds + * @return array}> + */ + public function forTasks(int $companyId, array $taskIds): array + { + $taskIds = array_values(array_unique(array_map(intval(...), $taskIds))); + + if ($taskIds === []) { + return []; + } + + $summaries = []; + foreach ($taskIds as $taskId) { + $summaries[$taskId] = self::empty(); + } + + foreach ($this->loggedMinutes($companyId, $taskIds) as $row) { + $summaries[(int) $row->task_id]['logged_minutes'] = (int) $row->logged_minutes; + } + + foreach ($this->billableTotals($companyId, $taskIds) as $row) { + $summaries[(int) $row->task_id] = [ + ...$summaries[(int) $row->task_id], + 'billable_minutes' => (int) $row->billable_minutes, + 'unbilled_minutes' => (int) $row->unbilled_minutes, + 'unbilled_amount' => (int) $row->unbilled_amount, + 'invoiced' => self::stateFor((int) $row->billable_entries, (int) $row->stamped_entries), + ]; + } + + foreach ($this->runningEntries($companyId, $taskIds) as $entry) { + $summaries[(int) $entry->task_id]['running'][] = [ + 'entry_id' => (int) $entry->id, + 'user_id' => (int) $entry->user_id, + 'started_at' => $entry->started_at instanceof Carbon ? $entry->started_at->toIso8601String() : null, + ]; + } + + return $summaries; + } + + /** + * The shape a task with no time at all still answers with. + * + * @return array{logged_minutes: int, billable_minutes: int, unbilled_minutes: int, unbilled_amount: int, invoiced: string, running: list} + */ + public static function empty(): array + { + return [ + 'logged_minutes' => 0, + 'billable_minutes' => 0, + 'unbilled_minutes' => 0, + 'unbilled_amount' => 0, + 'invoiced' => self::NONE, + 'running' => [], + ]; + } + + /** + * Uninvoiced the moment one billable minute is unbilled, invoiced once + * every billable entry is stamped, and none while nothing billable exists. + */ + private static function stateFor(int $billableEntries, int $stampedEntries): string + { + if ($billableEntries === 0) { + return self::NONE; + } + + return $stampedEntries === $billableEntries ? self::INVOICED : self::UNINVOICED; + } + + /** + * Everything logged against the task, billable or not. + * + * @param list $taskIds + * @return Collection + */ + private function loggedMinutes(int $companyId, array $taskIds) + { + return $this->stopped($companyId, $taskIds) + ->selectRaw('task_id, SUM(duration_minutes) as logged_minutes') + ->groupBy('task_id') + ->get(); + } + + /** + * The billable side, split into what is still unbilled and how many entries + * carry an invoice, counted with a CASE all three databases understand. + * + * @param list $taskIds + * @return Collection + */ + private function billableTotals(int $companyId, array $taskIds) + { + return $this->stopped($companyId, $taskIds) + ->where('billable', true) + ->selectRaw(implode(', ', [ + 'task_id', + 'SUM(duration_minutes) as billable_minutes', + 'SUM(CASE WHEN invoice_id IS NULL THEN duration_minutes ELSE 0 END) as unbilled_minutes', + 'SUM(CASE WHEN invoice_id IS NULL THEN amount ELSE 0 END) as unbilled_amount', + 'COUNT(*) as billable_entries', + 'SUM(CASE WHEN invoice_id IS NULL THEN 0 ELSE 1 END) as stamped_entries', + ])) + ->groupBy('task_id') + ->get(); + } + + /** + * The clocks running on these tasks, whoever they belong to. + * + * Totals are open to anyone who may see the task, so the running rows here + * carry a user id and nothing else; the time log is where per-member + * visibility is decided. + * + * @param list $taskIds + * @return \Illuminate\Database\Eloquent\Collection + */ + private function runningEntries(int $companyId, array $taskIds) + { + return TimeEntry::query() + ->forCompany($companyId) + ->whereIn('task_id', $taskIds) + ->whereNotNull('running_user_id') + ->orderBy('started_at') + ->orderBy('id') + ->get(); + } + + /** + * @param list $taskIds + * @return Builder + */ + private function stopped(int $companyId, array $taskIds) + { + return TimeEntry::query() + ->forCompany($companyId) + ->whereIn('task_id', $taskIds) + ->whereNull('running_user_id'); + } +} diff --git a/app/Application/TimeEntryService.php b/app/Application/TimeEntryService.php new file mode 100644 index 0000000..1d29b79 --- /dev/null +++ b/app/Application/TimeEntryService.php @@ -0,0 +1,310 @@ + $attributes */ + public function create(int $companyId, array $attributes): TimeEntry + { + $task = $this->tasks->findForCompany($companyId, (int) $attributes['task_id']); + $userId = (int) $attributes['user_id']; + + $startedAt = isset($attributes['started_at']) ? Carbon::parse($attributes['started_at']) : Carbon::now(); + $endedAt = isset($attributes['ended_at']) ? Carbon::parse($attributes['ended_at']) : null; + + $minutes = Rounding::roundMinutes( + $this->minutesFrom($attributes, $startedAt, $endedAt), + $this->settings->roundingMinutes($companyId), + $this->settings->roundingDirection($companyId), + ); + + $billable = (bool) ($attributes['billable'] ?? $task->billable); + $rate = array_key_exists('rate', $attributes) && $attributes['rate'] !== null + ? (int) $attributes['rate'] + : $this->rates->resolve($task, $userId, $this->settings); + + return TimeEntry::query()->create([ + 'company_id' => $companyId, + 'task_id' => $task->id, + 'project_id' => $task->project_id, + 'user_id' => $userId, + 'started_at' => $startedAt, + 'ended_at' => $endedAt, + 'duration_minutes' => $minutes, + 'description' => $attributes['description'] ?? null, + 'billable' => $billable, + 'rate' => $rate, + 'amount' => self::amountFor($minutes, $rate), + 'currency_id' => $this->currencyFor($task), + ]); + } + + /** + * Edit an entry, re-rounding the duration. + * + * The rate is re-resolved only while the entry is unbilled: once it is + * stamped with an invoice the money on it belongs to that invoice, and so + * does the time it was raised for. A stamped entry therefore accepts a new + * description and nothing else: its minutes are never re-rounded, its rate + * and amount are left exactly as the invoice recorded them, and an attempt + * to move the clock, the billable flag or the task is refused rather than + * quietly ignored. + * + * @param array $attributes + */ + public function update(int $companyId, int $id, array $attributes): TimeEntry + { + $entry = $this->findForCompany($companyId, $id); + + if ($entry->isStamped()) { + return $this->updateStamped($entry, $attributes); + } + + $task = $this->tasks->findForCompany($companyId, (int) ($attributes['task_id'] ?? $entry->task_id)); + + if ((int) $task->id !== (int) $entry->task_id) { + $entry->task_id = $task->id; + $entry->project_id = $task->project_id; + $entry->currency_id = $this->currencyFor($task); + } + + if (array_key_exists('started_at', $attributes)) { + $entry->started_at = $attributes['started_at'] === null ? null : Carbon::parse($attributes['started_at']); + } + + if (array_key_exists('ended_at', $attributes)) { + $entry->ended_at = $attributes['ended_at'] === null ? null : Carbon::parse($attributes['ended_at']); + } + + if (array_key_exists('description', $attributes)) { + $entry->description = $attributes['description']; + } + + if (array_key_exists('billable', $attributes)) { + $entry->billable = (bool) $attributes['billable']; + } + + $entry->duration_minutes = Rounding::roundMinutes( + $this->minutesFrom($attributes, $entry->started_at, $entry->ended_at, (int) $entry->duration_minutes), + $this->settings->roundingMinutes($companyId), + $this->settings->roundingDirection($companyId), + ); + + $entry->rate = array_key_exists('rate', $attributes) && $attributes['rate'] !== null + ? (int) $attributes['rate'] + : $this->rates->resolve($task, (int) $entry->user_id, $this->settings); + + $entry->amount = self::amountFor((int) $entry->duration_minutes, (int) $entry->rate); + $entry->save(); + + return $entry; + } + + /** + * Save the one field an invoiced entry still owns. + * + * Sending the unchanged value of a protected field is not an edit, so a + * form that posts the whole row back still works; only a real change is + * refused, and the message names the fields that would have moved. + * + * @param array $attributes + */ + private function updateStamped(TimeEntry $entry, array $attributes): TimeEntry + { + $changed = array_values(array_filter( + self::STAMPED_FIELDS, + static fn (string $field): bool => self::wouldChange($entry, $field, $attributes), + )); + + if ($changed !== []) { + throw EntriesAlreadyInvoiced::forLockedFields((int) $entry->id, $changed); + } + + if (array_key_exists('description', $attributes)) { + $entry->description = $attributes['description']; + } + + $entry->save(); + + return $entry; + } + + /** + * Whether the request really moves a protected field off its stored value. + * + * @param array $attributes + */ + private static function wouldChange(TimeEntry $entry, string $field, array $attributes): bool + { + if (! array_key_exists($field, $attributes)) { + return false; + } + + $wanted = $attributes[$field]; + $current = $entry->{$field}; + + return match ($field) { + 'started_at', 'ended_at' => $wanted === null || $current === null + ? $wanted !== $current + : ! $current->equalTo(Carbon::parse($wanted)), + 'billable' => (bool) $wanted !== (bool) $current, + default => $wanted !== null && (int) $wanted !== (int) $current, + }; + } + + /** Invoiced time is history: it can never be deleted from under an invoice. */ + public function delete(int $companyId, int $id): void + { + $entry = $this->findForCompany($companyId, $id); + + if ($entry->isStamped()) { + throw EntriesAlreadyInvoiced::forEntry((int) $entry->id); + } + + $entry->delete(); + } + + public function findForCompany(int $companyId, int $id): TimeEntry + { + $entry = TimeEntry::query()->forCompany($companyId)->find($id); + + if ($entry === null) { + throw (new ModelNotFoundException)->setModel(TimeEntry::class, [$id]); + } + + return $entry; + } + + /** + * A viewer without the view-all-time ability only ever sees their own time, + * whatever the filters ask for. + * + * @param array{user_id?: int, project_id?: int, task_id?: int, from?: string, to?: string, billable?: bool, billed?: bool} $filters + * @return Collection + */ + public function listFor(int $companyId, array $filters, ?int $viewerUserId, bool $canSeeAll): Collection + { + $query = TimeEntry::query()->forCompany($companyId)->whereNull('running_user_id'); + + if (! $canSeeAll) { + $query->where('user_id', $viewerUserId); + } elseif (isset($filters['user_id'])) { + $query->where('user_id', $filters['user_id']); + } + + foreach (['project_id', 'task_id'] as $field) { + if (isset($filters[$field])) { + $query->where($field, $filters[$field]); + } + } + + if (isset($filters['from'])) { + $query->where('started_at', '>=', Carbon::parse($filters['from'])->startOfDay()); + } + + if (isset($filters['to'])) { + $query->where('started_at', '<=', Carbon::parse($filters['to'])->endOfDay()); + } + + if (isset($filters['billable'])) { + $query->where('billable', (bool) $filters['billable']); + } + + if (isset($filters['billed'])) { + $filters['billed'] ? $query->whereNotNull('invoice_id') : $query->whereNull('invoice_id'); + } + + return $query->orderByDesc('started_at')->orderByDesc('id')->get(); + } + + /** + * The time log of one task: the running clocks first, then everything + * logged against it, newest first. + * + * A running entry has no duration yet, so it would sort among the oldest + * rows on `started_at` alone; a CASE every supported database understands + * lifts it to the top instead. The cap keeps a task somebody has been + * logging against for years from answering with a megabyte of JSON. + * + * @return Collection + */ + public function logForTask( + int $companyId, + int $taskId, + ?int $viewerUserId, + bool $canSeeAll, + int $limit = self::LOG_LIMIT, + ): Collection { + $query = TimeEntry::query() + ->forCompany($companyId) + ->where('task_id', $taskId); + + if (! $canSeeAll) { + $query->where('user_id', $viewerUserId); + } + + return $query + ->orderByRaw('CASE WHEN running_user_id IS NULL THEN 1 ELSE 0 END') + ->orderByDesc('started_at') + ->orderByDesc('id') + ->limit($limit) + ->get(); + } + + /** The cached money on an entry: minutes as hours, times the frozen rate. */ + public static function amountFor(int $minutes, int $rate): int + { + return (int) round($minutes / 60 * $rate); + } + + /** @param array $attributes */ + private function minutesFrom(array $attributes, ?Carbon $startedAt, ?Carbon $endedAt, int $fallback = 0): int + { + if (array_key_exists('duration_minutes', $attributes) && $attributes['duration_minutes'] !== null) { + return max(0, (int) $attributes['duration_minutes']); + } + + if ($startedAt !== null && $endedAt !== null) { + return max(0, (int) round($startedAt->diffInSeconds($endedAt, true) / 60)); + } + + return $fallback; + } + + private function currencyFor(Task $task): ?int + { + $project = $task->project_id === null ? null : $task->project()->first(); + + return $project?->currency_id === null ? null : (int) $project->currency_id; + } +} diff --git a/app/Application/TimerService.php b/app/Application/TimerService.php new file mode 100644 index 0000000..82f80f9 --- /dev/null +++ b/app/Application/TimerService.php @@ -0,0 +1,219 @@ +forCompany($companyId) + ->where('running_user_id', $userId) + ->first(); + } + + /** + * Put the caller's clock on a task. + * + * Starting the task the clock is already on is the same wish twice rather + * than a conflict, so it applies whatever the caller sent and answers the + * entry that is already running. That is what makes "create this task and + * start it" correct when `auto_start_tasks` started the clock on create. + * + * @throws TimerAlreadyRunning when the user's timer is on another task + */ + public function start( + int $companyId, + int $userId, + int $taskId, + ?string $description = null, + ?bool $billable = null, + ): TimeEntry { + $task = $this->tasks->findForCompany($companyId, $taskId); + $running = $this->running($companyId, $userId); + + if ($running !== null) { + if ((int) $running->task_id !== (int) $task->id) { + throw TimerAlreadyRunning::forUser($userId, $companyId); + } + + return $this->applyDetails($running, $description, $billable); + } + + try { + return TimeEntry::query()->create([ + 'company_id' => $companyId, + 'task_id' => $task->id, + 'project_id' => $task->project_id, + 'user_id' => $userId, + 'started_at' => Carbon::now(), + 'ended_at' => null, + 'duration_minutes' => 0, + 'description' => $description, + 'billable' => $billable ?? (bool) $task->billable, + 'rate' => 0, + 'amount' => 0, + 'currency_id' => $this->currencyFor($task), + 'running_user_id' => $userId, + ]); + } catch (QueryException $exception) { + if ($this->isUniqueViolation($exception)) { + throw TimerAlreadyRunning::forUser($userId, $companyId); + } + + throw $exception; + } + } + + /** + * Close the running entry: derive the elapsed minutes, round them to the + * company increment, resolve the rate and cache the amount. + * + * The description and the billable flag are what the stop dialog collected, + * and each is applied only when it was sent: a client that stops without a + * body keeps whatever the start recorded. + */ + public function stop( + int $companyId, + int $userId, + ?string $description = null, + ?bool $billable = null, + ): TimeEntry { + $entry = $this->requireRunning($companyId, $userId); + $endedAt = Carbon::now(); + $startedAt = $entry->started_at ?? $endedAt; + + $this->writeDetails($entry, $description, $billable); + + $entry->ended_at = $endedAt; + $entry->running_user_id = null; + $entry->duration_minutes = Rounding::roundMinutes( + max(0, (int) round($startedAt->diffInSeconds($endedAt, true) / 60)), + $this->settings->roundingMinutes($companyId), + $this->settings->roundingDirection($companyId), + ); + + $task = $this->tasks->findForCompany($companyId, (int) $entry->task_id); + $entry->rate = $this->rates->resolve($task, $userId, $this->settings); + $entry->amount = TimeEntryService::amountFor((int) $entry->duration_minutes, (int) $entry->rate); + $entry->save(); + + return $entry; + } + + /** + * Stop the clock the caller is running on one particular task. + * + * Stopping is addressed to a task rather than to "whatever is running", so + * a stale row or a second tab cannot stop a timer the user has since moved + * elsewhere. Nothing running and something else running are the same + * mismatch to the caller, who reloads the timer either way. + * + * @throws TimerMismatch when the caller's timer is not on this task + */ + public function stopOn( + int $companyId, + int $userId, + int $taskId, + ?string $description = null, + ?bool $billable = null, + ): TimeEntry { + $task = $this->tasks->findForCompany($companyId, $taskId); + $running = $this->running($companyId, $userId); + + if ($running === null || (int) $running->task_id !== (int) $task->id) { + throw TimerMismatch::forTask( + (int) $task->id, + $running === null ? null : (int) $running->task_id, + ); + } + + return $this->stop($companyId, $userId, $description, $billable); + } + + /** Throw away the running entry without recording any time. */ + public function discard(int $companyId, int $userId): void + { + $this->requireRunning($companyId, $userId)->delete(); + } + + /** Apply the caller's details to a running entry and save if anything moved. */ + private function applyDetails(TimeEntry $entry, ?string $description, ?bool $billable): TimeEntry + { + $this->writeDetails($entry, $description, $billable); + + if ($entry->isDirty()) { + $entry->save(); + } + + return $entry; + } + + /** + * Write onto an entry whatever the caller actually sent. + * + * Null is "the caller did not say", never "clear it", so a stop that + * carries only a description leaves the billable flag the start chose. + */ + private function writeDetails(TimeEntry $entry, ?string $description, ?bool $billable): void + { + if ($description !== null) { + $entry->description = $description; + } + + if ($billable !== null) { + $entry->billable = $billable; + } + } + + private function requireRunning(int $companyId, int $userId): TimeEntry + { + $entry = $this->running($companyId, $userId); + + if ($entry === null) { + throw (new ModelNotFoundException)->setModel(TimeEntry::class); + } + + return $entry; + } + + private function currencyFor(Task $task): ?int + { + $project = $task->project_id === null ? null : $task->project()->first(); + + return $project?->currency_id === null ? null : (int) $project->currency_id; + } +} diff --git a/app/Http/Controllers/BillingController.php b/app/Http/Controllers/BillingController.php new file mode 100644 index 0000000..f7ad1de --- /dev/null +++ b/app/Http/Controllers/BillingController.php @@ -0,0 +1,132 @@ +context($request); + $this->authorize($context, Abilities::INVOICE_TASKS); + + $filters = $request->validated(); + + return response()->json(['data' => $this->billing->customers( + $context->companyId, + $filters['from'] ?? null, + $filters['to'] ?? null, + )]); + } + + 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. + * + * The selection arrives in one of three shapes and leaves as one: entry + * ids, task ids or a project all become the same ordered list of entries, + * so the caller picks whichever shape its screen knows and reads the same + * answer back. 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, + $this->selectionFrom($validated), + $validated['grouping'] ?? BillingService::DEFAULT_GROUPING, + ); + + return response()->json(['data' => $payload], 200, [], JSON_PRESERVE_ZERO_FRACTION); + } + + /** + * The one selection the request carries. + * + * The request rules have already refused a body that names two of them or + * none, so the order the shapes are tried in never decides anything. + * + * @param array $validated + */ + private function selectionFrom(array $validated): BillingSelection + { + if (isset($validated['task_ids'])) { + return BillingSelection::fromTaskIds(array_map(intval(...), $validated['task_ids'])); + } + + if (isset($validated['project_id'])) { + return BillingSelection::fromProject((int) $validated['project_id']); + } + + return BillingSelection::fromEntryIds(array_map(intval(...), $validated['entry_ids'])); + } + + /** 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), + ]); + } +} diff --git a/app/Http/Controllers/BoardController.php b/app/Http/Controllers/BoardController.php new file mode 100644 index 0000000..381f74c --- /dev/null +++ b/app/Http/Controllers/BoardController.php @@ -0,0 +1,61 @@ +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, + ); + + $this->summary->attach($context->companyId, array_merge( + ...array_map(static fn (array $column): array => $column['tasks'], $columns), + )); + + 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)]); + } +} diff --git a/app/Http/Controllers/BulkTasksController.php b/app/Http/Controllers/BulkTasksController.php new file mode 100644 index 0000000..97a2fb0 --- /dev/null +++ b/app/Http/Controllers/BulkTasksController.php @@ -0,0 +1,77 @@ + */ + public const ACTIONS = [self::ACTION_STATUS, self::ACTION_DELETE]; + + public function __construct(Authorizes $authorizes, private readonly TaskService $tasks) + { + parent::__construct($authorizes); + } + + public function __invoke(BulkTasksRequest $request): JsonResponse + { + $context = $this->context($request); + $validated = $request->validated(); + $action = (string) $validated['action']; + + $this->authorize( + $context, + $action === self::ACTION_DELETE ? Abilities::DELETE_TASK : Abilities::EDIT_TASK, + ); + + $updated = []; + $failed = []; + + foreach (array_map(intval(...), $validated['ids']) as $taskId) { + try { + $action === self::ACTION_DELETE + ? $this->tasks->delete($context->companyId, $taskId) + : $this->tasks->move($context->companyId, $taskId, (int) $validated['task_status_id']); + + $updated[] = $taskId; + } catch (ModelNotFoundException) { + $failed[] = ['id' => $taskId, 'reason' => 'not_found']; + } catch (TasksProjectsException $exception) { + $failed[] = ['id' => $taskId, 'reason' => DomainExceptionRenderer::errorKey($exception)]; + } + } + + return response()->json(['updated' => $updated, 'failed' => $failed]); + } +} diff --git a/app/Http/Controllers/Controller.php b/app/Http/Controllers/Controller.php new file mode 100644 index 0000000..b1a7966 --- /dev/null +++ b/app/Http/Controllers/Controller.php @@ -0,0 +1,94 @@ +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 $items + * @return LengthAwarePaginator + */ + 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(), + ], + ); + } +} diff --git a/app/Http/Controllers/MembersController.php b/app/Http/Controllers/MembersController.php new file mode 100644 index 0000000..36a589d --- /dev/null +++ b/app/Http/Controllers/MembersController.php @@ -0,0 +1,33 @@ +context($request); + $this->authorize($context, Abilities::VIEW_PROJECT); + + return response()->json(['data' => $this->companyData->companyMembers($context->companyId)]); + } +} diff --git a/app/Http/Controllers/ProjectMembersController.php b/app/Http/Controllers/ProjectMembersController.php new file mode 100644 index 0000000..395cdec --- /dev/null +++ b/app/Http/Controllers/ProjectMembersController.php @@ -0,0 +1,61 @@ +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]); + } +} diff --git a/app/Http/Controllers/ProjectsController.php b/app/Http/Controllers/ProjectsController.php new file mode 100644 index 0000000..9edd6ec --- /dev/null +++ b/app/Http/Controllers/ProjectsController.php @@ -0,0 +1,97 @@ +context($request); + $this->authorize($context, Abilities::VIEW_PROJECT); + + $filters = $request->validated(); + $projects = $this->projects->listFor($context->companyId, array_filter([ + 'status' => $filters['status'] ?? null, + '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)); + } + + public function store(StoreProjectRequest $request): ProjectResource + { + $context = $this->context($request); + $this->authorize($context, Abilities::CREATE_PROJECT); + + return new ProjectResource($this->projects->create( + $context->companyId, + $request->validated() + ['creator_id' => $context->userId], + )); + } + + public function show(Request $request, int $id): ProjectResource + { + $context = $this->context($request); + $this->authorize($context, Abilities::VIEW_PROJECT); + + $project = $this->projects->findForCompany($context->companyId, $id); + + return (new ProjectResource($project))->withTotals($this->projects->totals($project)); + } + + public function update(UpdateProjectRequest $request, int $id): ProjectResource + { + $context = $this->context($request); + $this->authorize($context, Abilities::EDIT_PROJECT); + + return new ProjectResource($this->projects->update($context->companyId, $id, $request->validated())); + } + + public function destroy(Request $request, int $id): JsonResponse + { + $context = $this->context($request); + $this->authorize($context, Abilities::DELETE_PROJECT); + + $this->projects->delete($context->companyId, $id); + + return response()->json(['success' => true]); + } + + public function archive(Request $request, int $id): ProjectResource + { + $context = $this->context($request); + $this->authorize($context, Abilities::EDIT_PROJECT); + + return new ProjectResource($this->projects->archive($context->companyId, $id)); + } + + public function unarchive(Request $request, int $id): ProjectResource + { + $context = $this->context($request); + $this->authorize($context, Abilities::EDIT_PROJECT); + + return new ProjectResource($this->projects->unarchive($context->companyId, $id)); + } +} diff --git a/app/Http/Controllers/ReportsController.php b/app/Http/Controllers/ReportsController.php new file mode 100644 index 0000000..46a7c4f --- /dev/null +++ b/app/Http/Controllers/ReportsController.php @@ -0,0 +1,48 @@ +context($request); + $this->authorize($context, Abilities::VIEW_OWN_TIME); + + $filters = $request->validated(); + $from = isset($filters['from']) ? Carbon::parse($filters['from']) : Carbon::now()->startOfMonth(); + $to = isset($filters['to']) ? Carbon::parse($filters['to']) : Carbon::now(); + + return response()->json(['data' => $this->reports->summary( + $context->companyId, + $from->toDateString(), + $to->toDateString(), + $context->userId, + $this->canSeeAllTime($context, $this->settings), + )]); + } +} diff --git a/app/Http/Controllers/SettingsController.php b/app/Http/Controllers/SettingsController.php new file mode 100644 index 0000000..6be541e --- /dev/null +++ b/app/Http/Controllers/SettingsController.php @@ -0,0 +1,48 @@ +context($request); + $this->authorize($context, Abilities::VIEW_PROJECT); + + $companyId = $context->companyId; + + $flags = []; + foreach (array_keys(ModuleSettings::FLAGS) as $key) { + $flags[$key] = $this->settings->flag($companyId, $key); + } + + return response()->json(['data' => [ + 'default_rate' => $this->settings->defaultRate($companyId), + 'rounding_minutes' => $this->settings->roundingMinutes($companyId), + 'rounding_direction' => $this->settings->roundingDirection($companyId), + 'week_start' => $this->settings->weekStart($companyId), + ...$flags, + 'rounding_increments' => ModuleSettings::ROUNDING_INCREMENTS, + ]]); + } +} diff --git a/app/Http/Controllers/TaskStatusesController.php b/app/Http/Controllers/TaskStatusesController.php new file mode 100644 index 0000000..24e22ae --- /dev/null +++ b/app/Http/Controllers/TaskStatusesController.php @@ -0,0 +1,78 @@ +context($request); + $this->authorize($context, Abilities::VIEW_TASK); + + $this->statuses->ensureDefaults($context->companyId); + + return TaskStatusResource::collection($this->statuses->listFor($context->companyId)); + } + + public function store(StoreTaskStatusRequest $request): TaskStatusResource + { + $context = $this->context($request); + $this->authorize($context, Abilities::MANAGE_TASK_STATUS); + + return new TaskStatusResource($this->statuses->create($context->companyId, $request->validated())); + } + + public function update(UpdateTaskStatusRequest $request, int $id): TaskStatusResource + { + $context = $this->context($request); + $this->authorize($context, Abilities::MANAGE_TASK_STATUS); + + return new TaskStatusResource($this->statuses->update($context->companyId, $id, $request->validated())); + } + + public function destroy(Request $request, int $id): JsonResponse + { + $context = $this->context($request); + $this->authorize($context, Abilities::MANAGE_TASK_STATUS); + + $this->statuses->delete($context->companyId, $id); + + return response()->json(['success' => true]); + } + + /** Apply the wanted column order; anything left out follows the listed ids. */ + public function reorder(ReorderTaskStatusesRequest $request): AnonymousResourceCollection + { + $context = $this->context($request); + $this->authorize($context, Abilities::MANAGE_TASK_STATUS); + + $ids = array_map(intval(...), $request->validated()['ids']); + $this->statuses->reorder($context->companyId, $ids); + + return TaskStatusResource::collection($this->statuses->listFor($context->companyId)); + } +} diff --git a/app/Http/Controllers/TaskTimeLogController.php b/app/Http/Controllers/TaskTimeLogController.php new file mode 100644 index 0000000..0123749 --- /dev/null +++ b/app/Http/Controllers/TaskTimeLogController.php @@ -0,0 +1,55 @@ +context($request); + $this->authorize($context, Abilities::VIEW_TASK); + + $task = $this->tasks->findForCompany($context->companyId, $id); + + $entries = $this->entries->logForTask( + $context->companyId, + (int) $task->id, + $context->userId, + $this->canSeeAllTime($context, $this->settings), + ); + + return response()->json([ + 'data' => TimeEntryResource::collection($entries)->resolve($request), + ]); + } +} diff --git a/app/Http/Controllers/TasksController.php b/app/Http/Controllers/TasksController.php new file mode 100644 index 0000000..ec97dac --- /dev/null +++ b/app/Http/Controllers/TasksController.php @@ -0,0 +1,168 @@ +context($request); + $this->authorize($context, Abilities::VIEW_TASK); + + $filters = $request->validated(); + $tasks = $this->tasks->listFor($context->companyId, array_filter([ + 'project_id' => isset($filters['project_id']) ? (int) $filters['project_id'] : null, + 'assignee_id' => isset($filters['assignee_id']) ? (int) $filters['assignee_id'] : null, + 'task_status_id' => isset($filters['task_status_id']) ? (int) $filters['task_status_id'] : null, + 'customer_id' => isset($filters['customer_id']) ? (int) $filters['customer_id'] : null, + 'invoiced' => array_key_exists('invoiced', $filters) ? $request->boolean('invoiced') : null, + '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)); + + $page = $this->paginate($tasks, $request); + $this->summary->attach($context->companyId, $page->getCollection()); + + return TaskResource::collection($page); + } + + /** + * Create a task, and start its creator's clock when the company asks for it. + * + * Auto-start is a convenience, never a precondition: a creator who is + * already timing something else keeps that timer and still gets the task. + */ + public function store(StoreTaskRequest $request): TaskResource + { + $context = $this->context($request); + $this->authorize($context, Abilities::CREATE_TASK); + + $task = $this->tasks->create( + $context->companyId, + $request->validated() + ['creator_id' => $context->userId], + ); + + $this->autoStart($context, (int) $task->id); + + return $this->withTime($context, $task); + } + + public function show(Request $request, int $id): TaskResource + { + $context = $this->context($request); + $this->authorize($context, Abilities::VIEW_TASK); + + return $this->withTime($context, $this->tasks->findForCompany($context->companyId, $id)); + } + + public function update(UpdateTaskRequest $request, int $id): TaskResource + { + $context = $this->context($request); + $this->authorize($context, Abilities::EDIT_TASK); + + return $this->withTime($context, $this->tasks->update($context->companyId, $id, $request->validated())); + } + + public function destroy(Request $request, int $id): JsonResponse + { + $context = $this->context($request); + $this->authorize($context, Abilities::DELETE_TASK); + + $this->tasks->delete($context->companyId, $id); + + return response()->json(['success' => true]); + } + + /** Drop a task between two neighbours of the target column. */ + public function move(MoveTaskRequest $request, int $id): TaskResource + { + $context = $this->context($request); + $this->authorize($context, Abilities::EDIT_TASK); + + $validated = $request->validated(); + + return $this->withTime($context, $this->tasks->move( + $context->companyId, + $id, + (int) $validated['task_status_id'], + isset($validated['before_id']) ? (int) $validated['before_id'] : null, + isset($validated['after_id']) ? (int) $validated['after_id'] : null, + )); + } + + /** One task, with its time block filled in. */ + private function withTime(CompanyContext $context, Task $task): TaskResource + { + $this->summary->attach($context->companyId, [$task]); + + return new TaskResource($task); + } + + /** + * Start the creator's timer on a brand new task. + * + * The setting only ever adds a timer: a creator who already has one running + * keeps it, and a race that slips past the check is caught by the same + * unique index the timer relies on, so the create never fails over this. + */ + private function autoStart(CompanyContext $context, int $taskId): void + { + if (! $this->settings->autoStartTasks($context->companyId)) { + return; + } + + if (! $this->allows($context, Abilities::VIEW_OWN_TIME)) { + return; + } + + if ($this->timer->running($context->companyId, $context->userId) !== null) { + return; + } + + try { + $this->timer->start($context->companyId, $context->userId, $taskId); + } catch (TimerAlreadyRunning) { + // Another tab won the race; that timer is as good as this one. + } + } +} diff --git a/app/Http/Controllers/TimeEntriesController.php b/app/Http/Controllers/TimeEntriesController.php new file mode 100644 index 0000000..a16048b --- /dev/null +++ b/app/Http/Controllers/TimeEntriesController.php @@ -0,0 +1,124 @@ +context($request); + $this->authorize($context, Abilities::VIEW_OWN_TIME); + + $filters = $request->validated(); + $entries = $this->entries->listFor( + $context->companyId, + array_filter([ + 'user_id' => isset($filters['user_id']) ? (int) $filters['user_id'] : null, + 'project_id' => isset($filters['project_id']) ? (int) $filters['project_id'] : null, + 'task_id' => isset($filters['task_id']) ? (int) $filters['task_id'] : null, + 'from' => $filters['from'] ?? null, + 'to' => $filters['to'] ?? null, + 'billable' => array_key_exists('billable', $filters) ? $request->boolean('billable') : null, + 'billed' => array_key_exists('billed', $filters) ? $request->boolean('billed') : null, + ], static fn (mixed $value): bool => $value !== null), + $context->userId, + $this->canSeeAllTime($context, $this->settings), + ); + + return TimeEntryResource::collection($this->paginate($entries, $request)); + } + + public function store(StoreTimeEntryRequest $request): TimeEntryResource + { + $context = $this->context($request); + $this->authorize($context, Abilities::VIEW_OWN_TIME); + + $validated = $request->validated(); + $userId = isset($validated['user_id']) ? (int) $validated['user_id'] : $context->userId; + + if ($userId !== $context->userId) { + $this->authorize($context, Abilities::EDIT_ALL_TIME); + } + + return new TimeEntryResource($this->entries->create($context->companyId, ['user_id' => $userId] + $validated)); + } + + public function show(Request $request, int $id): TimeEntryResource + { + $context = $this->context($request); + $this->authorize($context, Abilities::VIEW_OWN_TIME); + + $entry = $this->entries->findForCompany($context->companyId, $id); + + if ($this->belongsToSomeoneElse($context, $entry) && ! $this->canSeeAllTime($context, $this->settings)) { + $this->authorize($context, Abilities::VIEW_ALL_TIME); + } + + return new TimeEntryResource($entry); + } + + public function update(UpdateTimeEntryRequest $request, int $id): TimeEntryResource + { + $context = $this->context($request); + $this->authorize($context, Abilities::VIEW_OWN_TIME); + + $this->requireWriteAccess($context, $this->entries->findForCompany($context->companyId, $id)); + + return new TimeEntryResource($this->entries->update($context->companyId, $id, $request->validated())); + } + + public function destroy(Request $request, int $id): JsonResponse + { + $context = $this->context($request); + $this->authorize($context, Abilities::VIEW_OWN_TIME); + + $this->requireWriteAccess($context, $this->entries->findForCompany($context->companyId, $id)); + $this->entries->delete($context->companyId, $id); + + return response()->json(['success' => true]); + } + + private function requireWriteAccess(CompanyContext $context, TimeEntry $entry): void + { + if ($this->belongsToSomeoneElse($context, $entry)) { + $this->authorize($context, Abilities::EDIT_ALL_TIME); + } + } + + private function belongsToSomeoneElse(CompanyContext $context, TimeEntry $entry): bool + { + return (int) $entry->user_id !== $context->userId; + } +} diff --git a/app/Http/Controllers/TimerController.php b/app/Http/Controllers/TimerController.php new file mode 100644 index 0000000..8cdfab5 --- /dev/null +++ b/app/Http/Controllers/TimerController.php @@ -0,0 +1,134 @@ +context($request); + $this->authorize($context, Abilities::VIEW_OWN_TIME); + + $entry = $this->timer->running($context->companyId, $context->userId); + + return response()->json([ + 'data' => $entry === null ? null : TimeEntryResource::make($entry)->resolve($request), + ]); + } + + public function start(StartTimerRequest $request): TimeEntryResource + { + $context = $this->context($request); + $this->authorize($context, Abilities::VIEW_OWN_TIME); + + $validated = $request->validated(); + + return new TimeEntryResource($this->timer->start( + $context->companyId, + $context->userId, + (int) $validated['task_id'], + $validated['description'] ?? null, + self::flag($request, 'billable'), + )); + } + + public function stop(StopTimerRequest $request): TimeEntryResource + { + $context = $this->context($request); + $this->authorize($context, Abilities::VIEW_OWN_TIME); + + return new TimeEntryResource($this->timer->stop( + $context->companyId, + $context->userId, + $request->validated()['description'] ?? null, + self::flag($request, 'billable'), + )); + } + + /** Start the caller's clock on one task, straight from its row or card. */ + public function startOnTask(StartTaskTimerRequest $request, int $id): TimeEntryResource + { + $context = $this->context($request); + $this->authorize($context, Abilities::VIEW_TASK); + $this->authorize($context, Abilities::VIEW_OWN_TIME); + + return new TimeEntryResource($this->timer->start( + $context->companyId, + $context->userId, + $id, + $request->validated()['description'] ?? null, + self::flag($request, 'billable'), + )); + } + + /** Stop the caller's clock, but only while it is running on this task. */ + public function stopOnTask(StopTimerRequest $request, int $id): TimeEntryResource + { + $context = $this->context($request); + $this->authorize($context, Abilities::VIEW_TASK); + $this->authorize($context, Abilities::VIEW_OWN_TIME); + + return new TimeEntryResource($this->timer->stopOn( + $context->companyId, + $context->userId, + $id, + $request->validated()['description'] ?? null, + self::flag($request, 'billable'), + )); + } + + /** Throw the running entry away without recording any time. */ + public function destroy(Request $request): JsonResponse + { + $context = $this->context($request); + $this->authorize($context, Abilities::VIEW_OWN_TIME); + + $this->timer->discard($context->companyId, $context->userId); + + return response()->json(['success' => true]); + } + + /** + * A boolean the caller sent, or null when they said nothing about it. + * + * The service treats null as "leave it alone", so an omitted flag has to + * stay distinguishable from a flag that was sent as false. + */ + private static function flag(Request $request, string $key): ?bool + { + return $request->has($key) ? $request->boolean($key) : null; + } +} diff --git a/app/Http/DomainExceptionRenderer.php b/app/Http/DomainExceptionRenderer.php new file mode 100644 index 0000000..38bb0ef --- /dev/null +++ b/app/Http/DomainExceptionRenderer.php @@ -0,0 +1,59 @@ + Response::HTTP_CONFLICT, + TimerMismatch::class => Response::HTTP_CONFLICT, + ]; + + public static function register(Handler $handler): void + { + $handler->renderable(static fn (TasksProjectsException $exception): JsonResponse => self::render($exception)); + } + + /** + * The refusal's own context is merged in beside the message, never over + * it: a rule that carries ids adds keys, and can never rename the two the + * UI always reads. + */ + public static function render(TasksProjectsException $exception): JsonResponse + { + $body = [ + 'message' => $exception->getMessage(), + 'error' => self::errorKey($exception), + ]; + + return new JsonResponse( + $body + $exception->context(), + self::STATUSES[$exception::class] ?? Response::HTTP_UNPROCESSABLE_ENTITY, + ); + } + + /** `TimerAlreadyRunning` becomes `timer_already_running`. */ + public static function errorKey(TasksProjectsException $exception): string + { + return Str::snake(class_basename($exception)); + } +} diff --git a/app/Http/Requests/AttachProjectMemberRequest.php b/app/Http/Requests/AttachProjectMemberRequest.php new file mode 100644 index 0000000..db8dc73 --- /dev/null +++ b/app/Http/Requests/AttachProjectMemberRequest.php @@ -0,0 +1,48 @@ +> */ + public function rules(): array + { + return [ + 'user_id' => ['required', 'integer', 'min:1', Rule::in($this->companyMemberIds())], + 'rate' => ['nullable', 'integer', 'min:0'], + ]; + } + + /** @return array */ + public function messages(): array + { + return [ + 'user_id.in' => 'The selected user is not a member of this company.', + ]; + } + + /** @return list */ + private function companyMemberIds(): array + { + $context = CompanyContext::fromRequest($this); + + return array_map( + static fn (array $member): int => (int) $member['id'], + app(CompanyDataReader::class)->companyMembers($context->companyId), + ); + } +} diff --git a/app/Http/Requests/BoardRequest.php b/app/Http/Requests/BoardRequest.php new file mode 100644 index 0000000..47fd3fe --- /dev/null +++ b/app/Http/Requests/BoardRequest.php @@ -0,0 +1,17 @@ +> */ + public function rules(): array + { + return [ + 'project_id' => ['sometimes', 'integer', 'min:1'], + 'assignee_id' => ['sometimes', 'integer', 'min:1'], + ]; + } +} diff --git a/app/Http/Requests/BulkTasksRequest.php b/app/Http/Requests/BulkTasksRequest.php new file mode 100644 index 0000000..9987d4c --- /dev/null +++ b/app/Http/Requests/BulkTasksRequest.php @@ -0,0 +1,35 @@ +> */ + public function rules(): array + { + return [ + 'action' => ['required', 'string', 'in:'.implode(',', BulkTasksController::ACTIONS)], + 'ids' => ['required', 'array', 'min:1', 'max:'.self::MAX_IDS], + 'ids.*' => ['integer', 'min:1'], + 'task_status_id' => [ + 'required_if:action,'.BulkTasksController::ACTION_STATUS, + 'prohibited_if:action,'.BulkTasksController::ACTION_DELETE, + 'integer', + 'min:1', + ], + ]; + } +} diff --git a/app/Http/Requests/ConfirmInvoiceRequest.php b/app/Http/Requests/ConfirmInvoiceRequest.php new file mode 100644 index 0000000..fe762dd --- /dev/null +++ b/app/Http/Requests/ConfirmInvoiceRequest.php @@ -0,0 +1,27 @@ +> */ + public function rules(): array + { + return [ + 'invoice_id' => ['required', 'integer', 'min:1'], + 'items' => ['required', 'array', 'min:1'], + 'items.*.invoice_item_id' => ['required', 'integer', 'min:1'], + 'items.*.entry_ids' => ['required', 'array', 'min:1'], + 'items.*.entry_ids.*' => ['integer', 'min:1'], + ]; + } +} diff --git a/app/Http/Requests/ListProjectsRequest.php b/app/Http/Requests/ListProjectsRequest.php new file mode 100644 index 0000000..ee4b46b --- /dev/null +++ b/app/Http/Requests/ListProjectsRequest.php @@ -0,0 +1,24 @@ +> */ + public function rules(): array + { + return [ + 'status' => ['sometimes', 'string', 'in:'.Project::STATUS_ACTIVE.','.Project::STATUS_ARCHIVED], + '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 new file mode 100644 index 0000000..83efddc --- /dev/null +++ b/app/Http/Requests/ListTasksRequest.php @@ -0,0 +1,27 @@ +> */ + public function rules(): array + { + return [ + 'project_id' => ['sometimes', 'integer', 'min:1'], + 'assignee_id' => ['sometimes', 'integer', 'min:1'], + 'task_status_id' => ['sometimes', 'integer', 'min:1'], + 'customer_id' => ['sometimes', 'integer', 'min:1'], + 'invoiced' => ['sometimes', 'boolean'], + '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/app/Http/Requests/ListTimeEntriesRequest.php b/app/Http/Requests/ListTimeEntriesRequest.php new file mode 100644 index 0000000..243f9bd --- /dev/null +++ b/app/Http/Requests/ListTimeEntriesRequest.php @@ -0,0 +1,22 @@ +> */ + public function rules(): array + { + return [ + 'user_id' => ['sometimes', 'integer', 'min:1'], + 'project_id' => ['sometimes', 'integer', 'min:1'], + 'task_id' => ['sometimes', 'integer', 'min:1'], + 'from' => ['sometimes', 'date'], + 'to' => ['sometimes', 'date'], + 'billable' => ['sometimes', 'boolean'], + 'billed' => ['sometimes', 'boolean'], + ]; + } +} diff --git a/app/Http/Requests/ModuleRequest.php b/app/Http/Requests/ModuleRequest.php new file mode 100644 index 0000000..0537cd6 --- /dev/null +++ b/app/Http/Requests/ModuleRequest.php @@ -0,0 +1,23 @@ +> */ + public function rules(): array + { + return [ + 'task_status_id' => ['required', 'integer', 'min:1'], + 'before_id' => ['nullable', 'integer', 'min:1', $this->neighbour()], + 'after_id' => ['nullable', 'integer', 'min:1', $this->neighbour()], + ]; + } + + private function neighbour(): Exists + { + $context = CompanyContext::fromRequest($this); + + return Rule::exists((new Task)->getTable(), 'id') + ->where('company_id', $context->companyId) + ->where('task_status_id', $this->integer('task_status_id')); + } +} diff --git a/app/Http/Requests/PrepareInvoiceRequest.php b/app/Http/Requests/PrepareInvoiceRequest.php new file mode 100644 index 0000000..b78ce37 --- /dev/null +++ b/app/Http/Requests/PrepareInvoiceRequest.php @@ -0,0 +1,32 @@ +> */ + public function rules(): array + { + return [ + 'entry_ids' => ['array', 'min:1', 'required_without_all:task_ids,project_id', 'prohibits:task_ids,project_id'], + 'entry_ids.*' => ['integer', 'min:1'], + 'task_ids' => ['array', 'min:1', 'required_without_all:entry_ids,project_id', 'prohibits:entry_ids,project_id'], + 'task_ids.*' => ['integer', 'min:1'], + 'project_id' => ['integer', 'min:1', 'required_without_all:entry_ids,task_ids', 'prohibits:entry_ids,task_ids'], + 'grouping' => ['sometimes', 'string', 'in:'.implode(',', BillingService::GROUPINGS)], + ]; + } +} diff --git a/app/Http/Requests/ReorderTaskStatusesRequest.php b/app/Http/Requests/ReorderTaskStatusesRequest.php new file mode 100644 index 0000000..6bb39f0 --- /dev/null +++ b/app/Http/Requests/ReorderTaskStatusesRequest.php @@ -0,0 +1,17 @@ +> */ + public function rules(): array + { + return [ + 'ids' => ['required', 'array', 'min:1'], + 'ids.*' => ['integer', 'min:1'], + ]; + } +} diff --git a/app/Http/Requests/ReportSummaryRequest.php b/app/Http/Requests/ReportSummaryRequest.php new file mode 100644 index 0000000..bf240db --- /dev/null +++ b/app/Http/Requests/ReportSummaryRequest.php @@ -0,0 +1,17 @@ +> */ + public function rules(): array + { + return [ + 'from' => ['sometimes', 'date'], + 'to' => ['sometimes', 'date'], + ]; + } +} diff --git a/app/Http/Requests/StartTaskTimerRequest.php b/app/Http/Requests/StartTaskTimerRequest.php new file mode 100644 index 0000000..5117d64 --- /dev/null +++ b/app/Http/Requests/StartTaskTimerRequest.php @@ -0,0 +1,21 @@ +> */ + public function rules(): array + { + return [ + 'description' => ['sometimes', 'nullable', 'string'], + 'billable' => ['sometimes', 'boolean'], + ]; + } +} diff --git a/app/Http/Requests/StartTimerRequest.php b/app/Http/Requests/StartTimerRequest.php new file mode 100644 index 0000000..33a775c --- /dev/null +++ b/app/Http/Requests/StartTimerRequest.php @@ -0,0 +1,18 @@ +> */ + public function rules(): array + { + return [ + 'task_id' => ['required', 'integer', 'min:1'], + 'description' => ['nullable', 'string'], + 'billable' => ['sometimes', 'boolean'], + ]; + } +} diff --git a/app/Http/Requests/StopTimerRequest.php b/app/Http/Requests/StopTimerRequest.php new file mode 100644 index 0000000..5cf3a0e --- /dev/null +++ b/app/Http/Requests/StopTimerRequest.php @@ -0,0 +1,23 @@ +> */ + public function rules(): array + { + return [ + 'description' => ['sometimes', 'nullable', 'string', 'max:2000'], + 'billable' => ['sometimes', 'boolean'], + ]; + } +} diff --git a/app/Http/Requests/StoreProjectRequest.php b/app/Http/Requests/StoreProjectRequest.php new file mode 100644 index 0000000..41b821e --- /dev/null +++ b/app/Http/Requests/StoreProjectRequest.php @@ -0,0 +1,27 @@ +> */ + public function rules(): array + { + return [ + 'name' => ['required', 'string', 'max:255'], + 'customer_id' => ['nullable', 'integer', 'min:1'], + 'identifier' => ['nullable', 'string', 'max:32'], + 'description' => ['nullable', 'string'], + 'colour' => ['nullable', 'string', 'max:16'], + 'status' => ['sometimes', 'string', 'in:'.Project::STATUS_ACTIVE.','.Project::STATUS_ARCHIVED], + 'currency_id' => ['nullable', 'integer', 'min:1'], + 'default_rate' => ['nullable', 'integer', 'min:0'], + 'budget_minutes' => ['nullable', 'integer', 'min:0'], + 'due_date' => ['nullable', 'date'], + ]; + } +} diff --git a/app/Http/Requests/StoreTaskRequest.php b/app/Http/Requests/StoreTaskRequest.php new file mode 100644 index 0000000..0a119c5 --- /dev/null +++ b/app/Http/Requests/StoreTaskRequest.php @@ -0,0 +1,28 @@ +> */ + public function rules(): array + { + return [ + 'name' => ['required', 'string', 'max:255'], + 'project_id' => ['nullable', 'integer', 'min:1'], + 'customer_id' => ['nullable', 'integer', 'min:1'], + 'task_status_id' => ['nullable', 'integer', 'min:1'], + 'description' => ['nullable', 'string'], + 'assignee_id' => ['nullable', 'integer', 'min:1'], + 'priority' => ['nullable', 'string', 'max:16', 'in:'.implode(',', Task::PRIORITIES)], + 'due_date' => ['nullable', 'date'], + 'estimated_minutes' => ['nullable', 'integer', 'min:0'], + 'billable' => ['sometimes', 'boolean'], + 'rate' => ['nullable', 'integer', 'min:0'], + ]; + } +} diff --git a/app/Http/Requests/StoreTaskStatusRequest.php b/app/Http/Requests/StoreTaskStatusRequest.php new file mode 100644 index 0000000..1f68f4d --- /dev/null +++ b/app/Http/Requests/StoreTaskStatusRequest.php @@ -0,0 +1,20 @@ +> */ + public function rules(): array + { + return [ + 'name' => ['required', 'string', 'max:255'], + 'colour' => ['nullable', 'string', 'max:16'], + 'position' => ['nullable', 'integer', 'min:0'], + 'is_default' => ['sometimes', 'boolean'], + 'is_closed' => ['sometimes', 'boolean'], + ]; + } +} diff --git a/app/Http/Requests/StoreTimeEntryRequest.php b/app/Http/Requests/StoreTimeEntryRequest.php new file mode 100644 index 0000000..766a2f7 --- /dev/null +++ b/app/Http/Requests/StoreTimeEntryRequest.php @@ -0,0 +1,23 @@ +> */ + public function rules(): array + { + return [ + 'task_id' => ['required', 'integer', 'min:1'], + 'user_id' => ['nullable', 'integer', 'min:1'], + 'started_at' => ['nullable', 'date'], + 'ended_at' => ['nullable', 'date', 'after_or_equal:started_at'], + 'duration_minutes' => ['nullable', 'integer', 'min:0'], + 'description' => ['nullable', 'string'], + 'billable' => ['sometimes', 'boolean'], + 'rate' => ['nullable', 'integer', 'min:0'], + ]; + } +} diff --git a/app/Http/Requests/UnbilledCustomersRequest.php b/app/Http/Requests/UnbilledCustomersRequest.php new file mode 100644 index 0000000..e4768d4 --- /dev/null +++ b/app/Http/Requests/UnbilledCustomersRequest.php @@ -0,0 +1,23 @@ +> */ + public function rules(): array + { + return [ + 'from' => ['sometimes', 'date'], + 'to' => ['sometimes', 'date'], + ]; + } +} diff --git a/app/Http/Requests/UnbilledTimeRequest.php b/app/Http/Requests/UnbilledTimeRequest.php new file mode 100644 index 0000000..20f2fe0 --- /dev/null +++ b/app/Http/Requests/UnbilledTimeRequest.php @@ -0,0 +1,18 @@ +> */ + public function rules(): array + { + return [ + 'customer_id' => ['required', 'integer', 'min:1'], + 'from' => ['sometimes', 'date'], + 'to' => ['sometimes', 'date'], + ]; + } +} diff --git a/app/Http/Requests/UpdateProjectRequest.php b/app/Http/Requests/UpdateProjectRequest.php new file mode 100644 index 0000000..5427556 --- /dev/null +++ b/app/Http/Requests/UpdateProjectRequest.php @@ -0,0 +1,27 @@ +> */ + public function rules(): array + { + return [ + 'name' => ['sometimes', 'required', 'string', 'max:255'], + 'customer_id' => ['sometimes', 'nullable', 'integer', 'min:1'], + 'identifier' => ['sometimes', 'nullable', 'string', 'max:32'], + 'description' => ['sometimes', 'nullable', 'string'], + 'colour' => ['sometimes', 'nullable', 'string', 'max:16'], + 'status' => ['sometimes', 'string', 'in:'.Project::STATUS_ACTIVE.','.Project::STATUS_ARCHIVED], + 'currency_id' => ['sometimes', 'nullable', 'integer', 'min:1'], + 'default_rate' => ['sometimes', 'nullable', 'integer', 'min:0'], + 'budget_minutes' => ['sometimes', 'nullable', 'integer', 'min:0'], + 'due_date' => ['sometimes', 'nullable', 'date'], + ]; + } +} diff --git a/app/Http/Requests/UpdateTaskRequest.php b/app/Http/Requests/UpdateTaskRequest.php new file mode 100644 index 0000000..1513592 --- /dev/null +++ b/app/Http/Requests/UpdateTaskRequest.php @@ -0,0 +1,28 @@ +> */ + public function rules(): array + { + return [ + 'name' => ['sometimes', 'required', 'string', 'max:255'], + 'project_id' => ['sometimes', 'nullable', 'integer', 'min:1'], + 'customer_id' => ['sometimes', 'nullable', 'integer', 'min:1'], + 'task_status_id' => ['sometimes', 'integer', 'min:1'], + 'description' => ['sometimes', 'nullable', 'string'], + 'assignee_id' => ['sometimes', 'nullable', 'integer', 'min:1'], + 'priority' => ['sometimes', 'nullable', 'string', 'max:16', 'in:'.implode(',', Task::PRIORITIES)], + 'due_date' => ['sometimes', 'nullable', 'date'], + 'estimated_minutes' => ['sometimes', 'nullable', 'integer', 'min:0'], + 'billable' => ['sometimes', 'boolean'], + 'rate' => ['sometimes', 'nullable', 'integer', 'min:0'], + ]; + } +} diff --git a/app/Http/Requests/UpdateTaskStatusRequest.php b/app/Http/Requests/UpdateTaskStatusRequest.php new file mode 100644 index 0000000..b67a31e --- /dev/null +++ b/app/Http/Requests/UpdateTaskStatusRequest.php @@ -0,0 +1,20 @@ +> */ + public function rules(): array + { + return [ + 'name' => ['sometimes', 'required', 'string', 'max:255'], + 'colour' => ['sometimes', 'nullable', 'string', 'max:16'], + 'position' => ['sometimes', 'integer', 'min:0'], + 'is_default' => ['sometimes', 'boolean'], + 'is_closed' => ['sometimes', 'boolean'], + ]; + } +} diff --git a/app/Http/Requests/UpdateTimeEntryRequest.php b/app/Http/Requests/UpdateTimeEntryRequest.php new file mode 100644 index 0000000..2a04ff1 --- /dev/null +++ b/app/Http/Requests/UpdateTimeEntryRequest.php @@ -0,0 +1,22 @@ +> */ + public function rules(): array + { + return [ + 'task_id' => ['sometimes', 'integer', 'min:1'], + 'started_at' => ['sometimes', 'nullable', 'date'], + 'ended_at' => ['sometimes', 'nullable', 'date', 'after_or_equal:started_at'], + 'duration_minutes' => ['sometimes', 'nullable', 'integer', 'min:0'], + 'description' => ['sometimes', 'nullable', 'string'], + 'billable' => ['sometimes', 'boolean'], + 'rate' => ['sometimes', 'nullable', 'integer', 'min:0'], + ]; + } +} diff --git a/app/Http/Resources/ProjectMemberResource.php b/app/Http/Resources/ProjectMemberResource.php new file mode 100644 index 0000000..9169159 --- /dev/null +++ b/app/Http/Resources/ProjectMemberResource.php @@ -0,0 +1,34 @@ + */ + public function toArray(Request $request): array + { + $member = $this->resource; + + return [ + 'id' => (int) $member->id, + 'company_id' => (int) $member->company_id, + 'project_id' => (int) $member->project_id, + 'user_id' => (int) $member->user_id, + 'rate' => $member->rate === null ? null : (int) $member->rate, + 'created_at' => $member->created_at?->toIso8601String(), + 'updated_at' => $member->updated_at?->toIso8601String(), + ]; + } +} diff --git a/app/Http/Resources/ProjectResource.php b/app/Http/Resources/ProjectResource.php new file mode 100644 index 0000000..574703a --- /dev/null +++ b/app/Http/Resources/ProjectResource.php @@ -0,0 +1,64 @@ +|null */ + private ?array $totals = null; + + /** + * Add the detail screen's totals, which the list deliberately leaves out. + * + * @param array $totals + */ + public function withTotals(array $totals): self + { + $this->totals = $totals; + + return $this; + } + + /** @return array */ + public function toArray(Request $request): array + { + $project = $this->resource; + + $data = [ + 'id' => (int) $project->id, + 'company_id' => (int) $project->company_id, + 'customer_id' => $project->customer_id === null ? null : (int) $project->customer_id, + 'name' => $project->name, + 'identifier' => $project->identifier, + 'description' => $project->description, + 'colour' => $project->colour, + 'status' => $project->status, + 'currency_id' => $project->currency_id === null ? null : (int) $project->currency_id, + 'default_rate' => $project->default_rate === null ? null : (int) $project->default_rate, + 'budget_minutes' => $project->budget_minutes === null ? null : (int) $project->budget_minutes, + 'due_date' => $project->due_date?->toDateString(), + 'creator_id' => $project->creator_id === null ? null : (int) $project->creator_id, + 'is_internal' => $project->isInternal(), + 'created_at' => $project->created_at?->toIso8601String(), + 'updated_at' => $project->updated_at?->toIso8601String(), + ]; + + if ($this->totals !== null) { + $data['totals'] = $this->totals; + } + + return $data; + } +} diff --git a/app/Http/Resources/TaskResource.php b/app/Http/Resources/TaskResource.php new file mode 100644 index 0000000..3de848a --- /dev/null +++ b/app/Http/Resources/TaskResource.php @@ -0,0 +1,55 @@ + */ + public function toArray(Request $request): array + { + $task = $this->resource; + + return [ + 'id' => (int) $task->id, + 'company_id' => (int) $task->company_id, + 'project_id' => $task->project_id === null ? null : (int) $task->project_id, + 'customer_id' => $task->customer_id === null ? null : (int) $task->customer_id, + 'task_status_id' => (int) $task->task_status_id, + 'number' => (int) $task->number, + 'name' => $task->name, + 'description' => $task->description, + 'assignee_id' => $task->assignee_id === null ? null : (int) $task->assignee_id, + 'priority' => $task->priority, + 'due_date' => $task->due_date?->toDateString(), + 'estimated_minutes' => $task->estimated_minutes === null ? null : (int) $task->estimated_minutes, + 'billable' => (bool) $task->billable, + 'rate' => $task->rate === null ? null : (int) $task->rate, + 'board_position' => (string) $task->board_position, + 'closed_at' => $task->closed_at?->toIso8601String(), + 'creator_id' => $task->creator_id === null ? null : (int) $task->creator_id, + 'created_at' => $task->created_at?->toIso8601String(), + 'updated_at' => $task->updated_at?->toIso8601String(), + 'time' => $task->timeSummary ?? TaskTimeSummary::empty(), + ]; + } +} diff --git a/app/Http/Resources/TaskStatusResource.php b/app/Http/Resources/TaskStatusResource.php new file mode 100644 index 0000000..a4a90c7 --- /dev/null +++ b/app/Http/Resources/TaskStatusResource.php @@ -0,0 +1,35 @@ + */ + public function toArray(Request $request): array + { + $status = $this->resource; + + return [ + 'id' => (int) $status->id, + 'company_id' => (int) $status->company_id, + 'name' => $status->name, + 'colour' => $status->colour, + 'position' => (int) $status->position, + 'is_default' => (bool) $status->is_default, + 'is_closed' => (bool) $status->is_closed, + 'created_at' => $status->created_at?->toIso8601String(), + 'updated_at' => $status->updated_at?->toIso8601String(), + ]; + } +} diff --git a/app/Http/Resources/TimeEntryResource.php b/app/Http/Resources/TimeEntryResource.php new file mode 100644 index 0000000..06c7023 --- /dev/null +++ b/app/Http/Resources/TimeEntryResource.php @@ -0,0 +1,46 @@ + */ + public function toArray(Request $request): array + { + $entry = $this->resource; + + return [ + 'id' => (int) $entry->id, + 'company_id' => (int) $entry->company_id, + 'task_id' => (int) $entry->task_id, + 'project_id' => $entry->project_id === null ? null : (int) $entry->project_id, + 'user_id' => (int) $entry->user_id, + 'started_at' => $entry->started_at?->toIso8601String(), + 'ended_at' => $entry->ended_at?->toIso8601String(), + 'duration_minutes' => (int) $entry->duration_minutes, + 'description' => $entry->description, + 'billable' => (bool) $entry->billable, + 'rate' => (int) $entry->rate, + 'amount' => (int) $entry->amount, + 'currency_id' => $entry->currency_id === null ? null : (int) $entry->currency_id, + 'is_running' => $entry->isRunning(), + 'invoice_id' => $entry->invoice_id === null ? null : (int) $entry->invoice_id, + 'invoice_item_id' => $entry->invoice_item_id === null ? null : (int) $entry->invoice_item_id, + 'invoiced_at' => $entry->invoiced_at?->toIso8601String(), + 'created_at' => $entry->created_at?->toIso8601String(), + 'updated_at' => $entry->updated_at?->toIso8601String(), + ]; + } +} diff --git a/app/Lifecycle/DataCleanup.php b/app/Lifecycle/DataCleanup.php index 4346415..2a57b47 100644 --- a/app/Lifecycle/DataCleanup.php +++ b/app/Lifecycle/DataCleanup.php @@ -6,24 +6,39 @@ use InvoiceShelf\Modules\Contracts\DataCleanup as DataCleanupContract; use InvoiceShelf\Modules\Contracts\Host\SettingsStore; +use Modules\TasksProjects\Support\ModuleSettings; /** Removes the module's per-company settings when the host asks to remove module data. */ final class DataCleanup implements DataCleanupContract { - /** Keys stored under `module.tasks-projects.` for each company. */ - private const SETTING_KEYS = [ + /** The keys that are not switches; the switches come from ModuleSettings. */ + private const VALUE_KEYS = [ 'default_rate', 'rounding_minutes', + 'rounding_direction', 'week_start', - 'members_see_all_time', ]; public function __construct(private readonly SettingsStore $settings) {} public function cleanup(): void { - foreach (self::SETTING_KEYS as $key) { - $this->settings->deleteCompanyForAll('module.tasks-projects.'.$key); + foreach (self::settingKeys() as $key) { + $this->settings->deleteCompanyForAll(ModuleSettings::PREFIX.$key); } } + + /** + * Every key stored under `module.tasks-projects.` for a company. + * + * The switches are read from the same table the getters and the settings + * schema use, so a new toggle is added in one place and is cleaned up here + * without anyone remembering to come back. + * + * @return list + */ + public static function settingKeys(): array + { + return [...self::VALUE_KEYS, ...array_keys(ModuleSettings::FLAGS)]; + } } diff --git a/app/Models/Project.php b/app/Models/Project.php new file mode 100644 index 0000000..a9920f5 --- /dev/null +++ b/app/Models/Project.php @@ -0,0 +1,77 @@ + 'integer', + 'customer_id' => 'integer', + 'currency_id' => 'integer', + 'default_rate' => 'integer', + 'budget_minutes' => 'integer', + 'creator_id' => 'integer', + 'due_date' => 'date', + ]; + } + + /** @return HasMany */ + public function members(): HasMany + { + return $this->hasMany(ProjectMember::class, 'project_id'); + } + + /** @return HasMany */ + public function tasks(): HasMany + { + return $this->hasMany(Task::class, 'project_id'); + } + + /** @return HasMany */ + public function timeEntries(): HasMany + { + return $this->hasMany(TimeEntry::class, 'project_id'); + } + + public function isInternal(): bool + { + return $this->customer_id === null; + } + + /** + * @param Builder<$this> $query + * @return Builder<$this> + */ + public function scopeForCompany(Builder $query, int $companyId): Builder + { + return $query->where($this->getTable().'.company_id', $companyId); + } +} diff --git a/app/Models/ProjectMember.php b/app/Models/ProjectMember.php new file mode 100644 index 0000000..d05a7a5 --- /dev/null +++ b/app/Models/ProjectMember.php @@ -0,0 +1,54 @@ + 'integer', + 'project_id' => 'integer', + 'user_id' => 'integer', + 'rate' => 'integer', + ]; + } + + /** @return BelongsTo */ + public function project(): BelongsTo + { + return $this->belongsTo(Project::class, 'project_id'); + } + + /** + * @param Builder<$this> $query + * @return Builder<$this> + */ + public function scopeForCompany(Builder $query, int $companyId): Builder + { + return $query->where($this->getTable().'.company_id', $companyId); + } +} diff --git a/app/Models/Task.php b/app/Models/Task.php new file mode 100644 index 0000000..2f356cb --- /dev/null +++ b/app/Models/Task.php @@ -0,0 +1,109 @@ + */ + public const PRIORITIES = [ + self::PRIORITY_LOW, + self::PRIORITY_NORMAL, + self::PRIORITY_HIGH, + self::PRIORITY_URGENT, + ]; + + /** + * The time block the API renders, hung on the model by TaskTimeSummary. + * + * A real property rather than an attribute: it is derived from the time + * entries, never a column, and must never travel back into a save(). + * + * @var array|null + */ + public ?array $timeSummary = null; + + protected $table = 'tp_tasks'; + + protected $guarded = ['id']; + + protected function casts(): array + { + return [ + 'company_id' => 'integer', + 'project_id' => 'integer', + 'customer_id' => 'integer', + 'task_status_id' => 'integer', + 'number' => 'integer', + 'assignee_id' => 'integer', + 'estimated_minutes' => 'integer', + 'billable' => 'boolean', + 'rate' => 'integer', + 'board_position' => 'decimal:10', + 'creator_id' => 'integer', + 'due_date' => 'date', + 'closed_at' => 'datetime', + ]; + } + + /** @return BelongsTo */ + public function project(): BelongsTo + { + return $this->belongsTo(Project::class, 'project_id'); + } + + /** @return BelongsTo */ + public function status(): BelongsTo + { + return $this->belongsTo(TaskStatus::class, 'task_status_id'); + } + + /** @return HasMany */ + public function timeEntries(): HasMany + { + return $this->hasMany(TimeEntry::class, 'task_id'); + } + + /** + * @param Builder<$this> $query + * @return Builder<$this> + */ + public function scopeForCompany(Builder $query, int $companyId): Builder + { + return $query->where($this->getTable().'.company_id', $companyId); + } +} diff --git a/app/Models/TaskStatus.php b/app/Models/TaskStatus.php new file mode 100644 index 0000000..3705c5c --- /dev/null +++ b/app/Models/TaskStatus.php @@ -0,0 +1,52 @@ + 'integer', + 'position' => 'integer', + 'is_default' => 'boolean', + 'is_closed' => 'boolean', + ]; + } + + /** @return HasMany */ + public function tasks(): HasMany + { + return $this->hasMany(Task::class, 'task_status_id'); + } + + /** + * @param Builder<$this> $query + * @return Builder<$this> + */ + public function scopeForCompany(Builder $query, int $companyId): Builder + { + return $query->where($this->getTable().'.company_id', $companyId); + } +} diff --git a/app/Models/TimeEntry.php b/app/Models/TimeEntry.php new file mode 100644 index 0000000..4c1cc8d --- /dev/null +++ b/app/Models/TimeEntry.php @@ -0,0 +1,95 @@ + 'integer', + 'task_id' => 'integer', + 'project_id' => 'integer', + 'user_id' => 'integer', + 'duration_minutes' => 'integer', + 'billable' => 'boolean', + 'rate' => 'integer', + 'amount' => 'integer', + 'currency_id' => 'integer', + 'running_user_id' => 'integer', + 'invoice_id' => 'integer', + 'invoice_item_id' => 'integer', + 'started_at' => 'datetime', + 'ended_at' => 'datetime', + 'invoiced_at' => 'datetime', + ]; + } + + /** @return BelongsTo */ + public function task(): BelongsTo + { + return $this->belongsTo(Task::class, 'task_id'); + } + + /** @return BelongsTo */ + public function project(): BelongsTo + { + return $this->belongsTo(Project::class, 'project_id'); + } + + public function isRunning(): bool + { + return $this->running_user_id !== null; + } + + public function isStamped(): bool + { + return $this->invoice_id !== null; + } + + /** + * @param Builder<$this> $query + * @return Builder<$this> + */ + public function scopeForCompany(Builder $query, int $companyId): Builder + { + return $query->where($this->getTable().'.company_id', $companyId); + } +} diff --git a/app/Providers/TasksProjectsServiceProvider.php b/app/Providers/TasksProjectsServiceProvider.php index 638caf4..9474d4e 100644 --- a/app/Providers/TasksProjectsServiceProvider.php +++ b/app/Providers/TasksProjectsServiceProvider.php @@ -4,10 +4,13 @@ namespace Modules\TasksProjects\Providers; +use Illuminate\Contracts\Debug\ExceptionHandler; use Illuminate\Contracts\Foundation\Application; +use Illuminate\Foundation\Exceptions\Handler; use InvoiceShelf\Modules\Contracts\DataCleanup; use InvoiceShelf\Modules\Contracts\Host\SettingsStore; use InvoiceShelf\Modules\Support\ModuleServiceProvider; +use Modules\TasksProjects\Http\DomainExceptionRenderer; use Modules\TasksProjects\Lifecycle\DataCleanup as TasksProjectsDataCleanup; use Modules\TasksProjects\Support\ModuleRegistration; @@ -37,6 +40,23 @@ public function boot(): void ModuleRegistration::register($modulePath); + $this->registerExceptionRendering(); + $this->loadRoutesFrom($modulePath.'/routes/api.php'); } + + /** + * Map every broken domain rule to its HTTP response in one place. + * + * The services throw a TasksProjectsException rather than returning an + * error, so a single renderable keeps the controllers free of try/catch. + */ + private function registerExceptionRendering(): void + { + $handler = $this->app->make(ExceptionHandler::class); + + if ($handler instanceof Handler) { + DomainExceptionRenderer::register($handler); + } + } } diff --git a/app/Support/Abilities.php b/app/Support/Abilities.php index 2687fff..41c535b 100644 --- a/app/Support/Abilities.php +++ b/app/Support/Abilities.php @@ -5,42 +5,50 @@ namespace Modules\TasksProjects\Support; /** - * Namespaced ability identifiers for the Tasks and Projects module. + * Ability names the Tasks and Projects module contributes to the host catalogue. * - * These are not yet registered with the host's ability catalogue: v1 gates - * through `InvoiceShelf\Modules\Contracts\Host\ModuleAuthorization` against - * existing host abilities (`view`/`create` on `customer` and `invoice`). - * These constants document the intended catalogue for when - * `Registry::registerAbility()` lands. See module-tasks-projects.md - * "Authorization". + * The constants hold the bare, un-namespaced names: `Registry::registerAbility()` + * namespaces every module ability as `{slug}:{ability}` at registration time and + * rejects a name that already carries a colon. Build the stored id with + * `Registry::abilityId(Abilities::SLUG, Abilities::VIEW_PROJECT)` wherever the + * namespaced form is needed, such as a frontend route's `meta.ability`. + * + * See specs/tasks-projects.md "Authorization" for the dependency table. */ final class Abilities { public const SLUG = 'tasks-projects'; - public const VIEW_PROJECT = 'tasks-projects:view-project'; + public const VIEW_PROJECT = 'view-project'; + + public const CREATE_PROJECT = 'create-project'; + + public const EDIT_PROJECT = 'edit-project'; + + public const DELETE_PROJECT = 'delete-project'; - public const CREATE_PROJECT = 'tasks-projects:create-project'; + public const VIEW_TASK = 'view-task'; - public const EDIT_PROJECT = 'tasks-projects:edit-project'; + public const CREATE_TASK = 'create-task'; - public const DELETE_PROJECT = 'tasks-projects:delete-project'; + public const EDIT_TASK = 'edit-task'; - public const VIEW_TASK = 'tasks-projects:view-task'; + public const DELETE_TASK = 'delete-task'; - public const CREATE_TASK = 'tasks-projects:create-task'; + public const MANAGE_TASK_STATUS = 'manage-task-status'; - public const EDIT_TASK = 'tasks-projects:edit-task'; + public const VIEW_OWN_TIME = 'view-own-time'; - public const DELETE_TASK = 'tasks-projects:delete-task'; + public const VIEW_ALL_TIME = 'view-all-time'; - public const MANAGE_TASK_STATUS = 'tasks-projects:manage-task-status'; + public const EDIT_ALL_TIME = 'edit-all-time'; - public const VIEW_OWN_TIME = 'tasks-projects:view-own-time'; + public const INVOICE_TASKS = 'invoice-tasks'; - public const VIEW_ALL_TIME = 'tasks-projects:view-all-time'; + /** Host abilities the module's own abilities depend on. */ + public const HOST_VIEW_CUSTOMER = 'view-customer'; - public const EDIT_ALL_TIME = 'tasks-projects:edit-all-time'; + public const HOST_CREATE_INVOICE = 'create-invoice'; - public const INVOICE_TASKS = 'tasks-projects:invoice-tasks'; + public const HOST_EDIT_INVOICE = 'edit-invoice'; } diff --git a/app/Support/Authorizes.php b/app/Support/Authorizes.php new file mode 100644 index 0000000..0c5d52d --- /dev/null +++ b/app/Support/Authorizes.php @@ -0,0 +1,41 @@ +authorization->allows($context->userId, $context->companyId, self::id($ability)); + } + + /** @throws AuthorizationException when the user lacks the ability in this company */ + public function require(CompanyContext $context, string $ability): void + { + if (! $this->allows($context, $ability)) { + throw new AuthorizationException('This action requires the '.self::id($ability).' ability.'); + } + } +} diff --git a/app/Support/CompanyContext.php b/app/Support/CompanyContext.php new file mode 100644 index 0000000..81b127f --- /dev/null +++ b/app/Support/CompanyContext.php @@ -0,0 +1,28 @@ +header('company'), + (int) $request->user()->getAuthIdentifier(), + ); + } +} diff --git a/app/Support/ModuleRegistration.php b/app/Support/ModuleRegistration.php index 2015b3f..5fa910c 100644 --- a/app/Support/ModuleRegistration.php +++ b/app/Support/ModuleRegistration.php @@ -5,6 +5,8 @@ namespace Modules\TasksProjects\Support; use InvoiceShelf\Modules\Registry; +use InvoiceShelf\Modules\Settings\FieldType; +use Modules\TasksProjects\Application\Rounding; final class ModuleRegistration { @@ -13,41 +15,85 @@ public static function register(string $modulePath): void Registry::registerScript('tasks-projects', $modulePath.'/dist/init.js'); Registry::registerStyle('tasks-projects', $modulePath.'/dist/style.css'); + self::registerMenu(); + Registry::registerSettings('tasks-projects', self::settingsSchema()); + self::registerAbilities(); + } + + /** + * Two sidebar entries, in the host's own main group. + * + * Projects and Tasks are two ways into the same module, not one feature and + * its sub-page: people either plan work or do work. They join `main` after + * Items (priorities 10, 20, 30) because a firm that installs this module + * lives in it all day, and a "Modules" heading would file it away as an + * add-on. The registry keys are separate, so `menuFor('tasks-projects')` + * still answers with the module's primary entry. + */ + private static function registerMenu(): void + { + Registry::registerMenu('tasks-projects.projects', [ + 'title' => 'tasksprojects::menu.projects', + 'link' => '/admin/modules/tasks-projects/projects', + 'icon' => 'FolderIcon', + 'group' => 'main', + 'group_label' => '', + // Lower sorts first within the group; the core entries end at 30. + 'priority' => 40, + ]); + Registry::registerMenu('tasks-projects', [ - 'title' => 'tasksprojects::menu.title', + 'title' => 'tasksprojects::menu.tasks', 'link' => '/admin/modules/tasks-projects', 'icon' => 'ClipboardDocumentListIcon', + 'group' => 'main', + 'group_label' => '', + 'priority' => 50, ]); + } - Registry::registerSettings('tasks-projects', [ + /** + * The per-company settings the host renders and validates. + * + * General holds how time is measured and who may see it; the second section + * is only about what an invoice line says, which is a different question and + * a different audience. + * + * @return array + */ + private static function settingsSchema(): array + { + return [ 'sections' => [ [ 'title' => 'tasksprojects::settings.general_section', 'fields' => [ [ 'key' => 'default_rate', - 'type' => 'number', + 'type' => FieldType::Number->value, 'label' => 'tasksprojects::settings.default_rate', 'default' => 0, 'rules' => ['integer', 'min:0'], ], [ 'key' => 'rounding_minutes', - 'type' => 'select', + 'type' => FieldType::Select->value, 'label' => 'tasksprojects::settings.rounding_minutes', - 'default' => 1, - 'options' => [ - 1 => '1', - 6 => '6', - 15 => '15', - 30 => '30', - ], + 'default' => ModuleSettings::DEFAULT_ROUNDING_MINUTES, + 'options' => self::roundingOptions(), + ], + [ + 'key' => 'rounding_direction', + 'type' => FieldType::Select->value, + 'label' => 'tasksprojects::settings.rounding_direction', + 'default' => ModuleSettings::DEFAULT_ROUNDING_DIRECTION, + 'options' => self::directionOptions(), ], [ 'key' => 'week_start', - 'type' => 'select', + 'type' => FieldType::Select->value, 'label' => 'tasksprojects::settings.week_start', - 'default' => 1, + 'default' => ModuleSettings::DEFAULT_WEEK_START, 'options' => [ 0 => 'Sunday', 1 => 'Monday', @@ -58,20 +104,115 @@ public static function register(string $modulePath): void 6 => 'Saturday', ], ], - [ - 'key' => 'members_see_all_time', - 'type' => 'switch', - 'label' => 'tasksprojects::settings.members_see_all_time', - 'default' => false, - ], + self::switchField('members_see_all_time'), + self::switchField('auto_start_tasks'), + self::switchField('lock_invoiced_tasks'), + self::switchField('hide_invoiced_on_board'), + ], + ], + [ + 'title' => 'tasksprojects::settings.invoice_section', + 'fields' => [ + self::switchField('invoice_project_heading'), + self::switchField('invoice_task_description'), + self::switchField('invoice_entry_dates'), + self::switchField('invoice_entry_times'), + self::switchField('invoice_entry_hours'), + self::switchField('invoice_entry_descriptions'), ], ], ], - ]); + ]; + } + + /** + * One stored switch, taking its default from the same table the readers use. + * + * @return array + */ + private static function switchField(string $key): array + { + return [ + 'key' => $key, + 'type' => FieldType::Switch_->value, + 'label' => 'tasksprojects::settings.'.$key, + 'default' => ModuleSettings::FLAGS[$key], + ]; + } + + /** + * The rounding directions, already in the reader's language. + * + * The host translates a field's label and a section's title, but hands a + * select's options to the form as they were registered, so an option that + * named a translation key would reach the screen as the key itself. + * + * @return array + */ + private static function directionOptions(): array + { + return [ + Rounding::NEAREST => __('tasksprojects::settings.rounding_nearest'), + Rounding::UP => __('tasksprojects::settings.rounding_up'), + Rounding::DOWN => __('tasksprojects::settings.rounding_down'), + ]; + } + + /** @return array */ + private static function roundingOptions(): array + { + $options = []; + + foreach (ModuleSettings::ROUNDING_INCREMENTS as $minutes) { + $options[$minutes] = (string) $minutes; + } + + return $options; + } + + /** + * Contribute the module's ability catalogue to the host role editor. + * + * The registry namespaces every name as `tasks-projects:{ability}`, so the + * ids below can never collide with a host ability. Dependencies on a host + * ability stay bare; dependencies on a module ability are namespaced with + * Registry::abilityId(). See specs/tasks-projects.md "Authorization". + */ + private static function registerAbilities(): void + { + $viewProject = Registry::abilityId(Abilities::SLUG, Abilities::VIEW_PROJECT); + $viewTask = Registry::abilityId(Abilities::SLUG, Abilities::VIEW_TASK); + $viewOwnTime = Registry::abilityId(Abilities::SLUG, Abilities::VIEW_OWN_TIME); + $viewAllTime = Registry::abilityId(Abilities::SLUG, Abilities::VIEW_ALL_TIME); + + $abilities = [ + [Abilities::VIEW_PROJECT, 'View projects', []], + [Abilities::CREATE_PROJECT, 'Create projects', [$viewProject, Abilities::HOST_VIEW_CUSTOMER]], + [Abilities::EDIT_PROJECT, 'Edit projects', [$viewProject, Abilities::HOST_VIEW_CUSTOMER]], + [Abilities::DELETE_PROJECT, 'Delete projects', [$viewProject]], + [Abilities::VIEW_TASK, 'View tasks', [$viewProject]], + [Abilities::CREATE_TASK, 'Create tasks', [$viewTask]], + [Abilities::EDIT_TASK, 'Edit tasks', [$viewTask]], + [Abilities::DELETE_TASK, 'Delete tasks', [$viewTask]], + [Abilities::MANAGE_TASK_STATUS, 'Manage task statuses', [$viewTask]], + [Abilities::VIEW_OWN_TIME, 'View own time', []], + [Abilities::VIEW_ALL_TIME, 'View all time', [$viewOwnTime]], + [Abilities::EDIT_ALL_TIME, 'Edit all time', [$viewAllTime]], + // Invoicing a task ends on the host invoice edit page, so the role + // that may raise the invoice must also be allowed to open it. + [Abilities::INVOICE_TASKS, 'Invoice tasks', [ + $viewAllTime, + Abilities::HOST_CREATE_INVOICE, + Abilities::HOST_EDIT_INVOICE, + ]], + ]; - // TODO(sdk-3.4): register abilities via Registry::registerAbility once the - // host ability catalogue is open to modules. Until then the module gates - // through Contracts\Host\ModuleAuthorization against existing host - // abilities; see Modules\TasksProjects\Support\Abilities. + foreach ($abilities as [$ability, $name, $dependsOn]) { + Registry::registerAbility(Abilities::SLUG, [ + 'ability' => $ability, + 'name' => $name, + 'depends_on' => $dependsOn, + ]); + } } } diff --git a/app/Support/ModuleSettings.php b/app/Support/ModuleSettings.php new file mode 100644 index 0000000..c078676 --- /dev/null +++ b/app/Support/ModuleSettings.php @@ -0,0 +1,137 @@ +` + * and come back as whatever the host wrote, so every getter coerces and clamps + * rather than trusting the stored type. + */ +final class ModuleSettings +{ + public const PREFIX = 'module.tasks-projects.'; + + /** @var list */ + public const ROUNDING_INCREMENTS = [1, 5, 6, 15, 30, 60]; + + public const DEFAULT_ROUNDING_MINUTES = 1; + + public const DEFAULT_ROUNDING_DIRECTION = Rounding::NEAREST; + + public const DEFAULT_WEEK_START = 1; + + /** Every switch the module stores, with the value a company starts from. */ + public const FLAGS = [ + 'members_see_all_time' => false, + 'auto_start_tasks' => false, + 'lock_invoiced_tasks' => false, + 'hide_invoiced_on_board' => false, + 'invoice_project_heading' => false, + 'invoice_task_description' => true, + 'invoice_entry_dates' => true, + 'invoice_entry_times' => false, + 'invoice_entry_hours' => true, + 'invoice_entry_descriptions' => false, + ]; + + public function __construct(private readonly SettingsStore $settings) {} + + /** Company default hourly rate, in minor units per hour. */ + public function defaultRate(int $companyId): int + { + $rate = (int) $this->read($companyId, 'default_rate', 0); + + return max(0, $rate); + } + + /** Billing increment applied when a time entry is saved. */ + public function roundingMinutes(int $companyId): int + { + $minutes = (int) $this->read($companyId, 'rounding_minutes', self::DEFAULT_ROUNDING_MINUTES); + + return in_array($minutes, self::ROUNDING_INCREMENTS, true) ? $minutes : self::DEFAULT_ROUNDING_MINUTES; + } + + /** Which way the increment is taken: nearest, up or down. */ + public function roundingDirection(int $companyId): string + { + $direction = (string) $this->read($companyId, 'rounding_direction', self::DEFAULT_ROUNDING_DIRECTION); + + return in_array($direction, Rounding::DIRECTIONS, true) ? $direction : self::DEFAULT_ROUNDING_DIRECTION; + } + + /** First day of the timesheet week, 0 (Sunday) through 6 (Saturday). */ + public function weekStart(int $companyId): int + { + $day = (int) $this->read($companyId, 'week_start', self::DEFAULT_WEEK_START); + + return $day >= 0 && $day <= 6 ? $day : self::DEFAULT_WEEK_START; + } + + /** Whether members without the view-all-time ability still see other members' time. */ + public function membersSeeAllTime(int $companyId): bool + { + return $this->flag($companyId, 'members_see_all_time'); + } + + /** Whether creating a task starts its creator's timer straight away. */ + public function autoStartTasks(int $companyId): bool + { + return $this->flag($companyId, 'auto_start_tasks'); + } + + /** Whether a fully invoiced task refuses edits, status moves and deletion. */ + public function lockInvoicedTasks(int $companyId): bool + { + return $this->flag($companyId, 'lock_invoiced_tasks'); + } + + /** Whether a fully invoiced task drops off the board. */ + public function hideInvoicedOnBoard(int $companyId): bool + { + return $this->flag($companyId, 'hide_invoiced_on_board'); + } + + /** + * The invoice line toggles, as the composer reads them. + * + * @return array{project_heading: bool, task_description: bool, entry_dates: bool, entry_times: bool, entry_hours: bool, entry_descriptions: bool} + */ + public function invoiceLineOptions(int $companyId): array + { + return [ + 'project_heading' => $this->flag($companyId, 'invoice_project_heading'), + 'task_description' => $this->flag($companyId, 'invoice_task_description'), + 'entry_dates' => $this->flag($companyId, 'invoice_entry_dates'), + 'entry_times' => $this->flag($companyId, 'invoice_entry_times'), + 'entry_hours' => $this->flag($companyId, 'invoice_entry_hours'), + 'entry_descriptions' => $this->flag($companyId, 'invoice_entry_descriptions'), + ]; + } + + /** One stored switch, read the way the host may have written it. */ + public function flag(int $companyId, string $key): bool + { + $value = $this->read($companyId, $key, self::FLAGS[$key] ?? false); + + if (is_string($value)) { + return in_array(strtoupper($value), ['YES', 'TRUE', '1', 'ON'], true); + } + + return (bool) $value; + } + + private function read(int $companyId, string $key, mixed $default): mixed + { + $value = $this->settings->getCompany($companyId, self::PREFIX.$key, $default); + + return $value ?? $default; + } +} diff --git a/composer.json b/composer.json index 48efe10..acba5ea 100644 --- a/composer.json +++ b/composer.json @@ -11,7 +11,7 @@ "require": { "php": "^8.4", "ext-json": "*", - "invoiceshelf/modules": "^3.3" + "invoiceshelf/modules": "^3.4.0" }, "require-dev": { "laravel/pint": "^1.26", @@ -42,8 +42,38 @@ "prefer-stable": true, "repositories": { "invoiceshelf-modules": { - "type": "vcs", - "url": "https://github.com/InvoiceShelf/modules.git" + "type": "package", + "package": { + "name": "invoiceshelf/modules", + "version": "3.4.0", + "type": "library", + "license": "MIT", + "source": { + "type": "git", + "url": "https://github.com/InvoiceShelf/modules.git", + "reference": "fb7b62961153a42a7c12aabed795cd4107e4ad0d" + }, + "require": { + "php": "^8.3", + "nikic/php-parser": "^5.0", + "nwidart/laravel-modules": "^13.0" + }, + "autoload": { + "psr-4": { + "InvoiceShelf\\Modules\\": "src/" + } + }, + "bin": [ + "bin/invoiceshelf-module" + ], + "extra": { + "laravel": { + "providers": [ + "InvoiceShelf\\Modules\\InvoiceShelfModulesServiceProvider" + ] + } + } + } } } } diff --git a/database/migrations/2026_09_15_000001_create_tp_projects_table.php b/database/migrations/2026_09_15_000001_create_tp_projects_table.php new file mode 100644 index 0000000..20145d6 --- /dev/null +++ b/database/migrations/2026_09_15_000001_create_tp_projects_table.php @@ -0,0 +1,47 @@ +bigIncrements('id'); + $table->unsignedInteger('company_id'); + $table->unsignedBigInteger('customer_id')->nullable(); + $table->string('name'); + $table->string('identifier', 32)->nullable(); + $table->text('description')->nullable(); + $table->string('colour', 16)->nullable(); + $table->string('status', 16)->default('ACTIVE'); + $table->unsignedInteger('currency_id')->nullable(); + $table->bigInteger('default_rate')->nullable(); + $table->unsignedInteger('budget_minutes')->nullable(); + $table->date('due_date')->nullable(); + $table->unsignedInteger('creator_id')->nullable(); + $table->timestamps(); + + $table->index(['company_id', 'status']); + $table->index(['company_id', 'customer_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('tp_projects'); + } +}; diff --git a/database/migrations/2026_09_15_000002_create_tp_project_members_table.php b/database/migrations/2026_09_15_000002_create_tp_project_members_table.php new file mode 100644 index 0000000..1a6469b --- /dev/null +++ b/database/migrations/2026_09_15_000002_create_tp_project_members_table.php @@ -0,0 +1,35 @@ +bigIncrements('id'); + $table->unsignedInteger('company_id'); + $table->unsignedBigInteger('project_id'); + $table->unsignedInteger('user_id'); + $table->bigInteger('rate')->nullable(); + $table->timestamps(); + + $table->unique(['project_id', 'user_id']); + $table->index('company_id'); + }); + } + + public function down(): void + { + Schema::dropIfExists('tp_project_members'); + } +}; diff --git a/database/migrations/2026_09_15_000003_create_tp_task_statuses_table.php b/database/migrations/2026_09_15_000003_create_tp_task_statuses_table.php new file mode 100644 index 0000000..466c088 --- /dev/null +++ b/database/migrations/2026_09_15_000003_create_tp_task_statuses_table.php @@ -0,0 +1,37 @@ +bigIncrements('id'); + $table->unsignedInteger('company_id'); + $table->string('name'); + $table->string('colour', 16)->nullable(); + $table->unsignedInteger('position'); + $table->boolean('is_default')->default(false); + $table->boolean('is_closed')->default(false); + $table->timestamps(); + + $table->index(['company_id', 'position']); + }); + } + + public function down(): void + { + Schema::dropIfExists('tp_task_statuses'); + } +}; diff --git a/database/migrations/2026_09_15_000004_create_tp_tasks_table.php b/database/migrations/2026_09_15_000004_create_tp_tasks_table.php new file mode 100644 index 0000000..69f7266 --- /dev/null +++ b/database/migrations/2026_09_15_000004_create_tp_tasks_table.php @@ -0,0 +1,51 @@ +bigIncrements('id'); + $table->unsignedInteger('company_id'); + $table->unsignedBigInteger('project_id')->nullable(); + $table->unsignedBigInteger('customer_id')->nullable(); + $table->unsignedBigInteger('task_status_id'); + $table->unsignedInteger('number'); + $table->string('name'); + $table->text('description')->nullable(); + $table->unsignedInteger('assignee_id')->nullable(); + $table->string('priority', 16)->nullable(); + $table->date('due_date')->nullable(); + $table->unsignedInteger('estimated_minutes')->nullable(); + $table->boolean('billable')->default(true); + $table->bigInteger('rate')->nullable(); + $table->decimal('board_position', 20, 10)->default(0); + $table->dateTime('closed_at')->nullable(); + $table->unsignedInteger('creator_id')->nullable(); + $table->timestamps(); + + $table->index(['company_id', 'task_status_id', 'board_position']); + $table->index(['company_id', 'project_id']); + $table->index(['company_id', 'assignee_id']); + $table->index(['company_id', 'customer_id']); + $table->unique(['company_id', 'number']); + }); + } + + public function down(): void + { + Schema::dropIfExists('tp_tasks'); + } +}; diff --git a/database/migrations/2026_09_15_000005_create_tp_time_entries_table.php b/database/migrations/2026_09_15_000005_create_tp_time_entries_table.php new file mode 100644 index 0000000..e05d11a --- /dev/null +++ b/database/migrations/2026_09_15_000005_create_tp_time_entries_table.php @@ -0,0 +1,54 @@ +bigIncrements('id'); + $table->unsignedInteger('company_id'); + $table->unsignedBigInteger('task_id'); + $table->unsignedBigInteger('project_id')->nullable(); + $table->unsignedInteger('user_id'); + $table->dateTime('started_at')->nullable(); + $table->dateTime('ended_at')->nullable(); + $table->unsignedInteger('duration_minutes')->default(0); + $table->text('description')->nullable(); + $table->boolean('billable')->default(true); + $table->bigInteger('rate')->default(0); + $table->bigInteger('amount')->default(0); + $table->unsignedInteger('currency_id')->nullable(); + $table->unsignedInteger('running_user_id')->nullable(); + $table->unsignedInteger('invoice_id')->nullable(); + $table->unsignedInteger('invoice_item_id')->nullable(); + $table->dateTime('invoiced_at')->nullable(); + $table->timestamps(); + + $table->unique(['company_id', 'running_user_id']); + $table->index(['company_id', 'user_id', 'started_at']); + $table->index(['company_id', 'billable', 'invoice_id']); + $table->index(['company_id', 'task_id']); + $table->index(['company_id', 'project_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('tp_time_entries'); + } +}; diff --git a/lang/en/menu.php b/lang/en/menu.php index e545096..1758726 100644 --- a/lang/en/menu.php +++ b/lang/en/menu.php @@ -1,5 +1,9 @@ 'Projects', + + 'projects' => 'Projects', + 'tasks' => 'Tasks', ]; diff --git a/lang/en/settings.php b/lang/en/settings.php index 53a36fe..9cb1538 100644 --- a/lang/en/settings.php +++ b/lang/en/settings.php @@ -2,9 +2,24 @@ return [ 'general_section' => 'General', + 'invoice_section' => 'Invoice lines', 'default_rate' => 'Default hourly rate', 'rounding_minutes' => 'Rounding increment (minutes)', + 'rounding_direction' => 'Rounding direction', + 'rounding_nearest' => 'Nearest', + 'rounding_up' => 'Up', + 'rounding_down' => 'Down', 'week_start' => 'Week starts on', 'members_see_all_time' => 'Members can see other members\' time', + 'auto_start_tasks' => 'Start the timer when a task is created', + 'lock_invoiced_tasks' => 'Lock tasks once they are invoiced', + 'hide_invoiced_on_board' => 'Hide invoiced tasks on the board', + + 'invoice_project_heading' => 'Show the project name above the task', + 'invoice_task_description' => 'Show the task description', + 'invoice_entry_dates' => 'Show the date of each time entry', + 'invoice_entry_times' => 'Show the start and end time of each entry', + 'invoice_entry_hours' => 'Show the hours of each entry', + 'invoice_entry_descriptions' => 'Show the description of each entry', ]; diff --git a/routes/api.php b/routes/api.php index cb7d514..82a244c 100644 --- a/routes/api.php +++ b/routes/api.php @@ -1,7 +1,79 @@ middleware(['api', 'auth:sanctum', 'company', 'bouncer'])->group(function (): void { - // Routes are added in the backend milestone (M3). See specs/tasks-projects.md "API surface". + Route::get('projects', [ProjectsController::class, 'index'])->name('tasks-projects.projects.index'); + Route::post('projects', [ProjectsController::class, 'store'])->name('tasks-projects.projects.store'); + Route::get('projects/{id}', [ProjectsController::class, 'show'])->name('tasks-projects.projects.show'); + Route::put('projects/{id}', [ProjectsController::class, 'update'])->name('tasks-projects.projects.update'); + Route::delete('projects/{id}', [ProjectsController::class, 'destroy'])->name('tasks-projects.projects.destroy'); + Route::post('projects/{id}/archive', [ProjectsController::class, 'archive'])->name('tasks-projects.projects.archive'); + Route::post('projects/{id}/unarchive', [ProjectsController::class, 'unarchive'])->name('tasks-projects.projects.unarchive'); + + Route::get('projects/{id}/members', [ProjectMembersController::class, 'index'])->name('tasks-projects.project-members.index'); + Route::post('projects/{id}/members', [ProjectMembersController::class, 'store'])->name('tasks-projects.project-members.store'); + Route::delete('projects/{id}/members/{userId}', [ProjectMembersController::class, 'destroy'])->name('tasks-projects.project-members.destroy'); + + Route::get('members', MembersController::class)->name('tasks-projects.members.index'); + + Route::get('tasks', [TasksController::class, 'index'])->name('tasks-projects.tasks.index'); + Route::post('tasks', [TasksController::class, 'store'])->name('tasks-projects.tasks.store'); + Route::post('tasks/bulk', BulkTasksController::class)->name('tasks-projects.tasks.bulk'); + Route::get('tasks/{id}', [TasksController::class, 'show'])->name('tasks-projects.tasks.show'); + Route::put('tasks/{id}', [TasksController::class, 'update'])->name('tasks-projects.tasks.update'); + Route::delete('tasks/{id}', [TasksController::class, 'destroy'])->name('tasks-projects.tasks.destroy'); + Route::post('tasks/{id}/move', [TasksController::class, 'move'])->name('tasks-projects.tasks.move'); + Route::post('tasks/{id}/start', [TimerController::class, 'startOnTask'])->name('tasks-projects.tasks.start'); + Route::post('tasks/{id}/stop', [TimerController::class, 'stopOnTask'])->name('tasks-projects.tasks.stop'); + Route::get('tasks/{id}/time-log', TaskTimeLogController::class)->name('tasks-projects.tasks.time-log'); + + Route::get('board', BoardController::class)->name('tasks-projects.board.index'); + + Route::get('task-statuses', [TaskStatusesController::class, 'index'])->name('tasks-projects.task-statuses.index'); + Route::post('task-statuses', [TaskStatusesController::class, 'store'])->name('tasks-projects.task-statuses.store'); + Route::post('task-statuses/reorder', [TaskStatusesController::class, 'reorder'])->name('tasks-projects.task-statuses.reorder'); + Route::put('task-statuses/{id}', [TaskStatusesController::class, 'update'])->name('tasks-projects.task-statuses.update'); + Route::delete('task-statuses/{id}', [TaskStatusesController::class, 'destroy'])->name('tasks-projects.task-statuses.destroy'); + + Route::get('time-entries', [TimeEntriesController::class, 'index'])->name('tasks-projects.time-entries.index'); + Route::post('time-entries', [TimeEntriesController::class, 'store'])->name('tasks-projects.time-entries.store'); + Route::get('time-entries/{id}', [TimeEntriesController::class, 'show'])->name('tasks-projects.time-entries.show'); + Route::put('time-entries/{id}', [TimeEntriesController::class, 'update'])->name('tasks-projects.time-entries.update'); + Route::delete('time-entries/{id}', [TimeEntriesController::class, 'destroy'])->name('tasks-projects.time-entries.destroy'); + + Route::get('timer', [TimerController::class, 'show'])->name('tasks-projects.timer.show'); + Route::delete('timer', [TimerController::class, 'destroy'])->name('tasks-projects.timer.destroy'); + Route::post('timer/start', [TimerController::class, 'start'])->name('tasks-projects.timer.start'); + Route::post('timer/stop', [TimerController::class, 'stop'])->name('tasks-projects.timer.stop'); + + Route::get('billing/customers', [BillingController::class, 'customers'])->name('tasks-projects.billing.customers'); + Route::get('billing/unbilled', [BillingController::class, 'unbilled'])->name('tasks-projects.billing.unbilled'); + Route::post('billing/prepare', [BillingController::class, 'prepare'])->name('tasks-projects.billing.prepare'); + Route::post('billing/confirm', [BillingController::class, 'confirm'])->name('tasks-projects.billing.confirm'); + + Route::get('reports/summary', [ReportsController::class, 'summary'])->name('tasks-projects.reports.summary'); + + Route::get('settings', SettingsController::class)->name('tasks-projects.settings.show'); }); diff --git a/tests/Feature/BillingApiTest.php b/tests/Feature/BillingApiTest.php new file mode 100644 index 0000000..00648bd --- /dev/null +++ b/tests/Feature/BillingApiTest.php @@ -0,0 +1,486 @@ +companyData->withMember(self::COMPANY, self::DEFAULT_USER, 'Ada Lovelace'); + $this->website = $this->makeProject(self::COMPANY, [ + 'name' => 'Website', + 'customer_id' => self::CUSTOMER, + 'currency_id' => self::CURRENCY, + ]); + $this->landing = $this->task('Landing page', $this->website); + $this->pricing = $this->task('Pricing page', $this->website); + } + + public function test_unbilled_collects_the_customers_billable_time_and_groups_it_four_ways(): void + { + $first = $this->entry($this->landing, 60, '2026-09-01'); + $second = $this->entry($this->pricing, 90, '2026-09-02'); + + $response = $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/billing/unbilled?customer_id='.self::CUSTOMER); + + $response->assertOk(); + $response->assertJsonPath('data.customer_id', self::CUSTOMER); + $response->assertJsonPath('data.minutes', 150); + $response->assertJsonPath('data.entry_ids', [(int) $first->id, (int) $second->id]); + $response->assertJsonPath('data.currencies', [ + ['currency_id' => self::CURRENCY, 'minutes' => 150, 'amount' => 15000], + ]); + $response->assertJsonPath('data.groups.task.0.label', 'Landing page'); + $response->assertJsonPath('data.groups.project.0.label', 'Website'); + $response->assertJsonPath('data.groups.member.0.label', 'Ada Lovelace'); + $response->assertJsonPath('data.groups.summary.0.minutes', 150); + } + + public function test_unbilled_skips_stamped_non_billable_running_and_internal_time(): void + { + $open = $this->entry($this->landing, 60, '2026-09-01'); + $this->entry($this->landing, 60, '2026-09-02', ['billable' => false]); + $this->entry($this->landing, 0, '2026-09-03', ['running_user_id' => self::DEFAULT_USER, 'ended_at' => null]); + $this->entry($this->landing, 60, '2026-09-04', ['invoice_id' => 77, 'invoice_item_id' => 101]); + $this->companyData->withInvoices(self::COMPANY, 77); + + $internal = $this->makeProject(self::COMPANY, ['name' => 'Internal tooling', 'customer_id' => null]); + $stray = $this->makeTask(self::COMPANY, ['name' => 'Stray', 'project_id' => $internal->id, 'customer_id' => self::CUSTOMER]); + $this->entry($stray, 60, '2026-09-05', ['project_id' => $internal->id]); + + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/billing/unbilled?customer_id='.self::CUSTOMER) + ->assertOk() + ->assertJsonPath('data.entry_ids', [(int) $open->id]); + } + + public function test_time_whose_invoice_vanished_from_the_host_comes_back_to_the_unbilled_list(): void + { + $orphan = $this->entry($this->landing, 60, '2026-09-01', ['invoice_id' => 88, 'invoice_item_id' => 101]); + $this->companyData->withInvoices(self::COMPANY, 77); + + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/billing/unbilled?customer_id='.self::CUSTOMER) + ->assertOk() + ->assertJsonPath('data.entry_ids', [(int) $orphan->id]); + } + + public function test_unbilled_honours_the_date_range_and_needs_a_customer(): void + { + $this->entry($this->landing, 60, '2026-09-01'); + $inside = $this->entry($this->landing, 60, '2026-09-10'); + + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/billing/unbilled?customer_id='.self::CUSTOMER.'&from=2026-09-05&to=2026-09-15') + ->assertOk() + ->assertJsonPath('data.entry_ids', [(int) $inside->id]); + + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/billing/unbilled') + ->assertStatus(422) + ->assertJsonValidationErrors(['customer_id']); + } + + public function test_prepare_returns_the_host_invoice_body_for_every_grouping(): void + { + Carbon::setTestNow('2026-09-15 08:00:00'); + $first = $this->entry($this->landing, 60, '2026-09-01'); + $second = $this->entry($this->pricing, 90, '2026-09-02'); + $ids = [(int) $first->id, (int) $second->id]; + + $byTask = $this->prepare($ids, 'task'); + + $byTask->assertOk(); + $byTask->assertJsonPath('data.invoice_date', '2026-09-15'); + $byTask->assertJsonPath('data.customer_id', self::CUSTOMER); + $byTask->assertJsonPath('data.currency_id', self::CURRENCY); + $byTask->assertJsonPath('data.sub_total', 15000); + $byTask->assertJsonPath('data.total', 15000); + $byTask->assertJsonPath('data.notes', null); + $byTask->assertJsonPath('data.template_name', null); + $byTask->assertJsonPath('data.taxes', []); + $byTask->assertJsonPath('data.items', [ + $this->line('#1 Landing page', 1.0, 6000, '2026-09-01 1.00 h'), + $this->line('#2 Pricing page', 1.5, 9000, '2026-09-02 1.50 h'), + ]); + $byTask->assertJsonPath('data.groups', [ + ['entry_ids' => [(int) $first->id]], + ['entry_ids' => [(int) $second->id]], + ]); + + $this->prepare($ids, 'project')->assertJsonPath('data.items.0.name', 'Website')->assertJsonPath('data.items.0.quantity', 2.5); + $this->prepare($ids, 'member')->assertJsonPath('data.items.0.name', 'Ada Lovelace'); + $this->prepare($ids, 'summary') + ->assertJsonPath('data.items.0.name', 'Time') + ->assertJsonPath('data.items.0.quantity', 2.5) + ->assertJsonPath('data.groups', [['entry_ids' => $ids]]); + } + + public function test_prepare_refuses_a_grouping_it_does_not_know_and_an_empty_selection(): void + { + $entry = $this->entry($this->landing, 60, '2026-09-01'); + + $this->asCompany(self::COMPANY) + ->postJson('/api/v1/tasks-projects/billing/prepare', ['entry_ids' => [(int) $entry->id], 'grouping' => 'weekday']) + ->assertStatus(422) + ->assertJsonValidationErrors(['grouping']); + + $this->asCompany(self::COMPANY) + ->postJson('/api/v1/tasks-projects/billing/prepare', ['entry_ids' => [], 'grouping' => 'task']) + ->assertStatus(422) + ->assertJsonValidationErrors(['entry_ids']); + } + + public function test_prepare_refuses_a_selection_spanning_two_customers(): void + { + $ours = $this->entry($this->landing, 60, '2026-09-01'); + $theirs = $this->entry($this->task('Theirs', null, 43), 60, '2026-09-02'); + + $this->prepare([(int) $ours->id, (int) $theirs->id], 'task') + ->assertStatus(422) + ->assertJsonPath('error', 'mixed_billing_selection'); + } + + public function test_prepare_refuses_an_entry_of_another_company(): void + { + $foreignTask = $this->makeTask(self::OTHER_COMPANY, ['customer_id' => self::CUSTOMER]); + $foreign = $this->makeEntry(self::OTHER_COMPANY, (int) $foreignTask->id); + + $this->prepare([(int) $foreign->id], 'task') + ->assertStatus(422) + ->assertJsonPath('error', 'unknown_time_entries'); + } + + public function test_prepare_refuses_non_billable_time_and_time_with_no_customer(): void + { + $free = $this->entry($this->landing, 60, '2026-09-01', ['billable' => false]); + + $this->prepare([(int) $free->id], 'task') + ->assertStatus(422) + ->assertJsonPath('error', 'not_billable'); + + $internal = $this->makeProject(self::COMPANY, ['name' => 'Internal tooling', 'customer_id' => null]); + $task = $this->makeTask(self::COMPANY, ['name' => 'Internal work', 'project_id' => $internal->id]); + $entry = $this->entry($task, 60, '2026-09-02', ['project_id' => $internal->id]); + + $this->prepare([(int) $entry->id], 'task') + ->assertStatus(422) + ->assertJsonPath('error', 'not_billable'); + } + + public function test_prepare_refuses_time_that_is_already_on_a_live_invoice(): void + { + $entry = $this->entry($this->landing, 60, '2026-09-01', ['invoice_id' => 77, 'invoice_item_id' => 101]); + $this->companyData->withInvoices(self::COMPANY, 77); + + $this->prepare([(int) $entry->id], 'task') + ->assertStatus(422) + ->assertJsonPath('error', 'entries_already_invoiced'); + } + + public function test_confirm_stamps_the_entries_and_is_safe_to_repeat(): void + { + $first = $this->entry($this->landing, 60, '2026-09-01'); + $second = $this->entry($this->pricing, 60, '2026-09-02'); + $payload = [ + 'invoice_id' => 77, + 'items' => [ + ['invoice_item_id' => 101, 'entry_ids' => [(int) $first->id]], + ['invoice_item_id' => 102, 'entry_ids' => [(int) $second->id]], + ], + ]; + + $this->asCompany(self::COMPANY) + ->postJson('/api/v1/tasks-projects/billing/confirm', $payload) + ->assertOk() + ->assertExactJson(['stamped' => 2]); + + $this->asCompany(self::COMPANY) + ->postJson('/api/v1/tasks-projects/billing/confirm', $payload) + ->assertOk() + ->assertExactJson(['stamped' => 0]); + + self::assertSame(77, $first->fresh()?->invoice_id); + self::assertSame(101, $first->fresh()?->invoice_item_id); + + $this->companyData->withInvoices(self::COMPANY, 77); + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/billing/unbilled?customer_id='.self::CUSTOMER) + ->assertJsonPath('data.entry_ids', []); + } + + public function test_confirm_refuses_an_entry_that_belongs_to_another_invoice(): void + { + $entry = $this->entry($this->landing, 60, '2026-09-01', ['invoice_id' => 77, 'invoice_item_id' => 101]); + + $this->asCompany(self::COMPANY) + ->postJson('/api/v1/tasks-projects/billing/confirm', [ + 'invoice_id' => 78, + 'items' => [['invoice_item_id' => 201, 'entry_ids' => [(int) $entry->id]]], + ]) + ->assertStatus(422) + ->assertJsonPath('error', 'entries_already_invoiced'); + } + + public function test_confirm_refuses_an_entry_of_another_company_and_stamps_nothing(): void + { + $ours = $this->entry($this->landing, 60, '2026-09-01'); + $foreignTask = $this->makeTask(self::OTHER_COMPANY); + $foreign = $this->makeEntry(self::OTHER_COMPANY, (int) $foreignTask->id); + + $this->asCompany(self::COMPANY) + ->postJson('/api/v1/tasks-projects/billing/confirm', [ + 'invoice_id' => 77, + 'items' => [['invoice_item_id' => 101, 'entry_ids' => [(int) $ours->id, (int) $foreign->id]]], + ]) + ->assertStatus(422) + ->assertJsonPath('error', 'unknown_time_entries'); + + self::assertNull($ours->fresh()?->invoice_id); + } + + public function test_billing_needs_the_invoice_tasks_ability(): void + { + $entry = $this->entry($this->landing, 60, '2026-09-01'); + $this->authorization->deny(Authorizes::id(Abilities::INVOICE_TASKS)); + + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/billing/customers') + ->assertForbidden(); + + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/billing/unbilled?customer_id='.self::CUSTOMER) + ->assertForbidden(); + + $this->prepare([(int) $entry->id], 'task')->assertForbidden(); + + $this->asCompany(self::COMPANY) + ->postJson('/api/v1/tasks-projects/billing/confirm', [ + 'invoice_id' => 77, + 'items' => [['invoice_item_id' => 101, 'entry_ids' => [(int) $entry->id]]], + ]) + ->assertForbidden(); + } + + public function test_unbilled_lists_the_entries_behind_the_totals(): void + { + $entry = $this->entry($this->landing, 90, '2026-09-01', ['description' => 'Hero section']); + + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/billing/unbilled?customer_id='.self::CUSTOMER) + ->assertOk() + ->assertJsonPath('data.entries', [[ + 'id' => (int) $entry->id, + 'task_id' => (int) $this->landing->id, + 'task_name' => 'Landing page', + 'project_id' => (int) $this->website->id, + 'project_name' => 'Website', + 'user_id' => self::DEFAULT_USER, + 'user_name' => 'Ada Lovelace', + 'date' => '2026-09-01', + 'minutes' => 90, + 'amount' => 9000, + 'rate' => self::RATE, + 'currency_id' => self::CURRENCY, + 'description' => 'Hero section', + ]]); + } + + public function test_customers_lists_who_has_time_waiting_to_be_invoiced(): void + { + $this->entry($this->landing, 60, '2026-09-01'); + $this->entry($this->pricing, 90, '2026-09-02'); + $this->entry($this->task('Their logo', null, 43), 30, '2026-09-03'); + $this->entry($this->landing, 60, '2026-09-04', ['billable' => false]); + + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/billing/customers') + ->assertOk() + ->assertJsonPath('data', [ + ['customer_id' => self::CUSTOMER, 'entries' => 2, 'minutes' => 150, 'amount' => 15000, 'currency_id' => self::CURRENCY], + ['customer_id' => 43, 'entries' => 1, 'minutes' => 30, 'amount' => 3000, 'currency_id' => self::CURRENCY], + ]); + } + + public function test_customers_honours_the_date_range(): void + { + $this->entry($this->landing, 60, '2026-09-01'); + $this->entry($this->landing, 90, '2026-09-10'); + + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/billing/customers?from=2026-09-05&to=2026-09-15') + ->assertOk() + ->assertJsonPath('data', [ + ['customer_id' => self::CUSTOMER, 'entries' => 1, 'minutes' => 90, 'amount' => 9000, 'currency_id' => self::CURRENCY], + ]); + } + + public function test_prepare_invoices_a_selection_of_tasks(): void + { + $first = $this->entry($this->landing, 60, '2026-09-01'); + $second = $this->entry($this->pricing, 90, '2026-09-02'); + $this->entry($this->task('Theirs', null, 43), 60, '2026-09-03'); + + $this->prepareBody(['task_ids' => [(int) $this->landing->id, (int) $this->pricing->id]]) + ->assertOk() + ->assertJsonPath('data.customer_id', self::CUSTOMER) + ->assertJsonPath('data.total', 15000) + ->assertJsonPath('data.items', [ + $this->line('#1 Landing page', 1.0, 6000, '2026-09-01 1.00 h'), + $this->line('#2 Pricing page', 1.5, 9000, '2026-09-02 1.50 h'), + ]) + ->assertJsonPath('data.groups', [ + ['entry_ids' => [(int) $first->id]], + ['entry_ids' => [(int) $second->id]], + ]); + } + + public function test_prepare_invoices_a_whole_project(): void + { + $this->entry($this->landing, 60, '2026-09-01'); + $this->entry($this->pricing, 90, '2026-09-02'); + $this->entry($this->task('Ad hoc call', null), 45, '2026-09-03'); + + $this->prepareBody(['project_id' => (int) $this->website->id]) + ->assertOk() + ->assertJsonPath('data.total', 15000) + ->assertJsonPath('data.items.0.name', '#1 Landing page') + ->assertJsonPath('data.items.1.name', '#2 Pricing page') + ->assertJsonCount(2, 'data.items'); + } + + public function test_prepare_takes_exactly_one_kind_of_selection(): void + { + $entry = $this->entry($this->landing, 60, '2026-09-01'); + + $this->prepareBody([]) + ->assertStatus(422) + ->assertJsonValidationErrors(['entry_ids', 'task_ids', 'project_id']); + + $this->prepareBody(['entry_ids' => [(int) $entry->id], 'task_ids' => [(int) $this->landing->id]]) + ->assertStatus(422) + ->assertJsonValidationErrors(['entry_ids', 'task_ids']); + + $this->prepareBody(['task_ids' => [(int) $this->landing->id], 'project_id' => (int) $this->website->id]) + ->assertStatus(422) + ->assertJsonValidationErrors(['task_ids', 'project_id']); + } + + public function test_prepare_says_when_a_selection_has_nothing_left_to_bill(): void + { + $this->entry($this->landing, 60, '2026-09-01', ['billable' => false]); + + $this->prepareBody(['task_ids' => [(int) $this->landing->id]]) + ->assertStatus(422) + ->assertJsonPath('error', 'nothing_to_invoice') + ->assertJsonPath('message', 'No unbilled billable time on the selected tasks.'); + + $this->prepareBody(['project_id' => (int) $this->website->id]) + ->assertStatus(422) + ->assertJsonPath('error', 'nothing_to_invoice'); + } + + public function test_prepare_names_the_customers_a_mixed_task_selection_spans(): void + { + $theirs = $this->task('Theirs', null, 43); + $this->entry($this->landing, 60, '2026-09-01'); + $this->entry($theirs, 60, '2026-09-02'); + + $this->prepareBody(['task_ids' => [(int) $this->landing->id, (int) $theirs->id]]) + ->assertStatus(422) + ->assertJsonPath('error', 'mixed_billing_selection') + ->assertJsonPath('customer_ids', [self::CUSTOMER, 43]); + } + + public function test_prepare_refuses_a_task_or_a_project_of_another_company(): void + { + $foreignTask = $this->makeTask(self::OTHER_COMPANY, ['customer_id' => self::CUSTOMER]); + $foreignProject = $this->makeProject(self::OTHER_COMPANY, ['name' => 'Theirs', 'customer_id' => self::CUSTOMER]); + + $this->prepareBody(['task_ids' => [(int) $foreignTask->id]])->assertNotFound(); + $this->prepareBody(['project_id' => (int) $foreignProject->id])->assertNotFound(); + } + + /** One prepared invoice line, with the zeroed keys the host writer reads. */ + private function line(string $name, float $quantity, int $total, ?string $description = null): array + { + return [ + 'name' => $name, + 'description' => $description, + 'quantity' => $quantity, + 'price' => self::RATE, + 'discount_type' => 'fixed', + 'discount' => 0, + 'discount_val' => 0, + 'tax' => 0, + 'taxes' => [], + 'total' => $total, + ]; + } + + /** @param list $entryIds */ + private function prepare(array $entryIds, string $grouping): TestResponse + { + return $this->prepareBody(['entry_ids' => $entryIds, 'grouping' => $grouping]); + } + + /** @param array $body */ + private function prepareBody(array $body): TestResponse + { + return $this->asCompany(self::COMPANY)->postJson('/api/v1/tasks-projects/billing/prepare', $body); + } + + private function task(string $name, ?Project $project, int $customerId = self::CUSTOMER): Task + { + return $this->makeTask(self::COMPANY, [ + 'name' => $name, + 'project_id' => $project?->id, + 'customer_id' => $project === null ? $customerId : $project->customer_id, + ]); + } + + /** @param array $attributes */ + private function entry(Task $task, int $minutes, string $day, array $attributes = []): TimeEntry + { + return $this->makeEntry(self::COMPANY, (int) $task->id, $attributes + [ + 'project_id' => $task->project_id, + 'user_id' => self::DEFAULT_USER, + 'started_at' => Carbon::parse($day.' 09:00:00'), + 'ended_at' => Carbon::parse($day.' 09:00:00')->addMinutes($minutes), + 'duration_minutes' => $minutes, + 'rate' => self::RATE, + 'amount' => (int) round($minutes / 60 * self::RATE), + 'currency_id' => self::CURRENCY, + ]); + } +} diff --git a/tests/Feature/MembersApiTest.php b/tests/Feature/MembersApiTest.php new file mode 100644 index 0000000..ede022d --- /dev/null +++ b/tests/Feature/MembersApiTest.php @@ -0,0 +1,49 @@ +companyData + ->withMember(self::COMPANY, 7, 'Ada Lovelace') + ->withMember(self::COMPANY, 8, 'Grace Hopper') + ->withMember(self::OTHER_COMPANY, 9, 'Someone Else'); + + $response = $this->asCompany(self::COMPANY)->getJson('/api/v1/tasks-projects/members'); + + $response->assertOk(); + $response->assertJsonCount(2, 'data'); + $response->assertJsonPath('data.0.id', 7); + $response->assertJsonPath('data.0.name', 'Ada Lovelace'); + $response->assertJsonPath('data.1.name', 'Grace Hopper'); + } + + public function test_a_company_without_members_returns_an_empty_list(): void + { + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/members') + ->assertOk() + ->assertExactJson(['data' => []]); + } + + public function test_the_picker_needs_the_project_view_ability(): void + { + $this->authorization->deny(Authorizes::id(Abilities::VIEW_PROJECT)); + + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/members') + ->assertForbidden(); + } +} diff --git a/tests/Feature/ModuleRegistrationTest.php b/tests/Feature/ModuleRegistrationTest.php index 1ce7712..a9a2e89 100644 --- a/tests/Feature/ModuleRegistrationTest.php +++ b/tests/Feature/ModuleRegistrationTest.php @@ -6,13 +6,16 @@ use InvoiceShelf\Modules\Contracts\Host\SettingsStore; use InvoiceShelf\Modules\Registry; +use Modules\TasksProjects\Application\Rounding; use Modules\TasksProjects\Lifecycle\DataCleanup; +use Modules\TasksProjects\Support\Abilities; use Modules\TasksProjects\Support\ModuleRegistration; +use Modules\TasksProjects\Support\ModuleSettings; use Modules\TasksProjects\Tests\TestCase; final class ModuleRegistrationTest extends TestCase { - public function test_it_registers_a_local_script_style_sidebar_entry_and_settings_schema(): void + public function test_it_registers_a_local_script_and_style(): void { $modulePath = dirname(__DIR__, 2); @@ -20,28 +23,189 @@ public function test_it_registers_a_local_script_style_sidebar_entry_and_setting self::assertSame(realpath($modulePath.'/dist/init.js'), Registry::scriptFor('tasks-projects')); self::assertSame(realpath($modulePath.'/dist/style.css'), Registry::styleFor('tasks-projects')); + } + + public function test_it_registers_projects_and_tasks_as_two_entries_of_the_core_main_group(): void + { + ModuleRegistration::register(dirname(__DIR__, 2)); + + self::assertSame([ + 'group' => 'main', + 'group_label' => '', + 'priority' => 40, + 'title' => 'tasksprojects::menu.projects', + 'link' => '/admin/modules/tasks-projects/projects', + 'icon' => 'FolderIcon', + ], Registry::menuFor('tasks-projects.projects')); self::assertSame([ - 'group' => 'modules', - 'group_label' => 'navigation.modules', - 'priority' => 100, - 'title' => 'tasksprojects::menu.title', + 'group' => 'main', + 'group_label' => '', + 'priority' => 50, + 'title' => 'tasksprojects::menu.tasks', 'link' => '/admin/modules/tasks-projects', 'icon' => 'ClipboardDocumentListIcon', ], Registry::menuFor('tasks-projects')); + // The primary slug still answers, which is what the host's module page + // lookup uses; the second key only ever adds a row to the sidebar. + self::assertSame( + ['tasks-projects.projects', 'tasks-projects'], + array_keys(Registry::allMenu()), + ); + } + + public function test_the_menu_titles_are_translation_keys_that_exist(): void + { + $menu = require dirname(__DIR__, 2).'/lang/en/menu.php'; + + self::assertSame('Projects', $menu['projects']); + self::assertSame('Tasks', $menu['tasks']); + self::assertArrayHasKey('title', $menu, 'The original key stays for compatibility.'); + } + + public function test_the_settings_schema_covers_every_stored_key(): void + { + ModuleRegistration::register(dirname(__DIR__, 2)); + + $settings = Registry::settingsFor('tasks-projects'); + + self::assertNotNull($settings); + + $fields = array_column($settings->fields(), null, 'key'); + + self::assertSame([ + 'default_rate', + 'rounding_minutes', + 'rounding_direction', + 'week_start', + 'members_see_all_time', + 'auto_start_tasks', + 'lock_invoiced_tasks', + 'hide_invoiced_on_board', + 'invoice_project_heading', + 'invoice_task_description', + 'invoice_entry_dates', + 'invoice_entry_times', + 'invoice_entry_hours', + 'invoice_entry_descriptions', + ], array_keys($fields)); + + self::assertSame(0, $fields['default_rate']['default']); + self::assertSame( + ['1' => '1', '5' => '5', '6' => '6', '15' => '15', '30' => '30', '60' => '60'], + $fields['rounding_minutes']['options'], + ); + self::assertSame(ModuleSettings::DEFAULT_ROUNDING_MINUTES, $fields['rounding_minutes']['default']); + self::assertSame(Rounding::NEAREST, $fields['rounding_direction']['default']); + self::assertSame( + ['nearest', 'up', 'down'], + array_keys($fields['rounding_direction']['options']), + ); + self::assertSame(ModuleSettings::DEFAULT_WEEK_START, $fields['week_start']['default']); + + foreach (ModuleSettings::FLAGS as $key => $default) { + self::assertSame('switch', $fields[$key]['type'], "Setting {$key} is not a switch."); + self::assertSame($default, $fields[$key]['default'], "Setting {$key} has the wrong default."); + } + } + + public function test_every_select_option_reaches_the_form_translated(): void + { + ModuleRegistration::register(dirname(__DIR__, 2)); + + $settings = Registry::settingsFor('tasks-projects'); + + self::assertNotNull($settings); + + $fields = array_column($settings->fields(), null, 'key'); + + self::assertSame( + [Rounding::NEAREST => 'Nearest', Rounding::UP => 'Up', Rounding::DOWN => 'Down'], + $fields['rounding_direction']['options'], + ); + + // The host translates a field's label and a section's title and hands + // the options over as they were registered, so an option naming a key + // would reach the screen as the key. + foreach ($fields as $key => $field) { + foreach ($field['options'] ?? [] as $label) { + self::assertStringNotContainsString( + '::', + (string) $label, + "Setting {$key} offers an option the reader would see as a translation key.", + ); + } + } + } + + public function test_the_schema_and_the_cleanup_keys_never_drift_apart(): void + { + ModuleRegistration::register(dirname(__DIR__, 2)); + $settings = Registry::settingsFor('tasks-projects'); self::assertNotNull($settings); self::assertSame( - ['default_rate', 'rounding_minutes', 'week_start', 'members_see_all_time'], array_column($settings->fields(), 'key'), + DataCleanup::settingKeys(), + ); + } + + public function test_it_contributes_the_whole_ability_catalogue_namespaced_by_slug(): void + { + ModuleRegistration::register(dirname(__DIR__, 2)); + + $abilities = Registry::abilitiesFor(Abilities::SLUG); + + self::assertSame([ + 'tasks-projects:view-project', + 'tasks-projects:create-project', + 'tasks-projects:edit-project', + 'tasks-projects:delete-project', + 'tasks-projects:view-task', + 'tasks-projects:create-task', + 'tasks-projects:edit-task', + 'tasks-projects:delete-task', + 'tasks-projects:manage-task-status', + 'tasks-projects:view-own-time', + 'tasks-projects:view-all-time', + 'tasks-projects:edit-all-time', + 'tasks-projects:invoice-tasks', + ], array_column($abilities, 'ability')); + + self::assertSame([ + 'View projects', + 'Create projects', + 'Edit projects', + 'Delete projects', + 'View tasks', + 'Create tasks', + 'Edit tasks', + 'Delete tasks', + 'Manage task statuses', + 'View own time', + 'View all time', + 'Edit all time', + 'Invoice tasks', + ], array_column($abilities, 'name')); + } + + public function test_billing_depends_on_seeing_all_time_and_on_both_host_invoice_abilities(): void + { + ModuleRegistration::register(dirname(__DIR__, 2)); + + $abilities = array_column(Registry::abilitiesFor(Abilities::SLUG), 'depends_on', 'ability'); + + self::assertSame( + ['tasks-projects:view-all-time', 'create-invoice', 'edit-invoice'], + $abilities['tasks-projects:invoice-tasks'], + ); + self::assertSame( + ['tasks-projects:view-project', 'view-customer'], + $abilities['tasks-projects:create-project'], ); - self::assertSame(0, $settings->fields()[0]['default']); - self::assertSame(1, $settings->fields()[1]['default']); - self::assertSame(['1' => '1', '6' => '6', '15' => '15', '30' => '30'], $settings->fields()[1]['options']); - self::assertSame(1, $settings->fields()[2]['default']); - self::assertFalse($settings->fields()[3]['default']); + self::assertSame([], $abilities['tasks-projects:view-project']); } public function test_its_data_cleanup_deletes_every_company_setting_and_is_safe_to_repeat(): void @@ -80,15 +244,14 @@ public function deleteCompanyForAll(string $key): void $cleanup->cleanup(); $cleanup->cleanup(); - self::assertSame([ - 'module.tasks-projects.default_rate', - 'module.tasks-projects.rounding_minutes', - 'module.tasks-projects.week_start', - 'module.tasks-projects.members_see_all_time', - 'module.tasks-projects.default_rate', - 'module.tasks-projects.rounding_minutes', - 'module.tasks-projects.week_start', - 'module.tasks-projects.members_see_all_time', - ], $settings->removedCompanyKeys); + $expected = array_map( + static fn (string $key): string => ModuleSettings::PREFIX.$key, + DataCleanup::settingKeys(), + ); + + self::assertSame([...$expected, ...$expected], $settings->removedCompanyKeys); + self::assertContains(ModuleSettings::PREFIX.'rounding_direction', $settings->removedCompanyKeys); + self::assertContains(ModuleSettings::PREFIX.'lock_invoiced_tasks', $settings->removedCompanyKeys); + self::assertContains(ModuleSettings::PREFIX.'invoice_entry_hours', $settings->removedCompanyKeys); } } diff --git a/tests/Feature/ProjectsApiTest.php b/tests/Feature/ProjectsApiTest.php new file mode 100644 index 0000000..eed3afe --- /dev/null +++ b/tests/Feature/ProjectsApiTest.php @@ -0,0 +1,363 @@ +makeProject(self::COMPANY, ['name' => 'Alpha']); + $this->makeProject(self::COMPANY, ['name' => 'Beta']); + $this->makeProject(self::OTHER_COMPANY, ['name' => 'Someone else']); + + $response = $this->asCompany(self::COMPANY)->getJson('/api/v1/tasks-projects/projects?limit=1'); + + $response->assertOk(); + $response->assertJsonPath('meta.total', 2); + $response->assertJsonPath('meta.per_page', 1); + $response->assertJsonCount(1, 'data'); + // 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 + { + $website = $this->makeProject(self::COMPANY, ['name' => 'Website', 'customer_id' => self::CUSTOMER]); + $this->makeProject(self::COMPANY, ['name' => 'Internal tooling']); + $archived = $this->makeProject(self::COMPANY, ['name' => 'Old site', 'status' => Project::STATUS_ARCHIVED]); + $this->makeMember(self::COMPANY, (int) $website->id, 12); + + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/projects?status='.Project::STATUS_ARCHIVED) + ->assertJsonPath('meta.total', 1) + ->assertJsonPath('data.0.id', (int) $archived->id); + + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/projects?customer_id='.self::CUSTOMER) + ->assertJsonPath('meta.total', 1) + ->assertJsonPath('data.0.id', (int) $website->id); + + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/projects?member_id=12') + ->assertJsonPath('meta.total', 1) + ->assertJsonPath('data.0.id', (int) $website->id); + + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/projects?search=tooling') + ->assertJsonPath('meta.total', 1) + ->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', [ + 'name' => 'Website redesign', + 'customer_id' => self::CUSTOMER, + 'identifier' => 'WEB', + 'default_rate' => 12000, + 'budget_minutes' => 600, + 'due_date' => '2026-12-31', + ]); + + $response->assertCreated(); + $response->assertJsonPath('data.company_id', self::COMPANY); + $response->assertJsonPath('data.creator_id', self::DEFAULT_USER); + $response->assertJsonPath('data.status', Project::STATUS_ACTIVE); + $response->assertJsonPath('data.default_rate', 12000); + $response->assertJsonPath('data.due_date', '2026-12-31'); + $response->assertJsonPath('data.is_internal', false); + + self::assertSame(1, Project::query()->forCompany(self::COMPANY)->count()); + } + + public function test_a_project_without_a_customer_is_internal(): void + { + $this->asCompany(self::COMPANY) + ->postJson('/api/v1/tasks-projects/projects', ['name' => 'Internal tooling']) + ->assertCreated() + ->assertJsonPath('data.is_internal', true); + } + + public function test_it_rejects_a_project_without_a_name(): void + { + $this->asCompany(self::COMPANY) + ->postJson('/api/v1/tasks-projects/projects', ['default_rate' => -1]) + ->assertStatus(422) + ->assertJsonValidationErrors(['name', 'default_rate']); + } + + public function test_the_detail_carries_the_totals_the_list_leaves_out(): void + { + $project = $this->makeProject(self::COMPANY, ['customer_id' => self::CUSTOMER, 'currency_id' => 3]); + $status = $this->makeStatus(self::COMPANY, ['is_closed' => true]); + $task = $this->makeTask(self::COMPANY, ['project_id' => $project->id, 'task_status_id' => $status->id, 'closed_at' => now()]); + $this->makeEntry(self::COMPANY, (int) $task->id, ['project_id' => $project->id, 'duration_minutes' => 90, 'amount' => 15000]); + + $response = $this->asCompany(self::COMPANY)->getJson('/api/v1/tasks-projects/projects/'.$project->id); + + $response->assertOk(); + $response->assertJsonPath('data.totals.tasks.total', 1); + $response->assertJsonPath('data.totals.tasks.closed', 1); + $response->assertJsonPath('data.totals.logged_minutes', 90); + $response->assertJsonPath('data.totals.billable_amount', 15000); + $response->assertJsonPath('data.totals.unbilled_amount', 15000); + $response->assertJsonPath('data.totals.currency_id', 3); + + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/projects') + ->assertJsonMissingPath('data.0.totals'); + } + + public function test_it_updates_a_project(): void + { + $project = $this->makeProject(self::COMPANY); + + $this->asCompany(self::COMPANY) + ->putJson('/api/v1/tasks-projects/projects/'.$project->id, ['name' => 'Renamed', 'colour' => '#ff0000']) + ->assertOk() + ->assertJsonPath('data.name', 'Renamed') + ->assertJsonPath('data.colour', '#ff0000'); + } + + public function test_archive_and_unarchive_flip_the_status(): void + { + $project = $this->makeProject(self::COMPANY); + + $this->asCompany(self::COMPANY) + ->postJson('/api/v1/tasks-projects/projects/'.$project->id.'/archive') + ->assertOk() + ->assertJsonPath('data.status', Project::STATUS_ARCHIVED); + + $this->asCompany(self::COMPANY) + ->postJson('/api/v1/tasks-projects/projects/'.$project->id.'/unarchive') + ->assertOk() + ->assertJsonPath('data.status', Project::STATUS_ACTIVE); + } + + public function test_it_deletes_a_project(): void + { + $project = $this->makeProject(self::COMPANY); + + $this->asCompany(self::COMPANY) + ->deleteJson('/api/v1/tasks-projects/projects/'.$project->id) + ->assertOk() + ->assertJson(['success' => true]); + + self::assertSame(0, Project::query()->forCompany(self::COMPANY)->count()); + } + + public function test_a_project_with_invoiced_time_refuses_to_be_deleted(): void + { + $project = $this->makeProject(self::COMPANY, ['customer_id' => self::CUSTOMER]); + $task = $this->makeTask(self::COMPANY, ['project_id' => $project->id]); + $this->makeEntry(self::COMPANY, (int) $task->id, [ + 'project_id' => $project->id, + 'invoice_id' => 77, + 'invoice_item_id' => 88, + ]); + + $this->asCompany(self::COMPANY) + ->deleteJson('/api/v1/tasks-projects/projects/'.$project->id) + ->assertStatus(422) + ->assertJsonPath('error', 'project_in_use'); + } + + public function test_attaching_a_member_needs_the_user_to_be_a_company_member(): void + { + $project = $this->makeProject(self::COMPANY); + $this->companyData->withMember(self::COMPANY, 12, 'Ada Lovelace'); + + $this->asCompany(self::COMPANY) + ->postJson('/api/v1/tasks-projects/projects/'.$project->id.'/members', ['user_id' => 99]) + ->assertStatus(422) + ->assertJsonValidationErrors(['user_id']); + + $this->asCompany(self::COMPANY) + ->postJson('/api/v1/tasks-projects/projects/'.$project->id.'/members', ['user_id' => 12, 'rate' => 9000]) + ->assertCreated() + ->assertJsonPath('data.user_id', 12) + ->assertJsonPath('data.rate', 9000); + + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/projects/'.$project->id.'/members') + ->assertOk() + ->assertJsonCount(1, 'data') + ->assertJsonPath('data.0.user_id', 12); + } + + public function test_detaching_a_member_leaves_their_time_alone(): void + { + $project = $this->makeProject(self::COMPANY); + $this->makeMember(self::COMPANY, (int) $project->id, 12); + $task = $this->makeTask(self::COMPANY, ['project_id' => $project->id]); + $entry = $this->makeEntry(self::COMPANY, (int) $task->id, ['project_id' => $project->id, 'user_id' => 12]); + + $this->asCompany(self::COMPANY) + ->deleteJson('/api/v1/tasks-projects/projects/'.$project->id.'/members/12') + ->assertOk(); + + self::assertSame(0, ProjectMember::query()->forCompany(self::COMPANY)->count()); + self::assertNotNull(TimeEntry::query()->find($entry->id)); + } + + public function test_a_project_of_another_company_is_not_found(): void + { + $project = $this->makeProject(self::OTHER_COMPANY); + + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/projects/'.$project->id) + ->assertNotFound(); + + $this->asCompany(self::COMPANY) + ->putJson('/api/v1/tasks-projects/projects/'.$project->id, ['name' => 'Mine now']) + ->assertNotFound(); + + $this->asCompany(self::COMPANY) + ->deleteJson('/api/v1/tasks-projects/projects/'.$project->id) + ->assertNotFound(); + } + + public function test_every_action_refuses_without_its_ability(): void + { + $project = $this->makeProject(self::COMPANY); + $this->companyData->withMember(self::COMPANY, 12, 'Ada Lovelace'); + + $this->authorization->deny( + Authorizes::id(Abilities::VIEW_PROJECT), + Authorizes::id(Abilities::CREATE_PROJECT), + Authorizes::id(Abilities::EDIT_PROJECT), + Authorizes::id(Abilities::DELETE_PROJECT), + ); + + $this->asCompany(self::COMPANY)->getJson('/api/v1/tasks-projects/projects')->assertForbidden(); + $this->asCompany(self::COMPANY)->postJson('/api/v1/tasks-projects/projects', ['name' => 'Nope'])->assertForbidden(); + $this->asCompany(self::COMPANY)->getJson('/api/v1/tasks-projects/projects/'.$project->id)->assertForbidden(); + $this->asCompany(self::COMPANY)->putJson('/api/v1/tasks-projects/projects/'.$project->id, ['name' => 'Nope'])->assertForbidden(); + $this->asCompany(self::COMPANY)->deleteJson('/api/v1/tasks-projects/projects/'.$project->id)->assertForbidden(); + $this->asCompany(self::COMPANY)->postJson('/api/v1/tasks-projects/projects/'.$project->id.'/archive')->assertForbidden(); + $this->asCompany(self::COMPANY)->postJson('/api/v1/tasks-projects/projects/'.$project->id.'/members', ['user_id' => 12])->assertForbidden(); + } + + public function test_the_ability_is_checked_for_the_header_company_and_the_authenticated_user(): void + { + $this->asCompany(self::COMPANY)->getJson('/api/v1/tasks-projects/projects')->assertOk(); + + self::assertSame([ + 'user_id' => self::DEFAULT_USER, + 'company_id' => self::COMPANY, + 'ability' => 'tasks-projects:view-project', + '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/ReportsApiTest.php b/tests/Feature/ReportsApiTest.php new file mode 100644 index 0000000..e113429 --- /dev/null +++ b/tests/Feature/ReportsApiTest.php @@ -0,0 +1,188 @@ +companyData + ->withMember(self::COMPANY, self::DEFAULT_USER, 'Ada Lovelace') + ->withMember(self::COMPANY, self::OTHER_USER, 'Grace Hopper'); + + $this->website = $this->makeProject(self::COMPANY, [ + 'name' => 'Website', + 'customer_id' => self::CUSTOMER, + 'currency_id' => 3, + ]); + $this->landing = $this->makeTask(self::COMPANY, [ + 'name' => 'Landing page', + 'project_id' => $this->website->id, + 'customer_id' => self::CUSTOMER, + ]); + } + + public function test_the_summary_totals_per_currency_and_splits_billable_from_the_rest(): void + { + $this->entry(60, '2026-09-01', 6000); + $this->entry(30, '2026-09-02', 3000, ['invoice_id' => 77, 'invoice_item_id' => 101]); + $this->entry(45, '2026-09-03', 0, ['billable' => false]); + $this->entry(60, '2026-09-04', 9000, ['currency_id' => 4]); + + $response = $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/reports/summary?from=2026-09-01&to=2026-09-30'); + + $response->assertOk(); + $response->assertJsonPath('data.from', '2026-09-01'); + $response->assertJsonPath('data.to', '2026-09-30'); + $response->assertJsonPath('data.totals', [ + [ + 'currency_id' => 3, + 'minutes' => 135, + 'amount' => 9000, + 'billable_minutes' => 90, + 'billable_amount' => 9000, + 'unbilled_amount' => 6000, + ], + [ + 'currency_id' => 4, + 'minutes' => 60, + 'amount' => 9000, + 'billable_minutes' => 60, + 'billable_amount' => 9000, + 'unbilled_amount' => 9000, + ], + ]); + $response->assertJsonPath('data.by_billable.0.billable', true); + $response->assertJsonPath('data.by_billable.0.minutes', 90); + $response->assertJsonPath('data.by_billable.1.billable', false); + $response->assertJsonPath('data.by_billable.1.minutes', 45); + } + + public function test_the_summary_breaks_down_by_project_member_and_customer(): void + { + $this->entry(60, '2026-09-01', 6000); + $this->entry(120, '2026-09-02', 12000, ['user_id' => self::OTHER_USER]); + + $response = $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/reports/summary?from=2026-09-01&to=2026-09-30'); + + $response->assertJsonPath('data.by_project.0.label', 'Website'); + $response->assertJsonPath('data.by_project.0.minutes', 180); + $response->assertJsonPath('data.by_member.0.label', 'Ada Lovelace'); + $response->assertJsonPath('data.by_member.1.label', 'Grace Hopper'); + $response->assertJsonPath('data.by_customer.0.customer_id', self::CUSTOMER); + $response->assertJsonPath('data.by_customer.0.minutes', 180); + } + + public function test_the_range_leaves_out_what_falls_outside_it(): void + { + $this->entry(60, '2026-08-31', 6000); + $this->entry(30, '2026-09-10', 3000); + $this->entry(60, '2026-10-01', 6000); + + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/reports/summary?from=2026-09-01&to=2026-09-30') + ->assertJsonPath('data.totals.0.minutes', 30); + } + + public function test_the_range_defaults_to_the_current_month(): void + { + Carbon::setTestNow('2026-09-15 12:00:00'); + $this->entry(60, '2026-08-20', 6000); + $this->entry(30, '2026-09-10', 3000); + + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/reports/summary') + ->assertOk() + ->assertJsonPath('data.from', '2026-09-01') + ->assertJsonPath('data.to', '2026-09-15') + ->assertJsonPath('data.totals.0.minutes', 30); + } + + public function test_a_viewer_with_only_their_own_time_reports_on_their_own_time(): void + { + $this->entry(60, '2026-09-01', 6000); + $this->entry(120, '2026-09-02', 12000, ['user_id' => self::OTHER_USER]); + + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/reports/summary?from=2026-09-01&to=2026-09-30') + ->assertJsonPath('data.totals.0.minutes', 180); + + $this->authorization->deny(Authorizes::id(Abilities::VIEW_ALL_TIME)); + + $response = $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/reports/summary?from=2026-09-01&to=2026-09-30'); + + $response->assertJsonPath('data.totals.0.minutes', 60); + $response->assertJsonPath('data.by_member', [[ + 'user_id' => self::DEFAULT_USER, + 'label' => 'Ada Lovelace', + 'currency_id' => 3, + 'minutes' => 60, + 'amount' => 6000, + 'billable_minutes' => 60, + 'billable_amount' => 6000, + 'unbilled_amount' => 6000, + ]]); + } + + public function test_the_summary_never_reaches_another_company(): void + { + $this->entry(60, '2026-09-01', 6000); + $foreign = $this->makeTask(self::OTHER_COMPANY); + $this->makeEntry(self::OTHER_COMPANY, (int) $foreign->id, ['duration_minutes' => 600, 'amount' => 60000]); + + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/reports/summary?from=2026-09-01&to=2026-09-30') + ->assertJsonPath('data.totals.0.minutes', 60); + } + + public function test_the_summary_needs_the_own_time_ability(): void + { + $this->authorization->deny(Authorizes::id(Abilities::VIEW_OWN_TIME)); + + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/reports/summary') + ->assertForbidden(); + } + + /** @param array $attributes */ + private function entry(int $minutes, string $day, int $amount, array $attributes = []): TimeEntry + { + return $this->makeEntry(self::COMPANY, (int) $this->landing->id, $attributes + [ + 'project_id' => $this->website->id, + 'user_id' => self::DEFAULT_USER, + 'started_at' => Carbon::parse($day.' 09:00:00'), + 'ended_at' => Carbon::parse($day.' 09:00:00')->addMinutes($minutes), + 'duration_minutes' => $minutes, + 'rate' => 6000, + 'amount' => $amount, + 'currency_id' => 3, + ]); + } +} diff --git a/tests/Feature/SettingsApiTest.php b/tests/Feature/SettingsApiTest.php new file mode 100644 index 0000000..1d71135 --- /dev/null +++ b/tests/Feature/SettingsApiTest.php @@ -0,0 +1,120 @@ +asCompany(self::COMPANY)->getJson('/api/v1/tasks-projects/settings'); + + $response->assertOk(); + $response->assertExactJson(['data' => [ + 'default_rate' => 0, + 'rounding_minutes' => ModuleSettings::DEFAULT_ROUNDING_MINUTES, + 'rounding_direction' => Rounding::NEAREST, + 'week_start' => ModuleSettings::DEFAULT_WEEK_START, + 'members_see_all_time' => false, + 'auto_start_tasks' => false, + 'lock_invoiced_tasks' => false, + 'hide_invoiced_on_board' => false, + 'invoice_project_heading' => false, + 'invoice_task_description' => true, + 'invoice_entry_dates' => true, + 'invoice_entry_times' => false, + 'invoice_entry_hours' => true, + 'invoice_entry_descriptions' => false, + 'rounding_increments' => ModuleSettings::ROUNDING_INCREMENTS, + ]]); + } + + public function test_the_offered_increments_cover_the_way_firms_actually_bill(): void + { + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/settings') + ->assertOk() + ->assertJsonPath('data.rounding_increments', [1, 5, 6, 15, 30, 60]); + } + + public function test_the_new_switches_and_the_direction_come_back_as_stored(): void + { + $this->settings->putCompany(self::COMPANY, ModuleSettings::PREFIX.'rounding_direction', Rounding::UP); + $this->settings->putCompany(self::COMPANY, ModuleSettings::PREFIX.'auto_start_tasks', 'YES'); + $this->settings->putCompany(self::COMPANY, ModuleSettings::PREFIX.'lock_invoiced_tasks', 1); + $this->settings->putCompany(self::COMPANY, ModuleSettings::PREFIX.'hide_invoiced_on_board', true); + $this->settings->putCompany(self::COMPANY, ModuleSettings::PREFIX.'invoice_task_description', false); + $this->settings->putCompany(self::COMPANY, ModuleSettings::PREFIX.'invoice_entry_times', 'true'); + + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/settings') + ->assertOk() + ->assertJsonPath('data.rounding_direction', Rounding::UP) + ->assertJsonPath('data.auto_start_tasks', true) + ->assertJsonPath('data.lock_invoiced_tasks', true) + ->assertJsonPath('data.hide_invoiced_on_board', true) + ->assertJsonPath('data.invoice_task_description', false) + ->assertJsonPath('data.invoice_entry_times', true); + } + + public function test_a_direction_the_module_does_not_know_falls_back_to_nearest(): void + { + $this->settings->putCompany(self::COMPANY, ModuleSettings::PREFIX.'rounding_direction', 'sideways'); + + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/settings') + ->assertJsonPath('data.rounding_direction', Rounding::NEAREST); + } + + public function test_stored_values_come_back_typed_whatever_the_host_wrote(): void + { + $this->settings->putCompany(self::COMPANY, ModuleSettings::PREFIX.'default_rate', '12000'); + $this->settings->putCompany(self::COMPANY, ModuleSettings::PREFIX.'rounding_minutes', '15'); + $this->settings->putCompany(self::COMPANY, ModuleSettings::PREFIX.'week_start', '0'); + $this->settings->putCompany(self::COMPANY, ModuleSettings::PREFIX.'members_see_all_time', 'YES'); + + $response = $this->asCompany(self::COMPANY)->getJson('/api/v1/tasks-projects/settings'); + + $response->assertOk(); + $response->assertJsonPath('data.default_rate', 12000); + $response->assertJsonPath('data.rounding_minutes', 15); + $response->assertJsonPath('data.week_start', 0); + $response->assertJsonPath('data.members_see_all_time', true); + } + + public function test_an_unusable_stored_value_falls_back_to_the_default(): void + { + $this->settings->putCompany(self::COMPANY, ModuleSettings::PREFIX.'rounding_minutes', 7); + + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/settings') + ->assertJsonPath('data.rounding_minutes', ModuleSettings::DEFAULT_ROUNDING_MINUTES); + } + + public function test_settings_are_per_company(): void + { + $this->settings->putCompany(self::COMPANY, ModuleSettings::PREFIX.'default_rate', 12000); + + $this->asCompany(10) + ->getJson('/api/v1/tasks-projects/settings') + ->assertJsonPath('data.default_rate', 0); + } + + public function test_reading_the_settings_needs_the_project_view_ability(): void + { + $this->authorization->deny(Authorizes::id(Abilities::VIEW_PROJECT)); + + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/settings') + ->assertForbidden(); + } +} diff --git a/tests/Feature/TaskStatusesApiTest.php b/tests/Feature/TaskStatusesApiTest.php new file mode 100644 index 0000000..34bbe8e --- /dev/null +++ b/tests/Feature/TaskStatusesApiTest.php @@ -0,0 +1,175 @@ +asCompany(self::COMPANY)->getJson('/api/v1/tasks-projects/task-statuses'); + + $response->assertOk(); + $response->assertJsonCount(4, 'data'); + $response->assertJsonPath('data.0.name', 'Backlog'); + $response->assertJsonPath('data.0.is_default', true); + $response->assertJsonPath('data.3.name', 'Done'); + $response->assertJsonPath('data.3.is_closed', true); + $response->assertJsonPath('data.0.position', 1); + $response->assertJsonPath('data.3.position', 4); + } + + public function test_asking_twice_does_not_seed_the_defaults_again(): void + { + $this->asCompany(self::COMPANY)->getJson('/api/v1/tasks-projects/task-statuses')->assertOk(); + $this->asCompany(self::COMPANY)->getJson('/api/v1/tasks-projects/task-statuses')->assertJsonCount(4, 'data'); + + self::assertSame(count(TaskStatusService::DEFAULTS), TaskStatus::query()->forCompany(self::COMPANY)->count()); + } + + public function test_two_companies_get_their_own_independent_columns(): void + { + $this->asCompany(self::COMPANY)->getJson('/api/v1/tasks-projects/task-statuses')->assertJsonCount(4, 'data'); + $this->asCompany(self::COMPANY)->postJson('/api/v1/tasks-projects/task-statuses', ['name' => 'Blocked'])->assertCreated(); + + $this->asCompany(self::OTHER_COMPANY) + ->getJson('/api/v1/tasks-projects/task-statuses') + ->assertJsonCount(4, 'data') + ->assertJsonMissing(['name' => 'Blocked']); + + self::assertSame(5, TaskStatus::query()->forCompany(self::COMPANY)->count()); + self::assertSame(4, TaskStatus::query()->forCompany(self::OTHER_COMPANY)->count()); + } + + public function test_a_created_status_lands_last_and_can_take_the_default_over(): void + { + $this->asCompany(self::COMPANY)->getJson('/api/v1/tasks-projects/task-statuses'); + + $response = $this->asCompany(self::COMPANY)->postJson('/api/v1/tasks-projects/task-statuses', [ + 'name' => 'Blocked', + 'colour' => '#ef4444', + 'is_default' => true, + ]); + + $response->assertCreated(); + $response->assertJsonPath('data.position', 5); + $response->assertJsonPath('data.is_default', true); + + $defaults = TaskStatus::query()->forCompany(self::COMPANY)->where('is_default', true)->pluck('name')->all(); + self::assertSame(['Blocked'], $defaults); + } + + public function test_only_one_status_stays_the_default_after_an_update(): void + { + $this->asCompany(self::COMPANY)->getJson('/api/v1/tasks-projects/task-statuses'); + $review = TaskStatus::query()->forCompany(self::COMPANY)->where('name', 'Review')->firstOrFail(); + + $this->asCompany(self::COMPANY) + ->putJson('/api/v1/tasks-projects/task-statuses/'.$review->id, ['is_default' => true]) + ->assertOk() + ->assertJsonPath('data.is_default', true); + + self::assertSame( + ['Review'], + TaskStatus::query()->forCompany(self::COMPANY)->where('is_default', true)->pluck('name')->all(), + ); + } + + public function test_reorder_applies_the_wanted_order_and_returns_the_new_board(): void + { + $this->asCompany(self::COMPANY)->getJson('/api/v1/tasks-projects/task-statuses'); + $statuses = TaskStatus::query()->forCompany(self::COMPANY)->orderBy('position')->pluck('id', 'name')->all(); + + $response = $this->asCompany(self::COMPANY)->postJson('/api/v1/tasks-projects/task-statuses/reorder', [ + 'ids' => [$statuses['Done'], $statuses['Review']], + ]); + + $response->assertOk(); + $response->assertJsonPath('data.0.name', 'Done'); + $response->assertJsonPath('data.0.position', 1); + $response->assertJsonPath('data.1.name', 'Review'); + $response->assertJsonPath('data.2.name', 'Backlog'); + $response->assertJsonPath('data.3.name', 'In Progress'); + } + + public function test_reorder_refuses_an_id_from_another_company(): void + { + $this->asCompany(self::COMPANY)->getJson('/api/v1/tasks-projects/task-statuses'); + $foreign = $this->makeStatus(self::OTHER_COMPANY); + + $this->asCompany(self::COMPANY) + ->postJson('/api/v1/tasks-projects/task-statuses/reorder', ['ids' => [$foreign->id]]) + ->assertNotFound(); + } + + public function test_a_status_holding_tasks_cannot_be_deleted(): void + { + $status = $this->makeStatus(self::COMPANY, ['name' => 'Backlog']); + $this->makeStatus(self::COMPANY, ['name' => 'Done', 'position' => 2, 'is_default' => false, 'is_closed' => true]); + $this->makeTask(self::COMPANY, ['task_status_id' => $status->id]); + + $this->asCompany(self::COMPANY) + ->deleteJson('/api/v1/tasks-projects/task-statuses/'.$status->id) + ->assertStatus(422) + ->assertJsonPath('error', 'status_in_use'); + } + + public function test_the_last_status_of_a_company_cannot_be_deleted(): void + { + $status = $this->makeStatus(self::COMPANY); + + $this->asCompany(self::COMPANY) + ->deleteJson('/api/v1/tasks-projects/task-statuses/'.$status->id) + ->assertStatus(422) + ->assertJsonPath('error', 'status_in_use'); + } + + public function test_an_empty_status_is_deleted(): void + { + $this->asCompany(self::COMPANY)->getJson('/api/v1/tasks-projects/task-statuses'); + $review = TaskStatus::query()->forCompany(self::COMPANY)->where('name', 'Review')->firstOrFail(); + + $this->asCompany(self::COMPANY) + ->deleteJson('/api/v1/tasks-projects/task-statuses/'.$review->id) + ->assertOk() + ->assertJson(['success' => true]); + + self::assertSame(3, TaskStatus::query()->forCompany(self::COMPANY)->count()); + } + + public function test_a_status_of_another_company_is_not_found(): void + { + $foreign = $this->makeStatus(self::OTHER_COMPANY); + + $this->asCompany(self::COMPANY) + ->putJson('/api/v1/tasks-projects/task-statuses/'.$foreign->id, ['name' => 'Mine now']) + ->assertNotFound(); + } + + public function test_reading_needs_view_task_and_writing_needs_manage_task_status(): void + { + $status = $this->makeStatus(self::COMPANY); + + $this->authorization->deny(Authorizes::id(Abilities::MANAGE_TASK_STATUS)); + + $this->asCompany(self::COMPANY)->getJson('/api/v1/tasks-projects/task-statuses')->assertOk(); + $this->asCompany(self::COMPANY)->postJson('/api/v1/tasks-projects/task-statuses', ['name' => 'Blocked'])->assertForbidden(); + $this->asCompany(self::COMPANY)->putJson('/api/v1/tasks-projects/task-statuses/'.$status->id, ['name' => 'Nope'])->assertForbidden(); + $this->asCompany(self::COMPANY)->deleteJson('/api/v1/tasks-projects/task-statuses/'.$status->id)->assertForbidden(); + $this->asCompany(self::COMPANY)->postJson('/api/v1/tasks-projects/task-statuses/reorder', ['ids' => [$status->id]])->assertForbidden(); + + $this->authorization->deny(Authorizes::id(Abilities::VIEW_TASK)); + $this->asCompany(self::COMPANY)->getJson('/api/v1/tasks-projects/task-statuses')->assertForbidden(); + } +} diff --git a/tests/Feature/TaskTimeLogApiTest.php b/tests/Feature/TaskTimeLogApiTest.php new file mode 100644 index 0000000..355caf5 --- /dev/null +++ b/tests/Feature/TaskTimeLogApiTest.php @@ -0,0 +1,164 @@ +makeTask(self::COMPANY); + + $oldest = $this->makeEntry(self::COMPANY, (int) $task->id, [ + 'started_at' => Carbon::parse('2026-09-01 09:00:00'), + 'ended_at' => Carbon::parse('2026-09-01 10:00:00'), + ]); + $newest = $this->makeEntry(self::COMPANY, (int) $task->id, [ + 'started_at' => Carbon::parse('2026-09-03 09:00:00'), + 'ended_at' => Carbon::parse('2026-09-03 10:00:00'), + ]); + $middle = $this->makeEntry(self::COMPANY, (int) $task->id, [ + 'started_at' => Carbon::parse('2026-09-02 09:00:00'), + 'ended_at' => Carbon::parse('2026-09-02 10:00:00'), + ]); + // A clock started long before any of them still belongs at the top. + $running = $this->makeEntry(self::COMPANY, (int) $task->id, [ + 'started_at' => Carbon::parse('2026-08-01 09:00:00'), + 'ended_at' => null, + 'duration_minutes' => 0, + 'running_user_id' => self::DEFAULT_USER, + ]); + + $response = $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/tasks/'.$task->id.'/time-log'); + + $response->assertOk(); + self::assertSame( + [(int) $running->id, (int) $newest->id, (int) $middle->id, (int) $oldest->id], + $response->json('data.*.id'), + ); + $response->assertJsonPath('data.0.is_running', true); + } + + public function test_the_log_only_carries_the_entries_of_its_own_task(): void + { + $task = $this->makeTask(self::COMPANY); + $other = $this->makeTask(self::COMPANY, ['name' => 'Something else']); + + $mine = $this->makeEntry(self::COMPANY, (int) $task->id); + $this->makeEntry(self::COMPANY, (int) $other->id); + + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/tasks/'.$task->id.'/time-log') + ->assertOk() + ->assertJsonCount(1, 'data') + ->assertJsonPath('data.0.id', (int) $mine->id); + } + + public function test_the_log_is_capped_rather_than_paged(): void + { + $task = $this->makeTask(self::COMPANY); + $rows = TimeEntryService::LOG_LIMIT + 3; + + for ($minute = 0; $minute < $rows; $minute++) { + $this->makeEntry(self::COMPANY, (int) $task->id, [ + 'started_at' => Carbon::parse('2026-09-01 00:00:00')->addMinutes($minute), + 'ended_at' => Carbon::parse('2026-09-01 00:30:00')->addMinutes($minute), + 'duration_minutes' => 30, + ]); + } + + $response = $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/tasks/'.$task->id.'/time-log'); + + $response->assertOk(); + $response->assertJsonCount(TimeEntryService::LOG_LIMIT, 'data'); + self::assertSame($rows, TimeEntry::query()->forCompany(self::COMPANY)->count()); + } + + public function test_without_view_all_time_the_log_is_the_callers_own_rows(): void + { + $task = $this->makeTask(self::COMPANY); + $mine = $this->makeEntry(self::COMPANY, (int) $task->id); + $theirs = $this->makeEntry(self::COMPANY, (int) $task->id, ['user_id' => self::OTHER_USER]); + + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/tasks/'.$task->id.'/time-log') + ->assertOk() + ->assertJsonCount(2, 'data'); + + $this->authorization->deny(Authorizes::id(Abilities::VIEW_ALL_TIME)); + + $response = $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/tasks/'.$task->id.'/time-log'); + + $response->assertOk(); + $response->assertJsonCount(1, 'data'); + $response->assertJsonPath('data.0.id', (int) $mine->id); + + // The totals on the task still count everybody's time. + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/tasks/'.$task->id) + ->assertJsonPath('data.time.logged_minutes', 120); + + self::assertNotNull($theirs->id); + } + + public function test_the_company_setting_opens_the_log_without_the_ability(): void + { + $task = $this->makeTask(self::COMPANY); + $this->makeEntry(self::COMPANY, (int) $task->id); + $this->makeEntry(self::COMPANY, (int) $task->id, ['user_id' => self::OTHER_USER]); + + $this->authorization->deny(Authorizes::id(Abilities::VIEW_ALL_TIME)); + $this->settings->putCompany(self::COMPANY, ModuleSettings::PREFIX.'members_see_all_time', true); + + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/tasks/'.$task->id.'/time-log') + ->assertOk() + ->assertJsonCount(2, 'data'); + } + + public function test_the_log_of_another_companys_task_is_not_found(): void + { + $task = $this->makeTask(self::OTHER_COMPANY); + $this->makeEntry(self::OTHER_COMPANY, (int) $task->id); + + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/tasks/'.$task->id.'/time-log') + ->assertNotFound(); + } + + public function test_reading_the_log_needs_the_task_view_ability(): void + { + $task = $this->makeTask(self::COMPANY); + + $this->authorization->deny(Authorizes::id(Abilities::VIEW_TASK)); + + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/tasks/'.$task->id.'/time-log') + ->assertForbidden(); + } +} diff --git a/tests/Feature/TasksApiTest.php b/tests/Feature/TasksApiTest.php new file mode 100644 index 0000000..1890a64 --- /dev/null +++ b/tests/Feature/TasksApiTest.php @@ -0,0 +1,759 @@ +asCompany(self::COMPANY)->postJson('/api/v1/tasks-projects/tasks', ['name' => 'First']); + $second = $this->asCompany(self::COMPANY)->postJson('/api/v1/tasks-projects/tasks', ['name' => 'Second']); + $elsewhere = $this->asCompany(self::OTHER_COMPANY)->postJson('/api/v1/tasks-projects/tasks', ['name' => 'Theirs']); + + $first->assertCreated()->assertJsonPath('data.number', 1); + $second->assertCreated()->assertJsonPath('data.number', 2); + $elsewhere->assertCreated()->assertJsonPath('data.number', 1); + } + + public function test_a_new_task_lands_in_the_default_column_and_takes_the_projects_customer(): void + { + $project = $this->makeProject(self::COMPANY, ['customer_id' => self::CUSTOMER]); + + $response = $this->asCompany(self::COMPANY)->postJson('/api/v1/tasks-projects/tasks', [ + 'name' => 'Landing page', + 'project_id' => $project->id, + 'priority' => Task::PRIORITY_HIGH, + 'estimated_minutes' => 120, + ]); + + $response->assertCreated(); + $response->assertJsonPath('data.customer_id', self::CUSTOMER); + $response->assertJsonPath('data.creator_id', self::DEFAULT_USER); + $response->assertJsonPath('data.billable', true); + $response->assertJsonPath('data.priority', Task::PRIORITY_HIGH); + $response->assertJsonPath('data.closed_at', null); + + $default = TaskStatus::query()->forCompany(self::COMPANY)->where('is_default', true)->firstOrFail(); + $response->assertJsonPath('data.task_status_id', (int) $default->id); + } + + public function test_a_standalone_task_can_carry_its_own_customer(): void + { + $this->asCompany(self::COMPANY) + ->postJson('/api/v1/tasks-projects/tasks', ['name' => 'Ad hoc call', 'customer_id' => self::CUSTOMER]) + ->assertCreated() + ->assertJsonPath('data.project_id', null) + ->assertJsonPath('data.customer_id', self::CUSTOMER); + } + + public function test_entering_a_closed_column_stamps_closed_at_and_leaving_it_clears_it(): void + { + $open = $this->makeStatus(self::COMPANY, ['name' => 'Backlog']); + $done = $this->makeStatus(self::COMPANY, ['name' => 'Done', 'position' => 2, 'is_default' => false, 'is_closed' => true]); + $task = $this->makeTask(self::COMPANY, ['task_status_id' => $open->id]); + + $this->asCompany(self::COMPANY) + ->putJson('/api/v1/tasks-projects/tasks/'.$task->id, ['task_status_id' => $done->id]) + ->assertOk() + ->assertJsonPath('data.task_status_id', (int) $done->id); + + self::assertNotNull(Task::query()->findOrFail($task->id)->closed_at); + + $this->asCompany(self::COMPANY) + ->putJson('/api/v1/tasks-projects/tasks/'.$task->id, ['task_status_id' => $open->id]) + ->assertOk() + ->assertJsonPath('data.closed_at', null); + } + + public function test_the_list_filters_by_project_assignee_status_due_date_and_text(): void + { + $project = $this->makeProject(self::COMPANY); + $status = $this->makeStatus(self::COMPANY); + $other = $this->makeStatus(self::COMPANY, ['name' => 'Done', 'position' => 2, 'is_default' => false]); + + $landing = $this->makeTask(self::COMPANY, [ + 'task_status_id' => $status->id, + 'project_id' => $project->id, + 'assignee_id' => 8, + 'name' => 'Landing page', + 'due_date' => '2026-09-10', + ]); + $pricing = $this->makeTask(self::COMPANY, [ + 'task_status_id' => $other->id, + 'name' => 'Pricing table', + 'due_date' => '2026-10-10', + ]); + + $this->assertListReturns([$landing->id], '?project_id='.$project->id); + $this->assertListReturns([$landing->id], '?assignee_id=8'); + $this->assertListReturns([$pricing->id], '?task_status_id='.$other->id); + $this->assertListReturns([$landing->id], '?due_before=2026-09-30'); + $this->assertListReturns([$pricing->id], '?due_after=2026-09-30'); + $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']); + $this->makeTask(self::OTHER_COMPANY, ['name' => 'Theirs']); + + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/tasks') + ->assertOk() + ->assertJsonPath('meta.total', 1) + ->assertJsonPath('meta.per_page', 15) + ->assertJsonPath('data.0.name', 'Mine'); + } + + public function test_a_move_lands_between_its_new_neighbours(): void + { + $status = $this->makeStatus(self::COMPANY); + $target = $this->makeStatus(self::COMPANY, ['name' => 'In Progress', 'position' => 2, 'is_default' => false]); + + $first = $this->makeTask(self::COMPANY, ['task_status_id' => $target->id, 'board_position' => '1024.0000000000']); + $second = $this->makeTask(self::COMPANY, ['task_status_id' => $target->id, 'board_position' => '2048.0000000000']); + $dragged = $this->makeTask(self::COMPANY, ['task_status_id' => $status->id]); + + $response = $this->asCompany(self::COMPANY)->postJson('/api/v1/tasks-projects/tasks/'.$dragged->id.'/move', [ + 'task_status_id' => $target->id, + 'before_id' => $first->id, + 'after_id' => $second->id, + ]); + + $response->assertOk(); + $response->assertJsonPath('data.task_status_id', (int) $target->id); + $response->assertJsonPath('data.board_position', '1536.0000000000'); + } + + public function test_a_move_to_the_end_of_a_column_appends(): void + { + $status = $this->makeStatus(self::COMPANY); + $last = $this->makeTask(self::COMPANY, ['task_status_id' => $status->id, 'board_position' => '2048.0000000000']); + $dragged = $this->makeTask(self::COMPANY, ['task_status_id' => $status->id, 'board_position' => '1024.0000000000']); + + $this->asCompany(self::COMPANY) + ->postJson('/api/v1/tasks-projects/tasks/'.$dragged->id.'/move', [ + 'task_status_id' => $status->id, + 'before_id' => $last->id, + ]) + ->assertOk() + ->assertJsonPath('data.board_position', '3072.0000000000'); + } + + public function test_a_move_refuses_a_neighbour_outside_the_target_column(): void + { + $status = $this->makeStatus(self::COMPANY); + $target = $this->makeStatus(self::COMPANY, ['name' => 'In Progress', 'position' => 2, 'is_default' => false]); + $stranger = $this->makeTask(self::COMPANY, ['task_status_id' => $status->id]); + $dragged = $this->makeTask(self::COMPANY, ['task_status_id' => $status->id]); + + $this->asCompany(self::COMPANY) + ->postJson('/api/v1/tasks-projects/tasks/'.$dragged->id.'/move', [ + 'task_status_id' => $target->id, + 'before_id' => $stranger->id, + ]) + ->assertStatus(422) + ->assertJsonValidationErrors(['before_id']); + } + + public function test_the_board_groups_tasks_into_columns_in_board_order(): void + { + $backlog = $this->makeStatus(self::COMPANY, ['name' => 'Backlog']); + $doing = $this->makeStatus(self::COMPANY, ['name' => 'In Progress', 'position' => 2, 'is_default' => false]); + + $second = $this->makeTask(self::COMPANY, ['task_status_id' => $backlog->id, 'name' => 'Second', 'board_position' => '2048.0000000000']); + $first = $this->makeTask(self::COMPANY, ['task_status_id' => $backlog->id, 'name' => 'First', 'board_position' => '1024.0000000000']); + $this->makeTask(self::COMPANY, ['task_status_id' => $doing->id, 'name' => 'Underway']); + $this->makeTask(self::OTHER_COMPANY, ['name' => 'Theirs']); + + $response = $this->asCompany(self::COMPANY)->getJson('/api/v1/tasks-projects/board'); + + $response->assertOk(); + $response->assertJsonCount(2, 'data'); + $response->assertJsonPath('data.0.status.name', 'Backlog'); + $response->assertJsonPath('data.0.tasks.0.id', (int) $first->id); + $response->assertJsonPath('data.0.tasks.1.id', (int) $second->id); + $response->assertJsonPath('data.1.status.name', 'In Progress'); + $response->assertJsonPath('data.1.tasks.0.name', 'Underway'); + } + + public function test_the_board_seeds_the_default_columns_and_filters_by_project_and_assignee(): void + { + $project = $this->makeProject(self::COMPANY); + + $response = $this->asCompany(self::COMPANY)->getJson('/api/v1/tasks-projects/board'); + + $response->assertOk(); + $response->assertJsonCount(4, 'data'); + $response->assertJsonPath('data.0.status.name', 'Backlog'); + + $status = TaskStatus::query()->forCompany(self::COMPANY)->where('is_default', true)->firstOrFail(); + $mine = $this->makeTask(self::COMPANY, ['task_status_id' => $status->id, 'project_id' => $project->id, 'assignee_id' => 8]); + $this->makeTask(self::COMPANY, ['task_status_id' => $status->id, 'name' => 'Unassigned']); + + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/board?project_id='.$project->id) + ->assertJsonCount(1, 'data.0.tasks') + ->assertJsonPath('data.0.tasks.0.id', (int) $mine->id); + + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/board?assignee_id=8') + ->assertJsonCount(1, 'data.0.tasks') + ->assertJsonPath('data.0.tasks.0.id', (int) $mine->id); + } + + public function test_deleting_a_task_takes_its_uninvoiced_time_with_it(): void + { + $task = $this->makeTask(self::COMPANY); + $this->makeEntry(self::COMPANY, (int) $task->id); + + $this->asCompany(self::COMPANY) + ->deleteJson('/api/v1/tasks-projects/tasks/'.$task->id) + ->assertOk() + ->assertJson(['success' => true]); + + self::assertSame(0, Task::query()->forCompany(self::COMPANY)->count()); + } + + public function test_deleting_a_task_with_invoiced_time_is_refused(): void + { + $task = $this->makeTask(self::COMPANY); + $this->makeEntry(self::COMPANY, (int) $task->id, ['invoice_id' => 77, 'invoice_item_id' => 88]); + + $this->asCompany(self::COMPANY) + ->deleteJson('/api/v1/tasks-projects/tasks/'.$task->id) + ->assertStatus(422) + ->assertJsonPath('error', 'entries_already_invoiced'); + } + + public function test_a_task_of_another_company_is_not_found(): void + { + $task = $this->makeTask(self::OTHER_COMPANY); + + $this->asCompany(self::COMPANY)->getJson('/api/v1/tasks-projects/tasks/'.$task->id)->assertNotFound(); + $this->asCompany(self::COMPANY)->putJson('/api/v1/tasks-projects/tasks/'.$task->id, ['name' => 'Mine now'])->assertNotFound(); + $this->asCompany(self::COMPANY)->deleteJson('/api/v1/tasks-projects/tasks/'.$task->id)->assertNotFound(); + } + + public function test_every_action_refuses_without_its_ability(): void + { + $task = $this->makeTask(self::COMPANY); + + $this->authorization->deny( + Authorizes::id(Abilities::VIEW_TASK), + Authorizes::id(Abilities::CREATE_TASK), + Authorizes::id(Abilities::EDIT_TASK), + Authorizes::id(Abilities::DELETE_TASK), + ); + + $this->asCompany(self::COMPANY)->getJson('/api/v1/tasks-projects/tasks')->assertForbidden(); + $this->asCompany(self::COMPANY)->postJson('/api/v1/tasks-projects/tasks', ['name' => 'Nope'])->assertForbidden(); + $this->asCompany(self::COMPANY)->getJson('/api/v1/tasks-projects/tasks/'.$task->id)->assertForbidden(); + $this->asCompany(self::COMPANY)->putJson('/api/v1/tasks-projects/tasks/'.$task->id, ['name' => 'Nope'])->assertForbidden(); + $this->asCompany(self::COMPANY)->deleteJson('/api/v1/tasks-projects/tasks/'.$task->id)->assertForbidden(); + $this->asCompany(self::COMPANY)->postJson('/api/v1/tasks-projects/tasks/'.$task->id.'/move', [ + 'task_status_id' => $task->task_status_id, + ])->assertForbidden(); + $this->asCompany(self::COMPANY)->getJson('/api/v1/tasks-projects/board')->assertForbidden(); + } + + public function test_a_task_carries_the_time_logged_against_it(): void + { + $task = $this->makeTask(self::COMPANY); + $this->makeEntry(self::COMPANY, (int) $task->id, ['duration_minutes' => 60, 'amount' => 10000]); + $this->makeEntry(self::COMPANY, (int) $task->id, ['duration_minutes' => 30, 'billable' => false, 'amount' => 0]); + $this->makeEntry(self::COMPANY, (int) $task->id, [ + 'duration_minutes' => 45, + 'amount' => 7500, + 'invoice_id' => 77, + 'invoice_item_id' => 88, + ]); + $running = $this->makeEntry(self::COMPANY, (int) $task->id, [ + 'running_user_id' => self::DEFAULT_USER, + 'ended_at' => null, + 'duration_minutes' => 0, + 'started_at' => Carbon::parse('2026-09-15 09:00:00'), + ]); + + $response = $this->asCompany(self::COMPANY)->getJson('/api/v1/tasks-projects/tasks/'.$task->id); + + $response->assertOk(); + // The running entry has no duration yet, so it is outside every total. + $response->assertJsonPath('data.time.logged_minutes', 135); + $response->assertJsonPath('data.time.billable_minutes', 105); + $response->assertJsonPath('data.time.unbilled_minutes', 60); + $response->assertJsonPath('data.time.unbilled_amount', 10000); + $response->assertJsonPath('data.time.invoiced', 'uninvoiced'); + $response->assertJsonCount(1, 'data.time.running'); + $response->assertJsonPath('data.time.running.0.entry_id', (int) $running->id); + $response->assertJsonPath('data.time.running.0.user_id', self::DEFAULT_USER); + $response->assertJsonPath('data.time.running.0.started_at', $running->started_at->toIso8601String()); + } + + public function test_the_time_block_costs_three_reads_however_long_the_list_is(): void + { + $status = $this->makeStatus(self::COMPANY); + + foreach (range(1, 5) as $index) { + $task = $this->makeTask(self::COMPANY, ['task_status_id' => $status->id, 'name' => 'Task '.$index]); + $this->makeEntry(self::COMPANY, (int) $task->id); + } + + DB::flushQueryLog(); + DB::enableQueryLog(); + + $this->asCompany(self::COMPANY)->getJson('/api/v1/tasks-projects/tasks')->assertOk(); + + $reads = array_filter( + DB::getQueryLog(), + static fn (array $query): bool => str_contains((string) $query['query'], 'tp_time_entries'), + ); + + DB::disableQueryLog(); + + self::assertCount(3, $reads, 'The summary must stay three grouped reads, whatever the page holds.'); + } + + public function test_the_invoiced_state_runs_none_then_uninvoiced_then_invoiced(): void + { + $untouched = $this->makeTask(self::COMPANY, ['name' => 'Untouched']); + $unpaidWork = $this->makeTask(self::COMPANY, ['name' => 'Free']); + $this->makeEntry(self::COMPANY, (int) $unpaidWork->id, ['billable' => false, 'amount' => 0]); + + $partly = $this->makeTask(self::COMPANY, ['name' => 'Partly']); + $this->makeEntry(self::COMPANY, (int) $partly->id, ['invoice_id' => 77]); + $this->makeEntry(self::COMPANY, (int) $partly->id); + + $billed = $this->makeTask(self::COMPANY, ['name' => 'Billed']); + $this->makeEntry(self::COMPANY, (int) $billed->id, ['invoice_id' => 77]); + $this->makeEntry(self::COMPANY, (int) $billed->id, ['invoice_id' => 78]); + $this->makeEntry(self::COMPANY, (int) $billed->id, ['billable' => false, 'amount' => 0]); + + self::assertSame('none', $this->timeOf((int) $untouched->id)['invoiced']); + self::assertSame('none', $this->timeOf((int) $unpaidWork->id)['invoiced']); + self::assertSame('uninvoiced', $this->timeOf((int) $partly->id)['invoiced']); + self::assertSame('invoiced', $this->timeOf((int) $billed->id)['invoiced']); + + // Time nobody may bill still counts as logged time. + self::assertSame(60, $this->timeOf((int) $unpaidWork->id)['logged_minutes']); + self::assertSame(0, $this->timeOf((int) $unpaidWork->id)['billable_minutes']); + } + + public function test_every_task_response_carries_the_time_block(): void + { + $status = $this->makeStatus(self::COMPANY); + $task = $this->makeTask(self::COMPANY, ['task_status_id' => $status->id]); + $this->makeEntry(self::COMPANY, (int) $task->id, ['duration_minutes' => 90, 'amount' => 15000]); + + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/tasks') + ->assertOk() + ->assertJsonPath('data.0.time.logged_minutes', 90) + ->assertJsonPath('data.0.time.unbilled_amount', 15000); + + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/board') + ->assertOk() + ->assertJsonPath('data.0.tasks.0.time.logged_minutes', 90); + + $this->asCompany(self::COMPANY) + ->putJson('/api/v1/tasks-projects/tasks/'.$task->id, ['name' => 'Renamed']) + ->assertOk() + ->assertJsonPath('data.time.logged_minutes', 90); + + $this->asCompany(self::COMPANY) + ->postJson('/api/v1/tasks-projects/tasks', ['name' => 'Brand new']) + ->assertCreated() + ->assertJsonPath('data.time.logged_minutes', 0) + ->assertJsonPath('data.time.invoiced', 'none') + ->assertJsonPath('data.time.running', []); + } + + public function test_a_tasks_time_never_counts_another_companys_entries(): void + { + $mine = $this->makeTask(self::COMPANY); + $theirs = $this->makeTask(self::OTHER_COMPANY); + + $this->makeEntry(self::COMPANY, (int) $mine->id, ['duration_minutes' => 60]); + $this->makeEntry(self::OTHER_COMPANY, (int) $theirs->id, ['duration_minutes' => 300]); + + self::assertSame(60, $this->timeOf((int) $mine->id)['logged_minutes']); + } + + public function test_the_invoiced_filter_splits_billed_tasks_from_unbilled_ones(): void + { + $status = $this->makeStatus(self::COMPANY); + $untouched = $this->makeTask(self::COMPANY, ['task_status_id' => $status->id, 'name' => 'Untouched']); + + $unbilled = $this->makeTask(self::COMPANY, ['task_status_id' => $status->id, 'name' => 'Unbilled']); + $this->makeEntry(self::COMPANY, (int) $unbilled->id); + + $partly = $this->makeTask(self::COMPANY, ['task_status_id' => $status->id, 'name' => 'Partly']); + $this->makeEntry(self::COMPANY, (int) $partly->id, ['invoice_id' => 77]); + $this->makeEntry(self::COMPANY, (int) $partly->id); + + $billed = $this->makeTask(self::COMPANY, ['task_status_id' => $status->id, 'name' => 'Billed']); + $this->makeEntry(self::COMPANY, (int) $billed->id, ['invoice_id' => 77]); + + $this->assertListReturns([$unbilled->id, $partly->id], '?invoiced=0'); + $this->assertListReturns([$billed->id], '?invoiced=1'); + $this->assertListReturns( + [$untouched->id, $unbilled->id, $partly->id, $billed->id], + '', + ); + } + + public function test_a_running_clock_alone_does_not_make_a_task_uninvoiced(): void + { + $task = $this->makeTask(self::COMPANY); + $this->makeEntry(self::COMPANY, (int) $task->id, [ + 'running_user_id' => self::DEFAULT_USER, + 'ended_at' => null, + 'duration_minutes' => 0, + ]); + + self::assertSame('none', $this->timeOf((int) $task->id)['invoiced']); + $this->assertListReturns([], '?invoiced=0'); + } + + public function test_auto_start_runs_the_creators_clock_on_the_new_task(): void + { + $this->settings->putCompany(self::COMPANY, ModuleSettings::PREFIX.'auto_start_tasks', true); + + $response = $this->asCompany(self::COMPANY) + ->postJson('/api/v1/tasks-projects/tasks', ['name' => 'Start me']); + + $response->assertCreated(); + $response->assertJsonCount(1, 'data.time.running'); + $response->assertJsonPath('data.time.running.0.user_id', self::DEFAULT_USER); + + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/timer') + ->assertJsonPath('data.task_id', $response->json('data.id')); + } + + public function test_auto_start_leaves_a_timer_that_is_already_running_alone(): void + { + $this->settings->putCompany(self::COMPANY, ModuleSettings::PREFIX.'auto_start_tasks', true); + $busy = $this->makeTask(self::COMPANY, ['name' => 'Busy']); + + $this->asCompany(self::COMPANY) + ->postJson('/api/v1/tasks-projects/timer/start', ['task_id' => $busy->id]) + ->assertCreated(); + + $this->asCompany(self::COMPANY) + ->postJson('/api/v1/tasks-projects/tasks', ['name' => 'Later']) + ->assertCreated() + ->assertJsonPath('data.time.running', []); + + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/timer') + ->assertJsonPath('data.task_id', (int) $busy->id); + } + + public function test_without_the_setting_a_new_task_starts_no_clock(): void + { + $this->asCompany(self::COMPANY) + ->postJson('/api/v1/tasks-projects/tasks', ['name' => 'Quiet']) + ->assertCreated() + ->assertJsonPath('data.time.running', []); + + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/timer') + ->assertExactJson(['data' => null]); + } + + public function test_the_lock_refuses_to_edit_move_or_delete_an_invoiced_task(): void + { + $this->settings->putCompany(self::COMPANY, ModuleSettings::PREFIX.'lock_invoiced_tasks', true); + + $status = $this->makeStatus(self::COMPANY); + $target = $this->makeStatus(self::COMPANY, ['name' => 'Done', 'position' => 2, 'is_default' => false]); + $task = $this->makeTask(self::COMPANY, ['task_status_id' => $status->id]); + $this->makeEntry(self::COMPANY, (int) $task->id, ['invoice_id' => 77, 'invoice_item_id' => 88]); + + $this->asCompany(self::COMPANY) + ->putJson('/api/v1/tasks-projects/tasks/'.$task->id, ['name' => 'Renamed']) + ->assertStatus(422) + ->assertJsonPath('error', 'task_locked'); + + $this->asCompany(self::COMPANY) + ->postJson('/api/v1/tasks-projects/tasks/'.$task->id.'/move', ['task_status_id' => $target->id]) + ->assertStatus(422) + ->assertJsonPath('error', 'task_locked'); + + $this->asCompany(self::COMPANY) + ->deleteJson('/api/v1/tasks-projects/tasks/'.$task->id) + ->assertStatus(422) + ->assertJsonPath('error', 'task_locked'); + + self::assertSame('Build the landing page', (string) Task::query()->findOrFail($task->id)->name); + } + + public function test_the_lock_leaves_a_task_that_is_only_partly_invoiced_editable(): void + { + $this->settings->putCompany(self::COMPANY, ModuleSettings::PREFIX.'lock_invoiced_tasks', true); + + $task = $this->makeTask(self::COMPANY); + $this->makeEntry(self::COMPANY, (int) $task->id, ['invoice_id' => 77]); + $this->makeEntry(self::COMPANY, (int) $task->id); + + $this->asCompany(self::COMPANY) + ->putJson('/api/v1/tasks-projects/tasks/'.$task->id, ['name' => 'Still moving']) + ->assertOk() + ->assertJsonPath('data.name', 'Still moving'); + } + + public function test_an_invoiced_task_is_editable_while_the_lock_is_off(): void + { + $task = $this->makeTask(self::COMPANY); + $this->makeEntry(self::COMPANY, (int) $task->id, ['invoice_id' => 77]); + + $this->asCompany(self::COMPANY) + ->putJson('/api/v1/tasks-projects/tasks/'.$task->id, ['name' => 'Renamed']) + ->assertOk() + ->assertJsonPath('data.name', 'Renamed'); + } + + public function test_a_bulk_status_change_moves_what_it_can_and_reports_what_it_cannot(): void + { + $this->settings->putCompany(self::COMPANY, ModuleSettings::PREFIX.'lock_invoiced_tasks', true); + + $backlog = $this->makeStatus(self::COMPANY); + $done = $this->makeStatus(self::COMPANY, ['name' => 'Done', 'position' => 2, 'is_default' => false, 'is_closed' => true]); + + $first = $this->makeTask(self::COMPANY, ['task_status_id' => $backlog->id]); + $second = $this->makeTask(self::COMPANY, ['task_status_id' => $backlog->id]); + $locked = $this->makeTask(self::COMPANY, ['task_status_id' => $backlog->id]); + $this->makeEntry(self::COMPANY, (int) $locked->id, ['invoice_id' => 77]); + $foreign = $this->makeTask(self::OTHER_COMPANY); + + $response = $this->asCompany(self::COMPANY)->postJson('/api/v1/tasks-projects/tasks/bulk', [ + 'action' => 'status', + 'task_status_id' => $done->id, + 'ids' => [$first->id, $second->id, $locked->id, $foreign->id], + ]); + + $response->assertOk(); + $response->assertExactJson([ + 'updated' => [(int) $first->id, (int) $second->id], + 'failed' => [ + ['id' => (int) $locked->id, 'reason' => 'task_locked'], + ['id' => (int) $foreign->id, 'reason' => 'not_found'], + ], + ]); + + self::assertSame((int) $done->id, (int) Task::query()->findOrFail($first->id)->task_status_id); + self::assertNotNull(Task::query()->findOrFail($second->id)->closed_at); + self::assertSame((int) $backlog->id, (int) Task::query()->findOrFail($locked->id)->task_status_id); + } + + public function test_a_bulk_delete_keeps_the_tasks_it_may_not_delete(): void + { + $status = $this->makeStatus(self::COMPANY); + $gone = $this->makeTask(self::COMPANY, ['task_status_id' => $status->id]); + $invoiced = $this->makeTask(self::COMPANY, ['task_status_id' => $status->id]); + $this->makeEntry(self::COMPANY, (int) $invoiced->id, ['invoice_id' => 77, 'invoice_item_id' => 88]); + + $response = $this->asCompany(self::COMPANY)->postJson('/api/v1/tasks-projects/tasks/bulk', [ + 'action' => 'delete', + 'ids' => [$gone->id, $invoiced->id], + ]); + + $response->assertOk(); + $response->assertExactJson([ + 'updated' => [(int) $gone->id], + 'failed' => [['id' => (int) $invoiced->id, 'reason' => 'entries_already_invoiced']], + ]); + + self::assertNull(Task::query()->find($gone->id)); + self::assertNotNull(Task::query()->find($invoiced->id)); + } + + public function test_a_bulk_request_is_checked_before_anything_moves(): void + { + $task = $this->makeTask(self::COMPANY); + + $this->asCompany(self::COMPANY) + ->postJson('/api/v1/tasks-projects/tasks/bulk', ['action' => 'archive', 'ids' => [$task->id]]) + ->assertStatus(422) + ->assertJsonValidationErrors(['action']); + + $this->asCompany(self::COMPANY) + ->postJson('/api/v1/tasks-projects/tasks/bulk', ['action' => 'status', 'ids' => [$task->id]]) + ->assertStatus(422) + ->assertJsonValidationErrors(['task_status_id']); + + $this->asCompany(self::COMPANY) + ->postJson('/api/v1/tasks-projects/tasks/bulk', ['action' => 'delete', 'ids' => []]) + ->assertStatus(422) + ->assertJsonValidationErrors(['ids']); + + // Deleting is not a status change with an extra field attached. + $this->asCompany(self::COMPANY) + ->postJson('/api/v1/tasks-projects/tasks/bulk', [ + 'action' => 'delete', + 'ids' => [$task->id], + 'task_status_id' => $task->task_status_id, + ]) + ->assertStatus(422) + ->assertJsonValidationErrors(['task_status_id']); + + self::assertNotNull(Task::query()->find($task->id)); + } + + public function test_each_bulk_action_checks_its_own_ability(): void + { + $task = $this->makeTask(self::COMPANY); + + $this->authorization->deny(Authorizes::id(Abilities::DELETE_TASK)); + + $this->asCompany(self::COMPANY) + ->postJson('/api/v1/tasks-projects/tasks/bulk', ['action' => 'delete', 'ids' => [$task->id]]) + ->assertForbidden(); + + $this->asCompany(self::COMPANY) + ->postJson('/api/v1/tasks-projects/tasks/bulk', [ + 'action' => 'status', + 'task_status_id' => $task->task_status_id, + 'ids' => [$task->id], + ]) + ->assertOk(); + + $this->authorization->deny(Authorizes::id(Abilities::EDIT_TASK)); + + $this->asCompany(self::COMPANY) + ->postJson('/api/v1/tasks-projects/tasks/bulk', [ + 'action' => 'status', + 'task_status_id' => $task->task_status_id, + 'ids' => [$task->id], + ]) + ->assertForbidden(); + } + + /** + * The time block of one task, as the API answers it. + * + * @return array + */ + private function timeOf(int $taskId): array + { + $response = $this->asCompany(self::COMPANY)->getJson('/api/v1/tasks-projects/tasks/'.$taskId); + + $response->assertOk(); + + return (array) $response->json('data.time'); + } + + /** @param list $expected */ + private function assertListReturns(array $expected, string $query): void + { + $ids = $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/tasks'.$query) + ->assertOk() + ->json('data.*.id'); + + 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/Feature/TimeEntriesApiTest.php b/tests/Feature/TimeEntriesApiTest.php new file mode 100644 index 0000000..f6d90a2 --- /dev/null +++ b/tests/Feature/TimeEntriesApiTest.php @@ -0,0 +1,348 @@ +taskOnProjectAt(6000); + + $response = $this->asCompany(self::COMPANY)->postJson('/api/v1/tasks-projects/time-entries', [ + 'task_id' => $task, + 'started_at' => '2026-09-15 09:00:00', + 'ended_at' => '2026-09-15 10:30:00', + 'description' => 'Pairing on the importer', + ]); + + $response->assertCreated(); + $response->assertJsonPath('data.user_id', self::DEFAULT_USER); + $response->assertJsonPath('data.duration_minutes', 90); + $response->assertJsonPath('data.rate', 6000); + $response->assertJsonPath('data.amount', 9000); + $response->assertJsonPath('data.is_running', false); + $response->assertJsonPath('data.description', 'Pairing on the importer'); + } + + public function test_the_company_rounding_increment_is_applied_when_the_entry_is_saved(): void + { + $this->settings->putCompany(self::COMPANY, ModuleSettings::PREFIX.'rounding_minutes', 15); + $task = $this->taskOnProjectAt(6000); + + $this->asCompany(self::COMPANY) + ->postJson('/api/v1/tasks-projects/time-entries', [ + 'task_id' => $task, + 'started_at' => '2026-09-15 09:00:00', + 'ended_at' => '2026-09-15 09:20:00', + ]) + ->assertCreated() + ->assertJsonPath('data.duration_minutes', 15) + ->assertJsonPath('data.amount', 1500); + } + + public function test_the_rate_is_frozen_at_save_and_survives_a_later_project_rate_change(): void + { + $project = $this->makeProject(self::COMPANY, ['customer_id' => 42, 'default_rate' => 6000]); + $task = $this->makeTask(self::COMPANY, ['project_id' => $project->id]); + + $entry = $this->asCompany(self::COMPANY)->postJson('/api/v1/tasks-projects/time-entries', [ + 'task_id' => $task->id, + 'duration_minutes' => 60, + ])->assertCreated()->json('data.id'); + + $this->asCompany(self::COMPANY) + ->putJson('/api/v1/tasks-projects/projects/'.$project->id, ['default_rate' => 9000]) + ->assertOk(); + + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/time-entries/'.$entry) + ->assertOk() + ->assertJsonPath('data.rate', 6000) + ->assertJsonPath('data.amount', 6000); + } + + public function test_the_list_filters_and_never_shows_a_running_entry(): void + { + $task = $this->taskOnProjectAt(6000); + $this->makeEntry(self::COMPANY, $task, ['duration_minutes' => 30]); + $this->makeEntry(self::COMPANY, $task, ['billable' => false, 'duration_minutes' => 45]); + $this->makeEntry(self::COMPANY, $task, ['invoice_id' => 77, 'invoice_item_id' => 88]); + $this->makeEntry(self::COMPANY, $task, ['running_user_id' => self::DEFAULT_USER, 'ended_at' => null]); + + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/time-entries') + ->assertOk() + ->assertJsonPath('meta.total', 3); + + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/time-entries?billed=1') + ->assertJsonPath('meta.total', 1); + + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/time-entries?billed=0') + ->assertJsonPath('meta.total', 2); + + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/time-entries?billable=0') + ->assertJsonPath('meta.total', 1); + + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/time-entries?task_id='.$task.'&from=2026-09-01&to=2026-09-01') + ->assertJsonPath('meta.total', 3); + + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/time-entries?from=2026-09-02') + ->assertJsonPath('meta.total', 0); + } + + public function test_without_view_all_time_the_list_is_the_callers_own_rows(): void + { + $task = $this->taskOnProjectAt(6000); + $this->makeEntry(self::COMPANY, $task); + $this->makeEntry(self::COMPANY, $task, ['user_id' => self::OTHER_USER]); + + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/time-entries') + ->assertJsonPath('meta.total', 2); + + $this->authorization->deny(Authorizes::id(Abilities::VIEW_ALL_TIME)); + + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/time-entries?user_id='.self::OTHER_USER) + ->assertJsonPath('meta.total', 1) + ->assertJsonPath('data.0.user_id', self::DEFAULT_USER); + } + + public function test_the_company_setting_opens_the_timesheet_without_the_ability(): void + { + $task = $this->taskOnProjectAt(6000); + $this->makeEntry(self::COMPANY, $task); + $this->makeEntry(self::COMPANY, $task, ['user_id' => self::OTHER_USER]); + + $this->authorization->deny(Authorizes::id(Abilities::VIEW_ALL_TIME)); + $this->settings->putCompany(self::COMPANY, ModuleSettings::PREFIX.'members_see_all_time', true); + + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/time-entries') + ->assertJsonPath('meta.total', 2); + } + + public function test_another_members_entry_is_invisible_without_the_ability_or_the_setting(): void + { + $task = $this->taskOnProjectAt(6000); + $entry = $this->makeEntry(self::COMPANY, $task, ['user_id' => self::OTHER_USER]); + + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/time-entries/'.$entry->id) + ->assertOk(); + + $this->authorization->deny(Authorizes::id(Abilities::VIEW_ALL_TIME)); + + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/time-entries/'.$entry->id) + ->assertForbidden(); + } + + public function test_writing_over_another_members_entry_needs_edit_all_time(): void + { + $task = $this->taskOnProjectAt(6000); + $mine = $this->makeEntry(self::COMPANY, $task); + $theirs = $this->makeEntry(self::COMPANY, $task, ['user_id' => self::OTHER_USER]); + + $this->authorization->deny(Authorizes::id(Abilities::EDIT_ALL_TIME)); + + $this->asCompany(self::COMPANY) + ->putJson('/api/v1/tasks-projects/time-entries/'.$mine->id, ['duration_minutes' => 30]) + ->assertOk() + ->assertJsonPath('data.duration_minutes', 30); + + $this->asCompany(self::COMPANY) + ->putJson('/api/v1/tasks-projects/time-entries/'.$theirs->id, ['duration_minutes' => 30]) + ->assertForbidden(); + + $this->asCompany(self::COMPANY) + ->deleteJson('/api/v1/tasks-projects/time-entries/'.$theirs->id) + ->assertForbidden(); + + $this->asCompany(self::COMPANY) + ->postJson('/api/v1/tasks-projects/time-entries', [ + 'task_id' => $task, + 'user_id' => self::OTHER_USER, + 'duration_minutes' => 30, + ]) + ->assertForbidden(); + } + + public function test_logging_time_for_someone_else_is_allowed_with_edit_all_time(): void + { + $task = $this->taskOnProjectAt(6000); + + $this->asCompany(self::COMPANY) + ->postJson('/api/v1/tasks-projects/time-entries', [ + 'task_id' => $task, + 'user_id' => self::OTHER_USER, + 'duration_minutes' => 60, + ]) + ->assertCreated() + ->assertJsonPath('data.user_id', self::OTHER_USER); + } + + public function test_an_invoiced_entry_refuses_a_change_to_its_time_or_its_task(): void + { + $task = $this->taskOnProjectAt(6000); + $elsewhere = (int) $this->makeTask(self::COMPANY, ['name' => 'Another task'])->id; + $entry = $this->makeEntry(self::COMPANY, $task, ['invoice_id' => 77, 'invoice_item_id' => 88]); + + $refused = [ + ['duration_minutes' => 240], + ['started_at' => '2026-09-01 08:00:00'], + ['ended_at' => '2026-09-01 12:00:00'], + ['billable' => false], + ['task_id' => $elsewhere], + ]; + + foreach ($refused as $payload) { + $this->asCompany(self::COMPANY) + ->putJson('/api/v1/tasks-projects/time-entries/'.$entry->id, $payload) + ->assertStatus(422) + ->assertJsonPath('error', 'entries_already_invoiced'); + } + + $stored = TimeEntry::query()->findOrFail($entry->id); + self::assertSame(60, (int) $stored->duration_minutes); + self::assertSame(10000, (int) $stored->amount); + self::assertTrue((bool) $stored->billable); + self::assertSame($task, (int) $stored->task_id); + } + + public function test_an_invoiced_entry_still_takes_a_new_description(): void + { + $task = $this->taskOnProjectAt(6000); + $entry = $this->makeEntry(self::COMPANY, $task, ['invoice_id' => 77, 'invoice_item_id' => 88]); + + $this->asCompany(self::COMPANY) + ->putJson('/api/v1/tasks-projects/time-entries/'.$entry->id, ['description' => 'Typo fix']) + ->assertOk() + ->assertJsonPath('data.description', 'Typo fix') + ->assertJsonPath('data.duration_minutes', 60) + ->assertJsonPath('data.amount', 10000); + } + + public function test_an_invoiced_entry_accepts_a_form_that_posts_its_own_values_back(): void + { + $task = $this->taskOnProjectAt(6000); + $entry = $this->makeEntry(self::COMPANY, $task, ['invoice_id' => 77, 'invoice_item_id' => 88]); + + $this->asCompany(self::COMPANY) + ->putJson('/api/v1/tasks-projects/time-entries/'.$entry->id, [ + 'task_id' => $task, + 'started_at' => $entry->started_at->toIso8601String(), + 'ended_at' => $entry->ended_at->toIso8601String(), + 'duration_minutes' => 60, + 'billable' => true, + 'description' => 'Same row, new note', + ]) + ->assertOk() + ->assertJsonPath('data.description', 'Same row, new note'); + } + + public function test_the_company_rounding_direction_is_applied_when_the_entry_is_saved(): void + { + $this->settings->putCompany(self::COMPANY, ModuleSettings::PREFIX.'rounding_minutes', 15); + $this->settings->putCompany(self::COMPANY, ModuleSettings::PREFIX.'rounding_direction', Rounding::DOWN); + $task = $this->taskOnProjectAt(6000); + + $this->asCompany(self::COMPANY) + ->postJson('/api/v1/tasks-projects/time-entries', [ + 'task_id' => $task, + 'started_at' => '2026-09-15 09:00:00', + 'ended_at' => '2026-09-15 09:20:00', + ]) + ->assertCreated() + ->assertJsonPath('data.duration_minutes', 15) + ->assertJsonPath('data.amount', 1500); + + $this->settings->putCompany(self::COMPANY, ModuleSettings::PREFIX.'rounding_direction', Rounding::UP); + + $this->asCompany(self::COMPANY) + ->postJson('/api/v1/tasks-projects/time-entries', [ + 'task_id' => $task, + 'started_at' => '2026-09-15 09:00:00', + 'ended_at' => '2026-09-15 09:20:00', + ]) + ->assertCreated() + ->assertJsonPath('data.duration_minutes', 30) + ->assertJsonPath('data.amount', 3000); + } + + public function test_an_invoiced_entry_cannot_be_deleted(): void + { + $task = $this->taskOnProjectAt(6000); + $entry = $this->makeEntry(self::COMPANY, $task, ['invoice_id' => 77, 'invoice_item_id' => 88]); + + $this->asCompany(self::COMPANY) + ->deleteJson('/api/v1/tasks-projects/time-entries/'.$entry->id) + ->assertStatus(422) + ->assertJsonPath('error', 'entries_already_invoiced'); + + self::assertNotNull(TimeEntry::query()->find($entry->id)); + } + + public function test_an_uninvoiced_entry_is_deleted(): void + { + $task = $this->taskOnProjectAt(6000); + $entry = $this->makeEntry(self::COMPANY, $task); + + $this->asCompany(self::COMPANY) + ->deleteJson('/api/v1/tasks-projects/time-entries/'.$entry->id) + ->assertOk(); + + self::assertNull(TimeEntry::query()->find($entry->id)); + } + + public function test_an_entry_of_another_company_is_not_found(): void + { + $task = $this->makeTask(self::OTHER_COMPANY); + $entry = $this->makeEntry(self::OTHER_COMPANY, (int) $task->id); + + $this->asCompany(self::COMPANY)->getJson('/api/v1/tasks-projects/time-entries/'.$entry->id)->assertNotFound(); + $this->asCompany(self::COMPANY)->putJson('/api/v1/tasks-projects/time-entries/'.$entry->id, ['duration_minutes' => 1])->assertNotFound(); + $this->asCompany(self::COMPANY)->deleteJson('/api/v1/tasks-projects/time-entries/'.$entry->id)->assertNotFound(); + } + + public function test_everything_here_needs_view_own_time(): void + { + $task = $this->taskOnProjectAt(6000); + $entry = $this->makeEntry(self::COMPANY, $task); + + $this->authorization->deny(Authorizes::id(Abilities::VIEW_OWN_TIME)); + + $this->asCompany(self::COMPANY)->getJson('/api/v1/tasks-projects/time-entries')->assertForbidden(); + $this->asCompany(self::COMPANY)->postJson('/api/v1/tasks-projects/time-entries', ['task_id' => $task])->assertForbidden(); + $this->asCompany(self::COMPANY)->getJson('/api/v1/tasks-projects/time-entries/'.$entry->id)->assertForbidden(); + $this->asCompany(self::COMPANY)->putJson('/api/v1/tasks-projects/time-entries/'.$entry->id, ['duration_minutes' => 1])->assertForbidden(); + $this->asCompany(self::COMPANY)->deleteJson('/api/v1/tasks-projects/time-entries/'.$entry->id)->assertForbidden(); + } + + private function taskOnProjectAt(int $rate): int + { + $project = $this->makeProject(self::COMPANY, ['customer_id' => 42, 'default_rate' => $rate, 'currency_id' => 3]); + + return (int) $this->makeTask(self::COMPANY, ['project_id' => $project->id])->id; + } +} diff --git a/tests/Feature/TimerApiTest.php b/tests/Feature/TimerApiTest.php new file mode 100644 index 0000000..32b1b40 --- /dev/null +++ b/tests/Feature/TimerApiTest.php @@ -0,0 +1,442 @@ +asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/timer') + ->assertOk() + ->assertExactJson(['data' => null]); + } + + public function test_starting_opens_an_entry_that_carries_no_money_yet(): void + { + Carbon::setTestNow('2026-09-15 09:00:00'); + $task = $this->taskOnProjectAt(6000); + + $response = $this->asCompany(self::COMPANY)->postJson('/api/v1/tasks-projects/timer/start', [ + 'task_id' => $task, + 'description' => 'Fixing the importer', + ]); + + $response->assertCreated(); + $response->assertJsonPath('data.is_running', true); + $response->assertJsonPath('data.user_id', self::DEFAULT_USER); + $response->assertJsonPath('data.duration_minutes', 0); + $response->assertJsonPath('data.amount', 0); + $response->assertJsonPath('data.ended_at', null); + + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/timer') + ->assertOk() + ->assertJsonPath('data.description', 'Fixing the importer'); + } + + public function test_a_start_on_another_task_is_a_conflict(): void + { + $task = $this->taskOnProjectAt(6000); + $other = (int) $this->makeTask(self::COMPANY, ['name' => 'Something else'])->id; + $this->asCompany(self::COMPANY)->postJson('/api/v1/tasks-projects/timer/start', ['task_id' => $task])->assertCreated(); + + $this->asCompany(self::COMPANY) + ->postJson('/api/v1/tasks-projects/timer/start', ['task_id' => $other]) + ->assertStatus(409) + ->assertJsonPath('error', 'timer_already_running'); + } + + public function test_starting_the_task_already_on_the_clock_updates_it_rather_than_conflicting(): void + { + Carbon::setTestNow('2026-09-15 09:00:00'); + $task = $this->taskOnProjectAt(6000); + + $started = $this->asCompany(self::COMPANY)->postJson('/api/v1/tasks-projects/timer/start', [ + 'task_id' => $task, + 'description' => 'Reading the ticket', + ]); + $started->assertCreated(); + + // The clock keeps running from where it was; only the details change. + Carbon::setTestNow('2026-09-15 09:20:00'); + $again = $this->asCompany(self::COMPANY)->postJson('/api/v1/tasks-projects/timer/start', [ + 'task_id' => $task, + 'description' => 'Fixing the importer', + 'billable' => false, + ]); + + $again->assertOk(); + $again->assertJsonPath('data.id', $started->json('data.id')); + $again->assertJsonPath('data.is_running', true); + $again->assertJsonPath('data.description', 'Fixing the importer'); + $again->assertJsonPath('data.billable', false); + $again->assertJsonPath('data.started_at', $started->json('data.started_at')); + + self::assertSame(1, TimeEntry::query()->forCompany(self::COMPANY)->count()); + } + + public function test_a_start_can_override_the_tasks_billable_flag(): void + { + $task = $this->taskOnProjectAt(6000); + + $this->asCompany(self::COMPANY) + ->postJson('/api/v1/tasks-projects/timer/start', ['task_id' => $task, 'billable' => false]) + ->assertCreated() + ->assertJsonPath('data.billable', false); + } + + public function test_stopping_applies_the_description_and_the_billable_flag_it_carries(): void + { + $task = $this->taskOnProjectAt(6000); + + Carbon::setTestNow('2026-09-15 09:00:00'); + $this->asCompany(self::COMPANY)->postJson('/api/v1/tasks-projects/timer/start', [ + 'task_id' => $task, + 'description' => 'Reading the ticket', + ])->assertCreated(); + + Carbon::setTestNow('2026-09-15 10:00:00'); + $this->asCompany(self::COMPANY) + ->postJson('/api/v1/tasks-projects/timer/stop', [ + 'description' => 'Fixed the importer', + 'billable' => false, + ]) + ->assertOk() + ->assertJsonPath('data.description', 'Fixed the importer') + ->assertJsonPath('data.billable', false) + ->assertJsonPath('data.duration_minutes', 60) + // Non-billable time is still rated; what it is worth is the + // invoice's question, not the timesheet's. + ->assertJsonPath('data.amount', 6000); + } + + public function test_stopping_without_details_keeps_what_the_start_recorded(): void + { + $task = $this->taskOnProjectAt(6000); + + Carbon::setTestNow('2026-09-15 09:00:00'); + $this->asCompany(self::COMPANY)->postJson('/api/v1/tasks-projects/timer/start', [ + 'task_id' => $task, + 'description' => 'Reading the ticket', + ])->assertCreated(); + + Carbon::setTestNow('2026-09-15 10:00:00'); + $this->asCompany(self::COMPANY) + ->postJson('/api/v1/tasks-projects/timer/stop') + ->assertOk() + ->assertJsonPath('data.description', 'Reading the ticket') + ->assertJsonPath('data.billable', true); + } + + public function test_stopping_rounds_the_elapsed_time_and_freezes_the_rate(): void + { + $this->settings->putCompany(self::COMPANY, ModuleSettings::PREFIX.'rounding_minutes', 15); + $task = $this->taskOnProjectAt(6000); + + Carbon::setTestNow('2026-09-15 09:00:00'); + $this->asCompany(self::COMPANY)->postJson('/api/v1/tasks-projects/timer/start', ['task_id' => $task])->assertCreated(); + + Carbon::setTestNow('2026-09-15 09:50:00'); + $response = $this->asCompany(self::COMPANY)->postJson('/api/v1/tasks-projects/timer/stop'); + + $response->assertOk(); + $response->assertJsonPath('data.is_running', false); + $response->assertJsonPath('data.duration_minutes', 45); + $response->assertJsonPath('data.rate', 6000); + $response->assertJsonPath('data.amount', 4500); + + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/timer') + ->assertExactJson(['data' => null]); + } + + public function test_stopping_follows_the_companys_rounding_direction(): void + { + $this->settings->putCompany(self::COMPANY, ModuleSettings::PREFIX.'rounding_minutes', 15); + $this->settings->putCompany(self::COMPANY, ModuleSettings::PREFIX.'rounding_direction', Rounding::UP); + $task = $this->taskOnProjectAt(6000); + + Carbon::setTestNow('2026-09-15 09:00:00'); + $this->asCompany(self::COMPANY)->postJson('/api/v1/tasks-projects/timer/start', ['task_id' => $task])->assertCreated(); + + Carbon::setTestNow('2026-09-15 09:50:00'); + + // Nearest would have billed 45 minutes; rounding up takes the hour. + $this->asCompany(self::COMPANY) + ->postJson('/api/v1/tasks-projects/timer/stop') + ->assertOk() + ->assertJsonPath('data.duration_minutes', 60) + ->assertJsonPath('data.amount', 6000); + } + + public function test_the_stopped_entry_joins_the_timesheet(): void + { + $task = $this->taskOnProjectAt(6000); + + Carbon::setTestNow('2026-09-15 09:00:00'); + $this->asCompany(self::COMPANY)->postJson('/api/v1/tasks-projects/timer/start', ['task_id' => $task]); + + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/time-entries') + ->assertJsonPath('meta.total', 0); + + Carbon::setTestNow('2026-09-15 10:00:00'); + $this->asCompany(self::COMPANY)->postJson('/api/v1/tasks-projects/timer/stop')->assertOk(); + + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/time-entries') + ->assertJsonPath('meta.total', 1) + ->assertJsonPath('data.0.duration_minutes', 60); + } + + public function test_discarding_throws_the_running_entry_away(): void + { + $task = $this->taskOnProjectAt(6000); + $this->asCompany(self::COMPANY)->postJson('/api/v1/tasks-projects/timer/start', ['task_id' => $task])->assertCreated(); + + $this->asCompany(self::COMPANY) + ->deleteJson('/api/v1/tasks-projects/timer') + ->assertOk() + ->assertJson(['success' => true]); + + self::assertSame(0, TimeEntry::query()->forCompany(self::COMPANY)->count()); + } + + public function test_stopping_or_discarding_an_idle_timer_is_not_found(): void + { + $this->asCompany(self::COMPANY)->postJson('/api/v1/tasks-projects/timer/stop')->assertNotFound(); + $this->asCompany(self::COMPANY)->deleteJson('/api/v1/tasks-projects/timer')->assertNotFound(); + } + + public function test_a_task_of_another_company_cannot_be_timed(): void + { + $task = $this->makeTask(self::OTHER_COMPANY); + + $this->asCompany(self::COMPANY) + ->postJson('/api/v1/tasks-projects/timer/start', ['task_id' => $task->id]) + ->assertNotFound(); + } + + public function test_the_running_timer_belongs_to_one_company_at_a_time(): void + { + $here = $this->taskOnProjectAt(6000); + $there = (int) $this->makeTask(self::OTHER_COMPANY)->id; + + $this->asCompany(self::COMPANY)->postJson('/api/v1/tasks-projects/timer/start', ['task_id' => $here])->assertCreated(); + + $this->asCompany(self::OTHER_COMPANY) + ->getJson('/api/v1/tasks-projects/timer') + ->assertExactJson(['data' => null]); + + $this->asCompany(self::OTHER_COMPANY) + ->postJson('/api/v1/tasks-projects/timer/start', ['task_id' => $there]) + ->assertCreated(); + + self::assertSame(1, TimeEntry::query()->forCompany(self::COMPANY)->whereNotNull('running_user_id')->count()); + self::assertSame(1, TimeEntry::query()->forCompany(self::OTHER_COMPANY)->whereNotNull('running_user_id')->count()); + } + + public function test_the_timer_needs_the_own_time_ability(): void + { + $task = $this->taskOnProjectAt(6000); + $this->authorization->deny(Authorizes::id(Abilities::VIEW_OWN_TIME)); + + $this->asCompany(self::COMPANY)->getJson('/api/v1/tasks-projects/timer')->assertForbidden(); + $this->asCompany(self::COMPANY)->postJson('/api/v1/tasks-projects/timer/start', ['task_id' => $task])->assertForbidden(); + $this->asCompany(self::COMPANY)->postJson('/api/v1/tasks-projects/timer/stop')->assertForbidden(); + $this->asCompany(self::COMPANY)->deleteJson('/api/v1/tasks-projects/timer')->assertForbidden(); + } + + public function test_a_task_row_starts_the_clock_on_that_task(): void + { + Carbon::setTestNow('2026-09-15 09:00:00'); + $task = $this->taskOnProjectAt(6000); + + $response = $this->asCompany(self::COMPANY)->postJson( + '/api/v1/tasks-projects/tasks/'.$task.'/start', + ['description' => 'Fixing the importer'], + ); + + $response->assertCreated(); + $response->assertJsonPath('data.task_id', $task); + $response->assertJsonPath('data.user_id', self::DEFAULT_USER); + $response->assertJsonPath('data.is_running', true); + $response->assertJsonPath('data.description', 'Fixing the importer'); + + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/tasks/'.$task) + ->assertJsonPath('data.time.running.0.user_id', self::DEFAULT_USER); + } + + public function test_a_second_task_start_is_the_same_conflict_the_timer_reports(): void + { + $first = $this->taskOnProjectAt(6000); + $second = (int) $this->makeTask(self::COMPANY, ['name' => 'Something else'])->id; + + $this->asCompany(self::COMPANY)->postJson('/api/v1/tasks-projects/tasks/'.$first.'/start')->assertCreated(); + + $this->asCompany(self::COMPANY) + ->postJson('/api/v1/tasks-projects/tasks/'.$second.'/start') + ->assertStatus(409) + ->assertJsonPath('error', 'timer_already_running'); + + self::assertSame(1, TimeEntry::query()->forCompany(self::COMPANY)->whereNotNull('running_user_id')->count()); + } + + public function test_stopping_a_task_closes_the_clock_that_runs_on_it(): void + { + $task = $this->taskOnProjectAt(6000); + + Carbon::setTestNow('2026-09-15 09:00:00'); + $this->asCompany(self::COMPANY)->postJson('/api/v1/tasks-projects/tasks/'.$task.'/start')->assertCreated(); + + Carbon::setTestNow('2026-09-15 10:00:00'); + $response = $this->asCompany(self::COMPANY)->postJson('/api/v1/tasks-projects/tasks/'.$task.'/stop'); + + $response->assertOk(); + $response->assertJsonPath('data.is_running', false); + $response->assertJsonPath('data.duration_minutes', 60); + $response->assertJsonPath('data.amount', 6000); + + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/tasks/'.$task) + ->assertJsonPath('data.time.logged_minutes', 60) + ->assertJsonPath('data.time.unbilled_amount', 6000) + ->assertJsonPath('data.time.running', []); + } + + public function test_a_task_start_on_the_running_task_applies_the_details_it_carries(): void + { + Carbon::setTestNow('2026-09-15 09:00:00'); + $task = $this->taskOnProjectAt(6000); + + $started = $this->asCompany(self::COMPANY)->postJson('/api/v1/tasks-projects/tasks/'.$task.'/start'); + $started->assertCreated(); + + $again = $this->asCompany(self::COMPANY)->postJson('/api/v1/tasks-projects/tasks/'.$task.'/start', [ + 'description' => 'Carried on with it', + 'billable' => false, + ]); + + $again->assertOk(); + $again->assertJsonPath('data.id', $started->json('data.id')); + $again->assertJsonPath('data.description', 'Carried on with it'); + $again->assertJsonPath('data.billable', false); + } + + public function test_stopping_a_task_carries_the_description_and_the_billable_flag(): void + { + $task = $this->taskOnProjectAt(6000); + + Carbon::setTestNow('2026-09-15 09:00:00'); + $this->asCompany(self::COMPANY)->postJson('/api/v1/tasks-projects/tasks/'.$task.'/start')->assertCreated(); + + Carbon::setTestNow('2026-09-15 10:00:00'); + $this->asCompany(self::COMPANY) + ->postJson('/api/v1/tasks-projects/tasks/'.$task.'/stop', [ + 'description' => 'Wrote the importer test', + 'billable' => false, + ]) + ->assertOk() + ->assertJsonPath('data.description', 'Wrote the importer test') + ->assertJsonPath('data.billable', false) + ->assertJsonPath('data.duration_minutes', 60); + } + + public function test_a_mismatched_stop_is_refused_before_it_writes_anything(): void + { + $running = $this->taskOnProjectAt(6000); + $idle = (int) $this->makeTask(self::COMPANY, ['name' => 'Idle'])->id; + + $this->asCompany(self::COMPANY)->postJson('/api/v1/tasks-projects/tasks/'.$running.'/start', [ + 'description' => 'Reading the ticket', + ])->assertCreated(); + + $this->asCompany(self::COMPANY) + ->postJson('/api/v1/tasks-projects/tasks/'.$idle.'/stop', [ + 'description' => 'Should never land', + 'billable' => false, + ]) + ->assertStatus(409) + ->assertJsonPath('error', 'timer_mismatch'); + + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/timer') + ->assertJsonPath('data.task_id', $running) + ->assertJsonPath('data.description', 'Reading the ticket') + ->assertJsonPath('data.billable', true); + } + + public function test_stopping_the_wrong_task_is_a_mismatch_rather_than_a_stop(): void + { + $running = $this->taskOnProjectAt(6000); + $idle = (int) $this->makeTask(self::COMPANY, ['name' => 'Idle'])->id; + + $this->asCompany(self::COMPANY) + ->postJson('/api/v1/tasks-projects/tasks/'.$idle.'/stop') + ->assertStatus(409) + ->assertJsonPath('error', 'timer_mismatch'); + + $this->asCompany(self::COMPANY)->postJson('/api/v1/tasks-projects/tasks/'.$running.'/start')->assertCreated(); + + $this->asCompany(self::COMPANY) + ->postJson('/api/v1/tasks-projects/tasks/'.$idle.'/stop') + ->assertStatus(409) + ->assertJsonPath('error', 'timer_mismatch'); + + // The clock the caller really had running is untouched. + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/timer') + ->assertJsonPath('data.task_id', $running); + } + + public function test_the_task_routes_stay_inside_the_company(): void + { + $theirs = (int) $this->makeTask(self::OTHER_COMPANY)->id; + + $this->asCompany(self::COMPANY) + ->postJson('/api/v1/tasks-projects/tasks/'.$theirs.'/start') + ->assertNotFound(); + + $this->asCompany(self::COMPANY) + ->postJson('/api/v1/tasks-projects/tasks/'.$theirs.'/stop') + ->assertNotFound(); + } + + public function test_the_task_routes_need_both_the_task_and_the_own_time_ability(): void + { + $task = $this->taskOnProjectAt(6000); + + $this->authorization->deny(Authorizes::id(Abilities::VIEW_TASK)); + + $this->asCompany(self::COMPANY)->postJson('/api/v1/tasks-projects/tasks/'.$task.'/start')->assertForbidden(); + $this->asCompany(self::COMPANY)->postJson('/api/v1/tasks-projects/tasks/'.$task.'/stop')->assertForbidden(); + + $this->authorization->denied = [Authorizes::id(Abilities::VIEW_OWN_TIME)]; + + $this->asCompany(self::COMPANY)->postJson('/api/v1/tasks-projects/tasks/'.$task.'/start')->assertForbidden(); + $this->asCompany(self::COMPANY)->postJson('/api/v1/tasks-projects/tasks/'.$task.'/stop')->assertForbidden(); + } + + private function taskOnProjectAt(int $rate): int + { + $project = $this->makeProject(self::COMPANY, ['customer_id' => 42, 'default_rate' => $rate, 'currency_id' => 3]); + + return (int) $this->makeTask(self::COMPANY, ['project_id' => $project->id])->id; + } +} diff --git a/tests/Support/MemoryCompanyDataReader.php b/tests/Support/MemoryCompanyDataReader.php new file mode 100644 index 0000000..b518a79 --- /dev/null +++ b/tests/Support/MemoryCompanyDataReader.php @@ -0,0 +1,153 @@ +> invoice ids that exist, keyed by company */ + public array $invoiceIds = []; + + /** @var array> */ + public array $members = []; + + /** @var array>> customers, keyed by company then id */ + public array $customers = []; + + /** @var list}> */ + public array $invoiceLookups = []; + + public function withInvoices(int $companyId, int ...$invoiceIds): self + { + $this->invoiceIds[$companyId] = array_values($invoiceIds); + + return $this; + } + + public function withMember(int $companyId, int $userId, string $name): self + { + $this->members[$companyId][] = [ + 'id' => $userId, + 'name' => $name, + 'email' => strtolower(str_replace(' ', '.', $name)).'@example.test', + 'avatar' => null, + ]; + + return $this; + } + + public function withCustomer(int $companyId, int $customerId, ?int $currencyId = null): self + { + $this->customers[$companyId][$customerId] = [ + 'id' => $customerId, + 'name' => 'Customer '.$customerId, + 'currency_id' => $currencyId, + 'currency' => $currencyId === null + ? null + : ['id' => $currencyId, 'code' => 'EUR', 'symbol' => 'E', 'precision' => 2], + ]; + + return $this; + } + + /** @return array */ + public function companyStats(int $companyId, string $startDate, string $endDate): array + { + return []; + } + + /** @return array|null */ + public function findCustomer(int $companyId, int $customerId): ?array + { + return $this->customers[$companyId][$customerId] ?? null; + } + + /** @return array */ + public function searchCustomers(int $companyId, ?string $query, int $limit): array + { + return []; + } + + /** @return array */ + public function rankCustomers(int $companyId, string $metric, ?string $startDate, ?string $endDate, int $limit): array + { + return []; + } + + /** @return array|null */ + public function findInvoice(int $companyId, string $invoiceNumber): ?array + { + return null; + } + + /** @return array */ + public function searchInvoices(int $companyId, ?string $query, ?string $status, ?int $customerId, int $limit): array + { + return []; + } + + /** @return array */ + public function overdueInvoices(int $companyId, int $limit): array + { + return []; + } + + /** @return array */ + public function recentPayments(int $companyId, string $startDate, int $limit): array + { + return []; + } + + /** @return array */ + public function expenseCategories(int $companyId): array + { + return []; + } + + /** @return array */ + public function rankExpenseCategories(int $companyId, ?string $startDate, ?string $endDate, int $limit): array + { + return []; + } + + /** @return array */ + public function searchItems(int $companyId, ?string $query, int $limit): array + { + return []; + } + + /** @return array */ + public function rankItems(int $companyId, string $metric, ?string $startDate, ?string $endDate, int $limit): array + { + return []; + } + + /** @return list */ + public function companyMembers(int $companyId): array + { + return $this->members[$companyId] ?? []; + } + + /** + * @param list $invoiceIds + * @return list + */ + public function existingInvoiceIds(int $companyId, array $invoiceIds): array + { + $this->invoiceLookups[] = ['company_id' => $companyId, 'invoice_ids' => array_values($invoiceIds)]; + + return array_values(array_intersect($invoiceIds, $this->invoiceIds[$companyId] ?? [])); + } +} diff --git a/tests/Support/MemorySettingsStore.php b/tests/Support/MemorySettingsStore.php new file mode 100644 index 0000000..c477078 --- /dev/null +++ b/tests/Support/MemorySettingsStore.php @@ -0,0 +1,54 @@ + */ + public array $global = []; + + /** @var array> */ + public array $company = []; + + public function getGlobal(string $key, mixed $default = null): mixed + { + return $this->global[$key] ?? $default; + } + + public function putGlobal(string $key, mixed $value): void + { + $this->global[$key] = $value; + } + + public function deleteGlobal(string $key): void + { + unset($this->global[$key]); + } + + public function getCompany(int $companyId, string $key, mixed $default = null): mixed + { + return $this->company[$companyId][$key] ?? $default; + } + + public function putCompany(int $companyId, string $key, mixed $value): void + { + $this->company[$companyId][$key] = $value; + } + + public function deleteCompany(int $companyId, string $key): void + { + unset($this->company[$companyId][$key]); + } + + public function deleteCompanyForAll(string $key): void + { + foreach (array_keys($this->company) as $companyId) { + unset($this->company[$companyId][$key]); + } + } +} diff --git a/tests/Support/RecordingAuthorization.php b/tests/Support/RecordingAuthorization.php new file mode 100644 index 0000000..2c4aab9 --- /dev/null +++ b/tests/Support/RecordingAuthorization.php @@ -0,0 +1,42 @@ + */ + public array $checks = []; + + /** @var list abilities to refuse, as `ability` or `ability:resource` */ + public array $denied = []; + + public function allows(int $userId, int $companyId, string $ability, ?string $resource = null): bool + { + $this->checks[] = [ + 'user_id' => $userId, + 'company_id' => $companyId, + 'ability' => $ability, + 'resource' => $resource, + ]; + + return ! in_array($ability, $this->denied, true) + && ! in_array($ability.':'.(string) $resource, $this->denied, true); + } + + public function deny(string ...$abilities): self + { + foreach ($abilities as $ability) { + $this->denied[] = $ability; + } + + return $this; + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php index a974e2c..f432ea9 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -4,22 +4,216 @@ namespace Modules\TasksProjects\Tests; +use Illuminate\Auth\GenericUser; +use Illuminate\Contracts\Debug\ExceptionHandler; +use Illuminate\Foundation\Exceptions\Handler; +use Illuminate\Routing\Router; +use Illuminate\Support\Carbon; +use Illuminate\Support\Facades\Auth; +use InvoiceShelf\Modules\Contracts\Host\CompanyDataReader; +use InvoiceShelf\Modules\Contracts\Host\ModuleAuthorization; +use InvoiceShelf\Modules\Contracts\Host\SettingsStore; use InvoiceShelf\Modules\InvoiceShelfModulesServiceProvider; use InvoiceShelf\Modules\Registry; +use Modules\TasksProjects\Application\BoardOrderingService; +use Modules\TasksProjects\Application\ProjectService; +use Modules\TasksProjects\Application\TaskLock; +use Modules\TasksProjects\Application\TaskNumberSequence; +use Modules\TasksProjects\Application\TaskService; +use Modules\TasksProjects\Application\TaskStatusService; +use Modules\TasksProjects\Application\TaskTimeSummary; +use Modules\TasksProjects\Http\DomainExceptionRenderer; +use Modules\TasksProjects\Models\Project; +use Modules\TasksProjects\Models\ProjectMember; +use Modules\TasksProjects\Models\Task; +use Modules\TasksProjects\Models\TaskStatus; +use Modules\TasksProjects\Models\TimeEntry; +use Modules\TasksProjects\Support\ModuleSettings; +use Modules\TasksProjects\Tests\Support\MemoryCompanyDataReader; +use Modules\TasksProjects\Tests\Support\MemorySettingsStore; +use Modules\TasksProjects\Tests\Support\RecordingAuthorization; use Orchestra\Testbench\TestCase as Orchestra; abstract class TestCase extends Orchestra { - /** @return list */ - protected function getPackageProviders($app): array + /** The user every request acts as until a test says otherwise. */ + public const DEFAULT_USER = 7; + + protected MemorySettingsStore $settings; + + protected MemoryCompanyDataReader $companyData; + + protected RecordingAuthorization $authorization; + + protected function setUp(): void { - return [InvoiceShelfModulesServiceProvider::class]; + parent::setUp(); + + $this->settings = new MemorySettingsStore; + $this->companyData = new MemoryCompanyDataReader; + $this->authorization = new RecordingAuthorization; + + $this->app->instance(SettingsStore::class, $this->settings); + $this->app->instance(CompanyDataReader::class, $this->companyData); + $this->app->instance(ModuleAuthorization::class, $this->authorization); + + $handler = $this->app->make(ExceptionHandler::class); + if ($handler instanceof Handler) { + DomainExceptionRenderer::register($handler); + } + + // `auth:sanctum`, `company` and `bouncer` are host middleware and do not + // exist here, so the harness stands in for all three: it authenticates + // the caller itself and puts the company on the request as a header. + $this->withoutMiddleware(); + $this->actingAsUser(self::DEFAULT_USER); } protected function tearDown(): void { + Carbon::setTestNow(); Registry::flush(); parent::tearDown(); } + + /** @return list */ + protected function getPackageProviders($app): array + { + return [InvoiceShelfModulesServiceProvider::class]; + } + + protected function getEnvironmentSetUp($app): void + { + $app['config']->set('app.key', 'base64:'.base64_encode(str_repeat('a', 32))); + $app['config']->set('database.default', 'testing'); + $app['config']->set('database.connections.testing', [ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'prefix' => '', + 'foreign_key_constraints' => true, + ]); + } + + /** + * Load the module's own translations, the way its provider does in the host. + * + * The harness boots the modules SDK rather than the module's provider, so + * without this any module code that resolves a `tasksprojects::` line would + * answer with the key and a test would never notice. + */ + protected function defineEnvironment($app): void + { + $app['translator']->addNamespace('tasksprojects', dirname(__DIR__).'/lang'); + } + + protected function defineDatabaseMigrations(): void + { + $this->loadMigrationsFrom(dirname(__DIR__).'/database/migrations'); + } + + protected function moduleSettings(): ModuleSettings + { + return new ModuleSettings($this->settings); + } + + /** A TaskService wired with the collaborators the container gives it. */ + protected function taskService(?TaskStatusService $statuses = null): TaskService + { + return new TaskService( + new TaskNumberSequence, + new BoardOrderingService, + $statuses ?? new TaskStatusService, + new ProjectService($this->companyData), + new TaskLock($this->moduleSettings(), new TaskTimeSummary), + ); + } + + /** + * Load the module's own route file, the way the provider does in the host. + * + * @param Router $router + */ + protected function defineRoutes($router): void + { + require dirname(__DIR__).'/routes/api.php'; + } + + /** Authenticate the caller without a host user model. */ + protected function actingAsUser(int $userId): static + { + Auth::setUser(new GenericUser(['id' => $userId])); + + return $this; + } + + /** Send the `company` header the host middleware would have set. */ + protected function asCompany(int $companyId): static + { + return $this->withHeader('company', (string) $companyId); + } + + /** @param array $attributes */ + protected function makeStatus(int $companyId, array $attributes = []): TaskStatus + { + return TaskStatus::query()->create($attributes + [ + 'company_id' => $companyId, + 'name' => 'Backlog', + 'position' => 1, + 'is_default' => true, + 'is_closed' => false, + ]); + } + + /** @param array $attributes */ + protected function makeProject(int $companyId, array $attributes = []): Project + { + return Project::query()->create($attributes + [ + 'company_id' => $companyId, + 'name' => 'Website redesign', + 'status' => Project::STATUS_ACTIVE, + ]); + } + + protected function makeMember(int $companyId, int $projectId, int $userId, ?int $rate = null): ProjectMember + { + return ProjectMember::query()->create([ + 'company_id' => $companyId, + 'project_id' => $projectId, + 'user_id' => $userId, + 'rate' => $rate, + ]); + } + + /** @param array $attributes */ + protected function makeTask(int $companyId, array $attributes = []): Task + { + $statusId = $attributes['task_status_id'] ?? $this->makeStatus($companyId)->id; + unset($attributes['task_status_id']); + + return Task::query()->create($attributes + [ + 'company_id' => $companyId, + 'task_status_id' => $statusId, + 'number' => (int) Task::query()->forCompany($companyId)->max('number') + 1, + 'name' => 'Build the landing page', + 'billable' => true, + 'board_position' => '1024.0000000000', + ]); + } + + /** @param array $attributes */ + protected function makeEntry(int $companyId, int $taskId, array $attributes = []): TimeEntry + { + return TimeEntry::query()->create($attributes + [ + 'company_id' => $companyId, + 'task_id' => $taskId, + 'user_id' => 7, + 'started_at' => Carbon::parse('2026-09-01 09:00:00'), + 'ended_at' => Carbon::parse('2026-09-01 10:00:00'), + 'duration_minutes' => 60, + 'billable' => true, + 'rate' => 10000, + 'amount' => 10000, + ]); + } } diff --git a/tests/Unit/BillingServiceTest.php b/tests/Unit/BillingServiceTest.php new file mode 100644 index 0000000..62a54c9 --- /dev/null +++ b/tests/Unit/BillingServiceTest.php @@ -0,0 +1,815 @@ +billing = new BillingService($this->companyData, $this->moduleSettings(), new InvoiceLineComposer); + $this->companyData->withMember(self::COMPANY, 7, 'Ada Lovelace')->withMember(self::COMPANY, 8, 'Grace Hopper'); + + $this->website = $this->makeProject(self::COMPANY, ['name' => 'Website', 'customer_id' => self::CUSTOMER, 'currency_id' => self::CURRENCY]); + $this->mobile = $this->makeProject(self::COMPANY, ['name' => 'Mobile app', 'customer_id' => self::CUSTOMER, 'currency_id' => self::CURRENCY]); + + $this->landing = $this->task('Landing page', $this->website); + $this->pricing = $this->task('Pricing page', $this->website); + $this->onboarding = $this->task('Onboarding flow', $this->mobile); + $this->adHoc = $this->task('Ad hoc call', null); + } + + public function test_unbilled_collects_the_customers_billable_time_per_currency(): void + { + $this->entries(); + + $unbilled = $this->billing->unbilled(self::COMPANY, self::CUSTOMER); + + self::assertSame(345, $unbilled['minutes']); + self::assertSame([['currency_id' => self::CURRENCY, 'minutes' => 345, 'amount' => 34500]], $unbilled['currencies']); + self::assertSame(['Landing page', 'Pricing page', 'Onboarding flow', 'Ad hoc call'], array_column($unbilled['groups']['task'], 'label')); + self::assertSame(['Website', 'Mobile app', 'No project'], array_column($unbilled['groups']['project'], 'label')); + self::assertSame(['Ada Lovelace', 'Grace Hopper'], array_column($unbilled['groups']['member'], 'label')); + self::assertSame([345], array_column($unbilled['groups']['summary'], 'minutes')); + } + + public function test_unbilled_never_shows_non_billable_time_a_running_timer_or_another_customer(): void + { + $billable = $this->entry($this->landing, 7, 60, '2026-09-01'); + $this->entry($this->landing, 7, 60, '2026-09-02', ['billable' => false]); + $this->entry($this->landing, 7, 0, '2026-09-03', ['running_user_id' => 7, 'ended_at' => null]); + $otherCustomer = $this->task('Other customer', null, 43); + $this->entry($otherCustomer, 7, 60, '2026-09-04'); + + self::assertSame([(int) $billable->id], $this->billing->unbilled(self::COMPANY, self::CUSTOMER)['entry_ids']); + } + + public function test_unbilled_never_shows_time_on_an_internal_project(): void + { + $internal = $this->makeProject(self::COMPANY, ['name' => 'Internal tooling', 'customer_id' => null]); + $strayTask = $this->task('Stray', $internal); + Task::query()->whereKey($strayTask->id)->update(['customer_id' => self::CUSTOMER]); + $this->entry($strayTask, 7, 60, '2026-09-01', ['project_id' => $internal->id]); + + self::assertSame([], $this->billing->unbilled(self::COMPANY, self::CUSTOMER)['entry_ids']); + } + + public function test_unbilled_drops_an_entry_whose_invoice_still_exists(): void + { + $open = $this->entry($this->landing, 7, 60, '2026-09-01'); + $this->entry($this->landing, 7, 60, '2026-09-02', ['invoice_id' => 77, 'invoice_item_id' => 5]); + $this->companyData->withInvoices(self::COMPANY, 77); + + $unbilled = $this->billing->unbilled(self::COMPANY, self::CUSTOMER); + + self::assertSame([(int) $open->id], $unbilled['entry_ids']); + self::assertSame([['company_id' => self::COMPANY, 'invoice_ids' => [77]]], $this->companyData->invoiceLookups); + } + + public function test_an_entry_whose_invoice_vanished_from_the_host_becomes_unbilled_again(): void + { + $open = $this->entry($this->landing, 7, 60, '2026-09-01'); + $orphan = $this->entry($this->landing, 7, 60, '2026-09-02', ['invoice_id' => 88, 'invoice_item_id' => 5]); + $this->companyData->withInvoices(self::COMPANY, 77); + + self::assertSame( + [(int) $open->id, (int) $orphan->id], + $this->billing->unbilled(self::COMPANY, self::CUSTOMER)['entry_ids'], + ); + } + + public function test_unbilled_honours_the_date_range(): void + { + $this->entry($this->landing, 7, 60, '2026-09-01'); + $inside = $this->entry($this->landing, 7, 60, '2026-09-10'); + $this->entry($this->landing, 7, 60, '2026-09-20'); + + self::assertSame( + [(int) $inside->id], + $this->billing->unbilled(self::COMPANY, self::CUSTOMER, '2026-09-05', '2026-09-15')['entry_ids'], + ); + } + + public function test_prepare_builds_one_line_per_task(): void + { + Carbon::setTestNow('2026-09-15 08:00:00'); + $entries = $this->entries(); + + $payload = $this->billing->prepare(self::COMPANY, BillingSelection::fromEntryIds($this->ids($entries)), 'task'); + + self::assertSame('2026-09-15', $payload['invoice_date']); + self::assertSame(self::CUSTOMER, $payload['customer_id']); + self::assertSame(self::CURRENCY, $payload['currency_id']); + self::assertSame(0, $payload['discount']); + self::assertSame('fixed', $payload['discount_type']); + self::assertSame(0, $payload['discount_val']); + self::assertSame(0, $payload['tax']); + self::assertSame(34500, $payload['sub_total']); + self::assertSame(34500, $payload['total']); + + self::assertSame([ + ['name' => '#1 Landing page', 'description' => "2026-09-01 1.00 h\n2026-09-02 0.50 h", 'quantity' => 1.5, 'price' => 6000, 'discount_type' => 'fixed', 'discount' => 0, 'discount_val' => 0, 'tax' => 0, 'taxes' => [], 'total' => 9000], + ['name' => '#2 Pricing page', 'description' => '2026-09-03 1.50 h', 'quantity' => 1.5, 'price' => 6000, 'discount_type' => 'fixed', 'discount' => 0, 'discount_val' => 0, 'tax' => 0, 'taxes' => [], 'total' => 9000], + ['name' => '#3 Onboarding flow', 'description' => '2026-09-04 2.00 h', 'quantity' => 2.0, 'price' => 6000, 'discount_type' => 'fixed', 'discount' => 0, 'discount_val' => 0, 'tax' => 0, 'taxes' => [], 'total' => 12000], + ['name' => '#4 Ad hoc call', 'description' => '2026-09-05 0.75 h', 'quantity' => 0.75, 'price' => 6000, 'discount_type' => 'fixed', 'discount' => 0, 'discount_val' => 0, 'tax' => 0, 'taxes' => [], 'total' => 4500], + ], $payload['items']); + + self::assertSame([ + ['entry_ids' => [(int) $entries[0]->id, (int) $entries[1]->id]], + ['entry_ids' => [(int) $entries[2]->id]], + ['entry_ids' => [(int) $entries[3]->id]], + ['entry_ids' => [(int) $entries[4]->id]], + ], $payload['groups']); + self::assertCount(count($payload['items']), $payload['groups']); + } + + public function test_prepare_carries_every_key_the_host_invoice_writer_reads(): void + { + $entries = $this->entries(); + + $payload = $this->billing->prepare(self::COMPANY, BillingSelection::fromEntryIds($this->ids($entries)), 'summary'); + + self::assertSame([ + 'invoice_date', + 'customer_id', + 'currency_id', + 'discount', + 'discount_type', + 'discount_val', + 'tax', + 'sub_total', + 'total', + 'notes', + 'template_name', + 'taxes', + 'items', + 'groups', + ], array_keys($payload)); + + self::assertNull($payload['notes']); + self::assertNull($payload['template_name']); + self::assertSame([], $payload['taxes']); + + self::assertSame([ + 'name', + 'description', + 'quantity', + 'price', + 'discount_type', + 'discount', + 'discount_val', + 'tax', + 'taxes', + 'total', + ], array_keys($payload['items'][0])); + } + + public function test_unbilled_lists_every_entry_with_the_names_the_review_step_shows(): void + { + $first = $this->entry($this->landing, 7, 60, '2026-09-01', ['description' => 'Hero section']); + $adHoc = $this->entry($this->adHoc, 8, 45, '2026-09-05'); + + $unbilled = $this->billing->unbilled(self::COMPANY, self::CUSTOMER); + + self::assertSame([ + [ + 'id' => (int) $first->id, + 'task_id' => (int) $this->landing->id, + 'task_name' => 'Landing page', + 'project_id' => (int) $this->website->id, + 'project_name' => 'Website', + 'user_id' => 7, + 'user_name' => 'Ada Lovelace', + 'date' => '2026-09-01', + 'minutes' => 60, + 'amount' => 6000, + 'rate' => self::RATE, + 'currency_id' => self::CURRENCY, + 'description' => 'Hero section', + ], + [ + 'id' => (int) $adHoc->id, + 'task_id' => (int) $this->adHoc->id, + 'task_name' => 'Ad hoc call', + 'project_id' => null, + 'project_name' => null, + 'user_id' => 8, + 'user_name' => 'Grace Hopper', + 'date' => '2026-09-05', + 'minutes' => 45, + 'amount' => 4500, + 'rate' => self::RATE, + 'currency_id' => self::CURRENCY, + 'description' => null, + ], + ], $unbilled['entries']); + } + + public function test_unbilled_names_an_entry_logged_by_someone_who_has_left(): void + { + $this->entry($this->landing, 99, 60, '2026-09-01'); + + self::assertSame( + ['Removed member'], + array_column($this->billing->unbilled(self::COMPANY, self::CUSTOMER)['entries'], 'user_name'), + ); + } + + public function test_customers_rolls_up_the_unbilled_time_of_every_customer(): void + { + $this->entries(); + $this->entry($this->task('Their logo', null, 43), 7, 30, '2026-09-06'); + + self::assertSame([ + ['customer_id' => self::CUSTOMER, 'entries' => 5, 'minutes' => 345, 'amount' => 34500, 'currency_id' => self::CURRENCY], + ['customer_id' => 43, 'entries' => 1, 'minutes' => 30, 'amount' => 3000, 'currency_id' => self::CURRENCY], + ], $this->billing->customers(self::COMPANY)); + } + + public function test_customers_gives_a_customer_billed_in_two_currencies_a_row_each(): void + { + $this->entry($this->landing, 7, 60, '2026-09-01'); + $this->entry($this->landing, 7, 60, '2026-09-02', ['currency_id' => 4]); + + self::assertSame([ + ['customer_id' => self::CUSTOMER, 'entries' => 1, 'minutes' => 60, 'amount' => 6000, 'currency_id' => self::CURRENCY], + ['customer_id' => self::CUSTOMER, 'entries' => 1, 'minutes' => 60, 'amount' => 6000, 'currency_id' => 4], + ], $this->billing->customers(self::COMPANY)); + } + + public function test_customers_applies_the_same_rule_the_unbilled_list_does(): void + { + $this->entry($this->landing, 7, 60, '2026-09-01'); + $this->entry($this->landing, 7, 60, '2026-09-02', ['billable' => false]); + $this->entry($this->landing, 7, 0, '2026-09-03', ['running_user_id' => 7, 'ended_at' => null]); + $this->entry($this->landing, 7, 60, '2026-09-04', ['invoice_id' => 77, 'invoice_item_id' => 5]); + $this->companyData->withInvoices(self::COMPANY, 77); + + $internal = $this->makeProject(self::COMPANY, ['name' => 'Internal tooling', 'customer_id' => null]); + $stray = $this->task('Stray', $internal); + Task::query()->whereKey($stray->id)->update(['customer_id' => self::CUSTOMER]); + $this->entry($stray, 7, 60, '2026-09-05', ['project_id' => $internal->id]); + + self::assertSame([ + ['customer_id' => self::CUSTOMER, 'entries' => 1, 'minutes' => 60, 'amount' => 6000, 'currency_id' => self::CURRENCY], + ], $this->billing->customers(self::COMPANY)); + } + + public function test_customers_honours_the_date_range_and_ignores_another_company(): void + { + $this->entry($this->landing, 7, 60, '2026-09-01'); + $this->entry($this->landing, 7, 90, '2026-09-10'); + $foreignTask = $this->makeTask(10, ['customer_id' => self::CUSTOMER]); + $this->makeEntry(10, (int) $foreignTask->id); + + self::assertSame([ + ['customer_id' => self::CUSTOMER, 'entries' => 1, 'minutes' => 90, 'amount' => 9000, 'currency_id' => self::CURRENCY], + ], $this->billing->customers(self::COMPANY, '2026-09-05', '2026-09-15')); + } + + public function test_customers_is_empty_when_nothing_is_waiting_to_be_billed(): void + { + self::assertSame([], $this->billing->customers(self::COMPANY)); + } + + public function test_prepare_builds_one_line_per_project(): void + { + $this->noteSettings(); + $entries = $this->entries(); + + $payload = $this->billing->prepare(self::COMPANY, BillingSelection::fromEntryIds($this->ids($entries)), 'project'); + + self::assertSame([ + ['name' => 'Website', 'description' => null, 'quantity' => 3.0, 'price' => 6000, 'discount_type' => 'fixed', 'discount' => 0, 'discount_val' => 0, 'tax' => 0, 'taxes' => [], 'total' => 18000], + ['name' => 'Mobile app', 'description' => null, 'quantity' => 2.0, 'price' => 6000, 'discount_type' => 'fixed', 'discount' => 0, 'discount_val' => 0, 'tax' => 0, 'taxes' => [], 'total' => 12000], + ['name' => 'No project', 'description' => null, 'quantity' => 0.75, 'price' => 6000, 'discount_type' => 'fixed', 'discount' => 0, 'discount_val' => 0, 'tax' => 0, 'taxes' => [], 'total' => 4500], + ], $payload['items']); + self::assertSame(34500, $payload['total']); + } + + public function test_prepare_builds_one_line_per_member_and_names_a_leaver(): void + { + $this->noteSettings(); + $entries = $this->entries(); + $entries[] = $this->entry($this->landing, 99, 60, '2026-09-06'); + + $payload = $this->billing->prepare(self::COMPANY, BillingSelection::fromEntryIds($this->ids($entries)), 'member'); + + self::assertSame([ + ['name' => 'Ada Lovelace', 'description' => null, 'quantity' => 3.25, 'price' => 6000, 'discount_type' => 'fixed', 'discount' => 0, 'discount_val' => 0, 'tax' => 0, 'taxes' => [], 'total' => 19500], + ['name' => 'Grace Hopper', 'description' => null, 'quantity' => 2.5, 'price' => 6000, 'discount_type' => 'fixed', 'discount' => 0, 'discount_val' => 0, 'tax' => 0, 'taxes' => [], 'total' => 15000], + ['name' => 'Removed member', 'description' => null, 'quantity' => 1.0, 'price' => 6000, 'discount_type' => 'fixed', 'discount' => 0, 'discount_val' => 0, 'tax' => 0, 'taxes' => [], 'total' => 6000], + ], $payload['items']); + } + + public function test_prepare_collapses_everything_into_one_summary_line(): void + { + $this->noteSettings(); + $entries = $this->entries(); + + $payload = $this->billing->prepare(self::COMPANY, BillingSelection::fromEntryIds($this->ids($entries)), 'summary'); + + self::assertSame([ + ['name' => 'Time', 'description' => null, 'quantity' => 5.75, 'price' => 6000, 'discount_type' => 'fixed', 'discount' => 0, 'discount_val' => 0, 'tax' => 0, 'taxes' => [], 'total' => 34500], + ], $payload['items']); + self::assertSame([['entry_ids' => $this->ids($entries)]], $payload['groups']); + } + + public function test_a_line_over_two_rates_bills_the_blended_rate(): void + { + $this->noteSettings(); + $first = $this->entry($this->landing, 7, 60, '2026-09-01', ['rate' => 6000, 'amount' => 6000]); + $second = $this->entry($this->landing, 7, 30, '2026-09-02', ['rate' => 12000, 'amount' => 6000]); + + $payload = $this->billing->prepare(self::COMPANY, BillingSelection::fromEntryIds($this->ids([$first, $second])), 'task'); + + self::assertSame( + [['name' => '#1 Landing page', 'description' => null, 'quantity' => 1.5, 'price' => 8000, 'discount_type' => 'fixed', 'discount' => 0, 'discount_val' => 0, 'tax' => 0, 'taxes' => [], 'total' => 12000]], + $payload['items'], + ); + } + + public function test_entry_descriptions_become_one_note_line_each(): void + { + $this->noteSettings('invoice_entry_descriptions'); + $first = $this->entry($this->landing, 7, 60, '2026-09-01', ['description' => 'Hero section']); + $second = $this->entry($this->landing, 7, 60, '2026-09-02', ['description' => 'Hero section']); + $third = $this->entry($this->landing, 7, 60, '2026-09-03', ['description' => 'Footer']); + + $payload = $this->billing->prepare(self::COMPANY, BillingSelection::fromEntryIds($this->ids([$first, $second, $third])), 'task'); + + // Two days of the same work are two days of work, not one line: the + // note follows the time log rather than de-duplicating it. + self::assertSame("Hero section\nHero section\nFooter", $payload['items'][0]['description']); + } + + public function test_prepare_refuses_a_selection_spanning_two_customers(): void + { + $ours = $this->entry($this->landing, 7, 60, '2026-09-01'); + $theirs = $this->entry($this->task('Theirs', null, 43), 7, 60, '2026-09-02'); + + $this->expectException(MixedBillingSelection::class); + $this->expectExceptionMessage('more than one customer'); + + $this->billing->prepare(self::COMPANY, BillingSelection::fromEntryIds($this->ids([$ours, $theirs])), 'task'); + } + + public function test_prepare_refuses_a_selection_spanning_two_currencies(): void + { + $euros = $this->entry($this->landing, 7, 60, '2026-09-01'); + $pounds = $this->entry($this->landing, 7, 60, '2026-09-02', ['currency_id' => 4]); + + $this->expectException(MixedBillingSelection::class); + $this->expectExceptionMessage('more than one currency'); + + $this->billing->prepare(self::COMPANY, BillingSelection::fromEntryIds($this->ids([$euros, $pounds])), 'task'); + } + + public function test_prepare_refuses_an_empty_selection(): void + { + $this->expectException(MixedBillingSelection::class); + $this->expectExceptionMessage('No time entries were selected.'); + + $this->billing->prepare(self::COMPANY, BillingSelection::fromEntryIds([]), 'task'); + } + + public function test_prepare_refuses_an_entry_of_another_company(): void + { + $foreignTask = $this->makeTask(10, ['customer_id' => self::CUSTOMER]); + $foreign = $this->makeEntry(10, (int) $foreignTask->id); + + $this->expectException(UnknownTimeEntries::class); + $this->expectExceptionMessage("Time entries {$foreign->id} do not belong to this company."); + + $this->billing->prepare(self::COMPANY, BillingSelection::fromEntryIds([(int) $foreign->id]), 'task'); + } + + public function test_prepare_refuses_non_billable_time(): void + { + $entry = $this->entry($this->landing, 7, 60, '2026-09-01', ['billable' => false]); + + $this->expectException(NotBillable::class); + $this->expectExceptionMessage("Time entries {$entry->id} are not billable."); + + $this->billing->prepare(self::COMPANY, BillingSelection::fromEntryIds([(int) $entry->id]), 'task'); + } + + public function test_prepare_refuses_a_timer_that_is_still_running(): void + { + $entry = $this->entry($this->landing, 7, 0, '2026-09-01', ['running_user_id' => 7, 'ended_at' => null]); + + $this->expectException(NotBillable::class); + $this->expectExceptionMessage('still running'); + + $this->billing->prepare(self::COMPANY, BillingSelection::fromEntryIds([(int) $entry->id]), 'task'); + } + + public function test_prepare_refuses_time_that_is_already_on_a_live_invoice(): void + { + $entry = $this->entry($this->landing, 7, 60, '2026-09-01', ['invoice_id' => 77]); + $this->companyData->withInvoices(self::COMPANY, 77); + + $this->expectException(EntriesAlreadyInvoiced::class); + + $this->billing->prepare(self::COMPANY, BillingSelection::fromEntryIds([(int) $entry->id]), 'task'); + } + + public function test_prepare_re_bills_time_whose_invoice_vanished(): void + { + $entry = $this->entry($this->landing, 7, 60, '2026-09-01', ['invoice_id' => 88]); + + $payload = $this->billing->prepare(self::COMPANY, BillingSelection::fromEntryIds([(int) $entry->id]), 'task'); + + self::assertSame(6000, $payload['total']); + } + + public function test_prepare_refuses_a_grouping_it_does_not_know(): void + { + $entry = $this->entry($this->landing, 7, 60, '2026-09-01'); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage("Grouping 'weekday' is not one of task, project, member, summary."); + + $this->billing->prepare(self::COMPANY, BillingSelection::fromEntryIds([(int) $entry->id]), 'weekday'); + } + + public function test_prepare_refuses_time_with_no_customer_to_bill(): void + { + $internal = $this->makeProject(self::COMPANY, ['name' => 'Internal', 'customer_id' => null]); + $task = $this->task('Internal work', $internal); + $entry = $this->entry($task, 7, 60, '2026-09-01', ['project_id' => $internal->id]); + + $this->expectException(NotBillable::class); + $this->expectExceptionMessage('has no customer to bill'); + + $this->billing->prepare(self::COMPANY, BillingSelection::fromEntryIds([(int) $entry->id]), 'task'); + } + + public function test_confirm_stamps_every_entry_with_its_line(): void + { + Carbon::setTestNow('2026-09-15 08:00:00'); + $first = $this->entry($this->landing, 7, 60, '2026-09-01'); + $second = $this->entry($this->pricing, 7, 60, '2026-09-02'); + + $stamped = $this->billing->confirm(self::COMPANY, 77, [ + ['invoice_item_id' => 101, 'entry_ids' => [(int) $first->id]], + ['invoice_item_id' => 102, 'entry_ids' => [(int) $second->id]], + ]); + + self::assertSame(2, $stamped); + self::assertSame(77, $first->fresh()->invoice_id); + self::assertSame(101, $first->fresh()->invoice_item_id); + self::assertSame('2026-09-15 08:00:00', $first->fresh()->invoiced_at?->toDateTimeString()); + self::assertSame(102, $second->fresh()->invoice_item_id); + } + + public function test_confirm_can_be_replayed_after_a_half_finished_round_trip(): void + { + $entry = $this->entry($this->landing, 7, 60, '2026-09-01'); + $items = [['invoice_item_id' => 101, 'entry_ids' => [(int) $entry->id]]]; + + self::assertSame(1, $this->billing->confirm(self::COMPANY, 77, $items)); + self::assertSame(0, $this->billing->confirm(self::COMPANY, 77, $items)); + self::assertSame(77, $entry->fresh()->invoice_id); + } + + public function test_confirm_refuses_an_entry_that_belongs_to_another_invoice(): void + { + $entry = $this->entry($this->landing, 7, 60, '2026-09-01', ['invoice_id' => 77, 'invoice_item_id' => 101]); + + $this->expectException(EntriesAlreadyInvoiced::class); + $this->expectExceptionMessage("Time entry {$entry->id} is already stamped with invoice 77."); + + $this->billing->confirm(self::COMPANY, 78, [['invoice_item_id' => 201, 'entry_ids' => [(int) $entry->id]]]); + } + + public function test_confirm_refuses_an_entry_of_another_company_and_stamps_nothing(): void + { + $ours = $this->entry($this->landing, 7, 60, '2026-09-01'); + $foreignTask = $this->makeTask(10); + $foreign = $this->makeEntry(10, (int) $foreignTask->id); + + try { + $this->billing->confirm(self::COMPANY, 77, [ + ['invoice_item_id' => 101, 'entry_ids' => [(int) $ours->id, (int) $foreign->id]], + ]); + self::fail('Expected the confirmation to be refused.'); + } catch (UnknownTimeEntries $exception) { + self::assertSame("Time entries {$foreign->id} do not belong to this company.", $exception->getMessage()); + } + + self::assertNull($ours->fresh()->invoice_id); + } + + public function test_prepare_invoices_every_unbilled_entry_of_the_named_tasks(): void + { + $this->noteSettings(); + $first = $this->entry($this->landing, 7, 60, '2026-09-01'); + $second = $this->entry($this->landing, 8, 30, '2026-09-02'); + $third = $this->entry($this->pricing, 7, 90, '2026-09-03'); + $this->entry($this->onboarding, 7, 60, '2026-09-04'); + + $payload = $this->billing->prepare( + self::COMPANY, + BillingSelection::fromTaskIds([(int) $this->landing->id, (int) $this->pricing->id]), + ); + + self::assertSame(['#1 Landing page', '#2 Pricing page'], array_column($payload['items'], 'name')); + self::assertSame([ + ['entry_ids' => [(int) $first->id, (int) $second->id]], + ['entry_ids' => [(int) $third->id]], + ], $payload['groups']); + self::assertSame(18000, $payload['total']); + } + + public function test_a_task_selection_takes_only_the_time_that_can_be_billed_today(): void + { + $open = $this->entry($this->landing, 7, 60, '2026-09-01'); + $this->entry($this->landing, 7, 60, '2026-09-02', ['billable' => false]); + $this->entry($this->landing, 7, 0, '2026-09-03', ['running_user_id' => 7, 'ended_at' => null]); + $this->entry($this->landing, 7, 60, '2026-09-04', ['invoice_id' => 77, 'invoice_item_id' => 5]); + $orphan = $this->entry($this->landing, 7, 30, '2026-09-05', ['invoice_id' => 88, 'invoice_item_id' => 6]); + $this->companyData->withInvoices(self::COMPANY, 77); + + $payload = $this->billing->prepare(self::COMPANY, BillingSelection::fromTaskIds([(int) $this->landing->id])); + + // 88 was deleted in the host, so that half hour is unbilled again. + self::assertSame([['entry_ids' => [(int) $open->id, (int) $orphan->id]]], $payload['groups']); + self::assertSame([['company_id' => self::COMPANY, 'invoice_ids' => [77, 88]]], $this->companyData->invoiceLookups); + } + + public function test_a_project_selection_covers_every_task_filed_under_it(): void + { + $this->noteSettings(); + $this->entry($this->landing, 7, 60, '2026-09-01'); + $this->entry($this->pricing, 7, 90, '2026-09-02'); + $this->entry($this->onboarding, 7, 60, '2026-09-03'); + $this->entry($this->adHoc, 7, 45, '2026-09-04'); + + $payload = $this->billing->prepare(self::COMPANY, BillingSelection::fromProject((int) $this->website->id)); + + self::assertSame(['#1 Landing page', '#2 Pricing page'], array_column($payload['items'], 'name')); + self::assertSame(15000, $payload['total']); + } + + public function test_a_task_selection_with_nothing_left_to_bill_is_refused(): void + { + $this->entry($this->landing, 7, 60, '2026-09-01', ['billable' => false]); + + $this->expectException(NothingToInvoice::class); + $this->expectExceptionMessage('No unbilled billable time on the selected tasks.'); + + $this->billing->prepare(self::COMPANY, BillingSelection::fromTaskIds([(int) $this->landing->id])); + } + + public function test_a_project_with_no_unbilled_time_is_refused(): void + { + $this->expectException(NothingToInvoice::class); + $this->expectExceptionMessage('No unbilled billable time on the selected tasks.'); + + $this->billing->prepare(self::COMPANY, BillingSelection::fromProject((int) $this->mobile->id)); + } + + public function test_a_task_selection_spanning_two_customers_names_them_in_the_refusal(): void + { + $theirs = $this->task('Theirs', null, 43); + $this->entry($this->landing, 7, 60, '2026-09-01'); + $this->entry($theirs, 7, 60, '2026-09-02'); + + try { + $this->billing->prepare( + self::COMPANY, + BillingSelection::fromTaskIds([(int) $this->landing->id, (int) $theirs->id]), + ); + self::fail('Expected the selection to be refused.'); + } catch (MixedBillingSelection $exception) { + self::assertStringContainsString('more than one customer', $exception->getMessage()); + self::assertSame(['customer_ids' => [self::CUSTOMER, 43]], $exception->context()); + } + } + + public function test_prepare_refuses_a_task_of_another_company(): void + { + $foreignTask = $this->makeTask(10, ['customer_id' => self::CUSTOMER]); + $this->makeEntry(10, (int) $foreignTask->id); + + $this->expectException(ModelNotFoundException::class); + + $this->billing->prepare(self::COMPANY, BillingSelection::fromTaskIds([(int) $foreignTask->id])); + } + + public function test_prepare_refuses_a_project_of_another_company(): void + { + $foreignProject = $this->makeProject(10, ['name' => 'Theirs', 'customer_id' => self::CUSTOMER]); + + $this->expectException(ModelNotFoundException::class); + + $this->billing->prepare(self::COMPANY, BillingSelection::fromProject((int) $foreignProject->id)); + } + + public function test_a_note_line_carries_exactly_the_parts_the_company_asked_for(): void + { + $entry = $this->entry($this->landing, 7, 90, '2026-09-01', ['description' => 'Hero section']); + $selection = BillingSelection::fromEntryIds([(int) $entry->id]); + + $this->noteSettings(); + self::assertNull($this->billing->prepare(self::COMPANY, $selection)['items'][0]['description']); + + $this->noteSettings('invoice_entry_dates'); + self::assertSame('2026-09-01', $this->billing->prepare(self::COMPANY, $selection)['items'][0]['description']); + + $this->noteSettings('invoice_entry_times'); + self::assertSame('09:00-10:30', $this->billing->prepare(self::COMPANY, $selection)['items'][0]['description']); + + $this->noteSettings('invoice_entry_hours'); + self::assertSame('1.50 h', $this->billing->prepare(self::COMPANY, $selection)['items'][0]['description']); + + $this->noteSettings('invoice_entry_descriptions'); + self::assertSame('Hero section', $this->billing->prepare(self::COMPANY, $selection)['items'][0]['description']); + + $this->noteSettings('invoice_entry_dates', 'invoice_entry_times', 'invoice_entry_hours', 'invoice_entry_descriptions'); + self::assertSame( + '2026-09-01 09:00-10:30 1.50 h Hero section', + $this->billing->prepare(self::COMPANY, $selection)['items'][0]['description'], + ); + } + + public function test_an_entry_with_nothing_switched_on_to_show_leaves_no_note_line(): void + { + $this->noteSettings('invoice_entry_times', 'invoice_entry_descriptions'); + $described = $this->entry($this->landing, 7, 60, '2026-09-01', ['description' => 'Hero section']); + $manual = $this->entry($this->landing, 7, 60, '2026-09-02', ['started_at' => null, 'ended_at' => null]); + + $payload = $this->billing->prepare(self::COMPANY, BillingSelection::fromEntryIds($this->ids([$described, $manual]))); + + self::assertSame('09:00-10:00 Hero section', $payload['items'][0]['description']); + } + + public function test_a_note_can_open_with_the_project_name_and_the_task_description(): void + { + Task::query()->whereKey($this->landing->id)->update(['description' => " Rebuild the hero \n"]); + $entry = $this->entry($this->landing, 7, 60, '2026-09-01'); + $selection = BillingSelection::fromEntryIds([(int) $entry->id]); + + $this->noteSettings('invoice_project_heading', 'invoice_task_description', 'invoice_entry_dates'); + self::assertSame( + "## Website\nRebuild the hero\n2026-09-01", + $this->billing->prepare(self::COMPANY, $selection)['items'][0]['description'], + ); + + $this->noteSettings('invoice_entry_dates'); + self::assertSame('2026-09-01', $this->billing->prepare(self::COMPANY, $selection)['items'][0]['description']); + } + + public function test_the_task_description_never_heads_a_line_that_is_not_one_task(): void + { + Task::query()->whereKey($this->landing->id)->update(['description' => 'Rebuild the hero']); + $this->noteSettings('invoice_project_heading', 'invoice_task_description', 'invoice_entry_dates'); + $entry = $this->entry($this->landing, 7, 60, '2026-09-01'); + + $payload = $this->billing->prepare(self::COMPANY, BillingSelection::fromEntryIds([(int) $entry->id]), 'member'); + + self::assertSame("## Website\n2026-09-01", $payload['items'][0]['description']); + } + + public function test_a_note_too_long_to_print_says_how_many_entries_it_left_out(): void + { + $this->noteSettings('invoice_entry_dates', 'invoice_entry_descriptions'); + $entries = []; + for ($day = 1; $day <= 60; $day++) { + $entries[] = $this->entry($this->landing, 7, 60, '2026-09-01', ['description' => str_repeat('x', 60)]); + } + + $payload = $this->billing->prepare(self::COMPANY, BillingSelection::fromEntryIds($this->ids($entries))); + $description = (string) $payload['items'][0]['description']; + $lines = explode("\n", $description); + + self::assertLessThanOrEqual(InvoiceLineComposer::MAX_LENGTH, mb_strlen($description)); + self::assertCount(28, $lines); + self::assertSame('and 33 more entries', end($lines)); + self::assertSame('2026-09-01 '.str_repeat('x', 60), $lines[0]); + } + + public function test_a_note_that_drops_exactly_one_entry_says_so_in_the_singular(): void + { + $this->noteSettings('invoice_entry_dates', 'invoice_entry_descriptions'); + $entries = [ + $this->entry($this->landing, 7, 60, '2026-09-01', ['description' => str_repeat('x', 990)]), + $this->entry($this->landing, 7, 60, '2026-09-02', ['description' => str_repeat('x', 990)]), + ]; + + $payload = $this->billing->prepare(self::COMPANY, BillingSelection::fromEntryIds($this->ids($entries))); + $description = (string) $payload['items'][0]['description']; + $lines = explode("\n", $description); + + self::assertLessThanOrEqual(InvoiceLineComposer::MAX_LENGTH, mb_strlen($description)); + self::assertCount(2, $lines); + self::assertSame('and 1 more entry', end($lines)); + self::assertSame('2026-09-01 '.str_repeat('x', 990), $lines[0]); + } + + /** + * Turn on exactly these line note settings, and nothing else. + * + * Most of what `prepare()` returns has nothing to do with the notes, so a + * test that is about quantities or groups says so by switching every part + * off, and a test that is about one part switches on only that one. + */ + private function noteSettings(string ...$on): void + { + foreach (array_keys(ModuleSettings::FLAGS) as $key) { + if (str_starts_with($key, 'invoice_')) { + $this->settings->putCompany(self::COMPANY, ModuleSettings::PREFIX.$key, in_array($key, $on, true)); + } + } + } + + private function task(string $name, ?Project $project, int $customerId = self::CUSTOMER): Task + { + return $this->makeTask(self::COMPANY, [ + 'name' => $name, + 'project_id' => $project?->id, + 'customer_id' => $project === null ? $customerId : $project->customer_id, + ]); + } + + /** + * @param array $attributes + */ + private function entry(Task $task, int $userId, int $minutes, string $day, array $attributes = []): TimeEntry + { + return $this->makeEntry(self::COMPANY, (int) $task->id, $attributes + [ + 'project_id' => $task->project_id, + 'user_id' => $userId, + 'started_at' => Carbon::parse($day.' 09:00:00'), + 'ended_at' => Carbon::parse($day.' 09:00:00')->addMinutes($minutes), + 'duration_minutes' => $minutes, + 'rate' => self::RATE, + 'amount' => (int) round($minutes / 60 * self::RATE), + 'currency_id' => self::CURRENCY, + ]); + } + + /** @return list */ + private function entries(): array + { + return [ + $this->entry($this->landing, 7, 60, '2026-09-01'), + $this->entry($this->landing, 8, 30, '2026-09-02'), + $this->entry($this->pricing, 7, 90, '2026-09-03'), + $this->entry($this->onboarding, 8, 120, '2026-09-04'), + $this->entry($this->adHoc, 7, 45, '2026-09-05'), + ]; + } + + /** + * @param list $entries + * @return list + */ + private function ids(array $entries): array + { + return array_map(static fn (TimeEntry $entry): int => (int) $entry->id, $entries); + } +} diff --git a/tests/Unit/BoardOrderingServiceTest.php b/tests/Unit/BoardOrderingServiceTest.php new file mode 100644 index 0000000..2a2dd3a --- /dev/null +++ b/tests/Unit/BoardOrderingServiceTest.php @@ -0,0 +1,128 @@ +board = new BoardOrderingService; + $this->status = $this->makeStatus(self::COMPANY); + } + + public function test_the_first_card_of_an_empty_column_takes_one_step(): void + { + self::assertSame('1024.0000000000', $this->board->positionFor(self::COMPANY, (int) $this->status->id)); + } + + public function test_appending_lands_one_step_past_the_last_card(): void + { + $this->cardAt('1024'); + $this->cardAt('2048'); + + self::assertSame('3072.0000000000', $this->board->positionFor(self::COMPANY, (int) $this->status->id)); + } + + public function test_dropping_under_the_last_card_lands_one_step_past_it(): void + { + $last = $this->cardAt('2048'); + + self::assertSame( + '3072.0000000000', + $this->board->positionFor(self::COMPANY, (int) $this->status->id, (int) $last->id), + ); + } + + public function test_prepending_halves_the_first_cards_position(): void + { + $first = $this->cardAt('1024'); + + self::assertSame( + '512.0000000000', + $this->board->positionFor(self::COMPANY, (int) $this->status->id, null, (int) $first->id), + ); + } + + public function test_dropping_between_two_cards_takes_the_midpoint(): void + { + $above = $this->cardAt('1024'); + $below = $this->cardAt('2048'); + + self::assertSame( + '1536.0000000000', + $this->board->positionFor(self::COMPANY, (int) $this->status->id, (int) $above->id, (int) $below->id), + ); + } + + public function test_a_gap_too_small_to_halve_renormalises_the_column_first(): void + { + $above = $this->cardAt('1024.0000000000'); + $below = $this->cardAt('1024.0000005000'); + $tail = $this->cardAt('4096.0000000000'); + + $position = $this->board->positionFor(self::COMPANY, (int) $this->status->id, (int) $above->id, (int) $below->id); + + self::assertSame('1536.0000000000', $position); + self::assertSame('1024.0000000000', $above->fresh()->board_position); + self::assertSame('2048.0000000000', $below->fresh()->board_position); + self::assertSame('3072.0000000000', $tail->fresh()->board_position); + } + + public function test_renormalise_rewrites_the_column_to_whole_steps_in_order(): void + { + $third = $this->cardAt('9000.0000000000'); + $first = $this->cardAt('12.5000000000'); + $second = $this->cardAt('900.0000000000'); + + self::assertSame(3, $this->board->renormalise(self::COMPANY, (int) $this->status->id)); + + self::assertSame('1024.0000000000', $first->fresh()->board_position); + self::assertSame('2048.0000000000', $second->fresh()->board_position); + self::assertSame('3072.0000000000', $third->fresh()->board_position); + } + + public function test_it_refuses_a_neighbour_from_another_company(): void + { + $foreign = $this->cardAt('1024', 10); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage("Task {$foreign->id} does not belong to company 9."); + + $this->board->positionFor(self::COMPANY, (int) $this->status->id, (int) $foreign->id); + } + + public function test_it_refuses_a_neighbour_from_another_column(): void + { + $other = $this->makeStatus(self::COMPANY, ['name' => 'Done', 'position' => 2, 'is_default' => false]); + $card = $this->cardAt('1024'); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage("Task {$card->id} is not in task status {$other->id}."); + + $this->board->positionFor(self::COMPANY, (int) $other->id, (int) $card->id); + } + + private function cardAt(string $position, int $companyId = self::COMPANY): Task + { + return $this->makeTask($companyId, [ + 'task_status_id' => $this->status->id, + 'board_position' => $position, + ]); + } +} diff --git a/tests/Unit/BoardQueryTest.php b/tests/Unit/BoardQueryTest.php new file mode 100644 index 0000000..e330215 --- /dev/null +++ b/tests/Unit/BoardQueryTest.php @@ -0,0 +1,71 @@ +board(); + $second = $this->card('Second', (int) $statuses['Backlog']->id, '2048'); + $first = $this->card('First', (int) $statuses['Backlog']->id, '1024'); + $done = $this->card('Done card', (int) $statuses['Done']->id, '1024'); + + $columns = (new BoardQuery)->columns(self::COMPANY); + + self::assertSame( + ['Backlog', 'In Progress', 'Review', 'Done'], + array_map(static fn (array $column): string => $column['status']->name, $columns), + ); + self::assertSame([(int) $first->id, (int) $second->id], array_map( + static fn (Task $task): int => (int) $task->id, + $columns[0]['tasks'], + )); + self::assertSame([], $columns[1]['tasks']); + self::assertSame([(int) $done->id], array_map(static fn (Task $task): int => (int) $task->id, $columns[3]['tasks'])); + } + + public function test_it_filters_by_project_and_assignee_and_never_leaves_the_company(): void + { + $statuses = $this->board(); + $backlog = (int) $statuses['Backlog']->id; + $project = $this->makeProject(self::COMPANY); + $mine = $this->card('Mine', $backlog, '1024', ['project_id' => $project->id, 'assignee_id' => 7]); + $this->card('Theirs', $backlog, '2048', ['project_id' => $project->id, 'assignee_id' => 8]); + $this->card('Other project', $backlog, '3072', ['assignee_id' => 7]); + $this->makeTask(10, ['name' => 'Another company']); + + $columns = (new BoardQuery)->columns(self::COMPANY, (int) $project->id, 7); + + self::assertSame([(int) $mine->id], array_map(static fn (Task $task): int => (int) $task->id, $columns[0]['tasks'])); + } + + /** @return array */ + private function board(): array + { + $statuses = new TaskStatusService; + $statuses->ensureDefaults(self::COMPANY); + + return $statuses->listFor(self::COMPANY)->keyBy('name')->all(); + } + + /** @param array $attributes */ + private function card(string $name, int $statusId, string $position, array $attributes = []): Task + { + return $this->makeTask(self::COMPANY, $attributes + [ + 'name' => $name, + 'task_status_id' => $statusId, + 'board_position' => $position, + ]); + } +} diff --git a/tests/Unit/MigrationRollbackTest.php b/tests/Unit/MigrationRollbackTest.php new file mode 100644 index 0000000..d294587 --- /dev/null +++ b/tests/Unit/MigrationRollbackTest.php @@ -0,0 +1,62 @@ + */ + private const TABLES = [ + 'tp_projects', + 'tp_project_members', + 'tp_task_statuses', + 'tp_tasks', + 'tp_time_entries', + ]; + + public function test_every_module_table_is_created(): void + { + foreach (self::TABLES as $table) { + self::assertTrue(Schema::hasTable($table), "Expected table {$table} to exist."); + } + } + + public function test_the_migrations_roll_back_and_forward_again(): void + { + $this->artisan('migrate:rollback', $this->migrationPath())->run(); + + foreach (self::TABLES as $table) { + self::assertFalse(Schema::hasTable($table), "Expected table {$table} to be dropped."); + } + + $this->artisan('migrate', $this->migrationPath())->run(); + + foreach (self::TABLES as $table) { + self::assertTrue(Schema::hasTable($table), "Expected table {$table} to come back."); + } + } + + /** @return array{--path: string, --realpath: bool} */ + private function migrationPath(): array + { + return ['--path' => dirname(__DIR__, 2).'/database/migrations', '--realpath' => true]; + } + + public function test_the_projects_table_carries_the_columns_the_services_write(): void + { + self::assertTrue(Schema::hasColumns('tp_projects', [ + 'company_id', 'customer_id', 'name', 'identifier', 'description', 'colour', + 'status', 'currency_id', 'default_rate', 'budget_minutes', 'due_date', 'creator_id', + ])); + + self::assertTrue(Schema::hasColumns('tp_time_entries', [ + 'company_id', 'task_id', 'project_id', 'user_id', 'started_at', 'ended_at', + 'duration_minutes', 'description', 'billable', 'rate', 'amount', 'currency_id', + 'running_user_id', 'invoice_id', 'invoice_item_id', 'invoiced_at', + ])); + } +} diff --git a/tests/Unit/ModuleRoutesTest.php b/tests/Unit/ModuleRoutesTest.php new file mode 100644 index 0000000..fabc679 --- /dev/null +++ b/tests/Unit/ModuleRoutesTest.php @@ -0,0 +1,130 @@ + */ + private const MIDDLEWARE = ['api', 'auth:sanctum', 'company', 'bouncer']; + + public function test_it_registers_the_documented_route_table(): void + { + $routes = array_map( + static fn (Route $route): array => [ + implode('|', array_values(array_diff($route->methods(), ['HEAD']))), + $route->uri(), + (string) $route->getName(), + ], + $this->moduleRoutes(), + ); + + self::assertSame([ + ['GET', 'api/v1/tasks-projects/projects', 'tasks-projects.projects.index'], + ['POST', 'api/v1/tasks-projects/projects', 'tasks-projects.projects.store'], + ['GET', 'api/v1/tasks-projects/projects/{id}', 'tasks-projects.projects.show'], + ['PUT', 'api/v1/tasks-projects/projects/{id}', 'tasks-projects.projects.update'], + ['DELETE', 'api/v1/tasks-projects/projects/{id}', 'tasks-projects.projects.destroy'], + ['POST', 'api/v1/tasks-projects/projects/{id}/archive', 'tasks-projects.projects.archive'], + ['POST', 'api/v1/tasks-projects/projects/{id}/unarchive', 'tasks-projects.projects.unarchive'], + ['GET', 'api/v1/tasks-projects/projects/{id}/members', 'tasks-projects.project-members.index'], + ['POST', 'api/v1/tasks-projects/projects/{id}/members', 'tasks-projects.project-members.store'], + ['DELETE', 'api/v1/tasks-projects/projects/{id}/members/{userId}', 'tasks-projects.project-members.destroy'], + ['GET', 'api/v1/tasks-projects/members', 'tasks-projects.members.index'], + ['GET', 'api/v1/tasks-projects/tasks', 'tasks-projects.tasks.index'], + ['POST', 'api/v1/tasks-projects/tasks', 'tasks-projects.tasks.store'], + ['POST', 'api/v1/tasks-projects/tasks/bulk', 'tasks-projects.tasks.bulk'], + ['GET', 'api/v1/tasks-projects/tasks/{id}', 'tasks-projects.tasks.show'], + ['PUT', 'api/v1/tasks-projects/tasks/{id}', 'tasks-projects.tasks.update'], + ['DELETE', 'api/v1/tasks-projects/tasks/{id}', 'tasks-projects.tasks.destroy'], + ['POST', 'api/v1/tasks-projects/tasks/{id}/move', 'tasks-projects.tasks.move'], + ['POST', 'api/v1/tasks-projects/tasks/{id}/start', 'tasks-projects.tasks.start'], + ['POST', 'api/v1/tasks-projects/tasks/{id}/stop', 'tasks-projects.tasks.stop'], + ['GET', 'api/v1/tasks-projects/tasks/{id}/time-log', 'tasks-projects.tasks.time-log'], + ['GET', 'api/v1/tasks-projects/board', 'tasks-projects.board.index'], + ['GET', 'api/v1/tasks-projects/task-statuses', 'tasks-projects.task-statuses.index'], + ['POST', 'api/v1/tasks-projects/task-statuses', 'tasks-projects.task-statuses.store'], + ['POST', 'api/v1/tasks-projects/task-statuses/reorder', 'tasks-projects.task-statuses.reorder'], + ['PUT', 'api/v1/tasks-projects/task-statuses/{id}', 'tasks-projects.task-statuses.update'], + ['DELETE', 'api/v1/tasks-projects/task-statuses/{id}', 'tasks-projects.task-statuses.destroy'], + ['GET', 'api/v1/tasks-projects/time-entries', 'tasks-projects.time-entries.index'], + ['POST', 'api/v1/tasks-projects/time-entries', 'tasks-projects.time-entries.store'], + ['GET', 'api/v1/tasks-projects/time-entries/{id}', 'tasks-projects.time-entries.show'], + ['PUT', 'api/v1/tasks-projects/time-entries/{id}', 'tasks-projects.time-entries.update'], + ['DELETE', 'api/v1/tasks-projects/time-entries/{id}', 'tasks-projects.time-entries.destroy'], + ['GET', 'api/v1/tasks-projects/timer', 'tasks-projects.timer.show'], + ['DELETE', 'api/v1/tasks-projects/timer', 'tasks-projects.timer.destroy'], + ['POST', 'api/v1/tasks-projects/timer/start', 'tasks-projects.timer.start'], + ['POST', 'api/v1/tasks-projects/timer/stop', 'tasks-projects.timer.stop'], + ['GET', 'api/v1/tasks-projects/billing/customers', 'tasks-projects.billing.customers'], + ['GET', 'api/v1/tasks-projects/billing/unbilled', 'tasks-projects.billing.unbilled'], + ['POST', 'api/v1/tasks-projects/billing/prepare', 'tasks-projects.billing.prepare'], + ['POST', 'api/v1/tasks-projects/billing/confirm', 'tasks-projects.billing.confirm'], + ['GET', 'api/v1/tasks-projects/reports/summary', 'tasks-projects.reports.summary'], + ['GET', 'api/v1/tasks-projects/settings', 'tasks-projects.settings.show'], + ], $routes); + } + + public function test_every_route_sits_behind_the_host_middleware_stack(): void + { + foreach ($this->moduleRoutes() as $route) { + foreach (self::MIDDLEWARE as $middleware) { + self::assertContains( + $middleware, + $route->middleware(), + "Route {$route->uri()} is missing the {$middleware} middleware.", + ); + } + } + } + + public function test_every_route_is_named_and_prefixed_by_the_module_slug(): void + { + foreach ($this->moduleRoutes() as $route) { + self::assertStringStartsWith('tasks-projects.', (string) $route->getName()); + } + } + + public function test_no_controller_of_the_module_is_reachable_outside_the_slug_prefix(): void + { + foreach ($this->app['router']->getRoutes()->getRoutes() as $route) { + $action = $route->getAction('controller'); + + if (! is_string($action) || ! str_starts_with($action, 'Modules\\TasksProjects\\')) { + continue; + } + + self::assertStringStartsWith(self::PREFIX.'/', $route->uri()); + } + } + + /** @return list */ + private function moduleRoutes(): array + { + $routes = []; + + foreach ($this->app['router']->getRoutes()->getRoutes() as $route) { + if (str_starts_with($route->uri(), self::PREFIX)) { + $routes[] = $route; + } + } + + self::assertNotSame([], $routes, 'The module registered no routes at all.'); + + return $routes; + } +} diff --git a/tests/Unit/ProjectServiceTest.php b/tests/Unit/ProjectServiceTest.php new file mode 100644 index 0000000..156fbd3 --- /dev/null +++ b/tests/Unit/ProjectServiceTest.php @@ -0,0 +1,253 @@ +projects = new ProjectService($this->companyData); + $this->members = new ProjectMemberService($this->projects); + } + + public function test_a_new_project_starts_active(): void + { + $project = $this->projects->create(self::COMPANY, ['name' => 'Website', 'customer_id' => 42]); + + self::assertSame(Project::STATUS_ACTIVE, $project->status); + self::assertSame(self::COMPANY, $project->company_id); + self::assertFalse($project->isInternal()); + } + + public function test_a_project_without_a_customer_is_internal(): void + { + $project = $this->projects->create(self::COMPANY, ['name' => 'Internal tooling']); + + self::assertTrue($project->isInternal()); + } + + public function test_it_never_reaches_a_project_of_another_company(): void + { + $foreign = $this->makeProject(10); + + $this->expectException(ModelNotFoundException::class); + + $this->projects->findForCompany(self::COMPANY, (int) $foreign->id); + } + + public function test_listing_is_scoped_to_the_company_and_filtered(): void + { + $this->projects->create(self::COMPANY, ['name' => 'Alpha', 'customer_id' => 42]); + $archived = $this->projects->create(self::COMPANY, ['name' => 'Beta', 'customer_id' => 43]); + $this->projects->archive(self::COMPANY, (int) $archived->id); + $this->projects->create(10, ['name' => 'Elsewhere']); + + // 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()); + } + + public function test_archiving_and_unarchiving_flips_the_status(): void + { + $project = $this->projects->create(self::COMPANY, ['name' => 'Website']); + + self::assertSame(Project::STATUS_ARCHIVED, $this->projects->archive(self::COMPANY, (int) $project->id)->status); + self::assertSame(Project::STATUS_ACTIVE, $this->projects->unarchive(self::COMPANY, (int) $project->id)->status); + } + + public function test_changing_the_customer_rewrites_the_tasks_that_follow_the_project(): void + { + $project = $this->projects->create(self::COMPANY, ['name' => 'Website', 'customer_id' => 42]); + $task = $this->makeTask(self::COMPANY, ['project_id' => $project->id, 'customer_id' => 42]); + + $this->projects->update(self::COMPANY, (int) $project->id, ['customer_id' => 43]); + + self::assertSame(43, $task->fresh()->customer_id); + } + + public function test_a_new_project_inherits_the_currency_of_its_customer(): void + { + $this->companyData->withCustomer(self::COMPANY, 42, 3); + + $project = $this->projects->create(self::COMPANY, ['name' => 'Website', 'customer_id' => 42]); + + self::assertSame(3, $project->currency_id); + } + + public function test_a_named_currency_survives_the_customer_it_was_filed_under(): void + { + $this->companyData->withCustomer(self::COMPANY, 42, 3); + + $project = $this->projects->create(self::COMPANY, [ + 'name' => 'Website', + 'customer_id' => 42, + 'currency_id' => 4, + ]); + + self::assertSame(4, $project->currency_id); + } + + public function test_an_internal_project_and_a_customer_without_a_currency_stay_currencyless(): void + { + $this->companyData->withCustomer(self::COMPANY, 43, null); + + $internal = $this->projects->create(self::COMPANY, ['name' => 'Internal tooling']); + $unpriced = $this->projects->create(self::COMPANY, ['name' => 'Favour', 'customer_id' => 43]); + + self::assertNull($internal->currency_id); + self::assertNull($unpriced->currency_id); + } + + public function test_changing_the_customer_moves_the_project_to_that_customers_currency(): void + { + $this->companyData->withCustomer(self::COMPANY, 42, 3)->withCustomer(self::COMPANY, 43, 4); + + $project = $this->projects->create(self::COMPANY, ['name' => 'Website', 'customer_id' => 42]); + $moved = $this->projects->update(self::COMPANY, (int) $project->id, ['customer_id' => 43]); + + self::assertSame(4, $moved->currency_id); + } + + public function test_an_update_that_leaves_the_customer_alone_leaves_the_currency_alone(): void + { + $this->companyData->withCustomer(self::COMPANY, 42, 3); + + $project = $this->projects->create(self::COMPANY, [ + 'name' => 'Website', + 'customer_id' => 42, + 'currency_id' => 4, + ]); + $renamed = $this->projects->update(self::COMPANY, (int) $project->id, ['name' => 'Website 2']); + + self::assertSame(4, $renamed->currency_id); + } + + public function test_an_explicit_null_currency_clears_it(): void + { + $this->companyData->withCustomer(self::COMPANY, 42, 3); + + $project = $this->projects->create(self::COMPANY, ['name' => 'Website', 'customer_id' => 42]); + $cleared = $this->projects->update(self::COMPANY, (int) $project->id, [ + 'customer_id' => 42, + 'currency_id' => null, + ]); + + self::assertNull($cleared->currency_id); + } + + public function test_a_member_is_attached_with_a_rate_and_reattaching_updates_it(): void + { + $project = $this->projects->create(self::COMPANY, ['name' => 'Website']); + + $this->members->attach(self::COMPANY, (int) $project->id, 7, 5000); + $this->members->attach(self::COMPANY, (int) $project->id, 7, 6000); + + $members = $this->members->listFor(self::COMPANY, (int) $project->id); + + self::assertCount(1, $members); + self::assertSame(6000, $members->first()->rate); + } + + public function test_detaching_a_member_leaves_their_time_entries_alone(): void + { + $project = $this->projects->create(self::COMPANY, ['name' => 'Website']); + $task = $this->makeTask(self::COMPANY, ['project_id' => $project->id]); + $this->members->attach(self::COMPANY, (int) $project->id, 7, 5000); + $entry = $this->makeEntry(self::COMPANY, (int) $task->id, ['project_id' => $project->id, 'user_id' => 7]); + + $this->members->detach(self::COMPANY, (int) $project->id, 7); + + self::assertSame(0, ProjectMember::query()->forCompany(self::COMPANY)->count()); + self::assertSame(7, $entry->fresh()->user_id); + } + + public function test_detaching_someone_who_is_not_a_member_is_refused(): void + { + $project = $this->projects->create(self::COMPANY, ['name' => 'Website']); + + $this->expectException(ModelNotFoundException::class); + + $this->members->detach(self::COMPANY, (int) $project->id, 7); + } + + public function test_members_of_another_companys_project_are_out_of_reach(): void + { + $foreign = $this->makeProject(10); + + $this->expectException(ModelNotFoundException::class); + + $this->members->listFor(self::COMPANY, (int) $foreign->id); + } + + public function test_deleting_a_project_removes_its_members_tasks_and_time(): void + { + $project = $this->projects->create(self::COMPANY, ['name' => 'Website']); + $task = $this->makeTask(self::COMPANY, ['project_id' => $project->id]); + $this->members->attach(self::COMPANY, (int) $project->id, 7); + $this->makeEntry(self::COMPANY, (int) $task->id, ['project_id' => $project->id]); + + $this->projects->delete(self::COMPANY, (int) $project->id); + + self::assertSame(0, Project::query()->forCompany(self::COMPANY)->count()); + self::assertSame(0, Task::query()->forCompany(self::COMPANY)->count()); + self::assertSame(0, TimeEntry::query()->forCompany(self::COMPANY)->count()); + self::assertSame(0, ProjectMember::query()->forCompany(self::COMPANY)->count()); + } + + public function test_a_project_with_invoiced_time_is_archived_not_deleted(): void + { + $project = $this->projects->create(self::COMPANY, ['name' => 'Website']); + $task = $this->makeTask(self::COMPANY, ['project_id' => $project->id]); + $this->makeEntry(self::COMPANY, (int) $task->id, ['project_id' => $project->id, 'invoice_id' => 77]); + + $this->expectException(ProjectInUse::class); + $this->expectExceptionMessage("Project {$project->id} has invoiced time entries and cannot be deleted."); + + $this->projects->delete(self::COMPANY, (int) $project->id); + } + + public function test_totals_count_tasks_and_split_billable_from_unbilled_money(): void + { + $project = $this->projects->create(self::COMPANY, ['name' => 'Website', 'customer_id' => 42, 'currency_id' => 3]); + $open = $this->makeTask(self::COMPANY, ['project_id' => $project->id]); + $this->makeTask(self::COMPANY, [ + 'project_id' => $project->id, + 'task_status_id' => $open->task_status_id, + 'closed_at' => '2026-09-10 10:00:00', + ]); + + $this->makeEntry(self::COMPANY, (int) $open->id, ['project_id' => $project->id, 'duration_minutes' => 60, 'rate' => 10000, 'amount' => 10000]); + $this->makeEntry(self::COMPANY, (int) $open->id, ['project_id' => $project->id, 'duration_minutes' => 30, 'rate' => 10000, 'amount' => 5000, 'invoice_id' => 77]); + $this->makeEntry(self::COMPANY, (int) $open->id, ['project_id' => $project->id, 'duration_minutes' => 45, 'rate' => 10000, 'amount' => 7500, 'billable' => false]); + + $totals = $this->projects->totals($project->fresh()); + + self::assertSame(['total' => 2, 'open' => 1, 'closed' => 1], $totals['tasks']); + self::assertSame(135, $totals['logged_minutes']); + self::assertSame(90, $totals['billable_minutes']); + self::assertSame(15000, $totals['billable_amount']); + self::assertSame(10000, $totals['unbilled_amount']); + self::assertSame(3, $totals['currency_id']); + } +} diff --git a/tests/Unit/RateResolverTest.php b/tests/Unit/RateResolverTest.php new file mode 100644 index 0000000..ae1e1f9 --- /dev/null +++ b/tests/Unit/RateResolverTest.php @@ -0,0 +1,86 @@ +settings->putCompany(self::COMPANY, ModuleSettings::PREFIX.'default_rate', 1000); + $project = $this->makeProject(self::COMPANY, ['default_rate' => 2000]); + $this->makeMember(self::COMPANY, (int) $project->id, self::USER, 3000); + $task = $this->makeTask(self::COMPANY, ['project_id' => $project->id, 'rate' => 4000]); + + self::assertSame(4000, $this->resolve($task->id)); + } + + public function test_the_members_rate_on_the_project_beats_the_project_default(): void + { + $project = $this->makeProject(self::COMPANY, ['default_rate' => 2000]); + $this->makeMember(self::COMPANY, (int) $project->id, self::USER, 3000); + $task = $this->makeTask(self::COMPANY, ['project_id' => $project->id]); + + self::assertSame(3000, $this->resolve($task->id)); + } + + public function test_a_member_without_a_rate_of_their_own_falls_through_to_the_project(): void + { + $project = $this->makeProject(self::COMPANY, ['default_rate' => 2000]); + $this->makeMember(self::COMPANY, (int) $project->id, self::USER); + $task = $this->makeTask(self::COMPANY, ['project_id' => $project->id]); + + self::assertSame(2000, $this->resolve($task->id)); + } + + public function test_another_members_rate_never_applies(): void + { + $project = $this->makeProject(self::COMPANY, ['default_rate' => 2000]); + $this->makeMember(self::COMPANY, (int) $project->id, 8, 9000); + $task = $this->makeTask(self::COMPANY, ['project_id' => $project->id]); + + self::assertSame(2000, $this->resolve($task->id)); + } + + public function test_a_standalone_task_falls_through_to_the_company_default(): void + { + $this->settings->putCompany(self::COMPANY, ModuleSettings::PREFIX.'default_rate', 1500); + $task = $this->makeTask(self::COMPANY, ['customer_id' => 3]); + + self::assertSame(1500, $this->resolve($task->id)); + } + + public function test_it_resolves_to_zero_when_nothing_sets_a_rate(): void + { + $task = $this->makeTask(self::COMPANY); + + self::assertSame(0, $this->resolve($task->id)); + } + + public function test_an_unknown_user_never_picks_up_a_member_rate(): void + { + $project = $this->makeProject(self::COMPANY, ['default_rate' => 2000]); + $this->makeMember(self::COMPANY, (int) $project->id, self::USER, 3000); + $task = $this->makeTask(self::COMPANY, ['project_id' => $project->id]); + + self::assertSame(2000, $this->resolve($task->id, null)); + } + + /** Reload the task so the resolver reads the same row a request would. */ + private function resolve(int $taskId, ?int $userId = self::USER): int + { + $task = Task::query()->findOrFail($taskId); + + return (new RateResolver)->resolve($task, $userId, $this->moduleSettings()); + } +} diff --git a/tests/Unit/ReportServiceTest.php b/tests/Unit/ReportServiceTest.php new file mode 100644 index 0000000..c7fe40b --- /dev/null +++ b/tests/Unit/ReportServiceTest.php @@ -0,0 +1,132 @@ +reports = new ReportService($this->companyData); + $this->companyData->withMember(self::COMPANY, 7, 'Ada Lovelace')->withMember(self::COMPANY, 8, 'Grace Hopper'); + + $this->website = $this->makeProject(self::COMPANY, ['name' => 'Website', 'customer_id' => 42, 'currency_id' => 3]); + $this->billed = $this->makeTask(self::COMPANY, ['name' => 'Landing page', 'project_id' => $this->website->id, 'customer_id' => 42]); + $this->standalone = $this->makeTask(self::COMPANY, ['name' => 'Ad hoc call', 'customer_id' => 43]); + + $this->entry($this->billed, 7, '2026-09-05', 60, 6000, 3); + $this->entry($this->billed, 8, '2026-09-06', 30, 3000, 3, ['billable' => false]); + $this->entry($this->standalone, 7, '2026-09-07', 120, 12000, 3, ['invoice_id' => 77]); + $this->entry($this->standalone, 7, '2026-09-08', 60, 4000, 4); + $this->entry($this->billed, 7, '2026-10-01', 600, 60000, 3); + $this->entry($this->billed, 7, '2026-09-09', 0, 0, 3, ['running_user_id' => 7, 'ended_at' => null]); + } + + public function test_totals_stay_per_currency_and_keep_the_unbilled_value_apart(): void + { + $summary = $this->summary(); + + self::assertSame([ + ['currency_id' => 3, 'minutes' => 210, 'amount' => 21000, 'billable_minutes' => 180, 'billable_amount' => 18000, 'unbilled_amount' => 6000], + ['currency_id' => 4, 'minutes' => 60, 'amount' => 4000, 'billable_minutes' => 60, 'billable_amount' => 4000, 'unbilled_amount' => 4000], + ], $summary['totals']); + } + + public function test_it_splits_time_by_project_member_customer_and_the_billable_flag(): void + { + $summary = $this->summary(); + + self::assertSame([ + [(int) $this->website->id, 'Website', 3, 90], + [null, 'No project', 3, 120], + [null, 'No project', 4, 60], + ], array_map( + static fn (array $row): array => [$row['project_id'], $row['label'], $row['currency_id'], $row['minutes']], + $summary['by_project'], + )); + + self::assertSame([ + [7, 'Ada Lovelace', 3, 180], + [8, 'Grace Hopper', 3, 30], + [7, 'Ada Lovelace', 4, 60], + ], array_map( + static fn (array $row): array => [$row['user_id'], $row['label'], $row['currency_id'], $row['minutes']], + $summary['by_member'], + )); + + self::assertSame([ + [42, 3, 90], + [43, 3, 120], + [43, 4, 60], + ], array_map( + static fn (array $row): array => [$row['customer_id'], $row['currency_id'], $row['minutes']], + $summary['by_customer'], + )); + + self::assertSame([ + [true, 3, 180, 18000], + [false, 3, 30, 3000], + [true, 4, 60, 4000], + ], array_map( + static fn (array $row): array => [$row['billable'], $row['currency_id'], $row['minutes'], $row['amount']], + $summary['by_billable'], + )); + } + + public function test_a_viewer_without_the_ability_only_aggregates_their_own_time(): void + { + $summary = $this->reports->summary(self::COMPANY, '2026-09-01', '2026-09-30', 8, false); + + self::assertSame([ + ['currency_id' => 3, 'minutes' => 30, 'amount' => 3000, 'billable_minutes' => 0, 'billable_amount' => 0, 'unbilled_amount' => 0], + ], $summary['totals']); + } + + public function test_another_companys_time_never_appears(): void + { + $foreign = $this->makeTask(10, ['customer_id' => 42]); + $this->makeEntry(10, (int) $foreign->id, ['started_at' => Carbon::parse('2026-09-05 09:00:00'), 'currency_id' => 3]); + + self::assertSame(210, $this->summary()['totals'][0]['minutes']); + } + + /** @return array */ + private function summary(): array + { + return $this->reports->summary(self::COMPANY, '2026-09-01', '2026-09-30', 7, true); + } + + /** @param array $attributes */ + private function entry(Task $task, int $userId, string $day, int $minutes, int $amount, int $currencyId, array $attributes = []): void + { + $this->makeEntry(self::COMPANY, (int) $task->id, $attributes + [ + 'project_id' => $task->project_id, + 'user_id' => $userId, + 'started_at' => Carbon::parse($day.' 09:00:00'), + 'ended_at' => Carbon::parse($day.' 09:00:00')->addMinutes($minutes), + 'duration_minutes' => $minutes, + 'rate' => 6000, + 'amount' => $amount, + 'currency_id' => $currencyId, + ]); + } +} diff --git a/tests/Unit/RoundingTest.php b/tests/Unit/RoundingTest.php new file mode 100644 index 0000000..85ac7d4 --- /dev/null +++ b/tests/Unit/RoundingTest.php @@ -0,0 +1,86 @@ +expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Rounding increment 7 is not one of 1, 5, 6, 15, 30, 60.'); + + Rounding::roundMinutes(10, 7); + } + + public function test_rounding_up_takes_the_whole_increment_every_time(): void + { + self::assertSame(15, Rounding::roundMinutes(1, 15, Rounding::UP)); + self::assertSame(15, Rounding::roundMinutes(15, 15, Rounding::UP)); + self::assertSame(30, Rounding::roundMinutes(16, 15, Rounding::UP)); + self::assertSame(60, Rounding::roundMinutes(46, 15, Rounding::UP)); + self::assertSame(137, Rounding::roundMinutes(137, 1, Rounding::UP)); + } + + public function test_rounding_down_drops_the_part_increment_and_may_bill_nothing(): void + { + self::assertSame(0, Rounding::roundMinutes(14, 15, Rounding::DOWN)); + self::assertSame(15, Rounding::roundMinutes(15, 15, Rounding::DOWN)); + self::assertSame(15, Rounding::roundMinutes(29, 15, Rounding::DOWN)); + self::assertSame(120, Rounding::roundMinutes(137, 60, Rounding::DOWN)); + self::assertSame(137, Rounding::roundMinutes(137, 1, Rounding::DOWN)); + } + + public function test_nothing_logged_stays_nothing_billed_whichever_way_it_rounds(): void + { + foreach (Rounding::DIRECTIONS as $direction) { + self::assertSame(0, Rounding::roundMinutes(0, 30, $direction)); + self::assertSame(0, Rounding::roundMinutes(-5, 30, $direction)); + } + } + + public function test_it_refuses_a_direction_it_does_not_know(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Rounding direction sideways is not one of nearest, up, down.'); + + Rounding::roundMinutes(10, 15, 'sideways'); + } +} diff --git a/tests/Unit/TaskNumberSequenceTest.php b/tests/Unit/TaskNumberSequenceTest.php new file mode 100644 index 0000000..e512654 --- /dev/null +++ b/tests/Unit/TaskNumberSequenceTest.php @@ -0,0 +1,34 @@ +next(9)); + } + + public function test_it_continues_from_the_highest_number_the_company_has_used(): void + { + $this->makeTask(9, ['number' => 1]); + $this->makeTask(9, ['number' => 7]); + + self::assertSame(8, (new TaskNumberSequence)->next(9)); + } + + public function test_each_company_numbers_its_own_tasks(): void + { + $this->makeTask(9, ['number' => 41]); + + $sequence = new TaskNumberSequence; + + self::assertSame(42, $sequence->next(9)); + self::assertSame(1, $sequence->next(10)); + } +} diff --git a/tests/Unit/TaskServiceTest.php b/tests/Unit/TaskServiceTest.php new file mode 100644 index 0000000..d7df584 --- /dev/null +++ b/tests/Unit/TaskServiceTest.php @@ -0,0 +1,187 @@ +statuses = new TaskStatusService; + $this->tasks = $this->taskService($this->statuses); + } + + public function test_it_denormalises_the_customer_from_the_project(): void + { + $project = $this->makeProject(self::COMPANY, ['customer_id' => 42]); + + $task = $this->tasks->create(self::COMPANY, ['name' => 'Wireframes', 'project_id' => $project->id]); + + self::assertSame(42, $task->customer_id); + self::assertSame((int) $project->id, $task->project_id); + } + + public function test_a_standalone_task_carries_its_own_customer(): void + { + $task = $this->tasks->create(self::COMPANY, ['name' => 'Ad hoc call', 'customer_id' => 42]); + + self::assertNull($task->project_id); + self::assertSame(42, $task->customer_id); + } + + public function test_a_task_on_an_internal_project_has_no_customer(): void + { + $project = $this->makeProject(self::COMPANY, ['customer_id' => null]); + + $task = $this->tasks->create(self::COMPANY, ['name' => 'Internal tooling', 'project_id' => $project->id]); + + self::assertNull($task->customer_id); + } + + public function test_new_tasks_are_numbered_per_company_and_land_in_the_default_status(): void + { + $first = $this->tasks->create(self::COMPANY, ['name' => 'One']); + $second = $this->tasks->create(self::COMPANY, ['name' => 'Two']); + $otherCompany = $this->tasks->create(10, ['name' => 'Elsewhere']); + + self::assertSame(1, $first->number); + self::assertSame(2, $second->number); + self::assertSame(1, $otherCompany->number); + self::assertSame('Backlog', $this->statuses->findForCompany(self::COMPANY, (int) $first->task_status_id)->name); + } + + public function test_new_tasks_are_appended_to_their_column(): void + { + $first = $this->tasks->create(self::COMPANY, ['name' => 'One']); + $second = $this->tasks->create(self::COMPANY, ['name' => 'Two']); + + self::assertSame('1024.0000000000', $first->board_position); + self::assertSame('2048.0000000000', $second->board_position); + } + + public function test_an_explicit_status_wins_over_the_default(): void + { + $this->statuses->ensureDefaults(self::COMPANY); + $review = $this->statuses->listFor(self::COMPANY)->firstWhere('name', 'Review'); + + $task = $this->tasks->create(self::COMPANY, ['name' => 'One', 'task_status_id' => $review->id]); + + self::assertSame((int) $review->id, $task->task_status_id); + self::assertNull($task->closed_at); + } + + public function test_moving_into_a_closed_status_stamps_closed_at_and_leaving_clears_it(): void + { + Carbon::setTestNow('2026-09-15 11:30:00'); + $this->statuses->ensureDefaults(self::COMPANY); + $statuses = $this->statuses->listFor(self::COMPANY)->keyBy('name'); + $task = $this->tasks->create(self::COMPANY, ['name' => 'One']); + + $closed = $this->tasks->update(self::COMPANY, (int) $task->id, ['task_status_id' => $statuses['Done']->id]); + self::assertSame('2026-09-15 11:30:00', $closed->closed_at?->toDateTimeString()); + + $reopened = $this->tasks->update(self::COMPANY, (int) $task->id, ['task_status_id' => $statuses['Review']->id]); + self::assertNull($reopened->closed_at); + } + + public function test_changing_the_project_rewrites_the_denormalised_customer(): void + { + $first = $this->makeProject(self::COMPANY, ['customer_id' => 42]); + $second = $this->makeProject(self::COMPANY, ['name' => 'Second', 'customer_id' => 43]); + $task = $this->tasks->create(self::COMPANY, ['name' => 'One', 'project_id' => $first->id]); + + $moved = $this->tasks->update(self::COMPANY, (int) $task->id, ['project_id' => $second->id]); + + self::assertSame(43, $moved->customer_id); + } + + public function test_move_drops_the_task_between_two_neighbours_of_the_target_column(): void + { + $this->statuses->ensureDefaults(self::COMPANY); + $progress = $this->statuses->listFor(self::COMPANY)->firstWhere('name', 'In Progress'); + $above = $this->tasks->create(self::COMPANY, ['name' => 'Above', 'task_status_id' => $progress->id]); + $below = $this->tasks->create(self::COMPANY, ['name' => 'Below', 'task_status_id' => $progress->id]); + $dragged = $this->tasks->create(self::COMPANY, ['name' => 'Dragged']); + + $moved = $this->tasks->move( + self::COMPANY, + (int) $dragged->id, + (int) $progress->id, + (int) $above->id, + (int) $below->id, + ); + + self::assertSame((int) $progress->id, $moved->task_status_id); + self::assertSame('1536.0000000000', $moved->board_position); + } + + public function test_deleting_a_task_takes_its_uninvoiced_time_with_it(): void + { + $task = $this->tasks->create(self::COMPANY, ['name' => 'One']); + $this->makeEntry(self::COMPANY, (int) $task->id); + + $this->tasks->delete(self::COMPANY, (int) $task->id); + + self::assertSame(0, Task::query()->forCompany(self::COMPANY)->count()); + self::assertSame(0, TimeEntry::query()->forCompany(self::COMPANY)->count()); + } + + public function test_it_refuses_to_delete_a_task_whose_time_is_already_invoiced(): void + { + $task = $this->tasks->create(self::COMPANY, ['name' => 'One']); + $entry = $this->makeEntry(self::COMPANY, (int) $task->id, ['invoice_id' => 77]); + + $this->expectException(EntriesAlreadyInvoiced::class); + $this->expectExceptionMessage("Time entries {$entry->id} are already on an invoice."); + + $this->tasks->delete(self::COMPANY, (int) $task->id); + } + + public function test_it_never_reaches_a_task_of_another_company(): void + { + $foreign = $this->makeTask(10); + + $this->expectException(ModelNotFoundException::class); + + $this->tasks->findForCompany(self::COMPANY, (int) $foreign->id); + } + + public function test_it_refuses_a_project_of_another_company(): void + { + $foreign = $this->makeProject(10); + + $this->expectException(ModelNotFoundException::class); + + $this->tasks->create(self::COMPANY, ['name' => 'One', 'project_id' => $foreign->id]); + } + + public function test_listing_filters_by_project_assignee_and_text(): void + { + $project = $this->makeProject(self::COMPANY, ['customer_id' => 42]); + $this->tasks->create(self::COMPANY, ['name' => 'Landing page', 'project_id' => $project->id, 'assignee_id' => 7]); + $this->tasks->create(self::COMPANY, ['name' => 'Pricing page', 'project_id' => $project->id, 'assignee_id' => 8]); + $this->tasks->create(self::COMPANY, ['name' => 'Ad hoc call']); + + self::assertSame(2, $this->tasks->listFor(self::COMPANY, ['project_id' => (int) $project->id])->count()); + self::assertSame(1, $this->tasks->listFor(self::COMPANY, ['assignee_id' => 8])->count()); + self::assertSame(['Landing page', 'Pricing page'], $this->tasks->listFor(self::COMPANY, ['search' => 'page'])->pluck('name')->all()); + } +} diff --git a/tests/Unit/TaskStatusServiceTest.php b/tests/Unit/TaskStatusServiceTest.php new file mode 100644 index 0000000..61a9521 --- /dev/null +++ b/tests/Unit/TaskStatusServiceTest.php @@ -0,0 +1,156 @@ +statuses = new TaskStatusService; + } + + public function test_it_seeds_backlog_in_progress_review_and_done(): void + { + $this->statuses->ensureDefaults(self::COMPANY); + + $statuses = $this->statuses->listFor(self::COMPANY); + + self::assertSame(['Backlog', 'In Progress', 'Review', 'Done'], $statuses->pluck('name')->all()); + self::assertSame([1, 2, 3, 4], $statuses->pluck('position')->all()); + self::assertSame([true, false, false, false], $statuses->pluck('is_default')->all()); + self::assertSame([false, false, false, true], $statuses->pluck('is_closed')->all()); + } + + public function test_seeding_twice_leaves_one_set_of_columns(): void + { + $this->statuses->ensureDefaults(self::COMPANY); + $this->statuses->ensureDefaults(self::COMPANY); + + self::assertSame(4, TaskStatus::query()->forCompany(self::COMPANY)->count()); + } + + public function test_it_never_seeds_over_a_company_that_already_arranged_its_board(): void + { + $this->makeStatus(self::COMPANY, ['name' => 'Ideas']); + + $this->statuses->ensureDefaults(self::COMPANY); + + self::assertSame(['Ideas'], $this->statuses->listFor(self::COMPANY)->pluck('name')->all()); + } + + public function test_each_company_gets_its_own_board(): void + { + $this->statuses->ensureDefaults(self::COMPANY); + $this->statuses->ensureDefaults(10); + + self::assertSame(4, TaskStatus::query()->forCompany(self::COMPANY)->count()); + self::assertSame(4, TaskStatus::query()->forCompany(10)->count()); + } + + public function test_it_refuses_to_find_a_status_of_another_company(): void + { + $foreign = $this->makeStatus(10); + + $this->expectException(ModelNotFoundException::class); + + $this->statuses->findForCompany(self::COMPANY, (int) $foreign->id); + } + + public function test_only_one_status_carries_the_default_flag(): void + { + $this->statuses->ensureDefaults(self::COMPANY); + $review = $this->statuses->listFor(self::COMPANY)->firstWhere('name', 'Review'); + + $this->statuses->update(self::COMPANY, (int) $review->id, ['is_default' => true]); + + self::assertSame( + ['Review'], + $this->statuses->listFor(self::COMPANY)->where('is_default', true)->pluck('name')->values()->all(), + ); + } + + public function test_reorder_applies_the_wanted_order_and_appends_what_was_left_out(): void + { + $this->statuses->ensureDefaults(self::COMPANY); + $ids = $this->statuses->listFor(self::COMPANY)->pluck('id', 'name'); + + $this->statuses->reorder(self::COMPANY, [(int) $ids['Done'], (int) $ids['Review']]); + + self::assertSame( + ['Done', 'Review', 'Backlog', 'In Progress'], + $this->statuses->listFor(self::COMPANY)->pluck('name')->all(), + ); + } + + public function test_reorder_refuses_a_status_of_another_company(): void + { + $this->statuses->ensureDefaults(self::COMPANY); + $foreign = $this->makeStatus(10); + + $this->expectException(ModelNotFoundException::class); + + $this->statuses->reorder(self::COMPANY, [(int) $foreign->id]); + } + + public function test_it_refuses_to_delete_a_status_that_still_holds_tasks(): void + { + $this->statuses->ensureDefaults(self::COMPANY); + $backlog = $this->statuses->listFor(self::COMPANY)->firstWhere('name', 'Backlog'); + $this->makeTask(self::COMPANY, ['task_status_id' => $backlog->id]); + + $this->expectException(StatusInUse::class); + $this->expectExceptionMessage('still holds 1 task(s)'); + + $this->statuses->delete(self::COMPANY, (int) $backlog->id); + } + + public function test_it_refuses_to_delete_the_last_status(): void + { + $only = $this->makeStatus(self::COMPANY); + + $this->expectException(StatusInUse::class); + $this->expectExceptionMessage('is the last status'); + + $this->statuses->delete(self::COMPANY, (int) $only->id); + } + + public function test_it_refuses_to_delete_the_default_without_another_default(): void + { + $this->statuses->ensureDefaults(self::COMPANY); + $backlog = $this->statuses->listFor(self::COMPANY)->firstWhere('name', 'Backlog'); + + $this->expectException(StatusInUse::class); + $this->expectExceptionMessage('is the default status'); + + $this->statuses->delete(self::COMPANY, (int) $backlog->id); + } + + public function test_an_empty_non_default_status_can_be_deleted(): void + { + $this->statuses->ensureDefaults(self::COMPANY); + $review = $this->statuses->listFor(self::COMPANY)->firstWhere('name', 'Review'); + + $this->statuses->delete(self::COMPANY, (int) $review->id); + + self::assertSame(['Backlog', 'In Progress', 'Done'], $this->statuses->listFor(self::COMPANY)->pluck('name')->all()); + } + + public function test_the_default_status_is_where_new_tasks_land(): void + { + self::assertSame('Backlog', $this->statuses->defaultFor(self::COMPANY)->name); + } +} diff --git a/tests/Unit/TimeEntryServiceTest.php b/tests/Unit/TimeEntryServiceTest.php new file mode 100644 index 0000000..5313bf5 --- /dev/null +++ b/tests/Unit/TimeEntryServiceTest.php @@ -0,0 +1,205 @@ +entries = new TimeEntryService( + new RateResolver, + $this->moduleSettings(), + $this->taskService(), + ); + } + + public function test_a_start_and_an_end_become_minutes(): void + { + $task = $this->makeTask(self::COMPANY); + + $entry = $this->entries->create(self::COMPANY, [ + 'task_id' => $task->id, + 'user_id' => self::USER, + 'started_at' => '2026-09-15 09:00:00', + 'ended_at' => '2026-09-15 10:30:00', + ]); + + self::assertSame(90, $entry->duration_minutes); + } + + public function test_the_company_increment_is_applied_when_the_entry_is_saved(): void + { + $this->settings->putCompany(self::COMPANY, ModuleSettings::PREFIX.'rounding_minutes', 15); + $task = $this->makeTask(self::COMPANY); + + $entry = $this->entries->create(self::COMPANY, [ + 'task_id' => $task->id, + 'user_id' => self::USER, + 'started_at' => '2026-09-15 09:00:00', + 'ended_at' => '2026-09-15 09:50:00', + ]); + + self::assertSame(45, $entry->duration_minutes); + } + + public function test_a_plain_duration_is_taken_as_typed(): void + { + $task = $this->makeTask(self::COMPANY); + + $entry = $this->entries->create(self::COMPANY, [ + 'task_id' => $task->id, + 'user_id' => self::USER, + 'duration_minutes' => 25, + ]); + + self::assertSame(25, $entry->duration_minutes); + self::assertNull($entry->ended_at); + } + + public function test_the_resolved_rate_and_the_cached_amount_are_written_onto_the_entry(): void + { + $project = $this->makeProject(self::COMPANY, ['default_rate' => 6000, 'currency_id' => 3]); + $task = $this->makeTask(self::COMPANY, ['project_id' => $project->id]); + + $entry = $this->entries->create(self::COMPANY, [ + 'task_id' => $task->id, + 'user_id' => self::USER, + 'duration_minutes' => 45, + ]); + + self::assertSame(6000, $entry->rate); + self::assertSame(4500, $entry->amount); + self::assertSame(3, $entry->currency_id); + self::assertSame((int) $project->id, $entry->project_id); + } + + public function test_a_later_rate_change_never_rewrites_what_was_already_logged(): void + { + $project = $this->makeProject(self::COMPANY, ['default_rate' => 6000]); + $task = $this->makeTask(self::COMPANY, ['project_id' => $project->id]); + $logged = $this->entries->create(self::COMPANY, [ + 'task_id' => $task->id, + 'user_id' => self::USER, + 'duration_minutes' => 60, + ]); + + Project::query()->whereKey($project->id)->update(['default_rate' => 9000]); + + $later = $this->entries->create(self::COMPANY, [ + 'task_id' => $task->id, + 'user_id' => self::USER, + 'duration_minutes' => 60, + ]); + + self::assertSame(6000, $logged->fresh()->rate); + self::assertSame(6000, $logged->fresh()->amount); + self::assertSame(9000, $later->rate); + } + + public function test_an_invoiced_entry_keeps_the_money_that_belongs_to_its_invoice(): void + { + $project = $this->makeProject(self::COMPANY, ['default_rate' => 6000]); + $task = $this->makeTask(self::COMPANY, ['project_id' => $project->id]); + $entry = $this->makeEntry(self::COMPANY, (int) $task->id, [ + 'project_id' => $project->id, + 'duration_minutes' => 60, + 'rate' => 6000, + 'amount' => 6000, + 'invoice_id' => 77, + ]); + + Project::query()->whereKey($project->id)->update(['default_rate' => 9000]); + + $updated = $this->entries->update(self::COMPANY, (int) $entry->id, ['description' => 'Typo fix']); + + self::assertSame(6000, $updated->rate); + self::assertSame(6000, $updated->amount); + } + + public function test_editing_an_unbilled_entry_re_rounds_and_re_prices_it(): void + { + $this->settings->putCompany(self::COMPANY, ModuleSettings::PREFIX.'rounding_minutes', 30); + $project = $this->makeProject(self::COMPANY, ['default_rate' => 6000]); + $task = $this->makeTask(self::COMPANY, ['project_id' => $project->id]); + $entry = $this->entries->create(self::COMPANY, [ + 'task_id' => $task->id, + 'user_id' => self::USER, + 'duration_minutes' => 30, + ]); + + $updated = $this->entries->update(self::COMPANY, (int) $entry->id, ['duration_minutes' => 100]); + + self::assertSame(90, $updated->duration_minutes); + self::assertSame(9000, $updated->amount); + } + + public function test_invoiced_time_can_never_be_deleted(): void + { + $task = $this->makeTask(self::COMPANY); + $entry = $this->makeEntry(self::COMPANY, (int) $task->id, ['invoice_id' => 77]); + + $this->expectException(EntriesAlreadyInvoiced::class); + + $this->entries->delete(self::COMPANY, (int) $entry->id); + } + + public function test_unbilled_time_is_deleted(): void + { + $task = $this->makeTask(self::COMPANY); + $entry = $this->makeEntry(self::COMPANY, (int) $task->id); + + $this->entries->delete(self::COMPANY, (int) $entry->id); + + self::assertSame(0, TimeEntry::query()->forCompany(self::COMPANY)->count()); + } + + public function test_it_never_reaches_an_entry_of_another_company(): void + { + $task = $this->makeTask(10); + $foreign = $this->makeEntry(10, (int) $task->id); + + $this->expectException(ModelNotFoundException::class); + + $this->entries->findForCompany(self::COMPANY, (int) $foreign->id); + } + + public function test_a_viewer_without_the_ability_only_ever_sees_their_own_time(): void + { + $task = $this->makeTask(self::COMPANY); + $this->makeEntry(self::COMPANY, (int) $task->id, ['user_id' => self::USER]); + $this->makeEntry(self::COMPANY, (int) $task->id, ['user_id' => 8]); + + $own = $this->entries->listFor(self::COMPANY, ['user_id' => 8], self::USER, false); + $all = $this->entries->listFor(self::COMPANY, [], self::USER, true); + + self::assertSame([self::USER], $own->pluck('user_id')->all()); + self::assertCount(2, $all); + } + + public function test_a_running_timer_is_not_a_timesheet_row_yet(): void + { + $task = $this->makeTask(self::COMPANY); + $this->makeEntry(self::COMPANY, (int) $task->id, ['running_user_id' => self::USER, 'ended_at' => null, 'duration_minutes' => 0]); + + self::assertCount(0, $this->entries->listFor(self::COMPANY, [], self::USER, true)); + } +} diff --git a/tests/Unit/TimerServiceTest.php b/tests/Unit/TimerServiceTest.php new file mode 100644 index 0000000..f0d8bf0 --- /dev/null +++ b/tests/Unit/TimerServiceTest.php @@ -0,0 +1,271 @@ +timer = new TimerService( + $this->taskService(), + new RateResolver, + $this->moduleSettings(), + ); + } + + protected function tearDown(): void + { + TimeEntry::flushEventListeners(); + + parent::tearDown(); + } + + public function test_nothing_is_running_until_the_user_starts_the_clock(): void + { + self::assertNull($this->timer->running(self::COMPANY, self::USER)); + } + + public function test_starting_opens_an_entry_with_no_end_and_no_time_on_it(): void + { + Carbon::setTestNow('2026-09-15 09:00:00'); + $project = $this->makeProject(self::COMPANY, ['currency_id' => 3]); + $task = $this->makeTask(self::COMPANY, ['project_id' => $project->id]); + + $entry = $this->timer->start(self::COMPANY, self::USER, (int) $task->id, 'Pairing on the board'); + + self::assertSame(self::USER, $entry->running_user_id); + self::assertSame(self::USER, $entry->user_id); + self::assertSame('2026-09-15 09:00:00', $entry->started_at?->toDateTimeString()); + self::assertNull($entry->ended_at); + self::assertSame(0, $entry->duration_minutes); + self::assertSame(0, $entry->amount); + self::assertSame(3, $entry->currency_id); + self::assertSame('Pairing on the board', $entry->description); + self::assertTrue($entry->is($this->timer->running(self::COMPANY, self::USER))); + } + + public function test_a_timer_on_another_task_for_the_same_user_is_refused(): void + { + $task = $this->makeTask(self::COMPANY); + $other = $this->makeTask(self::COMPANY, ['name' => 'Something else']); + $this->timer->start(self::COMPANY, self::USER, (int) $task->id); + + $this->expectException(TimerAlreadyRunning::class); + $this->expectExceptionMessage('User 7 already has a running timer in company 9.'); + + $this->timer->start(self::COMPANY, self::USER, (int) $other->id); + } + + public function test_starting_the_task_already_on_the_clock_updates_it_rather_than_refusing(): void + { + Carbon::setTestNow('2026-09-15 09:00:00'); + $task = $this->makeTask(self::COMPANY); + $started = $this->timer->start(self::COMPANY, self::USER, (int) $task->id, 'Reading the ticket'); + + Carbon::setTestNow('2026-09-15 09:20:00'); + $again = $this->timer->start(self::COMPANY, self::USER, (int) $task->id, 'Fixing the importer', false); + + self::assertSame((int) $started->id, (int) $again->id); + self::assertSame('2026-09-15 09:00:00', $again->started_at?->toDateTimeString()); + self::assertSame('Fixing the importer', $again->description); + self::assertFalse($again->billable); + self::assertSame(1, TimeEntry::query()->forCompany(self::COMPANY)->count()); + } + + public function test_starting_the_same_task_again_without_details_changes_nothing(): void + { + $task = $this->makeTask(self::COMPANY); + $this->timer->start(self::COMPANY, self::USER, (int) $task->id, 'Reading the ticket'); + + $again = $this->timer->start(self::COMPANY, self::USER, (int) $task->id); + + self::assertSame('Reading the ticket', $again->description); + self::assertTrue($again->billable); + } + + public function test_starting_can_override_the_tasks_billable_flag(): void + { + $task = $this->makeTask(self::COMPANY, ['billable' => true]); + + $entry = $this->timer->start(self::COMPANY, self::USER, (int) $task->id, null, false); + + self::assertFalse($entry->billable); + } + + public function test_two_users_and_two_companies_each_get_their_own_clock(): void + { + $task = $this->makeTask(self::COMPANY); + $otherCompanyTask = $this->makeTask(10); + + $this->timer->start(self::COMPANY, self::USER, (int) $task->id); + $this->timer->start(self::COMPANY, 8, (int) $task->id); + $this->timer->start(10, self::USER, (int) $otherCompanyTask->id); + + self::assertSame(3, TimeEntry::query()->whereNotNull('running_user_id')->count()); + } + + public function test_a_timer_that_slips_past_the_check_is_still_refused_by_the_unique_index(): void + { + $task = $this->makeTask(self::COMPANY); + $raced = false; + + TimeEntry::creating(function () use (&$raced): void { + if ($raced) { + return; + } + + $raced = true; + DB::table('tp_time_entries')->insert([ + 'company_id' => self::COMPANY, + 'task_id' => 1, + 'user_id' => self::USER, + 'duration_minutes' => 0, + 'billable' => true, + 'rate' => 0, + 'amount' => 0, + 'running_user_id' => self::USER, + ]); + }); + + $this->expectException(TimerAlreadyRunning::class); + + $this->timer->start(self::COMPANY, self::USER, (int) $task->id); + } + + public function test_stopping_closes_the_entry_and_freezes_the_money(): void + { + $project = $this->makeProject(self::COMPANY, ['default_rate' => 6000]); + $task = $this->makeTask(self::COMPANY, ['project_id' => $project->id]); + + Carbon::setTestNow('2026-09-15 09:00:00'); + $this->timer->start(self::COMPANY, self::USER, (int) $task->id); + + Carbon::setTestNow('2026-09-15 10:30:00'); + $entry = $this->timer->stop(self::COMPANY, self::USER); + + self::assertNull($entry->running_user_id); + self::assertSame('2026-09-15 10:30:00', $entry->ended_at?->toDateTimeString()); + self::assertSame(90, $entry->duration_minutes); + self::assertSame(6000, $entry->rate); + self::assertSame(9000, $entry->amount); + self::assertNull($this->timer->running(self::COMPANY, self::USER)); + } + + public function test_stopping_applies_the_description_and_the_billable_flag_it_is_given(): void + { + $task = $this->makeTask(self::COMPANY); + + Carbon::setTestNow('2026-09-15 09:00:00'); + $this->timer->start(self::COMPANY, self::USER, (int) $task->id, 'Reading the ticket'); + + Carbon::setTestNow('2026-09-15 10:00:00'); + $entry = $this->timer->stop(self::COMPANY, self::USER, 'Fixed the importer', false); + + self::assertSame('Fixed the importer', $entry->description); + self::assertFalse($entry->billable); + self::assertSame(60, $entry->duration_minutes); + } + + public function test_stopping_without_details_keeps_what_the_start_recorded(): void + { + $task = $this->makeTask(self::COMPANY); + $this->timer->start(self::COMPANY, self::USER, (int) $task->id, 'Reading the ticket'); + + $entry = $this->timer->stop(self::COMPANY, self::USER); + + self::assertSame('Reading the ticket', $entry->description); + self::assertTrue($entry->billable); + } + + public function test_stopping_on_a_task_forwards_the_details(): void + { + $task = $this->makeTask(self::COMPANY); + $this->timer->start(self::COMPANY, self::USER, (int) $task->id); + + $entry = $this->timer->stopOn(self::COMPANY, self::USER, (int) $task->id, 'Wrote the test', false); + + self::assertSame('Wrote the test', $entry->description); + self::assertFalse($entry->billable); + } + + public function test_a_mismatched_stop_is_refused_before_the_details_are_written(): void + { + $task = $this->makeTask(self::COMPANY); + $idle = $this->makeTask(self::COMPANY, ['name' => 'Idle']); + $this->timer->start(self::COMPANY, self::USER, (int) $task->id, 'Reading the ticket'); + + try { + $this->timer->stopOn(self::COMPANY, self::USER, (int) $idle->id, 'Should never land', false); + self::fail('The mismatched stop should have been refused.'); + } catch (TimerMismatch) { + // The running entry is the assertion: nothing of the refused call landed on it. + } + + $running = $this->timer->running(self::COMPANY, self::USER); + + self::assertNotNull($running); + self::assertSame('Reading the ticket', $running->description); + self::assertTrue($running->billable); + self::assertNull($running->ended_at); + } + + public function test_stopping_rounds_the_elapsed_time_to_the_company_increment(): void + { + $this->settings->putCompany(self::COMPANY, ModuleSettings::PREFIX.'rounding_minutes', 15); + $task = $this->makeTask(self::COMPANY); + + Carbon::setTestNow('2026-09-15 09:00:00'); + $this->timer->start(self::COMPANY, self::USER, (int) $task->id); + + Carbon::setTestNow('2026-09-15 09:50:00'); + + self::assertSame(45, $this->timer->stop(self::COMPANY, self::USER)->duration_minutes); + } + + public function test_stopping_a_clock_that_is_not_running_is_refused(): void + { + $this->expectException(ModelNotFoundException::class); + + $this->timer->stop(self::COMPANY, self::USER); + } + + public function test_discarding_throws_the_entry_away(): void + { + $task = $this->makeTask(self::COMPANY); + $this->timer->start(self::COMPANY, self::USER, (int) $task->id); + + $this->timer->discard(self::COMPANY, self::USER); + + self::assertSame(0, TimeEntry::query()->forCompany(self::COMPANY)->count()); + } + + public function test_it_refuses_to_time_a_task_of_another_company(): void + { + $foreign = $this->makeTask(10); + + $this->expectException(ModelNotFoundException::class); + + $this->timer->start(self::COMPANY, self::USER, (int) $foreign->id); + } +} From 97dbf701067f83c7ead2d164b28b32e146d14082 Mon Sep 17 00:00:00 2001 From: Darko Gjorgjijoski Date: Wed, 16 Sep 2026 02:05:36 +0200 Subject: [PATCH 2/4] feat(ui): Tasks and Projects screens, time tracking and invoicing Task-centric screens in the shape of Invoice Ninja v5: a Tasks screen with list, board and week views, a task page with its time log, project pages, the start dialog with project and task pickers and inline task creation, the save-and-stop dialog with description and billable, the header timer chip and the floating launcher, reports, and invoicing from a task, a selection, a project or the unbilled-time page that lands on the host invoice editor. The built bundle is included. --- dist/init.js | 9064 ++++++++++++++++- dist/style.css | 2 +- package.json | 4 + pnpm-lock.yaml | 17 + resources/css/module.css | 10 +- resources/js/api.ts | 139 + resources/js/api/billing.ts | 236 + resources/js/api/board.ts | 234 + resources/js/api/reports.ts | 94 + resources/js/api/time.ts | 233 + resources/js/components/AllTimeTable.vue | 279 + resources/js/components/BulkActionBar.vue | 104 + .../js/components/InvoiceNumberModal.vue | 81 + .../js/components/InvoiceRetryBanner.vue | 91 + resources/js/components/InvoicedBadge.vue | 46 + resources/js/components/ProjectFormModal.vue | 284 + resources/js/components/QuickStartOverlay.vue | 221 + .../js/components/ReportBreakdownTable.vue | 80 + resources/js/components/StartTimerModal.vue | 297 + resources/js/components/StopTimerModal.vue | 173 + resources/js/components/TaskCard.vue | 153 + resources/js/components/TaskFilters.vue | 158 + resources/js/components/TaskFormModal.vue | 446 + resources/js/components/TaskList.vue | 567 ++ resources/js/components/TaskRunControl.vue | 182 + resources/js/components/TaskStatusEditor.vue | 364 + resources/js/components/TimeEntryModal.vue | 469 + resources/js/components/TimeLogGrid.vue | 329 + resources/js/components/TimerChip.vue | 70 + resources/js/components/ViewSwitcher.vue | 85 + resources/js/components/WeekTimesheet.vue | 267 + resources/js/init.ts | 28 +- resources/js/messages.ts | 73 + resources/js/messages/billing.ts | 99 + resources/js/messages/projects.ts | 75 + resources/js/messages/reports.ts | 60 + resources/js/messages/tasks.ts | 165 + resources/js/messages/time.ts | 190 + resources/js/pages/ProjectDetailPage.vue | 346 + resources/js/pages/ProjectsIndexPage.vue | 423 + resources/js/pages/ReportsPage.vue | 409 + resources/js/pages/TaskPage.vue | 496 + resources/js/pages/TasksPage.vue | 210 + resources/js/pages/TimeSettingsPage.vue | 170 + resources/js/pages/UnbilledTimePage.vue | 537 + .../js/pages/project/ProjectMembersTab.vue | 203 + .../js/pages/project/ProjectOverviewTab.vue | 204 + .../js/pages/project/ProjectTasksTab.vue | 107 + resources/js/pages/project/ProjectTimeTab.vue | 280 + resources/js/pages/tasks/TasksBoardView.vue | 404 + resources/js/pages/tasks/TasksListView.vue | 46 + resources/js/pages/tasks/TasksWeekView.vue | 201 + resources/js/registrations/billing.ts | 52 + resources/js/registrations/projects.ts | 93 + resources/js/registrations/reports.ts | 39 + resources/js/registrations/tasks.ts | 113 + resources/js/registrations/time.ts | 146 + resources/js/stores/customers.ts | 108 + resources/js/stores/invoicing.ts | 118 + resources/js/stores/session.ts | 158 + resources/js/stores/tasks.ts | 152 + resources/js/stores/timer.ts | 596 ++ resources/js/support/errors.ts | 51 + resources/js/support/filters.ts | 158 + resources/js/support/format.ts | 102 + resources/js/support/http.ts | 50 + resources/js/support/i18n.ts | 18 + resources/js/support/invoicing.ts | 459 + resources/js/support/page.ts | 66 + resources/js/support/reports.ts | 72 + resources/js/support/time.ts | 246 + resources/js/types/api.ts | 30 + resources/js/types/billing.ts | 216 + resources/js/types/board.ts | 22 + resources/js/types/member.ts | 7 + resources/js/types/project-member.ts | 21 + resources/js/types/project.ts | 61 + resources/js/types/reports.ts | 64 + resources/js/types/settings.ts | 26 + resources/js/types/task-status.ts | 23 + resources/js/types/task-summary.ts | 11 + resources/js/types/task.ts | 133 + resources/js/types/time-entry.ts | 54 + resources/js/types/timer.ts | 58 + 84 files changed, 23018 insertions(+), 10 deletions(-) create mode 100644 resources/js/api.ts create mode 100644 resources/js/api/billing.ts create mode 100644 resources/js/api/board.ts create mode 100644 resources/js/api/reports.ts create mode 100644 resources/js/api/time.ts create mode 100644 resources/js/components/AllTimeTable.vue create mode 100644 resources/js/components/BulkActionBar.vue create mode 100644 resources/js/components/InvoiceNumberModal.vue create mode 100644 resources/js/components/InvoiceRetryBanner.vue create mode 100644 resources/js/components/InvoicedBadge.vue create mode 100644 resources/js/components/ProjectFormModal.vue create mode 100644 resources/js/components/QuickStartOverlay.vue create mode 100644 resources/js/components/ReportBreakdownTable.vue create mode 100644 resources/js/components/StartTimerModal.vue create mode 100644 resources/js/components/StopTimerModal.vue create mode 100644 resources/js/components/TaskCard.vue create mode 100644 resources/js/components/TaskFilters.vue create mode 100644 resources/js/components/TaskFormModal.vue create mode 100644 resources/js/components/TaskList.vue create mode 100644 resources/js/components/TaskRunControl.vue create mode 100644 resources/js/components/TaskStatusEditor.vue create mode 100644 resources/js/components/TimeEntryModal.vue create mode 100644 resources/js/components/TimeLogGrid.vue create mode 100644 resources/js/components/TimerChip.vue create mode 100644 resources/js/components/ViewSwitcher.vue create mode 100644 resources/js/components/WeekTimesheet.vue create mode 100644 resources/js/messages.ts create mode 100644 resources/js/messages/billing.ts create mode 100644 resources/js/messages/projects.ts create mode 100644 resources/js/messages/reports.ts create mode 100644 resources/js/messages/tasks.ts create mode 100644 resources/js/messages/time.ts create mode 100644 resources/js/pages/ProjectDetailPage.vue create mode 100644 resources/js/pages/ProjectsIndexPage.vue create mode 100644 resources/js/pages/ReportsPage.vue create mode 100644 resources/js/pages/TaskPage.vue create mode 100644 resources/js/pages/TasksPage.vue create mode 100644 resources/js/pages/TimeSettingsPage.vue create mode 100644 resources/js/pages/UnbilledTimePage.vue create mode 100644 resources/js/pages/project/ProjectMembersTab.vue create mode 100644 resources/js/pages/project/ProjectOverviewTab.vue create mode 100644 resources/js/pages/project/ProjectTasksTab.vue create mode 100644 resources/js/pages/project/ProjectTimeTab.vue create mode 100644 resources/js/pages/tasks/TasksBoardView.vue create mode 100644 resources/js/pages/tasks/TasksListView.vue create mode 100644 resources/js/pages/tasks/TasksWeekView.vue create mode 100644 resources/js/registrations/billing.ts create mode 100644 resources/js/registrations/projects.ts create mode 100644 resources/js/registrations/reports.ts create mode 100644 resources/js/registrations/tasks.ts create mode 100644 resources/js/registrations/time.ts create mode 100644 resources/js/stores/customers.ts create mode 100644 resources/js/stores/invoicing.ts create mode 100644 resources/js/stores/session.ts create mode 100644 resources/js/stores/tasks.ts create mode 100644 resources/js/stores/timer.ts create mode 100644 resources/js/support/errors.ts create mode 100644 resources/js/support/filters.ts create mode 100644 resources/js/support/format.ts create mode 100644 resources/js/support/http.ts create mode 100644 resources/js/support/i18n.ts create mode 100644 resources/js/support/invoicing.ts create mode 100644 resources/js/support/page.ts create mode 100644 resources/js/support/reports.ts create mode 100644 resources/js/support/time.ts create mode 100644 resources/js/types/api.ts create mode 100644 resources/js/types/billing.ts create mode 100644 resources/js/types/board.ts create mode 100644 resources/js/types/member.ts create mode 100644 resources/js/types/project-member.ts create mode 100644 resources/js/types/project.ts create mode 100644 resources/js/types/reports.ts create mode 100644 resources/js/types/settings.ts create mode 100644 resources/js/types/task-status.ts create mode 100644 resources/js/types/task-summary.ts create mode 100644 resources/js/types/task.ts create mode 100644 resources/js/types/time-entry.ts create mode 100644 resources/js/types/timer.ts diff --git a/dist/init.js b/dist/init.js index 768ac37..1a2d1c9 100644 --- a/dist/init.js +++ b/dist/init.js @@ -1,5 +1,9067 @@ +const { Fragment: e, Teleport: t, computed: n, createBlock: r, createCommentVNode: i, createElementBlock: a, createElementVNode: o, createTextVNode: s, createVNode: c, defineComponent: l, getCurrentInstance: u, h: d, nextTick: f, normalizeClass: p, normalizeStyle: m, onBeforeUnmount: h, onMounted: g, onScopeDispose: _, openBlock: v, reactive: y, ref: b, renderList: x, resolveComponent: S, toDisplayString: C, unref: w, vShow: T, watch: E, withCtx: D, withDirectives: O, withKeys: k, withModifiers: A } = window.__invoiceshelf_vue; +//#region resources/js/messages.ts +var j = { en: { tasks_projects: { + general: { + home: "Home", + filter: "Filter", + search: "Search", + actions: "Actions", + edit: "Edit", + delete: "Delete", + cancel: "Cancel", + save: "Save", + update: "Update" + }, + projects: { + title: "Projects", + new_project: "New project", + edit_project: "Edit project", + internal: "Internal", + archive: "Archive", + unarchive: "Restore", + search_placeholder: "Search by name or identifier", + empty_title: "No projects yet", + empty_description: "Create a project to group its tasks, time and billing.", + status: { + active: "Active", + archived: "Archived", + all: "All" + }, + columns: { + name: "Name", + status: "Status", + customer: "Customer", + default_rate: "Rate / hour", + due_date: "Due date" + }, + fields: { + name: "Name", + identifier: "Identifier", + identifier_help: "A short code, used as the task number prefix.", + customer: "Customer", + customer_help: "Leave empty for an internal project.", + customer_placeholder: "No customer", + due_date: "Due date", + default_rate: "Default rate", + default_rate_help: "Per hour, in the customer currency.", + budget_hours: "Budget (hours)", + colour: "Colour", + colour_none: "None", + description: "Description" + }, + created: "{name} was created.", + updated: "{name} was updated.", + archived: "{name} was archived.", + unarchived: "{name} was restored.", + deleted: "{name} was deleted.", + delete_confirm: "Delete {name}? Its tasks and time entries go with it.", + name_required: "Enter a project name.", + load_failed: "Unable to load the projects.", + save_failed: "Unable to save the project.", + delete_failed: "Unable to delete the project.", + customers_failed: "Unable to load the customers." + } +} } }, M = y({ + busy: !1, + pending: null, + allowed: !0, + prompt: null +}); +function N() { + return !M.busy && (M.busy = !0, !0); +} +function P() { + M.busy = !1; +} +function F(e) { + M.pending = e; +} +function I() { + M.pending = null; +} +function L() { + M.allowed = !1; +} +function R(e) { + return z(null), new Promise((t) => { + M.prompt = { + suggested: e, + resolve: t + }; + }); +} +function z(e) { + let t = M.prompt; + t !== null && (M.prompt = null, t.resolve(e)); +} +function ee() { + z(null), M.busy = !1, M.pending = null, M.allowed = !0; +} +//#endregion +//#region resources/js/support/i18n.ts +function B() { + return u()?.appContext.config.globalProperties.$t ?? ((e) => e); +} +//#endregion +//#region resources/js/components/InvoiceNumberModal.vue?vue&type=script&setup=true&lang.ts +var te = { class: "flex w-full items-center justify-between" }, ne = { class: "space-y-5 px-6 py-6" }, re = { class: "text-sm text-muted" }, ie = { class: "flex justify-end space-x-3 border-t border-line-default px-6 py-4" }, ae = /* @__PURE__ */ l({ + __name: "InvoiceNumberModal", + setup(e) { + let t = B(), i = b(""), a = n(() => M.prompt !== null), l = n(() => i.value.trim() !== ""); + E(() => M.prompt, (e) => { + i.value = e?.suggested ?? ""; + }); + function u() { + l.value && z(i.value.trim()); + } + function d() { + z(null); + } + return (e, n) => { + let f = S("BaseIcon"), p = S("BaseInput"), m = S("BaseInputGroup"), h = S("BaseButton"), g = S("BaseModal"); + return v(), r(g, { + show: a.value, + onClose: d + }, { + header: D(() => [o("div", te, [o("span", null, C(w(t)("tasks_projects.billing.number.title")), 1), c(f, { + name: "XMarkIcon", + class: "h-6 w-6 cursor-pointer text-subtle hover:text-body", + onClick: d + })])]), + default: D(() => [o("form", { onSubmit: A(u, ["prevent"]) }, [o("div", ne, [o("p", re, C(w(t)("tasks_projects.billing.number.description")), 1), c(m, { + label: w(t)("tasks_projects.billing.number.label"), + required: "" + }, { + default: D(() => [c(p, { + modelValue: i.value, + "onUpdate:modelValue": n[0] ||= (e) => i.value = e, + type: "text", + name: "invoice_number", + autocomplete: "off" + }, null, 8, ["modelValue"])]), + _: 1 + }, 8, ["label"])]), o("div", ie, [c(h, { + type: "button", + variant: "primary-outline", + onClick: d + }, { + default: D(() => [s(C(w(t)("tasks_projects.general.cancel")), 1)]), + _: 1 + }), c(h, { + type: "submit", + variant: "primary", + disabled: !l.value + }, { + default: D(() => [s(C(w(t)("tasks_projects.billing.number.save")), 1)]), + _: 1 + }, 8, ["disabled"])])], 32)]), + _: 1 + }, 8, ["show"]); + }; + } +}), oe = { en: { tasks_projects: { billing: { + title: "Unbilled time", + subtitle: "Time that has not reached an invoice yet, by customer.", + back: "Back to customers", + create: "Create invoice", + busy: "Creating the invoice", + prepare_failed: "Unable to prepare the invoice.", + create_failed: "Unable to create the invoice.", + rate_failed: "Unable to read the exchange rate; the invoice was created without one.", + forbidden: "You are not allowed to invoice time.", + nothing_to_invoice: "No unbilled billable time on the selected tasks.", + mixed_customers: "Select tasks of one customer. This selection spans {count} customers.", + mixed_selection: "One invoice covers one customer in one currency. Narrow the selection.", + pending_stamp: "Finish marking the last invoice as billed before creating another one.", + created: "Invoice {number} was created.", + stamped: "{count} time entry was marked as invoiced. | {count} time entries were marked as invoiced.", + stamp_failed: "Unable to mark the time as invoiced.", + stamp_failed_notice: "Invoice {number} was created, but its time is not marked as invoiced yet.", + stamp_unmatched: "Invoice {number} was created, but its lines could not be matched back to the time behind them.", + number: { + title: "Invoice number", + description: "This company numbers its invoices by hand, so the draft needs a number before it can be created.", + label: "Number", + save: "Create invoice" + }, + retry: { + title: "The invoice was created, but the time is not marked yet", + description: "Invoice {number} exists. Its time entries still count as unbilled until they are marked, which is safe to run again.", + action: "Retry stamping", + open_invoice: "Open the invoice", + dismiss: "Forget this invoice", + dismiss_confirm: "Forget this invoice? Its time stays unbilled and can reach a second invoice." + }, + customer: { + title: "Who has time waiting?", + description: "Customers with billable time that has not reached an invoice yet.", + entries: "{count} entry | {count} entries", + empty_title: "Nothing to invoice", + empty_description: "Billable time appears here once it has been logged against a task that belongs to a customer.", + load_failed: "Unable to load the customers with unbilled time.", + from: "From", + to: "To", + clear_range: "Clear dates" + }, + entries: { + title: "Which time goes on the invoice?", + grouping: "Group lines by", + group_by: { + task: "Task", + project: "Project", + member: "Member", + summary: "One summary line" + }, + select_all: "Select all", + selected: "{count} of {total} entries selected", + selected_total: "Selected: {hours}", + no_description: "No description", + columns: { + date: "Date", + task: "Task", + project: "Project", + member: "Member", + duration: "Duration", + amount: "Amount" + }, + empty_title: "No unbilled time", + empty_description: "This customer has nothing waiting to be invoiced in this range.", + load_failed: "Unable to load the unbilled time.", + none_selected: "Select at least one entry." + } +} } } }, se = "/api/v1/tasks-projects", ce = { + customers: `${se}/billing/customers`, + unbilled: `${se}/billing/unbilled`, + prepare: `${se}/billing/prepare`, + confirm: `${se}/billing/confirm` +}, le = { + bootstrap: "/api/v1/bootstrap", + customer: (e) => `/api/v1/customers/${e}`, + invoices: "/api/v1/invoices", + invoiceTemplates: "/api/v1/invoices/templates", + nextNumber: "/api/v1/next-number", + exchangeRate: (e) => `/api/v1/currencies/${e}/exchange-rate` +}; +async function ue(e, t = {}) { + let { data: n } = await e.get(ce.customers, { params: t }); + return n.data ?? []; +} +async function de(e, t, n = {}) { + let { data: r } = await e.get(ce.unbilled, { params: { + customer_id: t, + ...n + } }); + return r.data; +} +async function fe(e, t) { + let { data: n } = await e.post(ce.prepare, pe(t)); + return n.data; +} +function pe(e) { + let t = "taskIds" in e ? { task_ids: e.taskIds } : "projectId" in e ? { project_id: e.projectId } : { entry_ids: e.entryIds }; + return e.grouping !== void 0 && (t.grouping = e.grouping), t; +} +async function me(e, t, n) { + let { data: r } = await e.post(ce.confirm, { + invoice_id: t, + items: n + }); + return r?.stamped ?? 0; +} +async function he(e, t) { + let { data: n } = await e.get(le.customer(t)); + return n?.data ?? null; +} +async function ge(e, t) { + let { data: n } = await e.post(le.invoices, t); + return n.data; +} +async function _e(e) { + let { data: t } = await e.get(le.invoiceTemplates); + return t?.invoiceTemplates ?? []; +} +async function ve(e, t) { + let n = { key: "invoice" }; + t !== void 0 && (n.userId = t); + let { data: r } = await e.get(le.nextNumber, { params: n }); + return r?.success && typeof r.nextNumber == "string" ? r.nextNumber : null; +} +async function ye(e, t) { + let { data: n } = await e.get(le.exchangeRate(t)), r = Array.isArray(n?.exchangeRate) ? n.exchangeRate[0] : n?.exchangeRate, i = Number(r); + return Number.isFinite(i) && i > 0 ? i : null; +} +async function be(e) { + let { data: t } = await e.get(le.bootstrap), n = t?.current_company_settings ?? {}, r = t?.current_user_settings ?? {}, i = Number(n.invoice_due_date_days), a = r.default_invoice_template; + return { + currency: t?.current_company_currency ?? null, + dueDateDays: Number.isFinite(i) && i >= 0 ? i : 0, + setDueDateAutomatically: n.invoice_set_due_date_automatically === "YES", + autoGenerateNumber: n.invoice_auto_generate !== "NO", + defaultTemplate: typeof a == "string" && a !== "" ? a : null + }; +} +//#endregion +//#region resources/js/api/time.ts +var xe = "/api/v1/tasks-projects", Se = { + timeEntries: `${xe}/time-entries`, + timeEntry: (e) => `${xe}/time-entries/${e}`, + timer: `${xe}/timer`, + timerStart: `${xe}/timer/start`, + timerStop: `${xe}/timer/stop`, + taskStatuses: `${xe}/task-statuses`, + taskStatus: (e) => `${xe}/task-statuses/${e}`, + reorderTaskStatuses: `${xe}/task-statuses/reorder`, + tasks: `${xe}/tasks`, + task: (e) => `${xe}/tasks/${e}`, + members: `${xe}/members`, + settings: `${xe}/settings` +}, Ce = { bootstrap: "/api/v1/bootstrap" }, we = 100, Te = 5, Ee = 10; +async function De(e, t) { + let { data: n } = await e.get(Se.timeEntries, { params: t }); + return n; +} +async function Oe(e, t) { + let n = []; + for (let r = 1; r <= Te; r += 1) { + let i = await De(e, { + ...t, + page: r, + limit: we + }); + if (n.push(...i.data ?? []), !i.meta || r >= i.meta.last_page) break; + } + return n; +} +async function ke(e, t) { + let { data: n } = await e.post(Se.timeEntries, t); + return n.data; +} +async function Ae(e, t, n) { + let { data: r } = await e.put(Se.timeEntry(t), n); + return r.data; +} +async function je(e, t) { + await e.delete(Se.timeEntry(t)); +} +async function Me(e) { + let { data: t } = await e.get(Se.timer); + return t?.data ?? null; +} +async function Ne(e, t) { + let { data: n } = await e.post(Se.timerStart, t); + return n.data; +} +async function Pe(e, t = {}) { + let { data: n } = await e.post(Se.timerStop, t); + return n.data; +} +async function Fe(e) { + await e.delete(Se.timer); +} +async function Ie(e) { + let { data: t } = await e.get(Se.taskStatuses); + return t.data ?? []; +} +async function Le(e, t) { + let { data: n } = await e.post(Se.taskStatuses, t); + return n.data; +} +async function Re(e, t, n) { + let { data: r } = await e.put(Se.taskStatus(t), n); + return r.data; +} +async function ze(e, t) { + await e.delete(Se.taskStatus(t)); +} +async function Be(e, t) { + let { data: n } = await e.post(Se.reorderTaskStatuses, { ids: t }); + return n.data ?? []; +} +async function Ve(e, t, n = {}) { + let r = { limit: n.limit ?? Ee }; + t.trim() !== "" && (r.search = t.trim()), typeof n.projectId == "number" && (r.project_id = n.projectId), n.invoiced !== void 0 && (r.invoiced = n.invoiced); + let { data: i } = await e.get(Se.tasks, { params: r }); + return i.data ?? []; +} +async function He(e, t) { + let { data: n } = await e.get(Se.task(t)); + return n.data; +} +async function Ue(e) { + let { data: t } = await e.get(Se.settings); + return t.data; +} +async function We(e) { + let { data: t } = await e.get(Ce.bootstrap), n = t?.current_user?.id; + return typeof n == "number" ? n : null; +} +//#endregion +//#region resources/js/stores/tasks.ts +var Ge = y({}), Ke = /* @__PURE__ */ new Set(), qe = 5, Je = { + logged_minutes: 0, + billable_minutes: 0, + unbilled_minutes: 0, + unbilled_amount: 0, + invoiced: "none", + running: [] +}, Ye = b(0), Xe = y({}); +function Ze(e) { + return e === null ? "" : Ge[e] ?? `#${e}`; +} +function Qe(e) { + e && typeof e.id == "number" && typeof e.name == "string" && (Ge[e.id] = e.name); +} +async function $e(e, t) { + let n = [...new Set(t)].filter((e) => typeof e == "number" && Ge[e] === void 0 && !Ke.has(e)); + for (let e of n) Ke.add(e); + for (let t = 0; t < n.length; t += qe) await Promise.all(n.slice(t, t + qe).map(async (t) => { + try { + Qe(await He(e, t)); + } catch {} finally { + Ke.delete(t); + } + })); +} +var et = Ye; +function V() { + Ye.value += 1; +} +function tt(e, t) { + Xe[e] = { + ...Xe[e] ?? {}, + ...t + }; +} +function nt(e) { + if (!e || typeof e.id != "number") return Je; + let t = e.time ?? Je; + return { + ...Je, + ...t, + running: Array.isArray(t.running) ? t.running : [], + ...Xe[e.id] ?? {} + }; +} +function rt() { + for (let e of Object.keys(Ge)) delete Ge[Number(e)]; + for (let e of Object.keys(Xe)) delete Xe[Number(e)]; + Ke.clear(); +} +//#endregion +//#region resources/js/support/errors.ts +function it(e) { + if (typeof e != "object" || !e) return null; + let t = e.response; + return typeof t?.data != "object" || t.data === null ? null : t.data; +} +function H(e, t) { + let n = it(e)?.message; + return typeof n == "string" && n !== "" ? n : t; +} +function at(e) { + let t = it(e)?.errors, n = {}; + if (typeof t != "object" || !t) return n; + for (let [e, r] of Object.entries(t)) Array.isArray(r) && typeof r[0] == "string" && (n[e] = r[0]); + return n; +} +//#endregion +//#region resources/js/support/format.ts +function ot(e) { + return e === null ? "" : String(e / 100); +} +function st(e) { + let t = Number(e); + return e.trim() === "" || Number.isNaN(t) ? null : Math.round(t * 100); +} +function ct(e) { + return e === null ? "" : String(e / 60); +} +function lt(e) { + let t = Number(e); + return e.trim() === "" || Number.isNaN(t) ? null : Math.round(t * 60); +} +function ut(e) { + if (!e) return ""; + let [t, n, r] = e.slice(0, 10).split("-").map(Number); + return !t || !n || !r ? e : new Date(Date.UTC(t, n - 1, r)).toLocaleDateString(void 0, { + year: "numeric", + month: "short", + day: "numeric", + timeZone: "UTC" + }); +} +function dt(e) { + if (typeof e == "string") return e.slice(0, 10); + let t = String(e.getMonth() + 1).padStart(2, "0"), n = String(e.getDate()).padStart(2, "0"); + return `${e.getFullYear()}-${t}-${n}`; +} +function ft(e) { + let t = Math.max(0, Math.round(e ?? 0)), n = Math.floor(t / 60), r = t % 60; + return n === 0 ? `${r}m` : r === 0 ? `${n}h` : `${n}h ${r}m`; +} +function pt(e) { + let t = e.trim().split(/\s+/).filter(Boolean); + return t.length === 0 ? "?" : (t[0].charAt(0) + (t.length > 1 ? t[t.length - 1].charAt(0) : "")).toUpperCase(); +} +function mt(e) { + if (!e) return !1; + let t = /* @__PURE__ */ new Date(), n = `${t.getFullYear()}-${String(t.getMonth() + 1).padStart(2, "0")}-${String(t.getDate()).padStart(2, "0")}`; + return e.slice(0, 10) < n; +} +//#endregion +//#region resources/js/support/http.ts +function ht(e) { + if (typeof e != "object" || !e) return null; + let t = e.response?.status; + return typeof t == "number" ? t : null; +} +function gt(e) { + if (typeof e != "object" || !e) return null; + let t = e.response?.data; + if (typeof t != "object" || !t) return null; + let n = t.error; + return typeof n == "string" && n !== "" ? n : null; +} +function _t(e) { + return ht(e) === 409; +} +function vt(e) { + return ht(e) === 403; +} +//#endregion +//#region resources/js/support/invoicing.ts +var yt = "/admin/invoices"; +async function bt(e, t) { + let { notify: n, t: r } = e; + if (M.pending !== null) return n("warning", r("tasks_projects.billing.pending_stamp")), !1; + if (!N()) return !1; + try { + return await Ct(e, t); + } finally { + P(); + } +} +async function xt(e, t, n) { + let r = M.pending; + if (r === null || !N()) return !1; + try { + let i = await me(e, r.invoiceId, r.items); + return I(), V(), t("success", n("tasks_projects.billing.stamped", { count: i })), !0; + } catch (e) { + return t("error", H(e, n("tasks_projects.billing.stamp_failed"))), !1; + } finally { + P(); + } +} +function St(e) { + return `${yt}/${e}/view`; +} +async function Ct(e, t) { + let { client: n, notify: r, t: i } = e, a; + try { + a = await fe(n, t); + } catch (t) { + return wt(e, t), !1; + } + if (!Array.isArray(a.items) || a.items.length === 0) return r("warning", i("tasks_projects.billing.nothing_to_invoice")), !1; + let [o, s, c] = await Promise.all([ + be(n).catch(() => null), + _e(n).catch(() => []), + he(n, a.customer_id).catch(() => null) + ]), l = c?.currency_id ?? c?.currency?.id ?? a.currency_id, u = o?.currency?.id ?? null, d = u !== null && l !== null && l !== u, f = await Et(e, o, a.customer_id); + if (f === null) return !1; + let p = null; + d && l !== null && (p = await ye(n, l).catch(() => null), p === null && r("warning", i("tasks_projects.billing.rate_failed"))); + let m = Ot(a, { + invoiceNumber: f, + currencyId: l, + exchangeRate: p, + dueDate: Dt(a.invoice_date, o), + templateName: o?.defaultTemplate ?? s[0]?.name ?? "" + }), h; + try { + h = await ge(n, m); + } catch (e) { + return r("error", H(e, i("tasks_projects.billing.create_failed"))), !1; + } + let g = await kt(e, h, a); + return V(), g ? (r("success", i("tasks_projects.billing.created", { number: h.invoice_number })), await jt(e.router, h.id), !0) : !1; +} +function wt(e, t) { + let { notify: n, t: r } = e, i = gt(t); + if (i === "mixed_billing_selection") { + let e = Tt(t); + n("error", e > 1 ? r("tasks_projects.billing.mixed_customers", { count: e }) : H(t, r("tasks_projects.billing.mixed_selection"))); + return; + } + if (i === "nothing_to_invoice") { + n("warning", r("tasks_projects.billing.nothing_to_invoice")); + return; + } + if (ht(t) === 403) { + L(), n("error", r("tasks_projects.billing.forbidden")); + return; + } + n("error", H(t, r("tasks_projects.billing.prepare_failed"))); +} +function Tt(e) { + if (typeof e != "object" || !e) return 0; + let t = e.response?.data; + if (typeof t != "object" || !t) return 0; + let n = t.customer_ids; + return Array.isArray(n) ? n.length : 0; +} +async function Et(e, t, n) { + let r = await ve(e.client, n).catch(() => null); + return t?.autoGenerateNumber !== !1 && r !== null ? r : R(r ?? ""); +} +function Dt(e, t) { + if (t === null || !t.setDueDateAutomatically) return null; + let n = /* @__PURE__ */ new Date(`${e}T00:00:00`); + return Number.isNaN(n.getTime()) ? null : (n.setDate(n.getDate() + t.dueDateDays), dt(n)); +} +function Ot(e, t) { + return { + invoice_date: e.invoice_date, + due_date: t.dueDate, + customer_id: e.customer_id, + invoice_number: t.invoiceNumber, + currency_id: t.currencyId, + exchange_rate: t.exchangeRate, + discount: e.discount, + discount_type: e.discount_type, + discount_val: e.discount_val, + tax: e.tax, + sub_total: e.sub_total, + total: e.total, + tax_included: !1, + notes: e.notes, + template_name: t.templateName, + items: e.items.map((e) => ({ ...e })), + taxes: [] + }; +} +async function kt(e, t, n) { + let { client: r, notify: i, t: a } = e, o = At(t, n); + if (o.length === 0) return i("error", a("tasks_projects.billing.stamp_unmatched", { number: t.invoice_number })), await jt(e.router, t.id), !1; + try { + return await me(r, t.id, o), !0; + } catch (e) { + return F({ + invoiceId: t.id, + invoiceNumber: t.invoice_number, + items: o + }), i("error", H(e, a("tasks_projects.billing.stamp_failed_notice", { number: t.invoice_number }))), !1; + } +} +function At(e, t) { + let n = Array.isArray(e.items) ? e.items : [], r = Array.isArray(t.groups) ? t.groups : [], i = []; + return r.forEach((e, t) => { + let r = n[t]; + r && typeof r.id == "number" && e.entry_ids.length > 0 && i.push({ + invoice_item_id: r.id, + entry_ids: e.entry_ids + }); + }), i; +} +async function jt(e, t) { + await Mt(e, `${yt}/${t}/edit`) || await Mt(e, St(t)); +} +async function Mt(e, t) { + try { + return !await e.push(t); + } catch { + return !1; + } +} +//#endregion +//#region resources/js/components/InvoiceRetryBanner.vue?vue&type=script&setup=true&lang.ts +var Nt = { + key: 0, + class: "mt-4 rounded-xl border border-status-yellow bg-surface p-5", + role: "alert" +}, Pt = { class: "text-sm font-semibold text-heading" }, Ft = { class: "mt-1 text-sm text-muted" }, It = { class: "mt-4 flex flex-wrap items-center gap-3" }, Lt = /* @__PURE__ */ l({ + __name: "InvoiceRetryBanner", + props: { + client: { type: [Function, Object] }, + notify: { type: Function } + }, + setup(e) { + let t = e, r = B(), l = b(!1), u = n(() => M.pending); + async function d() { + if (!l.value) { + l.value = !0; + try { + await xt(t.client, t.notify, r); + } finally { + l.value = !1; + } + } + } + function f() { + window.confirm(r("tasks_projects.billing.retry.dismiss_confirm")) && I(); + } + return (e, t) => { + let n = S("BaseButton"), p = S("router-link"); + return u.value ? (v(), a("div", Nt, [ + o("p", Pt, C(w(r)("tasks_projects.billing.retry.title")), 1), + o("p", Ft, C(w(r)("tasks_projects.billing.retry.description", { number: u.value.invoiceNumber })), 1), + o("div", It, [ + c(n, { + variant: "primary", + loading: l.value, + disabled: l.value, + onClick: d + }, { + default: D(() => [s(C(w(r)("tasks_projects.billing.retry.action")), 1)]), + _: 1 + }, 8, ["loading", "disabled"]), + c(p, { to: w(St)(u.value.invoiceId) }, { + default: D(() => [c(n, { variant: "white" }, { + default: D(() => [s(C(w(r)("tasks_projects.billing.retry.open_invoice")), 1)]), + _: 1 + })]), + _: 1 + }, 8, ["to"]), + o("button", { + type: "button", + class: "text-sm font-medium text-muted hover:underline", + onClick: f + }, C(w(r)("tasks_projects.billing.retry.dismiss")), 1) + ]) + ])) : i("", !0); + }; + } +}), Rt = "/api/v1/tasks-projects", zt = { + projects: `${Rt}/projects`, + project: (e) => `${Rt}/projects/${e}`, + archiveProject: (e) => `${Rt}/projects/${e}/archive`, + unarchiveProject: (e) => `${Rt}/projects/${e}/unarchive`, + members: `${Rt}/members`, + settings: `${Rt}/settings` +}, Bt = { customers: "/api/v1/customers" }; +function Vt(e, t) { + if (e === void 0 || e.order === "") return {}; + let n = t[e.fieldName]; + return n === void 0 ? {} : { + sort_by: n, + sort_order: e.order + }; +} +async function Ht(e, t) { + let { data: n } = await e.get(zt.projects, { params: t }); + return n; +} +async function Ut(e, t) { + let { data: n } = await e.post(zt.projects, t); + return n.data; +} +async function Wt(e, t, n) { + let { data: r } = await e.put(zt.project(t), n); + return r.data; +} +async function Gt(e, t) { + let { data: n } = await e.post(zt.archiveProject(t)); + return n.data; +} +async function Kt(e, t) { + let { data: n } = await e.post(zt.unarchiveProject(t)); + return n.data; +} +async function qt(e, t) { + await e.delete(zt.project(t)); +} +async function Jt(e) { + let { data: t } = await e.get(zt.members); + return t.data; +} +async function Yt(e, t = 100) { + let { data: n } = await e.get(Bt.customers, { params: { limit: t } }); + return n.data; +} +var Xt = y({}), Zt = !1, Qt = null; +function $t(e) { + return e === null ? "" : Xt[e] ?? `#${e}`; +} +async function en(e) { + Zt || (Qt ??= nn(e), await Qt); +} +function tn() { + for (let e of Object.keys(Xt)) delete Xt[Number(e)]; + Zt = !1, Qt = null; +} +async function nn(e) { + try { + for (let t of await Yt(e, 200)) { + let e = t?.id; + typeof e == "number" && (Xt[e] = rn(t)); + } + Zt = !0; + } catch {} finally { + Qt = null; + } +} +function rn(e) { + let t = typeof e.display_name == "string" ? e.display_name.trim() : ""; + if (t !== "") return t; + let n = typeof e.name == "string" ? e.name.trim() : ""; + return n === "" ? `#${e.id}` : n; +} +//#endregion +//#region resources/js/support/page.ts +var U = "tasks-projects", an = `/admin/modules/${U}`, W = { + tasks: an, + board: `${an}/board`, + week: `${an}/week`, + task: (e) => `${an}/tasks/${e}`, + projects: `${an}/projects`, + project: (e) => `${an}/projects/${e}`, + reports: `${an}/reports`, + billing: `${an}/billing`, + settings: "/admin/settings/modules", + customer: (e) => `/admin/customers/${e}/view` +}, on = { + tasks: `extension.page.${U}.tasks`, + list: `extension.page.${U}.tasks.list`, + board: `extension.page.${U}.tasks.board`, + week: `extension.page.${U}.tasks.week`, + task: `extension.page.${U}.task`, + projects: `extension.page.${U}.projects`, + project: `extension.page.${U}.project` +}; +function sn(e, t) { + return l({ setup: (n, { attrs: r }) => () => d(t, { + ...r, + client: e.client, + notify: (t, n) => { + e.notify(t, n); + }, + router: e.router + }) }); +} +//#endregion +//#region resources/js/pages/UnbilledTimePage.vue?vue&type=script&setup=true&lang.ts +var cn = { class: "mt-2 text-sm text-muted" }, ln = { class: "flex flex-wrap items-center justify-end gap-3" }, un = { class: "mt-4 flex flex-wrap items-end gap-4" }, dn = { + key: 0, + class: "flex justify-center py-16" +}, fn = { + key: 1, + class: "mt-6" +}, pn = { class: "text-base font-semibold text-heading" }, mn = { class: "mt-1 text-sm text-muted" }, hn = { + key: 0, + class: "mt-5 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3" +}, gn = ["onClick"], _n = { class: "text-sm font-semibold text-heading" }, vn = { class: "mt-1 text-xs text-muted" }, yn = { class: "mt-3 text-xl font-semibold text-heading" }, bn = { + key: 2, + class: "mt-6" +}, xn = { class: "flex flex-wrap items-end justify-between gap-4" }, Sn = { class: "text-base font-semibold text-heading" }, Cn = { class: "mt-1 text-sm text-muted" }, wn = { + key: 0, + class: "flex justify-center py-16" +}, Tn = { class: "mt-4 flex flex-wrap items-center justify-between gap-3 rounded-lg border border-line-default bg-surface-secondary px-4 py-3" }, En = { class: "flex cursor-pointer items-center gap-2 text-sm font-medium text-heading" }, Dn = ["checked"], On = { class: "text-sm text-muted" }, kn = { class: "flex flex-wrap items-center justify-between gap-3 bg-surface-secondary px-4 py-3" }, An = { class: "flex cursor-pointer items-center gap-2 text-sm font-semibold text-heading" }, jn = ["checked", "onChange"], Mn = { class: "text-sm text-muted" }, Nn = { class: "overflow-x-auto" }, Pn = { class: "w-full table-auto" }, Fn = { class: "bg-surface text-xs tracking-wider text-muted uppercase" }, In = { class: "px-4 py-2 text-left font-medium" }, Ln = { class: "px-4 py-2 text-left font-medium" }, Rn = { class: "px-4 py-2 text-left font-medium" }, zn = { class: "px-4 py-2 text-left font-medium" }, Bn = { class: "px-4 py-2 text-right font-medium" }, Vn = { class: "px-4 py-2 text-right font-medium" }, Hn = { class: "divide-y divide-line-default bg-surface text-sm" }, Un = { class: "pl-4" }, Wn = ["checked", "onChange"], Gn = { class: "px-4 py-2 whitespace-nowrap text-muted" }, Kn = { class: "px-4 py-2" }, qn = { class: "block text-xs text-subtle" }, Jn = { class: "px-4 py-2 text-muted" }, Yn = { class: "px-4 py-2 text-muted" }, Xn = { class: "px-4 py-2 text-right whitespace-nowrap text-muted" }, Zn = { class: "px-4 py-2 text-right whitespace-nowrap text-heading" }, Qn = { class: "mt-5 flex flex-wrap items-center justify-between gap-4" }, $n = { class: "text-sm font-medium text-heading" }, er = { class: "flex items-center gap-3" }, tr = /* @__PURE__ */ l({ + __name: "UnbilledTimePage", + props: { + client: { type: [Function, Object] }, + notify: { type: Function }, + router: {} + }, + setup(t) { + let l = t, u = [ + "task", + "project", + "member", + "summary" + ], d = B(), f = b(!0), m = b([]), h = y({ + from: "", + to: "" + }), _ = b(null), T = b(null), O = b(!1), k = b("task"), A = b([]), j = n(() => M.busy), N = n(() => u.map((e) => ({ + id: e, + label: d(`tasks_projects.billing.entries.group_by.${e}`) + }))), P = n({ + get: () => N.value.find((e) => e.id === k.value) ?? N.value[0], + set: (e) => { + k.value = e.id; + } + }), F = n(() => (T.value?.entries ?? []).filter((e) => e.currency_id === (_.value?.currency_id ?? null))), I = n(() => { + let e = {}; + for (let t of F.value) e[t.id] = t; + return e; + }), L = n(() => (T.value?.groups?.[k.value] ?? []).filter((e) => e.currency_id === (_.value?.currency_id ?? null))), R = n(() => F.value.length), z = n(() => R.value > 0 && A.value.length === R.value), ee = n(() => A.value.reduce((e, t) => e + (I.value[t]?.minutes ?? 0), 0)), te = n(() => A.value.reduce((e, t) => e + (I.value[t]?.amount ?? 0), 0)), ne = n(() => { + let e = {}; + return h.from !== "" && (e.from = h.from), h.to !== "" && (e.to = h.to), e; + }); + E(() => [h.from, h.to], () => void ie()), g(() => void re()); + async function re() { + f.value = !0, await Promise.all([ae(), en(l.client)]), f.value = !1; + } + async function ie() { + await ae(), _.value !== null && await fe(_.value); + } + async function ae() { + try { + m.value = await ue(l.client, ne.value); + } catch (e) { + m.value = [], l.notify("error", H(e, d("tasks_projects.billing.customer.load_failed"))); + } + } + function oe(e) { + return $t(e); + } + function se() { + h.from = "", h.to = ""; + } + function ce(e) { + h.from = e ? dt(e) : ""; + } + function le(e) { + h.to = e ? dt(e) : ""; + } + async function fe(e) { + _.value = e, O.value = !0, T.value = null, A.value = []; + try { + T.value = await de(l.client, e.customer_id, ne.value), A.value = F.value.map((e) => e.id); + } catch (e) { + l.notify("error", H(e, d("tasks_projects.billing.entries.load_failed"))); + } finally { + O.value = !1; + } + } + function pe() { + _.value = null, T.value = null, A.value = [], ae(); + } + function me(e) { + return A.value.includes(e); + } + function he(e) { + A.value = me(e) ? A.value.filter((t) => t !== e) : [...A.value, e]; + } + function ge() { + A.value = z.value ? [] : F.value.map((e) => e.id); + } + function _e(e) { + return e.entry_ids.length > 0 && e.entry_ids.every((e) => me(e)); + } + function ve(e) { + if (_e(e)) { + A.value = A.value.filter((t) => !e.entry_ids.includes(t)); + return; + } + let t = e.entry_ids.filter((e) => !me(e)); + A.value = [...A.value, ...t]; + } + function ye(e) { + return e.entry_ids.map((e) => I.value[e]).filter((e) => e !== void 0); + } + async function be() { + if (A.value.length === 0) { + l.notify("warning", d("tasks_projects.billing.entries.none_selected")); + return; + } + await bt({ + client: l.client, + router: l.router, + notify: l.notify, + t: d + }, { + entryIds: [...A.value], + grouping: k.value + }) || await ie(); + } + return (n, l) => { + let u = S("BaseBreadcrumbItem"), g = S("BaseBreadcrumb"), y = S("BaseIcon"), b = S("BaseButton"), T = S("router-link"), E = S("BasePageHeader"), k = S("BaseDatePicker"), M = S("BaseInputGroup"), F = S("BaseSpinner"), I = S("BaseFormatMoney"), B = S("BaseEmptyPlaceholder"), ne = S("BaseSelectInput"), re = S("BasePage"); + return v(), r(re, null, { + default: D(() => [ + c(E, { title: w(d)("tasks_projects.billing.title") }, { + actions: D(() => [o("div", ln, [c(T, { to: w(W).reports }, { + default: D(() => [c(b, { variant: "white" }, { + left: D((e) => [c(y, { + name: "ChartBarIcon", + class: p(e.class) + }, null, 8, ["class"])]), + default: D(() => [s(" " + C(w(d)("tasks_projects.reports.title")), 1)]), + _: 1 + })]), + _: 1 + }, 8, ["to"]), c(T, { to: w(W).projects }, { + default: D(() => [c(b, { variant: "white" }, { + left: D((e) => [c(y, { + name: "FolderIcon", + class: p(e.class) + }, null, 8, ["class"])]), + default: D(() => [s(" " + C(w(d)("tasks_projects.projects.title")), 1)]), + _: 1 + })]), + _: 1 + }, 8, ["to"])])]), + default: D(() => [c(g, null, { + default: D(() => [ + c(u, { + title: w(d)("tasks_projects.general.home"), + to: "/admin/dashboard" + }, null, 8, ["title"]), + c(u, { + title: w(d)("tasks_projects.tasks.title"), + to: w(W).tasks + }, null, 8, ["title", "to"]), + c(u, { + title: w(d)("tasks_projects.billing.title"), + to: "#", + active: "" + }, null, 8, ["title"]) + ]), + _: 1 + }), o("p", cn, C(w(d)("tasks_projects.billing.subtitle")), 1)]), + _: 1 + }, 8, ["title"]), + c(Lt, { + client: t.client, + notify: t.notify + }, null, 8, ["client", "notify"]), + o("div", un, [ + c(M, { + label: w(d)("tasks_projects.billing.customer.from"), + class: "w-full sm:w-48" + }, { + default: D(() => [c(k, { + "model-value": h.from, + "onUpdate:modelValue": ce + }, null, 8, ["model-value"])]), + _: 1 + }, 8, ["label"]), + c(M, { + label: w(d)("tasks_projects.billing.customer.to"), + class: "w-full sm:w-48" + }, { + default: D(() => [c(k, { + "model-value": h.to, + "onUpdate:modelValue": le + }, null, 8, ["model-value"])]), + _: 1 + }, 8, ["label"]), + h.from !== "" || h.to !== "" ? (v(), r(b, { + key: 0, + variant: "primary-outline", + onClick: se + }, { + default: D(() => [s(C(w(d)("tasks_projects.billing.customer.clear_range")), 1)]), + _: 1 + })) : i("", !0) + ]), + f.value ? (v(), a("div", dn, [c(F, { class: "h-8 w-8 text-primary-500" })])) : _.value === null ? (v(), a("section", fn, [ + o("h2", pn, C(w(d)("tasks_projects.billing.customer.title")), 1), + o("p", mn, C(w(d)("tasks_projects.billing.customer.description")), 1), + m.value.length > 0 ? (v(), a("div", hn, [(v(!0), a(e, null, x(m.value, (e) => (v(), a("button", { + key: `${e.customer_id}-${e.currency_id ?? "none"}`, + type: "button", + class: "rounded-xl border border-line-default bg-surface p-5 text-left transition hover:border-primary-500", + onClick: (t) => fe(e) + }, [ + o("p", _n, C(oe(e.customer_id)), 1), + o("p", vn, C(w(d)("tasks_projects.billing.customer.entries", { count: e.entries })) + " · " + C(w(ft)(e.minutes)), 1), + o("p", yn, [c(I, { amount: e.amount }, null, 8, ["amount"])]) + ], 8, gn))), 128))])) : (v(), r(B, { + key: 1, + title: w(d)("tasks_projects.billing.customer.empty_title"), + description: w(d)("tasks_projects.billing.customer.empty_description") + }, { + default: D(() => [c(y, { + name: "BanknotesIcon", + class: "mt-5 mb-4 h-16 w-16 text-subtle" + })]), + _: 1 + }, 8, ["title", "description"])) + ])) : (v(), a("section", bn, [o("div", xn, [o("div", null, [o("h2", Sn, C(w(d)("tasks_projects.billing.entries.title")), 1), o("p", Cn, C(oe(_.value.customer_id)), 1)]), c(M, { + label: w(d)("tasks_projects.billing.entries.grouping"), + class: "w-full sm:w-56" + }, { + default: D(() => [c(ne, { + modelValue: P.value, + "onUpdate:modelValue": l[0] ||= (e) => P.value = e, + options: N.value, + "label-key": "label" + }, null, 8, ["modelValue", "options"])]), + _: 1 + }, 8, ["label"])]), O.value ? (v(), a("div", wn, [c(F, { class: "h-8 w-8 text-primary-500" })])) : R.value > 0 ? (v(), a(e, { key: 1 }, [ + o("div", Tn, [o("label", En, [o("input", { + type: "checkbox", + class: "h-4 w-4 cursor-pointer rounded border-line-strong", + checked: z.value, + onChange: ge + }, null, 40, Dn), s(" " + C(w(d)("tasks_projects.billing.entries.select_all")), 1)]), o("p", On, C(w(d)("tasks_projects.billing.entries.selected", { + count: A.value.length, + total: R.value + })), 1)]), + (v(!0), a(e, null, x(L.value, (t) => (v(), a("div", { + key: `${t.label}-${t.key ?? "none"}-${t.currency_id ?? "none"}`, + class: "mt-4 overflow-hidden rounded-xl border border-line-default" + }, [o("div", kn, [o("label", An, [o("input", { + type: "checkbox", + class: "h-4 w-4 cursor-pointer rounded border-line-strong", + checked: _e(t), + onChange: (e) => ve(t) + }, null, 40, jn), s(" " + C(t.label), 1)]), o("p", Mn, [s(C(w(ft)(t.minutes)) + " · ", 1), c(I, { amount: t.amount }, null, 8, ["amount"])])]), o("div", Nn, [o("table", Pn, [o("thead", Fn, [o("tr", null, [ + l[1] ||= o("th", { class: "w-10" }, null, -1), + o("th", In, C(w(d)("tasks_projects.billing.entries.columns.date")), 1), + o("th", Ln, C(w(d)("tasks_projects.billing.entries.columns.task")), 1), + o("th", Rn, C(w(d)("tasks_projects.billing.entries.columns.project")), 1), + o("th", zn, C(w(d)("tasks_projects.billing.entries.columns.member")), 1), + o("th", Bn, C(w(d)("tasks_projects.billing.entries.columns.duration")), 1), + o("th", Vn, C(w(d)("tasks_projects.billing.entries.columns.amount")), 1) + ])]), o("tbody", Hn, [(v(!0), a(e, null, x(ye(t), (e) => (v(), a("tr", { key: e.id }, [ + o("td", Un, [o("input", { + type: "checkbox", + class: "h-4 w-4 cursor-pointer rounded border-line-strong", + checked: me(e.id), + onChange: (t) => he(e.id) + }, null, 40, Wn)]), + o("td", Gn, C(w(ut)(e.date)), 1), + o("td", Kn, [c(T, { + class: "text-heading hover:text-primary-500", + to: w(W).task(e.task_id) + }, { + default: D(() => [s(C(e.task_name), 1)]), + _: 2 + }, 1032, ["to"]), o("span", qn, C(e.description || w(d)("tasks_projects.billing.entries.no_description")), 1)]), + o("td", Jn, C(e.project_name ?? "-"), 1), + o("td", Yn, C(e.user_name), 1), + o("td", Xn, C(w(ft)(e.minutes)), 1), + o("td", Zn, [c(I, { amount: e.amount }, null, 8, ["amount"])]) + ]))), 128))])])])]))), 128)), + o("div", Qn, [o("p", $n, [s(C(w(d)("tasks_projects.billing.entries.selected_total", { hours: w(ft)(ee.value) })) + " · ", 1), c(I, { amount: te.value }, null, 8, ["amount"])]), o("div", er, [c(b, { + variant: "primary-outline", + disabled: j.value, + onClick: pe + }, { + default: D(() => [s(C(w(d)("tasks_projects.billing.back")), 1)]), + _: 1 + }, 8, ["disabled"]), c(b, { + variant: "primary", + loading: j.value, + disabled: j.value || A.value.length === 0, + onClick: be + }, { + left: D((e) => [j.value ? i("", !0) : (v(), r(y, { + key: 0, + name: "DocumentPlusIcon", + class: p(e.class) + }, null, 8, ["class"]))]), + default: D(() => [s(" " + C(w(d)("tasks_projects.billing.create")), 1)]), + _: 1 + }, 8, ["loading", "disabled"])])]) + ], 64)) : (v(), r(B, { + key: 2, + title: w(d)("tasks_projects.billing.entries.empty_title"), + description: w(d)("tasks_projects.billing.entries.empty_description") + }, { + actions: D(() => [c(b, { + variant: "primary", + onClick: pe + }, { + default: D(() => [s(C(w(d)("tasks_projects.billing.back")), 1)]), + _: 1 + })]), + default: D(() => [c(y, { + name: "ClockIcon", + class: "mt-5 mb-4 h-16 w-16 text-subtle" + })]), + _: 1 + }, 8, ["title", "description"]))])) + ]), + _: 1 + }); + }; + } +}); +//#endregion +//#region resources/js/registrations/billing.ts +function nr(e) { + e.addMessages(oe), e.registerPage({ + id: "billing", + module: U, + path: "billing", + component: sn(e, tr), + meta: { + ability: `${U}:invoice-tasks`, + title: "tasks_projects.billing.title" + } + }), e.registerCompanyLayoutOverlay({ + id: `${U}.invoice-number`, + component: l({ setup: () => () => d(ae) }) + }), e.on("company:changing", () => { + ee(); + }); +} +//#endregion +//#region resources/js/messages/projects.ts +var rr = { en: { tasks_projects: { project: { + load_failed: "Unable to load the project.", + customer: "Customer", + identifier: "Identifier", + due_date: "Due date", + board: "Board", + tasks: "Tasks", + invoice_project: "Invoice project", + tabs: { + overview: "Overview", + tasks: "Tasks", + time: "Time", + members: "Members" + }, + overview: { + tasks: "Tasks", + open_tasks: "{count} open", + closed_tasks: "{count} done", + logged: "Logged", + billable: "Billable", + billable_amount: "Billable value", + unbilled_amount: "Unbilled", + budget: "Budget", + budget_used: "{used} of {total}", + budget_over: "Over budget by {amount}", + no_budget: "No budget set.", + description: "Description", + no_description: "No description yet." + }, + time: { + title: "Time log", + add_entry: "Add entry", + columns: { + date: "Date", + member: "Member", + task: "Task", + minutes: "Duration", + billable: "Billable", + amount: "Amount" + }, + running: "Running", + removed_member: "Removed member", + load_failed: "Unable to load the time entries." + }, + members: { + title: "Members", + member: "Member", + rate: "Rate / hour", + rate_help: "Per hour on this project. Leave empty to use the project default.", + attach: "Add member", + attach_placeholder: "Choose a member", + attached: "{name} was added to the project.", + detached: "{name} was removed from the project.", + detach_confirm: "Remove {name} from this project? Their time entries stay.", + empty: "Nobody is on this project yet.", + all_attached: "Every company member is already on this project.", + load_failed: "Unable to load the project members.", + attach_failed: "Unable to add the member.", + detach_failed: "Unable to remove the member." + } +} } } }, ir = { + board: `${Rt}/board`, + tasks: `${Rt}/tasks`, + task: (e) => `${Rt}/tasks/${e}`, + moveTask: (e) => `${Rt}/tasks/${e}/move`, + startTask: (e) => `${Rt}/tasks/${e}/start`, + stopTask: (e) => `${Rt}/tasks/${e}/stop`, + taskTimeLog: (e) => `${Rt}/tasks/${e}/time-log`, + bulkTasks: `${Rt}/tasks/bulk`, + taskStatuses: `${Rt}/task-statuses`, + timeEntries: `${Rt}/time-entries`, + projectMembers: (e) => `${Rt}/projects/${e}/members`, + projectMember: (e, t) => `${Rt}/projects/${e}/members/${t}` +}; +async function ar(e, t) { + let { data: n } = await e.get(ir.board, { params: t }); + return n.data; +} +async function or(e) { + let { data: t } = await e.get(ir.taskStatuses); + return t.data; +} +async function sr(e, t) { + let { data: n } = await e.get(ir.tasks, { params: t }); + return n; +} +async function cr(e, t) { + let { data: n } = await e.post(ir.tasks, t); + return n.data; +} +async function lr(e, t, n) { + let { data: r } = await e.put(ir.task(t), n); + return r.data; +} +async function ur(e, t) { + await e.delete(ir.task(t)); +} +async function dr(e, t) { + let { data: n } = await e.get(ir.task(t)); + return n.data; +} +async function fr(e, t, n = null, r) { + let i = {}; + n !== null && (i.description = n), r !== void 0 && (i.billable = r); + let { data: a } = await e.post(ir.startTask(t), i); + return a.data; +} +async function pr(e, t, n = {}) { + let { data: r } = await e.post(ir.stopTask(t), n); + return r.data; +} +async function mr(e, t) { + let { data: n } = await e.get(ir.taskTimeLog(t)); + return n.data ?? []; +} +async function hr(e, t) { + let { data: n } = await e.post(ir.bulkTasks, t); + return { + updated: n?.updated ?? [], + failed: n?.failed ?? [] + }; +} +async function gr(e, t, n) { + let { data: r } = await e.post(ir.moveTask(t), n); + return r.data; +} +async function _r(e, t) { + let { data: n } = await e.get(zt.project(t)); + return n.data; +} +async function vr(e, t) { + let { data: n } = await e.get(ir.projectMembers(t)); + return n.data; +} +async function yr(e, t, n) { + let { data: r } = await e.post(ir.projectMembers(t), n); + return r.data; +} +async function br(e, t, n) { + await e.delete(ir.projectMember(t, n)); +} +async function xr(e, t) { + let { data: n } = await e.get(ir.timeEntries, { params: t }); + return n; +} +//#endregion +//#region resources/js/components/ProjectFormModal.vue?vue&type=script&setup=true&lang.ts +var Sr = { class: "flex w-full items-center justify-between" }, Cr = { class: "space-y-5 px-6 py-6" }, wr = { class: "flex flex-wrap items-center gap-2" }, Tr = ["aria-label", "onClick"], Er = { class: "flex justify-end space-x-3 border-t border-line-default px-6 py-4" }, Dr = /* @__PURE__ */ l({ + __name: "ProjectFormModal", + props: { + show: { type: Boolean }, + client: { type: [Function, Object] }, + notify: { type: Function }, + project: {} + }, + emits: ["close", "saved"], + setup(t, { emit: i }) { + let l = t, u = i, d = [ + "#2563eb", + "#0891b2", + "#059669", + "#ca8a04", + "#ea580c", + "#dc2626", + "#7c3aed", + "#64748b" + ], f = B(), h = y({ + name: "", + identifier: "", + description: "", + colour: "", + defaultRate: "", + budgetHours: "", + dueDate: "" + }), g = b(null), _ = b([]), T = b(!1), O = b({}), k = b(!1), j = n(() => l.project !== null), M = n(() => j.value ? f("tasks_projects.projects.edit_project") : f("tasks_projects.projects.new_project")); + E(() => l.show, (e) => { + e && (N(), I()); + }, { immediate: !0 }); + function N() { + let e = l.project; + h.name = e?.name ?? "", h.identifier = e?.identifier ?? "", h.description = e?.description ?? "", h.colour = e?.colour ?? "", h.defaultRate = ot(e?.default_rate ?? null), h.budgetHours = ct(e?.budget_minutes ?? null), h.dueDate = e?.due_date ?? "", O.value = {}, g.value = P(e?.customer_id ?? null); + } + function P(e) { + return e === null ? null : _.value.find((t) => t.id === e) ?? null; + } + function F(e) { + return e.display_name || e.name || `#${e.id}`; + } + async function I() { + if (!T.value) try { + let e = await Yt(l.client); + _.value = e.map((e) => ({ + id: e.id, + label: F(e) + })), T.value = !0, g.value = P(l.project?.customer_id ?? null); + } catch (e) { + l.notify("error", H(e, f("tasks_projects.projects.customers_failed"))); + } + } + function L() { + return { + name: h.name.trim(), + customer_id: g.value?.id ?? null, + identifier: h.identifier.trim() || null, + description: h.description.trim() || null, + colour: h.colour || null, + default_rate: st(h.defaultRate), + budget_minutes: lt(h.budgetHours), + due_date: h.dueDate || null + }; + } + function R(e) { + h.dueDate = e ? dt(e) : ""; + } + async function z() { + if (!k.value) { + if (h.name.trim() === "") { + O.value = { name: f("tasks_projects.projects.name_required") }; + return; + } + k.value = !0, O.value = {}; + try { + let e = l.project, t = e ? await Wt(l.client, e.id, L()) : await Ut(l.client, L()); + u("saved", t); + } catch (e) { + O.value = at(e), l.notify("error", H(e, f("tasks_projects.projects.save_failed"))); + } finally { + k.value = !1; + } + } + } + return (n, i) => { + let l = S("BaseIcon"), y = S("BaseInput"), b = S("BaseInputGroup"), T = S("BaseSelectInput"), E = S("BaseDatePicker"), N = S("BaseInputGrid"), P = S("BaseTextarea"), F = S("BaseButton"), I = S("BaseModal"); + return v(), r(I, { + show: t.show, + onClose: i[9] ||= (e) => u("close") + }, { + header: D(() => [o("div", Sr, [o("span", null, C(M.value), 1), c(l, { + name: "XMarkIcon", + class: "h-6 w-6 cursor-pointer text-subtle hover:text-body", + onClick: i[0] ||= (e) => u("close") + })])]), + default: D(() => [o("form", { onSubmit: A(z, ["prevent"]) }, [o("div", Cr, [ + c(N, null, { + default: D(() => [ + c(b, { + label: w(f)("tasks_projects.projects.fields.name"), + error: O.value.name, + required: "" + }, { + default: D(() => [c(y, { + modelValue: h.name, + "onUpdate:modelValue": i[1] ||= (e) => h.name = e, + invalid: !!O.value.name, + type: "text" + }, null, 8, ["modelValue", "invalid"])]), + _: 1 + }, 8, ["label", "error"]), + c(b, { + label: w(f)("tasks_projects.projects.fields.identifier"), + error: O.value.identifier, + "help-text": w(f)("tasks_projects.projects.fields.identifier_help") + }, { + default: D(() => [c(y, { + modelValue: h.identifier, + "onUpdate:modelValue": i[2] ||= (e) => h.identifier = e, + invalid: !!O.value.identifier, + type: "text", + maxlength: "32" + }, null, 8, ["modelValue", "invalid"])]), + _: 1 + }, 8, [ + "label", + "error", + "help-text" + ]), + c(b, { + label: w(f)("tasks_projects.projects.fields.customer"), + error: O.value.customer_id, + "help-text": w(f)("tasks_projects.projects.fields.customer_help") + }, { + default: D(() => [c(T, { + modelValue: g.value, + "onUpdate:modelValue": i[3] ||= (e) => g.value = e, + options: _.value, + placeholder: w(f)("tasks_projects.projects.fields.customer_placeholder"), + "label-key": "label" + }, null, 8, [ + "modelValue", + "options", + "placeholder" + ])]), + _: 1 + }, 8, [ + "label", + "error", + "help-text" + ]), + c(b, { + label: w(f)("tasks_projects.projects.fields.due_date"), + error: O.value.due_date + }, { + default: D(() => [c(E, { + "model-value": h.dueDate, + "onUpdate:modelValue": R + }, null, 8, ["model-value"])]), + _: 1 + }, 8, ["label", "error"]), + c(b, { + label: w(f)("tasks_projects.projects.fields.default_rate"), + error: O.value.default_rate, + "help-text": w(f)("tasks_projects.projects.fields.default_rate_help") + }, { + default: D(() => [c(y, { + modelValue: h.defaultRate, + "onUpdate:modelValue": i[4] ||= (e) => h.defaultRate = e, + invalid: !!O.value.default_rate, + type: "number", + step: "0.01", + min: "0" + }, null, 8, ["modelValue", "invalid"])]), + _: 1 + }, 8, [ + "label", + "error", + "help-text" + ]), + c(b, { + label: w(f)("tasks_projects.projects.fields.budget_hours"), + error: O.value.budget_minutes + }, { + default: D(() => [c(y, { + modelValue: h.budgetHours, + "onUpdate:modelValue": i[5] ||= (e) => h.budgetHours = e, + invalid: !!O.value.budget_minutes, + type: "number", + step: "0.25", + min: "0" + }, null, 8, ["modelValue", "invalid"])]), + _: 1 + }, 8, ["label", "error"]) + ]), + _: 1 + }), + c(b, { + label: w(f)("tasks_projects.projects.fields.colour"), + error: O.value.colour + }, { + default: D(() => [o("div", wr, [(v(), a(e, null, x(d, (e) => o("button", { + key: e, + type: "button", + class: p(["h-7 w-7 rounded-full border-2 transition", h.colour === e ? "border-heading" : "border-line-default"]), + style: m({ backgroundColor: e }), + "aria-label": e, + onClick: (t) => h.colour = h.colour === e ? "" : e + }, null, 14, Tr)), 64)), o("button", { + type: "button", + class: "rounded-md border border-line-default px-2 py-1 text-xs text-muted hover:bg-hover", + onClick: i[6] ||= (e) => h.colour = "" + }, C(w(f)("tasks_projects.projects.fields.colour_none")), 1)])]), + _: 1 + }, 8, ["label", "error"]), + c(b, { + label: w(f)("tasks_projects.projects.fields.description"), + error: O.value.description + }, { + default: D(() => [c(P, { + modelValue: h.description, + "onUpdate:modelValue": i[7] ||= (e) => h.description = e, + row: 3, + invalid: !!O.value.description + }, null, 8, ["modelValue", "invalid"])]), + _: 1 + }, 8, ["label", "error"]) + ]), o("div", Er, [c(F, { + type: "button", + variant: "primary-outline", + onClick: i[8] ||= (e) => u("close") + }, { + default: D(() => [s(C(w(f)("tasks_projects.general.cancel")), 1)]), + _: 1 + }), c(F, { + type: "submit", + variant: "primary", + loading: k.value, + disabled: k.value + }, { + default: D(() => [s(C(j.value ? w(f)("tasks_projects.general.update") : w(f)("tasks_projects.general.save")), 1)]), + _: 1 + }, 8, ["loading", "disabled"])])], 32)]), + _: 1 + }, 8, ["show"]); + }; + } +}), Or = { + key: 0, + class: "mt-2 flex flex-wrap items-center gap-3 text-sm text-muted" +}, kr = { + key: 0, + class: "rounded-sm bg-surface-tertiary px-2 py-0.5 text-body" +}, Ar = { key: 1 }, jr = { class: "text-body" }, Mr = { + key: 2, + class: "text-subtle" +}, Nr = { key: 3 }, Pr = { class: "text-body" }, Fr = { class: "flex items-center justify-end space-x-5" }, Ir = { class: "mt-6 flex overflow-x-auto border-b border-line-default" }, Lr = [ + "href", + "aria-current", + "onClick" +], Rr = { + key: 0, + class: "flex justify-center py-16" +}, zr = /* @__PURE__ */ l({ + __name: "ProjectDetailPage", + props: { + id: {}, + client: { type: [Function, Object] }, + notify: { type: Function }, + router: {} + }, + setup(t) { + let l = t, u = on.project, d = B(), f = l.router, m = b(null), h = b(!0), _ = b(!1), y = b(!1), T = n(() => Number(l.id)), O = n(() => [ + { + id: "overview", + label: d("tasks_projects.project.tabs.overview"), + name: `${u}.overview` + }, + { + id: "tasks", + label: d("tasks_projects.project.tabs.tasks"), + name: `${u}.tasks` + }, + { + id: "time", + label: d("tasks_projects.project.tabs.time"), + name: `${u}.time` + }, + { + id: "members", + label: d("tasks_projects.project.tabs.members"), + name: `${u}.members` + } + ]), k = n(() => String(l.router.currentRoute.value.name ?? "")), A = n(() => m.value?.name ?? d("tasks_projects.projects.title")), j = n(() => ({ + path: W.board, + query: { project: String(T.value) } + })), N = n(() => $t(m.value?.customer_id ?? null)), P = n(() => M.busy), F = n(() => M.allowed && m.value !== null && m.value.customer_id !== null && (m.value.totals?.unbilled_amount ?? 0) > 0); + E(T, () => { + L(); + }), E(k, (e) => I(e)), g(() => { + I(k.value), L(); + }); + function I(e) { + e === u && l.router.replace({ + name: `${u}.overview`, + params: { id: l.id } + }); + } + async function L() { + h.value = !0; + try { + m.value = await _r(l.client, T.value), typeof m.value?.customer_id == "number" && await en(l.client); + } catch (e) { + l.notify("error", H(e, d("tasks_projects.project.load_failed"))); + } finally { + h.value = !1; + } + } + function R(e) { + return { + name: e.name, + params: { id: l.id } + }; + } + function z(e) { + return k.value === e.name; + } + function ee(e) { + y.value = !1, l.notify("success", d("tasks_projects.projects.updated", { name: e.name })), L(); + } + async function te() { + let e = m.value; + e !== null && F.value && !P.value && (await bt({ + client: l.client, + router: f, + notify: l.notify, + t: d + }, { projectId: e.id }) || await L()); + } + async function ne() { + let e = m.value; + if (!(e === null || _.value)) { + _.value = !0; + try { + e.status === "ARCHIVED" ? (await Kt(l.client, e.id), l.notify("success", d("tasks_projects.projects.unarchived", { name: e.name }))) : (await Gt(l.client, e.id), l.notify("success", d("tasks_projects.projects.archived", { name: e.name }))), await L(); + } catch (e) { + l.notify("error", H(e, d("tasks_projects.projects.save_failed"))); + } finally { + _.value = !1; + } + } + } + function re(e) { + return e === "ACTIVE" ? "bg-primary-50! text-primary-500!" : "bg-surface-tertiary! text-muted!"; + } + function ie(e) { + return d(e === "ACTIVE" ? "tasks_projects.projects.status.active" : "tasks_projects.projects.status.archived"); + } + return (n, l) => { + let u = S("BaseBreadcrumbItem"), f = S("BaseBreadcrumb"), g = S("BaseBadge"), b = S("BaseIcon"), T = S("BaseButton"), E = S("router-link"), k = S("BasePageHeader"), M = S("BaseSpinner"), I = S("router-view"), B = S("BasePage"); + return v(), r(B, null, { + default: D(() => [ + c(k, { title: A.value }, { + actions: D(() => [o("div", Fr, [ + c(E, { to: j.value }, { + default: D(() => [c(T, { variant: "white" }, { + left: D((e) => [c(b, { + name: "ViewColumnsIcon", + class: p(e.class) + }, null, 8, ["class"])]), + default: D(() => [s(" " + C(w(d)("tasks_projects.project.board")), 1)]), + _: 1 + })]), + _: 1 + }, 8, ["to"]), + F.value ? (v(), r(T, { + key: 0, + variant: "primary-outline", + loading: P.value, + disabled: P.value, + onClick: te + }, { + left: D((e) => [P.value ? i("", !0) : (v(), r(b, { + key: 0, + name: "BanknotesIcon", + class: p(e.class) + }, null, 8, ["class"]))]), + default: D(() => [s(" " + C(w(d)("tasks_projects.project.invoice_project")), 1)]), + _: 1 + }, 8, ["loading", "disabled"])) : i("", !0), + m.value ? (v(), r(T, { + key: 1, + variant: "primary-outline", + loading: _.value, + disabled: _.value, + onClick: ne + }, { + default: D(() => [s(C(m.value.status === "ARCHIVED" ? w(d)("tasks_projects.projects.unarchive") : w(d)("tasks_projects.projects.archive")), 1)]), + _: 1 + }, 8, ["loading", "disabled"])) : i("", !0), + m.value ? (v(), r(T, { + key: 2, + variant: "primary", + onClick: l[0] ||= (e) => y.value = !0 + }, { + left: D((e) => [c(b, { + name: "PencilIcon", + class: p(e.class) + }, null, 8, ["class"])]), + default: D(() => [s(" " + C(w(d)("tasks_projects.general.edit")), 1)]), + _: 1 + })) : i("", !0) + ])]), + default: D(() => [c(f, null, { + default: D(() => [ + c(u, { + title: w(d)("tasks_projects.general.home"), + to: "/admin/dashboard" + }, null, 8, ["title"]), + c(u, { + title: w(d)("tasks_projects.projects.title"), + to: w(W).projects + }, null, 8, ["title", "to"]), + c(u, { + title: A.value, + to: "#", + active: "" + }, null, 8, ["title"]) + ]), + _: 1 + }), m.value ? (v(), a("div", Or, [ + c(g, { class: p(["rounded-full", re(m.value.status)]) }, { + default: D(() => [s(C(ie(m.value.status)), 1)]), + _: 1 + }, 8, ["class"]), + m.value.identifier ? (v(), a("span", kr, C(m.value.identifier), 1)) : i("", !0), + m.value.customer_id ? (v(), a("span", Ar, [s(C(w(d)("tasks_projects.project.customer")) + ": ", 1), o("span", jr, C(N.value), 1)])) : (v(), a("span", Mr, C(w(d)("tasks_projects.projects.internal")), 1)), + m.value.due_date ? (v(), a("span", Nr, [s(C(w(d)("tasks_projects.project.due_date")) + ": ", 1), o("span", Pr, C(w(ut)(m.value.due_date)), 1)])) : i("", !0) + ])) : i("", !0)]), + _: 1 + }, 8, ["title"]), + c(Lt, { + client: t.client, + notify: t.notify + }, null, 8, ["client", "notify"]), + o("nav", Ir, [(v(!0), a(e, null, x(O.value, (e) => (v(), r(E, { + key: e.id, + to: R(e), + custom: "" + }, { + default: D(({ href: t, navigate: n }) => [o("a", { + href: t, + "aria-current": z(e) ? "page" : void 0, + class: p(["relative -mb-px flex items-center border-b-2 px-5 py-2.5 text-sm leading-5 font-medium whitespace-nowrap transition-colors focus:outline-hidden", z(e) ? "border-primary-400 text-heading" : "border-transparent text-muted hover:border-line-strong hover:text-body"]), + onClick: n + }, C(e.label), 11, Lr)]), + _: 2 + }, 1032, ["to"]))), 128))]), + h.value && m.value === null ? (v(), a("div", Rr, [c(M, { class: "h-8 w-8 text-primary-500" })])) : (v(), r(I, { + key: 1, + project: m.value, + onRefresh: L + }, null, 8, ["project"])), + c(Dr, { + show: y.value, + client: t.client, + notify: t.notify, + project: m.value, + onClose: l[1] ||= (e) => y.value = !1, + onSaved: ee + }, null, 8, [ + "show", + "client", + "notify", + "project" + ]) + ]), + _: 1 + }); + }; + } +}), Br = { class: "flex items-center justify-end space-x-5" }, Vr = { class: "relative table-container" }, Hr = { class: "flex items-center" }, Ur = { + key: 0, + class: "block text-xs font-normal text-muted" +}, Wr = { key: 0 }, Gr = { + key: 1, + class: "text-subtle" +}, Kr = { + key: 1, + class: "text-subtle" +}, qr = { key: 0 }, Jr = { + key: 1, + class: "text-subtle" +}, Yr = 10, Xr = 350, Zr = /* @__PURE__ */ l({ + __name: "ProjectsIndexPage", + props: { + client: { type: [Function, Object] }, + notify: { type: Function }, + router: {} + }, + setup(e) { + let t = e, l = { + name: "name", + status: "status", + default_rate: "default_rate", + due_date: "due_date" + }, u = B(), d = b(null), f = b(!1), g = b(!0), _ = b(0), x = b(!1), k = b(null), A = b(null), j = y({ + search: "", + status: "ACTIVE" + }), M = n(() => [ + { + id: "ACTIVE", + label: u("tasks_projects.projects.status.active") + }, + { + id: "ARCHIVED", + label: u("tasks_projects.projects.status.archived") + }, + { + id: "ALL", + label: u("tasks_projects.projects.status.all") + } + ]), N = n({ + get: () => M.value.find((e) => e.id === j.status) ?? M.value[0], + set: (e) => { + j.status = e.id; + } + }), P = n(() => [ + { + key: "name", + label: u("tasks_projects.projects.columns.name"), + sortable: !0, + sortBy: "name", + thClass: "extra", + tdClass: "font-medium text-heading" + }, + { + key: "status", + label: u("tasks_projects.projects.columns.status"), + sortable: !0, + sortBy: "status" + }, + { + key: "customer", + label: u("tasks_projects.projects.columns.customer"), + sortable: !1 + }, + { + key: "default_rate", + label: u("tasks_projects.projects.columns.default_rate"), + sortable: !0, + sortBy: "default_rate" + }, + { + key: "due_date", + label: u("tasks_projects.projects.columns.due_date"), + sortable: !0, + sortBy: "due_date" + }, + { + key: "actions", + label: u("tasks_projects.general.actions"), + sortable: !1, + tdClass: "text-right text-sm font-medium" + } + ]), F = n(() => j.search.trim() !== "" || j.status !== "ACTIVE"), I = n(() => !g.value && _.value === 0 && !F.value), L; + E(() => j.search, () => { + clearTimeout(L), L = setTimeout(() => z(), Xr); + }), E(() => j.status, () => z()), h(() => clearTimeout(L)); + async function R({ page: e, sort: n }) { + let r = { + page: e, + limit: Yr, + ...Vt(n, l) + }; + j.status !== "ALL" && (r.status = j.status), j.search.trim() !== "" && (r.search = j.search.trim()), g.value = !0; + try { + let e = await Ht(t.client, r); + return _.value = e.meta.total, e.data.some((e) => e.customer_id !== null) && en(t.client), { + data: e.data, + pagination: { + totalPages: e.meta.last_page, + currentPage: e.meta.current_page, + totalCount: e.meta.total, + limit: e.meta.per_page + } + }; + } catch (e) { + return t.notify("error", H(e, u("tasks_projects.projects.load_failed"))), { + data: [], + pagination: { + totalPages: 1, + currentPage: 1, + totalCount: 0, + limit: Yr + } + }; + } finally { + g.value = !1; + } + } + function z(e = !1) { + d.value?.refresh(e); + } + function ee() { + f.value && te(), f.value = !f.value; + } + function te() { + j.search = "", j.status = "ACTIVE"; + } + function ne() { + k.value = null, x.value = !0; + } + function re(e) { + k.value = e, x.value = !0; + } + function ie(e) { + let n = k.value ? u("tasks_projects.projects.updated", { name: e.name }) : u("tasks_projects.projects.created", { name: e.name }); + x.value = !1, k.value = null, t.notify("success", n), z(); + } + async function ae(e) { + A.value = e.id; + try { + e.status === "ARCHIVED" ? (await Kt(t.client, e.id), t.notify("success", u("tasks_projects.projects.unarchived", { name: e.name }))) : (await Gt(t.client, e.id), t.notify("success", u("tasks_projects.projects.archived", { name: e.name }))), z(!0); + } catch (e) { + t.notify("error", H(e, u("tasks_projects.projects.save_failed"))); + } finally { + A.value = null; + } + } + async function oe(e) { + if (window.confirm(u("tasks_projects.projects.delete_confirm", { name: e.name }))) { + A.value = e.id; + try { + await qt(t.client, e.id), t.notify("success", u("tasks_projects.projects.deleted", { name: e.name })), z(!0); + } catch (e) { + t.notify("error", H(e, u("tasks_projects.projects.delete_failed"))); + } finally { + A.value = null; + } + } + } + function se(e) { + return e === "ACTIVE" ? "bg-primary-50! text-primary-500!" : "bg-surface-tertiary! text-muted!"; + } + function ce(e) { + return u(e === "ACTIVE" ? "tasks_projects.projects.status.active" : "tasks_projects.projects.status.archived"); + } + return (t, n) => { + let l = S("BaseBreadcrumbItem"), h = S("BaseBreadcrumb"), g = S("BaseIcon"), _ = S("BaseButton"), y = S("router-link"), b = S("BasePageHeader"), E = S("BaseInput"), F = S("BaseInputGroup"), L = S("BaseSelectInput"), z = S("BaseFilterWrapper"), B = S("BaseEmptyPlaceholder"), le = S("BaseBadge"), ue = S("BaseFormatMoney"), de = S("BaseDropdownItem"), fe = S("BaseDropdown"), pe = S("BaseTable"), me = S("BasePage"); + return v(), r(me, null, { + default: D(() => [ + c(b, { title: w(u)("tasks_projects.projects.title") }, { + actions: D(() => [o("div", Br, [ + c(y, { to: w(W).tasks }, { + default: D(() => [c(_, { variant: "white" }, { + left: D((e) => [c(g, { + name: "ClipboardDocumentListIcon", + class: p(e.class) + }, null, 8, ["class"])]), + default: D(() => [s(" " + C(w(u)("tasks_projects.tasks.title")), 1)]), + _: 1 + })]), + _: 1 + }, 8, ["to"]), + c(y, { to: w(W).reports }, { + default: D(() => [c(_, { variant: "white" }, { + left: D((e) => [c(g, { + name: "ChartBarIcon", + class: p(e.class) + }, null, 8, ["class"])]), + default: D(() => [s(" " + C(w(u)("tasks_projects.reports.title")), 1)]), + _: 1 + })]), + _: 1 + }, 8, ["to"]), + c(y, { to: w(W).billing }, { + default: D(() => [c(_, { variant: "white" }, { + left: D((e) => [c(g, { + name: "BanknotesIcon", + class: p(e.class) + }, null, 8, ["class"])]), + default: D(() => [s(" " + C(w(u)("tasks_projects.billing.title")), 1)]), + _: 1 + })]), + _: 1 + }, 8, ["to"]), + c(_, { + variant: "primary-outline", + onClick: ee + }, { + right: D((e) => [f.value ? (v(), r(g, { + key: 1, + name: "XMarkIcon", + class: p(e.class) + }, null, 8, ["class"])) : (v(), r(g, { + key: 0, + name: "FunnelIcon", + class: p(e.class) + }, null, 8, ["class"]))]), + default: D(() => [s(C(w(u)("tasks_projects.general.filter")) + " ", 1)]), + _: 1 + }), + c(_, { + variant: "primary", + onClick: ne + }, { + left: D((e) => [c(g, { + name: "PlusIcon", + class: p(e.class) + }, null, 8, ["class"])]), + default: D(() => [s(" " + C(w(u)("tasks_projects.projects.new_project")), 1)]), + _: 1 + }) + ])]), + default: D(() => [c(h, null, { + default: D(() => [c(l, { + title: w(u)("tasks_projects.general.home"), + to: "/admin/dashboard" + }, null, 8, ["title"]), c(l, { + title: w(u)("tasks_projects.projects.title"), + to: "#", + active: "" + }, null, 8, ["title"])]), + _: 1 + })]), + _: 1 + }, 8, ["title"]), + c(z, { + show: f.value, + class: "mt-3", + onClear: te + }, { + default: D(() => [c(F, { + label: w(u)("tasks_projects.general.search"), + class: "mt-2 flex-1" + }, { + default: D(() => [c(E, { + modelValue: j.search, + "onUpdate:modelValue": n[0] ||= (e) => j.search = e, + type: "text", + name: "search", + autocomplete: "off", + placeholder: w(u)("tasks_projects.projects.search_placeholder") + }, null, 8, ["modelValue", "placeholder"])]), + _: 1 + }, 8, ["label"]), c(F, { + label: w(u)("tasks_projects.projects.columns.status"), + class: "mt-2 flex-1" + }, { + default: D(() => [c(L, { + modelValue: N.value, + "onUpdate:modelValue": n[1] ||= (e) => N.value = e, + options: M.value, + "label-key": "label" + }, null, 8, ["modelValue", "options"])]), + _: 1 + }, 8, ["label"])]), + _: 1 + }, 8, ["show"]), + O(c(B, { + title: w(u)("tasks_projects.projects.empty_title"), + description: w(u)("tasks_projects.projects.empty_description") + }, { + actions: D(() => [c(_, { + variant: "primary", + onClick: ne + }, { + left: D((e) => [c(g, { + name: "PlusIcon", + class: p(e.class) + }, null, 8, ["class"])]), + default: D(() => [s(" " + C(w(u)("tasks_projects.projects.new_project")), 1)]), + _: 1 + })]), + default: D(() => [c(g, { + name: "FolderIcon", + class: "mt-5 mb-4 h-16 w-16 text-subtle" + })]), + _: 1 + }, 8, ["title", "description"]), [[T, I.value]]), + O(o("div", Vr, [c(pe, { + ref_key: "tableRef", + ref: d, + data: R, + columns: P.value, + class: "mt-3" + }, { + "cell-name": D(({ row: e }) => [o("div", Hr, [o("span", { + class: p(["mr-3 inline-block h-2.5 w-2.5 shrink-0 rounded-full", e.data.colour ? "" : "bg-line-default"]), + style: m(e.data.colour ? { backgroundColor: e.data.colour } : void 0) + }, null, 6), o("span", null, [c(y, { + class: "hover:text-primary-500", + to: w(W).project(e.data.id) + }, { + default: D(() => [s(C(e.data.name), 1)]), + _: 2 + }, 1032, ["to"]), e.data.identifier ? (v(), a("span", Ur, C(e.data.identifier), 1)) : i("", !0)])])]), + "cell-status": D(({ row: e }) => [c(le, { class: p(["rounded-full", se(e.data.status)]) }, { + default: D(() => [s(C(ce(e.data.status)), 1)]), + _: 2 + }, 1032, ["class"])]), + "cell-customer": D(({ row: e }) => [e.data.customer_id ? (v(), a("span", Wr, C(w($t)(e.data.customer_id)), 1)) : (v(), a("span", Gr, C(w(u)("tasks_projects.projects.internal")), 1))]), + "cell-default_rate": D(({ row: e }) => [e.data.default_rate === null ? (v(), a("span", Kr, "-")) : (v(), r(ue, { + key: 0, + amount: e.data.default_rate + }, null, 8, ["amount"]))]), + "cell-due_date": D(({ row: e }) => [e.data.due_date ? (v(), a("span", qr, C(w(ut)(e.data.due_date)), 1)) : (v(), a("span", Jr, "-"))]), + "cell-actions": D(({ row: e }) => [c(fe, { "content-loading": A.value === e.data.id }, { + activator: D(() => [c(g, { + name: "EllipsisHorizontalIcon", + class: "h-5 text-muted" + })]), + default: D(() => [ + c(de, { onClick: (t) => re(e.data) }, { + default: D(() => [c(g, { + name: "PencilIcon", + class: "mr-3 h-5 w-5 text-subtle group-hover:text-muted" + }), s(" " + C(w(u)("tasks_projects.general.edit")), 1)]), + _: 1 + }, 8, ["onClick"]), + c(de, { onClick: (t) => ae(e.data) }, { + default: D(() => [c(g, { + name: e.data.status === "ARCHIVED" ? "ArrowPathIcon" : "ArchiveBoxIcon", + class: "mr-3 h-5 w-5 text-subtle group-hover:text-muted" + }, null, 8, ["name"]), s(" " + C(e.data.status === "ARCHIVED" ? w(u)("tasks_projects.projects.unarchive") : w(u)("tasks_projects.projects.archive")), 1)]), + _: 2 + }, 1032, ["onClick"]), + c(de, { onClick: (t) => oe(e.data) }, { + default: D(() => [c(g, { + name: "TrashIcon", + class: "mr-3 h-5 w-5 text-subtle group-hover:text-muted" + }), s(" " + C(w(u)("tasks_projects.general.delete")), 1)]), + _: 1 + }, 8, ["onClick"]) + ]), + _: 2 + }, 1032, ["content-loading"])]), + _: 1 + }, 8, ["columns"])], 512), [[T, !I.value]]), + c(Dr, { + show: x.value, + client: e.client, + notify: e.notify, + project: k.value, + onClose: n[2] ||= (e) => x.value = !1, + onSaved: ie + }, null, 8, [ + "show", + "client", + "notify", + "project" + ]) + ]), + _: 1 + }); + }; + } +}), Qr = { class: "py-4" }, $r = { class: "rounded-xl border border-line-default bg-surface p-5" }, ei = { class: "flex flex-col gap-4 lg:flex-row lg:items-end" }, ti = { + key: 0, + class: "mt-3 text-xs text-subtle" +}, ni = { class: "mt-4 overflow-hidden rounded-xl border border-line-default bg-surface" }, ri = { + key: 0, + class: "flex justify-center py-10" +}, ii = { + key: 1, + class: "px-5 py-8 text-center text-sm text-muted" +}, ai = { + key: 2, + class: "divide-y divide-line-light" +}, oi = { class: "text-sm font-medium text-heading" }, si = { class: "text-xs text-muted" }, ci = { + key: 1, + class: "text-subtle" +}, li = /* @__PURE__ */ l({ + __name: "ProjectMembersTab", + props: { + id: {}, + client: { type: [Function, Object] }, + notify: { type: Function }, + project: {} + }, + setup(t) { + let l = t, u = B(), d = b([]), f = b([]), m = b(!0), h = b(!1), _ = b(null), y = b(null), T = b(""), E = b({}), O = n(() => l.project?.id ?? Number(l.id)), k = n(() => f.value.filter((e) => !d.value.some((t) => t.user_id === e.id)).map((e) => ({ + id: e.id, + label: e.name + }))); + g(() => { + A(); + }); + async function A() { + m.value = !0; + try { + f.value = await Jt(l.client); + } catch (e) { + l.notify("error", H(e, u("tasks_projects.tasks.members_failed"))); + } + try { + d.value = await vr(l.client, O.value); + } catch (e) { + l.notify("error", H(e, u("tasks_projects.project.members.load_failed"))); + } finally { + m.value = !1; + } + } + function j(e) { + return f.value.find((t) => t.id === e)?.name ?? u("tasks_projects.project.time.removed_member"); + } + async function M() { + let e = y.value; + if (!(e === null || h.value)) { + h.value = !0, E.value = {}; + try { + await yr(l.client, O.value, { + user_id: e.id, + rate: st(T.value) + }), l.notify("success", u("tasks_projects.project.members.attached", { name: e.label })), y.value = null, T.value = "", await A(); + } catch (e) { + E.value = at(e), l.notify("error", H(e, u("tasks_projects.project.members.attach_failed"))); + } finally { + h.value = !1; + } + } + } + async function N(e) { + let t = j(e.user_id); + if (window.confirm(u("tasks_projects.project.members.detach_confirm", { name: t }))) { + _.value = e.user_id; + try { + await br(l.client, O.value, e.user_id), l.notify("success", u("tasks_projects.project.members.detached", { name: t })), await A(); + } catch (e) { + l.notify("error", H(e, u("tasks_projects.project.members.detach_failed"))); + } finally { + _.value = null; + } + } + } + return (t, n) => { + let l = S("BaseSelectInput"), f = S("BaseInputGroup"), g = S("BaseInput"), b = S("BaseIcon"), O = S("BaseButton"), A = S("BaseSpinner"), P = S("BaseFormatMoney"); + return v(), a("div", Qr, [o("div", $r, [o("div", ei, [ + c(f, { + label: w(u)("tasks_projects.project.members.member"), + error: E.value.user_id, + class: "flex-1" + }, { + default: D(() => [c(l, { + modelValue: y.value, + "onUpdate:modelValue": n[0] ||= (e) => y.value = e, + options: k.value, + placeholder: w(u)("tasks_projects.project.members.attach_placeholder"), + "label-key": "label" + }, null, 8, [ + "modelValue", + "options", + "placeholder" + ])]), + _: 1 + }, 8, ["label", "error"]), + c(f, { + label: w(u)("tasks_projects.project.members.rate"), + error: E.value.rate, + "help-text": w(u)("tasks_projects.project.members.rate_help"), + class: "flex-1" + }, { + default: D(() => [c(g, { + modelValue: T.value, + "onUpdate:modelValue": n[1] ||= (e) => T.value = e, + type: "number", + step: "0.01", + min: "0" + }, null, 8, ["modelValue"])]), + _: 1 + }, 8, [ + "label", + "error", + "help-text" + ]), + c(O, { + variant: "primary", + class: "lg:mb-1", + loading: h.value, + disabled: h.value || y.value === null, + onClick: M + }, { + left: D((e) => [c(b, { + name: "PlusIcon", + class: p(e.class) + }, null, 8, ["class"])]), + default: D(() => [s(" " + C(w(u)("tasks_projects.project.members.attach")), 1)]), + _: 1 + }, 8, ["loading", "disabled"]) + ]), k.value.length === 0 && !m.value ? (v(), a("p", ti, C(w(u)("tasks_projects.project.members.all_attached")), 1)) : i("", !0)]), o("div", ni, [m.value ? (v(), a("div", ri, [c(A, { class: "h-6 w-6 text-primary-500" })])) : d.value.length === 0 ? (v(), a("p", ii, C(w(u)("tasks_projects.project.members.empty")), 1)) : (v(), a("ul", ai, [(v(!0), a(e, null, x(d.value, (e) => (v(), a("li", { + key: e.id, + class: "flex items-center justify-between px-5 py-4" + }, [o("div", null, [o("p", oi, C(j(e.user_id)), 1), o("p", si, [s(C(w(u)("tasks_projects.project.members.rate")) + ": ", 1), e.rate === null ? (v(), a("span", ci, C(w(u)("tasks_projects.tasks.none")), 1)) : (v(), r(P, { + key: 0, + amount: e.rate + }, null, 8, ["amount"]))])]), c(O, { + variant: "danger", + size: "sm", + loading: _.value === e.user_id, + disabled: _.value === e.user_id, + onClick: (t) => N(e) + }, { + default: D(() => [s(C(w(u)("tasks_projects.general.delete")), 1)]), + _: 1 + }, 8, [ + "loading", + "disabled", + "onClick" + ])]))), 128))]))])]); + }; + } +}), ui = { + key: 0, + class: "py-6" +}, di = { class: "grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4" }, fi = { class: "rounded-xl border border-line-default bg-surface p-5" }, pi = { class: "text-xs font-medium tracking-wider text-muted uppercase" }, mi = { class: "mt-2 text-2xl font-semibold text-heading" }, hi = { class: "mt-1 text-xs text-muted" }, gi = { class: "rounded-xl border border-line-default bg-surface p-5" }, _i = { class: "text-xs font-medium tracking-wider text-muted uppercase" }, vi = { class: "mt-2 text-2xl font-semibold text-heading" }, yi = { class: "mt-1 text-xs text-muted" }, bi = { class: "rounded-xl border border-line-default bg-surface p-5" }, xi = { class: "text-xs font-medium tracking-wider text-muted uppercase" }, Si = { class: "mt-2 text-2xl font-semibold text-heading" }, Ci = { class: "rounded-xl border border-line-default bg-surface p-5" }, wi = { class: "text-xs font-medium tracking-wider text-muted uppercase" }, Ti = { class: "mt-2 text-2xl font-semibold text-heading" }, Ei = { class: "mt-4 rounded-xl border border-line-default bg-surface p-5" }, Di = { class: "text-xs font-medium tracking-wider text-muted uppercase" }, Oi = { class: "mt-2 text-sm text-body" }, ki = { class: "mt-3 h-2 w-full overflow-hidden rounded-full bg-surface-tertiary" }, Ai = { + key: 0, + class: "mt-2 text-xs font-medium text-status-red" +}, ji = { + key: 1, + class: "mt-2 text-sm text-subtle" +}, Mi = { class: "mt-4 rounded-xl border border-line-default bg-surface p-5" }, Ni = { class: "text-xs font-medium tracking-wider text-muted uppercase" }, Pi = { + key: 0, + class: "mt-2 text-sm whitespace-pre-line text-body" +}, Fi = { + key: 1, + class: "mt-2 text-sm text-subtle" +}, Ii = { + key: 1, + class: "flex justify-center py-16" +}, Li = /* @__PURE__ */ l({ + __name: "ProjectOverviewTab", + props: { + client: { type: [Function, Object] }, + notify: { type: Function }, + router: {}, + project: {} + }, + emits: ["refresh"], + setup(t, { emit: l }) { + let u = t, d = l, f = B(), h = n(() => u.project?.totals ?? null), g = n(() => u.project?.budget_minutes ?? null), _ = n(() => { + let e = g.value, t = h.value?.logged_minutes ?? 0; + return e ? Math.min(100, Math.round(t / e * 100)) : 0; + }), y = n(() => M.allowed && u.project?.customer_id !== null && (h.value?.unbilled_amount ?? 0) > 0), b = n(() => M.busy), x = n(() => { + let e = g.value, t = h.value?.logged_minutes ?? 0; + return e && t > e ? t - e : 0; + }); + async function T() { + let e = u.project; + e !== null && y.value && !b.value && (await bt({ + client: u.client, + router: u.router, + notify: u.notify, + t: f + }, { projectId: e.id }) || d("refresh")); + } + return (n, l) => { + let u = S("BaseFormatMoney"), d = S("BaseIcon"), E = S("BaseButton"), O = S("BaseSpinner"); + return t.project && h.value ? (v(), a("div", ui, [ + o("div", di, [ + o("div", fi, [ + o("p", pi, C(w(f)("tasks_projects.project.overview.tasks")), 1), + o("p", mi, C(h.value.tasks.total), 1), + o("p", hi, C(w(f)("tasks_projects.project.overview.open_tasks", { count: h.value.tasks.open })) + " · " + C(w(f)("tasks_projects.project.overview.closed_tasks", { count: h.value.tasks.closed })), 1) + ]), + o("div", gi, [ + o("p", _i, C(w(f)("tasks_projects.project.overview.logged")), 1), + o("p", vi, C(w(ft)(h.value.logged_minutes)), 1), + o("p", yi, C(w(f)("tasks_projects.project.overview.billable")) + ": " + C(w(ft)(h.value.billable_minutes)), 1) + ]), + o("div", bi, [o("p", xi, C(w(f)("tasks_projects.project.overview.billable_amount")), 1), o("p", Si, [c(u, { amount: h.value.billable_amount }, null, 8, ["amount"])])]), + o("div", Ci, [ + o("p", wi, C(w(f)("tasks_projects.project.overview.unbilled_amount")), 1), + o("p", Ti, [c(u, { amount: h.value.unbilled_amount }, null, 8, ["amount"])]), + y.value ? (v(), r(E, { + key: 0, + class: "mt-2", + variant: "primary-outline", + size: "sm", + loading: b.value, + disabled: b.value, + onClick: T + }, { + left: D((e) => [b.value ? i("", !0) : (v(), r(d, { + key: 0, + name: "BanknotesIcon", + class: p(e.class) + }, null, 8, ["class"]))]), + default: D(() => [s(" " + C(w(f)("tasks_projects.project.invoice_project")), 1)]), + _: 1 + }, 8, ["loading", "disabled"])) : i("", !0) + ]) + ]), + o("div", Ei, [o("p", Di, C(w(f)("tasks_projects.project.overview.budget")), 1), g.value ? (v(), a(e, { key: 0 }, [ + o("p", Oi, C(w(f)("tasks_projects.project.overview.budget_used", { + used: w(ft)(h.value.logged_minutes), + total: w(ft)(g.value) + })), 1), + o("div", ki, [o("div", { + class: p(["h-2 rounded-full", x.value > 0 ? "bg-status-red" : "bg-primary-500"]), + style: m({ width: `${_.value}%` }) + }, null, 6)]), + x.value > 0 ? (v(), a("p", Ai, C(w(f)("tasks_projects.project.overview.budget_over", { amount: w(ft)(x.value) })), 1)) : i("", !0) + ], 64)) : (v(), a("p", ji, C(w(f)("tasks_projects.project.overview.no_budget")), 1))]), + o("div", Mi, [o("p", Ni, C(w(f)("tasks_projects.project.overview.description")), 1), t.project.description ? (v(), a("p", Pi, C(t.project.description), 1)) : (v(), a("p", Fi, C(w(f)("tasks_projects.project.overview.no_description")), 1))]) + ])) : (v(), a("div", Ii, [c(O, { class: "h-8 w-8 text-primary-500" })])); + }; + } +}), Ri = { + project: "", + user: "", + status: "", + search: "" +}; +function zi(e) { + return e === "uninvoiced" || e === "invoiced"; +} +function Bi(e) { + return { + project: Ji(e.project), + user: Ji(e.user), + status: Yi(e.status), + search: qi(e.search).slice(0, 200) + }; +} +function Vi(e) { + let t = {}; + for (let n of [ + "project", + "user", + "status", + "search" + ]) e[n] !== "" && (t[n] = e[n]); + return t; +} +function Hi(e) { + return e.project !== "" || e.user !== "" || e.status !== "" || e.search !== ""; +} +function Ui(e) { + return [ + e.project, + e.user, + e.status, + e.search + ].join("|"); +} +function Wi(e, t) { + return e.project === t.project && e.user === t.user && e.status === t.status && e.search === t.search; +} +function Gi(e, t = {}) { + let n = {}, r = t.projectId ?? Ki(e.project); + r !== null && (n.project_id = r); + let i = Ki(e.user); + if (i !== null && (n.assignee_id = i), zi(e.status)) n.invoiced = +(e.status === "invoiced"); + else { + let t = Ki(e.status); + t !== null && (n.task_status_id = t); + } + return e.search !== "" && (n.search = e.search), n; +} +function Ki(e) { + let t = Number(e); + return e !== "" && Number.isInteger(t) && t > 0 ? t : null; +} +function qi(e) { + let t = Array.isArray(e) ? e[0] : e; + return typeof t == "string" ? t.trim() : ""; +} +function Ji(e) { + let t = qi(e); + return Ki(t) === null ? "" : t; +} +function Yi(e) { + let t = qi(e); + return zi(t) ? t : Ji(e); +} +//#endregion +//#region resources/js/components/TaskFilters.vue?vue&type=script&setup=true&lang.ts +var Xi = { class: "mt-4 flex flex-wrap items-end gap-3" }, Zi = { + key: 0, + class: "min-w-44 flex-1" +}, Qi = { class: "mb-1 block text-xs font-medium text-muted" }, $i = { class: "min-w-44 flex-1" }, ea = { class: "mb-1 block text-xs font-medium text-muted" }, ta = { class: "min-w-44 flex-1" }, na = { class: "mb-1 block text-xs font-medium text-muted" }, ra = { class: "min-w-44 flex-1" }, ia = { class: "mb-1 block text-xs font-medium text-muted" }, aa = 350, oa = /* @__PURE__ */ l({ + __name: "TaskFilters", + props: { + modelValue: {}, + projects: {}, + members: {}, + statuses: {}, + lockProject: { + type: Boolean, + default: !1 + } + }, + emits: ["update:modelValue"], + setup(e, { emit: t }) { + let r = e, s = t, l = B(), u = b(r.modelValue.search), d, f = n(() => [{ + id: "", + label: l("tasks_projects.tasks.filters.all_projects") + }, ...r.projects.map((e) => ({ + id: String(e.id), + label: e.name + }))]), p = n(() => [{ + id: "", + label: l("tasks_projects.tasks.filters.all_members") + }, ...r.members.map((e) => ({ + id: String(e.id), + label: e.name + }))]), m = n(() => [ + { + id: "", + label: l("tasks_projects.tasks.filters.all_statuses") + }, + ...r.statuses.map((e) => ({ + id: String(e.id), + label: e.name + })), + { + id: "uninvoiced", + label: l("tasks_projects.tasks.uninvoiced") + }, + { + id: "invoiced", + label: l("tasks_projects.tasks.invoiced") + } + ]), g = n({ + get: () => T(f.value, r.modelValue.project), + set: (e) => D({ project: e?.id ?? "" }) + }), _ = n({ + get: () => T(p.value, r.modelValue.user), + set: (e) => D({ user: e?.id ?? "" }) + }), y = n({ + get: () => T(m.value, r.modelValue.status), + set: (e) => D({ status: e?.id ?? "" }) + }), x = n(() => Hi(r.modelValue)); + E(() => r.modelValue.search, (e) => { + e !== u.value && (u.value = e); + }), E(u, (e) => { + clearTimeout(d), d = setTimeout(() => D({ search: e.trim() }), aa); + }), h(() => clearTimeout(d)); + function T(e, t) { + return e.find((e) => e.id === t) ?? e[0]; + } + function D(e) { + s("update:modelValue", { + ...r.modelValue, + ...e + }); + } + function O() { + u.value = "", s("update:modelValue", { ...Ri }); + } + return (t, n) => { + let r = S("BaseSelectInput"), s = S("BaseInput"); + return v(), a("div", Xi, [ + e.lockProject ? i("", !0) : (v(), a("label", Zi, [o("span", Qi, C(w(l)("tasks_projects.tasks.filters.project")), 1), c(r, { + modelValue: g.value, + "onUpdate:modelValue": n[0] ||= (e) => g.value = e, + options: f.value, + "label-key": "label" + }, null, 8, ["modelValue", "options"])])), + o("label", $i, [o("span", ea, C(w(l)("tasks_projects.tasks.filters.member")), 1), c(r, { + modelValue: _.value, + "onUpdate:modelValue": n[1] ||= (e) => _.value = e, + options: p.value, + "label-key": "label" + }, null, 8, ["modelValue", "options"])]), + o("label", ta, [o("span", na, C(w(l)("tasks_projects.tasks.filters.status")), 1), c(r, { + modelValue: y.value, + "onUpdate:modelValue": n[2] ||= (e) => y.value = e, + options: m.value, + "label-key": "label" + }, null, 8, ["modelValue", "options"])]), + o("label", ra, [o("span", ia, C(w(l)("tasks_projects.tasks.filters.search")), 1), c(s, { + modelValue: u.value, + "onUpdate:modelValue": n[3] ||= (e) => u.value = e, + type: "text", + name: "search", + autocomplete: "off", + placeholder: w(l)("tasks_projects.tasks.search_placeholder") + }, null, 8, ["modelValue", "placeholder"])]), + x.value ? (v(), a("button", { + key: 1, + type: "button", + class: "pb-2 text-sm font-medium text-primary-500 hover:underline", + onClick: O + }, C(w(l)("tasks_projects.tasks.bulk.clear")), 1)) : i("", !0) + ]); + }; + } +}), sa = { + key: 0, + class: "mt-3 flex flex-wrap items-center gap-3 rounded-lg border border-primary-200 bg-primary-50 px-4 py-2.5" +}, ca = { class: "text-sm font-medium text-primary-700" }, la = { class: "min-w-48" }, ua = /* @__PURE__ */ l({ + __name: "BulkActionBar", + props: { + count: {}, + statuses: {}, + busy: { type: Boolean }, + invoicing: { + type: Boolean, + default: !1 + }, + canInvoice: { + type: Boolean, + default: !0 + } + }, + emits: [ + "status", + "delete", + "invoice", + "clear", + "select-page" + ], + setup(e, { emit: t }) { + let l = e, u = t, d = B(), f = b(null), m = n(() => l.statuses.map((e) => ({ + id: e.id, + label: e.name + }))); + return E(f, (e) => { + e !== null && (u("status", e.id), f.value = null); + }), (t, n) => { + let l = S("BaseSelectInput"), h = S("BaseIcon"), g = S("BaseButton"); + return e.count > 0 ? (v(), a("div", sa, [ + o("span", ca, C(w(d)("tasks_projects.tasks.bulk.selected", { count: e.count })), 1), + o("div", la, [c(l, { + modelValue: f.value, + "onUpdate:modelValue": n[0] ||= (e) => f.value = e, + options: m.value, + disabled: e.busy, + placeholder: w(d)("tasks_projects.tasks.bulk.change_status"), + "label-key": "label" + }, null, 8, [ + "modelValue", + "options", + "disabled", + "placeholder" + ])]), + c(g, { + variant: "primary-outline", + size: "sm", + disabled: e.busy, + onClick: n[1] ||= (e) => u("delete") + }, { + left: D((e) => [c(h, { + name: "TrashIcon", + class: p(e.class) + }, null, 8, ["class"])]), + default: D(() => [s(" " + C(w(d)("tasks_projects.tasks.bulk.delete")), 1)]), + _: 1 + }, 8, ["disabled"]), + e.canInvoice ? (v(), r(g, { + key: 0, + variant: "primary-outline", + size: "sm", + loading: e.invoicing, + disabled: e.busy || e.invoicing, + onClick: n[2] ||= (e) => u("invoice") + }, { + left: D((t) => [e.invoicing ? i("", !0) : (v(), r(h, { + key: 0, + name: "BanknotesIcon", + class: p(t.class) + }, null, 8, ["class"]))]), + default: D(() => [s(" " + C(w(d)("tasks_projects.tasks.bulk.invoice")), 1)]), + _: 1 + }, 8, ["loading", "disabled"])) : i("", !0), + o("button", { + type: "button", + class: "ml-auto text-sm font-medium text-primary-600 hover:underline", + onClick: n[3] ||= (e) => u("select-page") + }, C(w(d)("tasks_projects.tasks.bulk.select_page")), 1), + o("button", { + type: "button", + class: "text-sm font-medium text-primary-600 hover:underline", + onClick: n[4] ||= (e) => u("clear") + }, C(w(d)("tasks_projects.tasks.bulk.clear")), 1) + ])) : i("", !0); + }; + } +}), da = /* @__PURE__ */ l({ + __name: "InvoicedBadge", + props: { state: { default: "none" } }, + setup(e) { + let t = e, a = B(), o = n(() => t.state === "invoiced" || t.state === "uninvoiced"), c = n(() => t.state === "invoiced" ? a("tasks_projects.tasks.invoiced") : a("tasks_projects.tasks.uninvoiced")), l = n(() => t.state === "invoiced" ? "bg-alert-success-bg! text-alert-success-text!" : "bg-alert-warning-bg! text-alert-warning-text!"); + return (e, t) => { + let n = S("BaseBadge"); + return o.value ? (v(), r(n, { + key: 0, + class: p(["rounded-full whitespace-nowrap", l.value]) + }, { + default: D(() => [s(C(c.value), 1)]), + _: 1 + }, 8, ["class"])) : i("", !0); + }; + } +}), fa = [ + "LOW", + "NORMAL", + "HIGH", + "URGENT" +], pa = { class: "flex w-full items-center justify-between" }, ma = { + key: 0, + class: "ml-2 text-sm font-normal text-muted" +}, ha = { class: "space-y-5 px-6 py-6" }, ga = { class: "flex items-center justify-between border-t border-line-default px-6 py-4" }, _a = { key: 1 }, va = { class: "flex space-x-3" }, ya = /* @__PURE__ */ l({ + __name: "TaskFormModal", + props: { + show: { type: Boolean }, + client: { type: [Function, Object] }, + notify: { type: Function }, + task: {}, + statuses: {}, + members: {}, + projects: {}, + defaults: {}, + lockProject: { type: Boolean }, + compact: { type: Boolean } + }, + emits: [ + "close", + "saved", + "deleted" + ], + setup(e, { emit: t }) { + let l = e, u = t, d = B(), f = y({ + name: "", + description: "", + estimateHours: "", + rate: "", + dueDate: "", + billable: !0 + }), p = b(null), m = b(null), h = b(null), g = b(null), _ = b(null), x = b({}), T = b(!1), O = b(!1), k = b(!1), j = n(() => !l.compact || k.value), M = n({ + get: () => R(l.projects, m.value), + set: (e) => { + m.value = e?.id ?? null; + } + }), N = n(() => l.task !== null), P = n(() => N.value ? d("tasks_projects.tasks.edit_task") : d("tasks_projects.tasks.new_task")), F = n(() => l.statuses.map((e) => ({ + id: e.id, + label: e.name + }))), I = n(() => l.members.map((e) => ({ + id: e.id, + label: e.name + }))), L = n(() => fa.map((e, t) => ({ + id: t, + label: d(`tasks_projects.tasks.priority.${e.toLowerCase()}`) + }))); + E(() => l.show, (e) => { + e && z(); + }, { immediate: !0 }); + function R(e, t) { + return t === null ? null : e.find((e) => e.id === t) ?? null; + } + function z() { + let e = l.task; + f.name = e?.name ?? "", f.description = e?.description ?? "", f.estimateHours = ct(e?.estimated_minutes ?? null), f.rate = ot(e?.rate ?? null), f.dueDate = e?.due_date ?? "", f.billable = e?.billable ?? !0; + let t = l.statuses.find((e) => e.is_default) ?? l.statuses[0], n = e?.task_status_id ?? l.defaults?.task_status_id ?? t?.id ?? null; + p.value = R(F.value, n), m.value = e?.project_id ?? l.defaults?.project_id ?? null, h.value = R(I.value, e?.assignee_id ?? null), g.value = e?.priority ? L.value[fa.indexOf(e.priority)] ?? null : null, _.value = e?.customer_id ?? null, x.value = {}, k.value = !1, _.value !== null && en(l.client); + } + function ee(e) { + f.dueDate = e ? dt(e) : ""; + } + function te() { + let e = p.value?.id ?? null; + if (e === null) return null; + let t = g.value === null ? null : fa[g.value.id]; + return { + name: f.name.trim(), + task_status_id: e, + project_id: m.value, + customer_id: m.value === null ? _.value : null, + description: f.description.trim() || null, + assignee_id: h.value?.id ?? null, + priority: t, + due_date: f.dueDate || null, + estimated_minutes: lt(f.estimateHours), + billable: f.billable, + rate: st(f.rate) + }; + } + async function ne() { + if (T.value) return; + if (f.name.trim() === "") { + x.value = { name: d("tasks_projects.tasks.name_required") }; + return; + } + let e = te(); + if (e === null) { + l.notify("error", d("tasks_projects.task_statuses.none")); + return; + } + T.value = !0, x.value = {}; + try { + let t = l.task, n = t ? await lr(l.client, t.id, e) : await cr(l.client, e); + u("saved", n); + } catch (e) { + x.value = at(e), l.notify("error", re(e, "save_failed")); + } finally { + T.value = !1; + } + } + function re(e, t) { + return gt(e) === "task_locked" ? d("tasks_projects.tasks.locked") : H(e, d(`tasks_projects.tasks.${t}`)); + } + async function ie() { + let e = l.task; + if (!(e === null || O.value) && window.confirm(d("tasks_projects.tasks.delete_confirm", { name: e.name }))) { + O.value = !0; + try { + await ur(l.client, e.id), u("deleted", e); + } catch (e) { + l.notify("error", re(e, "delete_failed")); + } finally { + O.value = !1; + } + } + } + return (t, n) => { + let l = S("BaseIcon"), m = S("BaseInput"), y = S("BaseInputGroup"), b = S("BaseSelectInput"), E = S("BaseDatePicker"), R = S("BaseInputGrid"), z = S("BaseSwitch"), B = S("BaseTextarea"), te = S("BaseButton"), re = S("BaseModal"); + return v(), r(re, { + show: e.show, + onClose: n[12] ||= (e) => u("close") + }, { + header: D(() => [o("div", pa, [o("span", null, [s(C(P.value) + " ", 1), e.task ? (v(), a("span", ma, "#" + C(e.task.number), 1)) : i("", !0)]), c(l, { + name: "XMarkIcon", + class: "h-6 w-6 cursor-pointer text-subtle hover:text-body", + onClick: n[0] ||= (e) => u("close") + })])]), + default: D(() => [o("form", { onSubmit: A(ne, ["prevent"]) }, [o("div", ha, [ + c(y, { + label: w(d)("tasks_projects.tasks.fields.name"), + error: x.value.name, + required: "" + }, { + default: D(() => [c(m, { + modelValue: f.name, + "onUpdate:modelValue": n[1] ||= (e) => f.name = e, + invalid: !!x.value.name, + type: "text" + }, null, 8, ["modelValue", "invalid"])]), + _: 1 + }, 8, ["label", "error"]), + c(R, null, { + default: D(() => [ + e.lockProject ? i("", !0) : (v(), r(y, { + key: 0, + label: w(d)("tasks_projects.tasks.fields.project"), + error: x.value.project_id, + "help-text": w(d)("tasks_projects.tasks.fields.project_help") + }, { + default: D(() => [c(b, { + modelValue: M.value, + "onUpdate:modelValue": n[2] ||= (e) => M.value = e, + options: e.projects, + placeholder: w(d)("tasks_projects.tasks.fields.project_placeholder"), + "label-key": "label" + }, null, 8, [ + "modelValue", + "options", + "placeholder" + ])]), + _: 1 + }, 8, [ + "label", + "error", + "help-text" + ])), + _.value === null ? i("", !0) : (v(), r(y, { + key: 1, + label: w(d)("tasks_projects.tasks.fields.customer"), + "help-text": w(d)("tasks_projects.tasks.fields.customer_help") + }, { + default: D(() => [c(m, { + "model-value": w($t)(_.value), + type: "text", + disabled: "" + }, null, 8, ["model-value"])]), + _: 1 + }, 8, ["label", "help-text"])), + c(y, { + label: w(d)("tasks_projects.tasks.fields.status"), + error: x.value.task_status_id + }, { + default: D(() => [c(b, { + modelValue: p.value, + "onUpdate:modelValue": n[3] ||= (e) => p.value = e, + options: F.value, + "label-key": "label" + }, null, 8, ["modelValue", "options"])]), + _: 1 + }, 8, ["label", "error"]), + c(y, { + label: w(d)("tasks_projects.tasks.fields.assignee"), + error: x.value.assignee_id + }, { + default: D(() => [c(b, { + modelValue: h.value, + "onUpdate:modelValue": n[4] ||= (e) => h.value = e, + options: I.value, + placeholder: w(d)("tasks_projects.tasks.fields.assignee_placeholder"), + "label-key": "label" + }, null, 8, [ + "modelValue", + "options", + "placeholder" + ])]), + _: 1 + }, 8, ["label", "error"]), + j.value ? (v(), r(y, { + key: 2, + label: w(d)("tasks_projects.tasks.fields.priority"), + error: x.value.priority + }, { + default: D(() => [c(b, { + modelValue: g.value, + "onUpdate:modelValue": n[5] ||= (e) => g.value = e, + options: L.value, + placeholder: w(d)("tasks_projects.tasks.fields.priority_placeholder"), + "label-key": "label" + }, null, 8, [ + "modelValue", + "options", + "placeholder" + ])]), + _: 1 + }, 8, ["label", "error"])) : i("", !0), + c(y, { + label: w(d)("tasks_projects.tasks.fields.due_date"), + error: x.value.due_date + }, { + default: D(() => [c(E, { + "model-value": f.dueDate, + "onUpdate:modelValue": ee + }, null, 8, ["model-value"])]), + _: 1 + }, 8, ["label", "error"]), + j.value ? (v(), r(y, { + key: 3, + label: w(d)("tasks_projects.tasks.fields.estimate_hours"), + error: x.value.estimated_minutes + }, { + default: D(() => [c(m, { + modelValue: f.estimateHours, + "onUpdate:modelValue": n[6] ||= (e) => f.estimateHours = e, + invalid: !!x.value.estimated_minutes, + type: "number", + step: "0.25", + min: "0" + }, null, 8, ["modelValue", "invalid"])]), + _: 1 + }, 8, ["label", "error"])) : i("", !0), + j.value ? (v(), r(y, { + key: 4, + label: w(d)("tasks_projects.tasks.fields.rate"), + error: x.value.rate, + "help-text": w(d)("tasks_projects.tasks.fields.rate_help") + }, { + default: D(() => [c(m, { + modelValue: f.rate, + "onUpdate:modelValue": n[7] ||= (e) => f.rate = e, + invalid: !!x.value.rate, + type: "number", + step: "0.01", + min: "0" + }, null, 8, ["modelValue", "invalid"])]), + _: 1 + }, 8, [ + "label", + "error", + "help-text" + ])) : i("", !0) + ]), + _: 1 + }), + c(y, { + label: w(d)("tasks_projects.tasks.fields.billable"), + error: x.value.billable + }, { + default: D(() => [c(z, { + modelValue: f.billable, + "onUpdate:modelValue": n[8] ||= (e) => f.billable = e, + class: "mt-1" + }, null, 8, ["modelValue"])]), + _: 1 + }, 8, ["label", "error"]), + j.value ? (v(), r(y, { + key: 0, + label: w(d)("tasks_projects.tasks.fields.description"), + error: x.value.description + }, { + default: D(() => [c(B, { + modelValue: f.description, + "onUpdate:modelValue": n[9] ||= (e) => f.description = e, + row: 3, + invalid: !!x.value.description + }, null, 8, ["modelValue", "invalid"])]), + _: 1 + }, 8, ["label", "error"])) : i("", !0), + e.compact ? (v(), a("button", { + key: 1, + type: "button", + class: "flex items-center gap-1 text-sm font-medium text-primary-500 hover:underline", + onClick: n[10] ||= (e) => k.value = !k.value + }, [c(l, { + name: k.value ? "ChevronUpIcon" : "ChevronDownIcon", + class: "h-4 w-4" + }, null, 8, ["name"]), s(" " + C(k.value ? w(d)("tasks_projects.tasks.fewer_fields") : w(d)("tasks_projects.tasks.all_fields")), 1)])) : i("", !0) + ]), o("div", ga, [N.value ? (v(), r(te, { + key: 0, + type: "button", + variant: "danger", + loading: O.value, + disabled: O.value, + onClick: ie + }, { + default: D(() => [s(C(w(d)("tasks_projects.general.delete")), 1)]), + _: 1 + }, 8, ["loading", "disabled"])) : (v(), a("span", _a)), o("div", va, [c(te, { + type: "button", + variant: "primary-outline", + onClick: n[11] ||= (e) => u("close") + }, { + default: D(() => [s(C(w(d)("tasks_projects.general.cancel")), 1)]), + _: 1 + }), c(te, { + type: "submit", + variant: "primary", + loading: T.value, + disabled: T.value + }, { + default: D(() => [s(C(N.value ? w(d)("tasks_projects.general.update") : w(d)("tasks_projects.general.save")), 1)]), + _: 1 + }, 8, ["loading", "disabled"])])])], 32)]), + _: 1 + }, 8, ["show"]); + }; + } +}), ba = { + default_rate: 0, + rounding_minutes: 1, + rounding_direction: "nearest", + week_start: 1, + members_see_all_time: !1, + auto_start_tasks: !1, + lock_invoiced_tasks: !1, + hide_invoiced_on_board: !1, + invoice_project_heading: !1, + invoice_task_description: !0, + invoice_entry_dates: !0, + invoice_entry_times: !1, + invoice_entry_hours: !0, + invoice_entry_descriptions: !1, + rounding_increments: [ + 1, + 5, + 6, + 15, + 30, + 60 + ] +}, G = y({ + adminMode: !1, + userId: null, + settings: { ...ba }, + companySession: 0, + loading: !1 +}); +async function xa(e) { + if (G.adminMode) return; + G.loading = !0; + let [t, n] = await Promise.all([We(e).catch(() => null), Ue(e).catch(() => null)]); + G.userId = t, G.settings = wa(n), G.loading = !1; +} +function Sa() { + G.userId = null, G.settings = { ...ba }, G.companySession += 1, G.loading = !1; +} +function Ca(e) { + G.adminMode = e; +} +function wa(e) { + if (typeof e != "object" || !e) return { ...ba }; + let t = Array.isArray(e.rounding_increments) ? e.rounding_increments.filter((e) => typeof e == "number") : ba.rounding_increments; + return { + default_rate: Ta(e.default_rate, ba.default_rate), + rounding_minutes: Ta(e.rounding_minutes, ba.rounding_minutes), + rounding_direction: Da(e.rounding_direction), + week_start: Oa(e.week_start), + members_see_all_time: e.members_see_all_time === !0, + auto_start_tasks: Ea(e.auto_start_tasks, ba.auto_start_tasks), + lock_invoiced_tasks: Ea(e.lock_invoiced_tasks, ba.lock_invoiced_tasks), + hide_invoiced_on_board: Ea(e.hide_invoiced_on_board, ba.hide_invoiced_on_board), + invoice_project_heading: Ea(e.invoice_project_heading, ba.invoice_project_heading), + invoice_task_description: Ea(e.invoice_task_description, ba.invoice_task_description), + invoice_entry_dates: Ea(e.invoice_entry_dates, ba.invoice_entry_dates), + invoice_entry_times: Ea(e.invoice_entry_times, ba.invoice_entry_times), + invoice_entry_hours: Ea(e.invoice_entry_hours, ba.invoice_entry_hours), + invoice_entry_descriptions: Ea(e.invoice_entry_descriptions, ba.invoice_entry_descriptions), + rounding_increments: t.length > 0 ? t : ba.rounding_increments + }; +} +function Ta(e, t) { + return typeof e == "number" && Number.isFinite(e) ? e : t; +} +function Ea(e, t) { + return typeof e == "boolean" ? e : t; +} +function Da(e) { + return e === "up" || e === "down" || e === "nearest" ? e : ba.rounding_direction; +} +function Oa(e) { + return typeof e == "number" && Number.isInteger(e) && e >= 0 && e <= 6 ? e : ba.week_start; +} +//#endregion +//#region resources/js/support/time.ts +var ka = 60, Aa = 60, ja = 7; +function Ma(e) { + let t = Number.isFinite(e) && e > 0 ? Math.floor(e) : 0, n = Math.floor(t / 3600), r = Math.floor(t % 3600 / Aa), i = t % Aa; + return `${n}:${Xa(r)}:${Xa(i)}`; +} +function Na(e) { + let t = e !== null && Number.isFinite(e) && e > 0 ? Math.round(e) : 0; + return `${Math.floor(t / ka)}:${Xa(t % ka)}`; +} +function Pa(e) { + let t = e.trim(); + if (t === "") return null; + let n = /^(\d+):([0-5]?\d)$/.exec(t); + if (n) return Number(n[1]) * ka + Number(n[2]); + if (!/^\d+([.,]\d+)?$/.test(t)) return null; + let r = Number(t.replace(",", ".")); + return Number.isNaN(r) ? null : Math.round(r * ka); +} +function Fa(e, t, n = "nearest") { + let r = Number.isFinite(t) && t >= 1 ? Math.floor(t) : 1, i = Number.isFinite(e) ? Math.floor(e) : 0; + return i <= 0 ? 0 : n === "up" ? Math.ceil(i / r) * r : n === "down" ? Math.floor(i / r) * r : i < r ? r : Math.round(i / r) * r; +} +function Ia(e) { + let t = qa(e); + return t === null ? "" : Ua(t); +} +function La(e) { + let t = qa(e); + return t === null ? "" : `${Xa(t.getHours())}:${Xa(t.getMinutes())}`; +} +function Ra(e, t = "09:00") { + let n = Ja(e), r = /^(\d{1,2}):([0-5]\d)$/.exec(t.trim()); + if (n === null || r === null) return null; + let i = Number(r[1]); + return i > 23 ? null : (n.setHours(i, Number(r[2]), 0, 0), n.toISOString()); +} +function za(e, t) { + let n = new Date(e); + return n.setTime(n.getTime() + t * Aa * 1e3), n.toISOString(); +} +function Ba(e, t) { + let n = Number.isInteger(t) && t >= 0 && t <= 6 ? t : 1, r = Ya(e), i = (r.getDay() - n + ja) % ja; + return r.setDate(r.getDate() - i), r; +} +function Va(e) { + return Array.from({ length: ja }, (t, n) => Ha(e, n)); +} +function Ha(e, t) { + let n = Ya(e); + return n.setDate(n.getDate() + t), n; +} +function Ua(e) { + return `${e.getFullYear()}-${Xa(e.getMonth() + 1)}-${Xa(e.getDate())}`; +} +function Wa(e) { + return { + weekday: e.toLocaleDateString(void 0, { weekday: "short" }), + day: e.toLocaleDateString(void 0, { + day: "numeric", + month: "short" + }) + }; +} +function Ga(e) { + return Ua(e) === Ua(/* @__PURE__ */ new Date()); +} +function Ka(e, t) { + let n = qa(e); + return n === null ? 0 : Math.max(0, Math.floor((t - n.getTime()) / 1e3)); +} +function qa(e) { + if (!e) return null; + let t = new Date(e); + return Number.isNaN(t.getTime()) ? null : t; +} +function Ja(e) { + let t = /^(\d{4})-(\d{2})-(\d{2})/.exec(e.trim()); + if (t === null) return null; + let n = new Date(Number(t[1]), Number(t[2]) - 1, Number(t[3]), 0, 0, 0, 0); + return Number.isNaN(n.getTime()) ? null : n; +} +function Ya(e) { + let t = new Date(e.getTime()); + return t.setHours(0, 0, 0, 0), t; +} +function Xa(e) { + return String(e).padStart(2, "0"); +} +//#endregion +//#region resources/js/stores/timer.ts +var K = y({ + running: null, + busy: !1, + stopPrompt: null, + startPrompt: null +}), Za = y({ now: Date.now() }), Qa, $a = 0; +function eo() { + Za.now = Date.now(); +} +function to() { + $a += 1, Qa === void 0 && (eo(), Qa = setInterval(eo, 1e3)); +} +function no() { + $a = Math.max(0, $a - 1), $a === 0 && Qa !== void 0 && (clearInterval(Qa), Qa = void 0); +} +function ro() { + return to(), _(no, !0), n(() => Za.now); +} +var io = !1; +function ao(e, t) { + if (K.running = e && typeof e.id == "number" ? e : null, K.running === null) { + io && (io = !1, no()); + return; + } + io || (io = !0, to()), t && typeof K.running.task_id == "number" && $e(t, [K.running.task_id]); +} +function oo(e, t, n) { + e?.notify("error", H(t, e.t(n))); +} +function so(e) { + tt(e.task_id, { running: [{ + entry_id: e.id, + user_id: e.user_id, + started_at: e.started_at + }] }); +} +async function co(e, t, n) { + try { + let n = await cr(e, { + name: t.name, + project_id: t.projectId + }); + return Qe(n), V(), typeof n.id == "number" ? n.id : null; + } catch (e) { + return oo(n, e, "tasks_projects.tasks.save_failed"), null; + } +} +var q = { + get running() { + return K.running; + }, + get runningTaskId() { + let e = K.running?.task_id; + return typeof e == "number" ? e : null; + }, + get elapsedSeconds() { + return K.running === null ? 0 : Ka(K.running.started_at, Za.now); + }, + get busy() { + return K.busy; + }, + get stopPrompt() { + return K.stopPrompt; + }, + get startPrompt() { + return K.startPrompt; + }, + isRunningOn(e) { + return K.running !== null && K.running.task_id === e; + }, + async refresh(e) { + try { + ao(await Me(e), e); + } catch { + ao(null); + } + }, + async start(e, t, n = null, r) { + if (K.busy) return null; + K.busy = !0; + try { + let r = await Ne(e, { + task_id: t, + description: n + }); + return ao(r, e), so(r), V(), r; + } catch (t) { + return _t(t) ? (r?.notify("warning", r.t("tasks_projects.timer.already_running")), await this.refresh(e)) : oo(r, t, "tasks_projects.timer.start_failed"), null; + } finally { + K.busy = !1; + } + }, + async startOnTask(e, t, n = null, r, i) { + if (K.busy) return null; + K.busy = !0; + try { + let r = await fr(e, t, n, i); + return ao(r, e), so(r), V(), r; + } catch (t) { + return gt(t) === "timer_already_running" ? (r?.notify("warning", r.t("tasks_projects.timer.already_running")), await this.refresh(e)) : oo(r, t, "tasks_projects.timer.start_failed"), null; + } finally { + K.busy = !1; + } + }, + async stop(e, t, n) { + if (K.busy || K.running === null) return null; + let r = K.running.task_id; + K.busy = !0; + try { + let t = await Pe(e, n); + return ao(null), tt(r, { running: [] }), V(), t; + } catch (n) { + return oo(t, n, "tasks_projects.timer.stop_failed"), await this.refresh(e), null; + } finally { + K.busy = !1; + } + }, + async stopOnTask(e, t, n, r) { + if (K.busy) return null; + K.busy = !0; + try { + let n = await pr(e, t, r); + return ao(null), tt(t, { running: [] }), V(), n; + } catch (t) { + return gt(t) === "timer_mismatch" ? n?.notify("warning", n.t("tasks_projects.timer.mismatch")) : oo(n, t, "tasks_projects.timer.stop_failed"), await this.refresh(e), null; + } finally { + K.busy = !1; + } + }, + askStop() { + this.answerStop(null); + let e = K.running; + return e === null ? Promise.resolve(null) : new Promise((t) => { + K.stopPrompt = { + entry: e, + resolve: t + }; + }); + }, + answerStop(e) { + let t = K.stopPrompt; + t !== null && (K.stopPrompt = null, t.resolve(e)); + }, + askStart(e = {}) { + return this.answerStart(null), new Promise((t) => { + K.startPrompt = { + ...e, + resolve: t + }; + }); + }, + answerStart(e) { + let t = K.startPrompt; + t !== null && (K.startPrompt = null, t.resolve(e)); + }, + async stopWithPrompt(e, t, n = {}) { + let r = K.running; + if (r === null) return null; + let i = n.taskId; + if (typeof i == "number" && r.task_id !== i) return t?.notify("warning", t.t("tasks_projects.timer.mismatch")), await this.refresh(e), null; + let a = Ze(r.task_id), o = await this.askStop(); + if (o === null) return null; + if (o.action === "discard") return await this.discard(e, t) && t?.notify("success", t.t("tasks_projects.timer.discarded")), null; + let s = { + description: o.description, + billable: o.billable + }, c = typeof i == "number" ? await this.stopOnTask(e, i, t, s) : await this.stop(e, t, s); + return c !== null && t?.notify("success", t.t("tasks_projects.timer.stopped", { + name: a, + duration: Na(c.duration_minutes) + })), c; + }, + async startWithPrompt(e, t, n = {}) { + let r = await this.askStart(n); + if (r === null) return null; + let i = "taskId" in r ? r.taskId : await co(e, r.create, t); + if (i === null) return null; + let a = await this.startOnTask(e, i, r.description, t, r.billable); + return a !== null && t?.notify("success", t.t("tasks_projects.timer.started", { name: Ze(i) })), a; + }, + async discard(e, t) { + if (K.busy || K.running === null) return !1; + let n = K.running.task_id; + K.busy = !0; + try { + return await Fe(e), ao(null), tt(n, { running: [] }), V(), !0; + } catch (n) { + return oo(t, n, "tasks_projects.timer.discard_failed"), await this.refresh(e), !1; + } finally { + K.busy = !1; + } + }, + reset() { + this.answerStop(null), this.answerStart(null), K.busy = !1, ao(null); + } +}, lo = { class: "flex items-center gap-1.5" }, uo = [ + "disabled", + "title", + "aria-label" +], fo = ["title", "aria-label"], po = ["disabled", "title"], mo = [ + "disabled", + "title", + "aria-label" +], ho = ["title"], go = /* @__PURE__ */ l({ + __name: "TaskRunControl", + props: { + client: {}, + notify: {}, + task: {}, + members: { default: () => [] }, + size: { default: "sm" } + }, + setup(t) { + let r = t, i = B(), l = ro(), u = n(() => ({ + notify: r.notify, + t: i + })), d = n(() => q.isRunningOn(r.task.id)), f = n(() => q.runningTaskId !== null && !d.value), m = n(() => Ze(q.runningTaskId)), h = n(() => nt(r.task).running.filter((e) => e.user_id !== G.userId && e.entry_id !== q.running?.id)), g = n(() => Ma(q.elapsedSeconds)), _ = n(() => r.size === "md" ? "h-5 w-5" : "h-4 w-4"), y = n(() => r.size === "md" ? "p-2" : "p-1.5"); + function b(e) { + return r.members.find((t) => t.id === e)?.name ?? `#${e}`; + } + function T(e) { + let t = r.members.find((t) => t.id === e); + return t ? pt(t.name) : "?"; + } + function E(e) { + return Ma(Ka(e.started_at, l.value)); + } + function D(e) { + return i("tasks_projects.timer.running_by", { + name: b(e.user_id), + time: E(e) + }); + } + async function O() { + await q.startOnTask(r.client, r.task.id, null, u.value) !== null && r.notify("success", i("tasks_projects.timer.started", { name: r.task.name })); + } + function k() { + q.stopWithPrompt(r.client, u.value, { taskId: r.task.id }); + } + async function j() { + await q.stopWithPrompt(r.client, u.value) !== null && await O(); + } + return (n, r) => { + let l = S("BaseIcon"); + return v(), a("div", lo, [d.value ? (v(), a(e, { key: 0 }, [o("button", { + type: "button", + class: p(["rounded-md text-status-red hover:bg-hover disabled:opacity-50", y.value]), + disabled: w(q).busy, + title: w(i)("tasks_projects.timer.stop_on", { name: t.task.name }), + "aria-label": w(i)("tasks_projects.timer.stop_on", { name: t.task.name }), + onClick: A(k, ["stop"]) + }, [c(l, { + name: "StopIcon", + class: p(_.value) + }, null, 8, ["class"])], 10, uo), o("span", { class: p(["font-medium tabular-nums text-primary-500", t.size === "md" ? "text-base" : "text-xs"]) }, C(g.value), 3)], 64)) : f.value ? (v(), a(e, { key: 1 }, [o("button", { + type: "button", + class: p(["cursor-not-allowed rounded-md text-subtle", y.value]), + disabled: "", + title: w(i)("tasks_projects.timer.busy_elsewhere", { name: m.value }), + "aria-label": w(i)("tasks_projects.timer.busy_elsewhere", { name: m.value }) + }, [c(l, { + name: "PlayIcon", + class: p(_.value) + }, null, 8, ["class"])], 10, fo), o("button", { + type: "button", + class: "rounded-md px-1.5 py-0.5 text-[11px] font-medium text-primary-500 hover:bg-hover disabled:opacity-50", + disabled: w(q).busy, + title: w(i)("tasks_projects.timer.stop_and_start"), + onClick: A(j, ["stop"]) + }, C(w(i)("tasks_projects.timer.stop_and_start")), 9, po)], 64)) : (v(), a("button", { + key: 2, + type: "button", + class: p(["rounded-md text-primary-500 hover:bg-hover disabled:opacity-50", y.value]), + disabled: w(q).busy, + title: w(i)("tasks_projects.timer.start_on", { name: t.task.name }), + "aria-label": w(i)("tasks_projects.timer.start_on", { name: t.task.name }), + onClick: A(O, ["stop"]) + }, [c(l, { + name: "PlayIcon", + class: p(_.value) + }, null, 8, ["class"])], 10, mo)), (v(!0), a(e, null, x(h.value, (e) => (v(), a("span", { + key: e.entry_id, + class: "flex items-center gap-1 rounded-full bg-surface-tertiary px-1.5 py-0.5 text-[11px] text-muted", + title: D(e) + }, [c(l, { + name: "ClockIcon", + class: "h-3.5 w-3.5 text-primary-500" + }), s(" " + C(T(e.user_id)), 1)], 8, ho))), 128))]); + }; + } +}), _o = { class: "relative table-container" }, vo = { class: "inline-flex items-center whitespace-nowrap" }, yo = { class: "tabular-nums" }, bo = { + key: 1, + class: "text-subtle" +}, xo = ["title"], So = 10, Co = "whitespace-nowrap px-3 py-3 text-left text-xs font-medium text-muted uppercase tracking-wider", wo = "px-3 py-4 text-sm text-muted whitespace-nowrap", To = /* @__PURE__ */ l({ + __name: "TaskList", + props: { + client: {}, + notify: {}, + router: {}, + filters: {}, + statuses: {}, + members: {}, + projects: {}, + projectId: { default: null } + }, + emits: ["changed"], + setup(t, { expose: l, emit: u }) { + let d = t, f = u, h = { + number: "number", + name: "name" + }, g = B(), _ = b(null), y = b(!0), x = b(0), k = b([]), A = b([]), j = b(!1), N = b(!1), P = b(null), F = b({}), I = b(null), L = n(() => d.projects.map((e) => ({ + id: e.id, + label: e.name + }))), R = n(() => M.busy), z = n(() => M.allowed), ee = n(() => d.filters.search !== "" || d.filters.status !== "" || d.filters.user !== "" || d.projectId === null && d.filters.project !== ""), te = n(() => !y.value && x.value === 0 && !ee.value), ne = n(() => [ + { + key: "select", + label: "", + sortable: !1, + tdClass: "w-8" + }, + { + key: "number", + label: g("tasks_projects.tasks.columns.number"), + sortable: !0, + sortBy: "number", + tdClass: "text-muted" + }, + { + key: "name", + label: g("tasks_projects.tasks.columns.name"), + sortable: !0, + sortBy: "name", + thClass: "extra", + tdClass: "font-medium text-heading" + }, + { + key: "status", + label: g("tasks_projects.tasks.columns.status"), + sortable: !1 + }, + { + key: "assignee", + label: g("tasks_projects.tasks.columns.assignee"), + sortable: !1 + }, + { + key: "logged", + label: g("tasks_projects.tasks.columns.logged"), + sortable: !1 + }, + { + key: "unbilled", + label: g("tasks_projects.tasks.columns.unbilled"), + sortable: !1 + }, + { + key: "invoiced", + label: g("tasks_projects.tasks.columns.invoiced"), + sortable: !1 + }, + { + key: "timer", + label: g("tasks_projects.tasks.columns.timer"), + sortable: !1 + }, + { + key: "actions", + label: g("tasks_projects.general.actions"), + sortable: !1, + tdClass: "text-right text-sm font-medium" + } + ].map((e) => ({ + defaultThClass: Co, + defaultTdClass: wo, + ...e + }))); + E(() => Ui(d.filters), () => ie()), E(() => d.projectId, () => ie()), E(et, () => ie(!0)); + async function re({ page: e, sort: t }) { + let n = Vt(t, h), r = { + page: e, + limit: So, + ...Gi(d.filters, { projectId: d.projectId }), + ...n + }; + y.value = !0; + try { + let e = await sr(d.client, r), t = e.data ?? [], n = e.meta; + return x.value = n?.total ?? t.length, k.value = t, A.value = A.value.filter((e) => t.some((t) => t.id === e)), { + data: t, + pagination: { + totalPages: n?.last_page ?? 1, + currentPage: n?.current_page ?? 1, + totalCount: n?.total ?? t.length, + limit: n?.per_page ?? So + } + }; + } catch (e) { + return d.notify("error", H(e, g("tasks_projects.tasks.load_failed"))), k.value = [], { + data: [], + pagination: { + totalPages: 1, + currentPage: 1, + totalCount: 0, + limit: So + } + }; + } finally { + y.value = !1; + } + } + function ie(e = !1) { + _.value?.refresh(e); + } + function ae(e) { + return A.value.includes(e.id); + } + function oe(e) { + A.value = ae(e) ? A.value.filter((t) => t !== e.id) : [...A.value, e.id]; + } + function se() { + A.value = k.value.map((e) => e.id); + } + function ce() { + A.value = []; + } + function le() { + P.value = null, F.value = { project_id: d.projectId }, N.value = !0; + } + function ue(e) { + P.value = e, F.value = {}, N.value = !0; + } + function de(e) { + let t = P.value ? g("tasks_projects.tasks.updated", { name: e.name }) : g("tasks_projects.tasks.created", { name: e.name }); + N.value = !1, P.value = null, d.notify("success", t), V(), f("changed"); + } + function fe(e) { + N.value = !1, P.value = null, d.notify("success", g("tasks_projects.tasks.deleted", { name: e.name })), V(), f("changed"); + } + function pe(e) { + return d.statuses.find((t) => t.id === e.task_status_id) ?? null; + } + function me(e) { + return e.assignee_id === null ? g("tasks_projects.tasks.unassigned") : d.members.find((t) => t.id === e.assignee_id)?.name ?? `#${e.assignee_id}`; + } + function he(e) { + return Na(nt(e).logged_minutes); + } + function ge(e) { + return nt(e).invoiced === "uninvoiced"; + } + function _e(e) { + return nt(e).invoiced === "invoiced" ? g("tasks_projects.tasks.already_invoiced") : g("tasks_projects.tasks.nothing_to_invoice"); + } + let ve = n(() => ({ + client: d.client, + router: d.router, + notify: d.notify, + t: g + })); + async function ye(e) { + if (!R.value) { + I.value = e.id; + try { + await bt(ve.value, { taskIds: [e.id] }); + } finally { + I.value = null; + } + } + } + async function be() { + R.value || A.value.length === 0 || await bt(ve.value, { taskIds: [...A.value] }) && ce(); + } + async function xe(e) { + if (window.confirm(g("tasks_projects.tasks.delete_confirm", { name: e.name }))) { + I.value = e.id; + try { + await ur(d.client, e.id), d.notify("success", g("tasks_projects.tasks.deleted", { name: e.name })), V(), f("changed"); + } catch (e) { + d.notify("error", H(e, g("tasks_projects.tasks.delete_failed"))); + } finally { + I.value = null; + } + } + } + async function Se(e) { + await we({ + action: "status", + ids: [...A.value], + task_status_id: e + }, "applied"); + } + async function Ce() { + let e = A.value.length; + window.confirm(g("tasks_projects.tasks.bulk.delete_confirm", { count: e })) && await we({ + action: "delete", + ids: [...A.value] + }, "deleted"); + } + async function we(e, t) { + if (!(j.value || e.ids.length === 0)) { + j.value = !0; + try { + let n = await hr(d.client, e); + n.failed.length > 0 ? d.notify("warning", g("tasks_projects.tasks.bulk.partial", { + count: n.updated.length, + failed: n.failed.length, + ids: n.failed.map((e) => `#${e.id}`).join(", ") + })) : n.updated.length === 0 ? d.notify("warning", g("tasks_projects.tasks.bulk.nothing")) : d.notify("success", g(`tasks_projects.tasks.bulk.${t}`, { count: n.updated.length })), ce(), V(), f("changed"); + } catch (e) { + d.notify("error", H(e, g("tasks_projects.tasks.bulk.failed"))); + } finally { + j.value = !1; + } + } + } + return l({ + openCreate: le, + refresh: ie + }), (n, l) => { + let u = S("BaseIcon"), d = S("BaseButton"), f = S("BaseEmptyPlaceholder"), h = S("BaseCheckbox"), y = S("router-link"), b = S("BaseFormatMoney"), x = S("BaseDropdownItem"), E = S("BaseDropdown"), k = S("BaseTable"); + return v(), a("div", null, [ + c(ua, { + count: A.value.length, + statuses: t.statuses, + busy: j.value, + invoicing: R.value, + "can-invoice": z.value, + onStatus: Se, + onDelete: Ce, + onInvoice: be, + onClear: ce, + onSelectPage: se + }, null, 8, [ + "count", + "statuses", + "busy", + "invoicing", + "can-invoice" + ]), + O(c(f, { + title: w(g)("tasks_projects.tasks.empty_title"), + description: w(g)("tasks_projects.tasks.empty_description") + }, { + actions: D(() => [c(d, { + variant: "primary", + onClick: le + }, { + left: D((e) => [c(u, { + name: "PlusIcon", + class: p(e.class) + }, null, 8, ["class"])]), + default: D(() => [s(" " + C(w(g)("tasks_projects.tasks.new_task")), 1)]), + _: 1 + })]), + default: D(() => [c(u, { + name: "ClipboardDocumentListIcon", + class: "mt-5 mb-4 h-16 w-16 text-subtle" + })]), + _: 1 + }, 8, ["title", "description"]), [[T, te.value]]), + O(o("div", _o, [c(k, { + ref_key: "tableRef", + ref: _, + data: re, + columns: ne.value, + class: "mt-3" + }, { + "cell-select": D(({ row: e }) => [c(h, { + "model-value": ae(e.data), + "aria-label": e.data.name, + onChange: (t) => oe(e.data) + }, null, 8, [ + "model-value", + "aria-label", + "onChange" + ])]), + "cell-number": D(({ row: e }) => [s("#" + C(e.data.number), 1)]), + "cell-name": D(({ row: e }) => [c(y, { + class: "hover:text-primary-500", + to: w(W).task(e.data.id) + }, { + default: D(() => [s(C(e.data.name), 1)]), + _: 2 + }, 1032, ["to"]), e.data.due_date ? (v(), a("span", { + key: 0, + class: p(["mt-0.5 block text-xs font-normal", w(mt)(e.data.due_date) && !e.data.closed_at ? "font-medium text-status-red" : "text-muted"]) + }, C(w(g)("tasks_projects.tasks.columns.due_date")) + ": " + C(w(ut)(e.data.due_date)), 3)) : i("", !0)]), + "cell-status": D(({ row: e }) => [o("span", vo, [o("span", { + class: p(["mr-2 inline-block h-2.5 w-2.5 shrink-0 rounded-full", pe(e.data)?.colour ? "" : "bg-line-default"]), + style: m(pe(e.data)?.colour ? { backgroundColor: pe(e.data)?.colour } : void 0) + }, null, 6), s(" " + C(pe(e.data)?.name ?? "-"), 1)])]), + "cell-assignee": D(({ row: e }) => [o("span", { class: p(e.data.assignee_id === null ? "text-subtle" : "") }, C(me(e.data)), 3)]), + "cell-logged": D(({ row: e }) => [o("span", yo, C(he(e.data)), 1)]), + "cell-unbilled": D(({ row: e }) => [w(nt)(e.data).unbilled_amount > 0 ? (v(), r(b, { + key: 0, + amount: w(nt)(e.data).unbilled_amount + }, null, 8, ["amount"])) : (v(), a("span", bo, "-"))]), + "cell-invoiced": D(({ row: e }) => [c(da, { state: w(nt)(e.data).invoiced }, null, 8, ["state"])]), + "cell-timer": D(({ row: e }) => [c(go, { + client: t.client, + notify: t.notify, + task: e.data, + members: t.members + }, null, 8, [ + "client", + "notify", + "task", + "members" + ])]), + "cell-actions": D(({ row: t }) => [c(E, { "content-loading": I.value === t.data.id }, { + activator: D(() => [c(u, { + name: "EllipsisHorizontalIcon", + class: "h-5 text-muted" + })]), + default: D(() => [ + c(x, { onClick: (e) => ue(t.data) }, { + default: D(() => [c(u, { + name: "PencilIcon", + class: "mr-3 h-5 w-5 text-subtle group-hover:text-muted" + }), s(" " + C(w(g)("tasks_projects.general.edit")), 1)]), + _: 1 + }, 8, ["onClick"]), + z.value ? (v(), a(e, { key: 0 }, [ge(t.data) && !R.value ? (v(), r(x, { + key: 0, + onClick: (e) => ye(t.data) + }, { + default: D(() => [c(u, { + name: "BanknotesIcon", + class: "mr-3 h-5 w-5 text-subtle group-hover:text-muted" + }), s(" " + C(w(g)("tasks_projects.tasks.invoice_task")), 1)]), + _: 1 + }, 8, ["onClick"])) : (v(), a("div", { + key: 1, + class: "group flex cursor-not-allowed items-center px-4 py-2 text-sm font-normal text-subtle", + title: R.value ? w(g)("tasks_projects.billing.busy") : _e(t.data) + }, [c(u, { + name: "BanknotesIcon", + class: "mr-3 h-5 w-5 text-subtle" + }), s(" " + C(w(g)("tasks_projects.tasks.invoice_task")), 1)], 8, xo))], 64)) : i("", !0), + c(x, { onClick: (e) => xe(t.data) }, { + default: D(() => [c(u, { + name: "TrashIcon", + class: "mr-3 h-5 w-5 text-subtle group-hover:text-muted" + }), s(" " + C(w(g)("tasks_projects.general.delete")), 1)]), + _: 1 + }, 8, ["onClick"]) + ]), + _: 2 + }, 1032, ["content-loading"])]), + _: 1 + }, 8, ["columns"])], 512), [[T, !te.value]]), + c(ya, { + show: N.value, + client: t.client, + notify: t.notify, + task: P.value, + statuses: t.statuses, + members: t.members, + projects: L.value, + defaults: F.value, + "lock-project": t.projectId !== null, + compact: P.value === null, + onClose: l[0] ||= (e) => N.value = !1, + onSaved: de, + onDeleted: fe + }, null, 8, [ + "show", + "client", + "notify", + "task", + "statuses", + "members", + "projects", + "defaults", + "lock-project", + "compact" + ]) + ]); + }; + } +}), Eo = { class: "py-4" }, Do = { class: "flex flex-wrap items-end justify-between gap-3" }, Oo = /* @__PURE__ */ l({ + __name: "ProjectTasksTab", + props: { + id: {}, + client: { type: [Function, Object] }, + notify: { type: Function }, + router: {}, + project: {} + }, + emits: ["refresh"], + setup(e, { emit: t }) { + let r = e, i = t, l = B(), u = b(null), d = b([]), f = b([]), m = b({ ...Ri }), h = n(() => r.project?.id ?? Number(r.id)), _ = n(() => r.project === null ? [] : [r.project]); + g(() => void y()); + async function y() { + try { + d.value = await or(r.client); + } catch (e) { + r.notify("error", H(e, l("tasks_projects.task_statuses.load_failed"))); + } + try { + f.value = await Jt(r.client); + } catch { + f.value = []; + } + } + function x() { + i("refresh"); + } + return (t, n) => { + let r = S("BaseIcon"), i = S("BaseButton"); + return v(), a("div", Eo, [o("div", Do, [c(oa, { + modelValue: m.value, + "onUpdate:modelValue": n[0] ||= (e) => m.value = e, + class: "flex-1", + projects: _.value, + members: f.value, + statuses: d.value, + "lock-project": "" + }, null, 8, [ + "modelValue", + "projects", + "members", + "statuses" + ]), c(i, { + variant: "primary", + onClick: n[1] ||= (e) => u.value?.openCreate() + }, { + left: D((e) => [c(r, { + name: "PlusIcon", + class: p(e.class) + }, null, 8, ["class"])]), + default: D(() => [s(" " + C(w(l)("tasks_projects.tasks.new_task")), 1)]), + _: 1 + })]), c(To, { + ref_key: "listRef", + ref: u, + client: e.client, + notify: e.notify, + router: e.router, + filters: m.value, + statuses: d.value, + members: f.value, + projects: _.value, + "project-id": h.value, + onChanged: x + }, null, 8, [ + "client", + "notify", + "router", + "filters", + "statuses", + "members", + "projects", + "project-id" + ])]); + }; + } +}), ko = { class: "flex w-full items-center justify-between" }, Ao = { class: "space-y-5 px-6 py-6" }, jo = { + key: 0, + class: "rounded-md bg-alert-warning-bg px-3 py-2 text-sm text-alert-warning-text" +}, Mo = { class: "inline-flex overflow-hidden rounded-md border border-line-default" }, No = ["disabled", "onClick"], Po = { + key: 1, + class: "text-sm text-muted" +}, Fo = { class: "flex items-center justify-between border-t border-line-default px-6 py-4" }, Io = { key: 1 }, Lo = { class: "flex space-x-3" }, Ro = "09:00", zo = /* @__PURE__ */ l({ + __name: "TimeEntryModal", + props: { + show: { type: Boolean }, + client: { type: [Function, Object] }, + notify: { type: Function }, + entry: {}, + defaultDate: {}, + defaultTask: {}, + lockTask: { type: Boolean } + }, + emits: [ + "close", + "saved", + "deleted" + ], + setup(t, { emit: l }) { + let u = t, d = l, f = ["duration", "range"], m = B(), h = y({ + date: "", + mode: "duration", + duration: "", + start: Ro, + end: "", + description: "", + billable: !0 + }), g = b(null), _ = b({}), T = b(!1), O = b(!1), k = n(() => u.entry !== null), j = n(() => u.entry?.invoice_id != null), M = n(() => j.value ? m("tasks_projects.time.view_entry") : k.value ? m("tasks_projects.time.edit_entry") : m("tasks_projects.time.new_entry")); + E(() => u.show, (e) => { + e && N(); + }, { immediate: !0 }); + function N() { + let e = u.entry; + _.value = {}, g.value = e === null ? u.defaultTask ?? null : null, h.date = e ? Ia(e.started_at) : u.defaultDate ?? Ua(/* @__PURE__ */ new Date()), h.duration = e ? Na(e.duration_minutes) : "", h.start = e?.started_at ? La(e.started_at) : Ro, h.end = e?.ended_at ? La(e.ended_at) : "", h.description = e?.description ?? "", h.billable = e ? e.billable : g.value?.billable ?? !0, h.mode = e !== null && P(e) ? "range" : "duration", h.date === "" && (h.date = u.defaultDate ?? Ua(/* @__PURE__ */ new Date())), e !== null && F(e.task_id); + } + function P(e) { + if (!e.started_at || !e.ended_at) return !1; + let t = new Date(e.started_at).getTime(), n = new Date(e.ended_at).getTime(); + return Number.isNaN(t) || Number.isNaN(n) ? !1 : Math.round((n - t) / 6e4) === e.duration_minutes; + } + async function F(e) { + try { + let t = await He(u.client, e); + g.value = t, Qe(t); + } catch {} + } + async function I(e) { + try { + let t = await Ve(u.client, e ?? ""); + return t.forEach(Qe), t; + } catch (e) { + return u.notify("error", H(e, m("tasks_projects.time.tasks_failed"))), []; + } + } + function L(e) { + h.date = e ? dt(e) : ""; + } + function R(e) { + g.value = e, e !== null && u.entry === null && (h.billable = e.billable !== !1); + } + function z() { + let e = {}, t = g.value; + (t === null || typeof t.id != "number") && (e.task_id = m("tasks_projects.time.task_required")), h.date === "" && (e.date = m("tasks_projects.time.date_required")); + let n = Ra(h.date, h.mode === "range" ? h.start : Ro); + n === null && (e.started_at = m("tasks_projects.time.range_invalid")); + let r = h.mode === "duration" ? Pa(h.duration) : null; + h.mode === "duration" && r === null && (e.duration_minutes = m("tasks_projects.time.duration_invalid")); + let i = h.mode === "range" ? Ra(h.date, h.end) : null; + if (h.mode === "range" && (i === null || n === null || i <= n) && (e.ended_at = m("tasks_projects.time.range_invalid")), _.value = e, Object.keys(e).length > 0 || t === null || n === null) return null; + let a = { + task_id: t.id, + started_at: n, + description: h.description.trim() || null, + billable: h.billable + }; + return h.mode === "duration" && r !== null ? (a.duration_minutes = r, a.ended_at = za(n, r)) : a.ended_at = i, a; + } + async function ee() { + if (T.value || j.value) return; + let e = z(); + if (e !== null) { + T.value = !0; + try { + let t = u.entry, n = t ? await Ae(u.client, t.id, e) : await ke(u.client, e); + d("saved", n); + } catch (e) { + _.value = at(e), u.notify("error", H(e, m("tasks_projects.time.save_failed"))); + } finally { + T.value = !1; + } + } + } + async function te() { + let e = u.entry; + if (!(e === null || O.value || j.value) && window.confirm(m("tasks_projects.time.delete_confirm"))) { + O.value = !0; + try { + await je(u.client, e.id), d("deleted", e); + } catch (e) { + u.notify("error", H(e, m("tasks_projects.time.delete_failed"))); + } finally { + O.value = !1; + } + } + } + return (n, l) => { + let u = S("BaseIcon"), y = S("BaseInput"), b = S("BaseMultiselect"), E = S("BaseInputGroup"), N = S("BaseDatePicker"), P = S("BaseInputGrid"), F = S("BaseTextarea"), z = S("BaseSwitch"), B = S("BaseButton"), ne = S("BaseModal"); + return v(), r(ne, { + show: t.show, + onClose: l[8] ||= (e) => d("close") + }, { + header: D(() => [o("div", ko, [o("span", null, C(M.value), 1), c(u, { + name: "XMarkIcon", + class: "h-6 w-6 cursor-pointer text-subtle hover:text-body", + onClick: l[0] ||= (e) => d("close") + })])]), + default: D(() => [o("form", { onSubmit: A(ee, ["prevent"]) }, [o("div", Ao, [ + j.value ? (v(), a("p", jo, C(w(m)("tasks_projects.time.stamped_notice")), 1)) : i("", !0), + c(E, { + label: w(m)("tasks_projects.time.fields.task"), + error: _.value.task_id, + required: "" + }, { + default: D(() => [t.lockTask ? (v(), r(y, { + key: 0, + "model-value": g.value?.name ?? "", + type: "text", + disabled: "" + }, null, 8, ["model-value"])) : (v(), r(b, { + key: 1, + "model-value": g.value, + options: I, + disabled: j.value, + invalid: !!_.value.task_id, + placeholder: w(m)("tasks_projects.time.fields.task_placeholder"), + "initial-search": g.value?.name ?? "", + delay: 400, + "filter-results": !1, + "value-prop": "id", + "track-by": "name", + label: "name", + object: "", + searchable: "", + "preserve-search": "", + "resolve-on-load": "", + "onUpdate:modelValue": l[1] ||= (e) => R(e) + }, null, 8, [ + "model-value", + "disabled", + "invalid", + "placeholder", + "initial-search" + ]))]), + _: 1 + }, 8, ["label", "error"]), + c(P, null, { + default: D(() => [c(E, { + label: w(m)("tasks_projects.time.fields.date"), + error: _.value.date, + required: "" + }, { + default: D(() => [c(N, { + "model-value": h.date, + disabled: j.value, + invalid: !!_.value.date, + "onUpdate:modelValue": L + }, null, 8, [ + "model-value", + "disabled", + "invalid" + ])]), + _: 1 + }, 8, ["label", "error"]), c(E, { label: w(m)("tasks_projects.time.fields.mode") }, { + default: D(() => [o("div", Mo, [(v(), a(e, null, x(f, (e) => o("button", { + key: e, + type: "button", + class: p(["px-3 py-2 text-sm", h.mode === e ? "bg-primary-500 text-white" : "bg-surface text-body hover:bg-hover"]), + disabled: j.value, + onClick: (t) => h.mode = e + }, C(w(m)(`tasks_projects.time.mode.${e}`)), 11, No)), 64))])]), + _: 1 + }, 8, ["label"])]), + _: 1 + }), + h.mode === "duration" ? (v(), r(E, { + key: 1, + label: w(m)("tasks_projects.time.fields.duration"), + error: _.value.duration_minutes, + "help-text": w(m)("tasks_projects.time.fields.duration_help"), + required: "" + }, { + default: D(() => [c(y, { + modelValue: h.duration, + "onUpdate:modelValue": l[2] ||= (e) => h.duration = e, + type: "text", + inputmode: "text", + placeholder: "1:30", + disabled: j.value, + invalid: !!_.value.duration_minutes + }, null, 8, [ + "modelValue", + "disabled", + "invalid" + ])]), + _: 1 + }, 8, [ + "label", + "error", + "help-text" + ])) : (v(), r(P, { key: 2 }, { + default: D(() => [c(E, { + label: w(m)("tasks_projects.time.fields.start"), + error: _.value.started_at, + required: "" + }, { + default: D(() => [c(y, { + modelValue: h.start, + "onUpdate:modelValue": l[3] ||= (e) => h.start = e, + type: "time", + disabled: j.value, + invalid: !!_.value.started_at + }, null, 8, [ + "modelValue", + "disabled", + "invalid" + ])]), + _: 1 + }, 8, ["label", "error"]), c(E, { + label: w(m)("tasks_projects.time.fields.end"), + error: _.value.ended_at, + required: "" + }, { + default: D(() => [c(y, { + modelValue: h.end, + "onUpdate:modelValue": l[4] ||= (e) => h.end = e, + type: "time", + disabled: j.value, + invalid: !!_.value.ended_at + }, null, 8, [ + "modelValue", + "disabled", + "invalid" + ])]), + _: 1 + }, 8, ["label", "error"])]), + _: 1 + })), + c(E, { + label: w(m)("tasks_projects.time.fields.description"), + error: _.value.description + }, { + default: D(() => [c(F, { + modelValue: h.description, + "onUpdate:modelValue": l[5] ||= (e) => h.description = e, + row: 3, + disabled: j.value, + invalid: !!_.value.description + }, null, 8, [ + "modelValue", + "disabled", + "invalid" + ])]), + _: 1 + }, 8, ["label", "error"]), + c(E, { + label: w(m)("tasks_projects.time.fields.billable"), + error: _.value.billable + }, { + default: D(() => [j.value ? (v(), a("span", Po, C(h.billable ? w(m)("tasks_projects.time.billable") : w(m)("tasks_projects.time.non_billable")), 1)) : (v(), r(z, { + key: 0, + modelValue: h.billable, + "onUpdate:modelValue": l[6] ||= (e) => h.billable = e, + class: "flex" + }, null, 8, ["modelValue"]))]), + _: 1 + }, 8, ["label", "error"]) + ]), o("div", Fo, [k.value && !j.value ? (v(), r(B, { + key: 0, + type: "button", + variant: "danger", + size: "sm", + loading: O.value, + disabled: O.value, + onClick: te + }, { + default: D(() => [s(C(w(m)("tasks_projects.general.delete")), 1)]), + _: 1 + }, 8, ["loading", "disabled"])) : (v(), a("span", Io)), o("div", Lo, [c(B, { + type: "button", + variant: "primary-outline", + onClick: l[7] ||= (e) => d("close") + }, { + default: D(() => [s(C(j.value ? w(m)("tasks_projects.timer.close") : w(m)("tasks_projects.general.cancel")), 1)]), + _: 1 + }), j.value ? i("", !0) : (v(), r(B, { + key: 0, + type: "submit", + variant: "primary", + loading: T.value, + disabled: T.value + }, { + default: D(() => [s(C(k.value ? w(m)("tasks_projects.general.update") : w(m)("tasks_projects.general.save")), 1)]), + _: 1 + }, 8, ["loading", "disabled"]))])])], 32)]), + _: 1 + }, 8, ["show"]); + }; + } +}), Bo = { class: "py-4" }, Vo = { class: "flex justify-end" }, Ho = { class: "relative mt-3 table-container" }, Uo = { class: "font-medium text-heading" }, Wo = { + key: 0, + class: "block text-xs text-muted" +}, Go = { + key: 1, + class: "text-subtle" +}, Ko = { + key: 0, + class: "text-xs text-primary-500" +}, qo = 15, Jo = 100, Yo = /* @__PURE__ */ l({ + __name: "ProjectTimeTab", + props: { + id: {}, + client: { type: [Function, Object] }, + notify: { type: Function }, + project: {} + }, + emits: ["refresh"], + setup(e, { emit: t }) { + let l = e, u = t, d = B(), f = ro(), m = b(null), h = b([]), _ = b([]), y = b(!1), x = b(null), T = n(() => l.project?.id ?? Number(l.id)), O = n(() => [ + { + key: "started_at", + label: d("tasks_projects.project.time.columns.date"), + sortable: !1 + }, + { + key: "user", + label: d("tasks_projects.project.time.columns.member"), + sortable: !1 + }, + { + key: "task", + label: d("tasks_projects.project.time.columns.task"), + sortable: !1, + thClass: "extra" + }, + { + key: "duration_minutes", + label: d("tasks_projects.project.time.columns.minutes"), + sortable: !1 + }, + { + key: "billable", + label: d("tasks_projects.project.time.columns.billable"), + sortable: !1 + }, + { + key: "amount", + label: d("tasks_projects.project.time.columns.amount"), + sortable: !1 + }, + { + key: "actions", + label: d("tasks_projects.general.actions"), + sortable: !1, + tdClass: "text-right text-sm font-medium" + } + ]); + g(() => { + k(); + }), E(et, () => m.value?.refresh(!0)); + async function k() { + try { + h.value = await Jt(l.client); + } catch (e) { + l.notify("error", H(e, d("tasks_projects.tasks.members_failed"))); + } + try { + let e = await sr(l.client, { + project_id: T.value, + limit: Jo + }); + _.value = e.data ?? []; + } catch (e) { + l.notify("error", H(e, d("tasks_projects.tasks.load_failed"))); + } + } + async function A({ page: e }) { + let t = { + page: e, + limit: qo, + project_id: T.value + }; + try { + let e = await xr(l.client, t); + return { + data: e.data ?? [], + pagination: { + totalPages: e.meta?.last_page ?? 1, + currentPage: e.meta?.current_page ?? 1, + totalCount: e.meta?.total ?? 0, + limit: e.meta?.per_page ?? qo + } + }; + } catch (e) { + return l.notify("error", H(e, d("tasks_projects.project.time.load_failed"))), { + data: [], + pagination: { + totalPages: 1, + currentPage: 1, + totalCount: 0, + limit: qo + } + }; + } + } + function j(e) { + return h.value.find((t) => t.id === e)?.name ?? d("tasks_projects.project.time.removed_member"); + } + function M(e) { + return _.value.find((t) => t.id === e)?.name ?? `#${e}`; + } + function N(e) { + return e.is_running ? Ma(Ka(e.started_at, f.value)) : ft(e.duration_minutes); + } + function P() { + x.value = null, y.value = !0; + } + function F(e) { + e.is_running || (x.value = e, y.value = !0); + } + function I() { + let e = x.value ? d("tasks_projects.time.updated") : d("tasks_projects.time.created"); + y.value = !1, x.value = null, l.notify("success", e), m.value?.refresh(!0), V(), u("refresh"); + } + function L() { + y.value = !1, x.value = null, l.notify("success", d("tasks_projects.time.deleted")), m.value?.refresh(!0), V(), u("refresh"); + } + async function R(e) { + if (window.confirm(d("tasks_projects.time.delete_confirm"))) try { + await je(l.client, e.id), l.notify("success", d("tasks_projects.time.deleted")), m.value?.refresh(!0), V(), u("refresh"); + } catch (e) { + l.notify("error", H(e, d("tasks_projects.time.delete_failed"))); + } + } + return (t, n) => { + let l = S("BaseIcon"), u = S("BaseButton"), f = S("BaseFormatMoney"), h = S("BaseDropdownItem"), g = S("BaseDropdown"), _ = S("BaseTable"); + return v(), a("div", Bo, [ + o("div", Vo, [c(u, { + variant: "primary", + onClick: P + }, { + left: D((e) => [c(l, { + name: "PlusIcon", + class: p(e.class) + }, null, 8, ["class"])]), + default: D(() => [s(" " + C(w(d)("tasks_projects.project.time.add_entry")), 1)]), + _: 1 + })]), + o("div", Ho, [c(_, { + ref_key: "tableRef", + ref: m, + data: A, + columns: O.value + }, { + "cell-started_at": D(({ row: e }) => [s(C(e.data.started_at ? w(ut)(e.data.started_at) : "-"), 1)]), + "cell-user": D(({ row: e }) => [s(C(j(e.data.user_id)), 1)]), + "cell-task": D(({ row: e }) => [o("span", Uo, C(M(e.data.task_id)), 1), e.data.description ? (v(), a("span", Wo, C(e.data.description), 1)) : i("", !0)]), + "cell-duration_minutes": D(({ row: e }) => [o("span", { class: p(["tabular-nums", e.data.is_running ? "font-medium text-primary-500" : ""]) }, C(N(e.data)), 3)]), + "cell-billable": D(({ row: e }) => [e.data.billable ? (v(), r(l, { + key: 0, + name: "CheckCircleIcon", + class: "h-5 w-5 text-status-green" + })) : (v(), a("span", Go, "-"))]), + "cell-amount": D(({ row: e }) => [c(f, { amount: e.data.amount }, null, 8, ["amount"])]), + "cell-actions": D(({ row: e }) => [e.data.is_running ? (v(), a("span", Ko, C(w(d)("tasks_projects.project.time.running")), 1)) : (v(), r(g, { key: 1 }, { + activator: D(() => [c(l, { + name: "EllipsisHorizontalIcon", + class: "h-5 text-muted" + })]), + default: D(() => [c(h, { onClick: (t) => F(e.data) }, { + default: D(() => [c(l, { + name: "PencilIcon", + class: "mr-3 h-5 w-5 text-subtle group-hover:text-muted" + }), s(" " + C(w(d)("tasks_projects.general.edit")), 1)]), + _: 1 + }, 8, ["onClick"]), e.data.invoice_id === null ? (v(), r(h, { + key: 0, + onClick: (t) => R(e.data) + }, { + default: D(() => [c(l, { + name: "TrashIcon", + class: "mr-3 h-5 w-5 text-subtle group-hover:text-muted" + }), s(" " + C(w(d)("tasks_projects.general.delete")), 1)]), + _: 1 + }, 8, ["onClick"])) : i("", !0)]), + _: 2 + }, 1024))]), + _: 1 + }, 8, ["columns"])]), + c(zo, { + show: y.value, + client: e.client, + notify: e.notify, + entry: x.value, + onClose: n[0] ||= (e) => y.value = !1, + onSaved: I, + onDeleted: L + }, null, 8, [ + "show", + "client", + "notify", + "entry" + ]) + ]); + }; + } +}), Xo = { + viewProject: `${U}:view-project`, + editProject: `${U}:edit-project`, + viewTask: `${U}:view-task`, + viewOwnTime: `${U}:view-own-time` +}; +function Zo(e) { + e.addMessages(rr), e.registerPage({ + id: "projects", + module: U, + path: "projects", + component: sn(e, Zr), + meta: { + ability: Xo.viewProject, + title: "tasks_projects.projects.title" + } + }), e.registerPage({ + id: "project", + module: U, + path: "projects/:id", + component: sn(e, zr), + meta: { + ability: Xo.viewProject, + title: "tasks_projects.projects.title" + }, + children: [ + { + id: "overview", + path: "", + component: sn(e, Li), + meta: { + ability: Xo.viewProject, + title: "tasks_projects.project.tabs.overview" + } + }, + { + id: "tasks", + path: "tasks", + component: sn(e, Oo), + meta: { + ability: Xo.viewTask, + title: "tasks_projects.project.tabs.tasks" + } + }, + { + id: "time", + path: "time", + component: sn(e, Yo), + meta: { + ability: Xo.viewOwnTime, + title: "tasks_projects.project.tabs.time" + } + }, + { + id: "members", + path: "members", + component: sn(e, li), + meta: { + ability: Xo.editProject, + title: "tasks_projects.project.tabs.members" + } + } + ] + }); +} +//#endregion +//#region resources/js/messages/reports.ts +var Qo = { 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" + } +} } } }, $o = { summary: `${Rt}/reports/summary` }; +async function es(e, t) { + let { data: n } = await e.get($o.summary, { params: t }); + return ts(n?.data, t); +} +function ts(e, t) { + let n = is(e) ? e : {}; + return { + from: ss(n.from, t.from ?? ""), + to: ss(n.to, t.to ?? ""), + totals: rs(n.totals).map(ns), + by_project: rs(n.by_project).map((e) => ({ + ...ns(e), + project_id: os(e.project_id), + label: ss(e.label, "") + })), + by_member: rs(n.by_member).map((e) => ({ + ...ns(e), + user_id: os(e.user_id), + label: ss(e.label, "") + })), + by_customer: rs(n.by_customer).map((e) => ({ + ...ns(e), + customer_id: os(e.customer_id) + })), + by_billable: rs(n.by_billable).map((e) => ({ + ...ns(e), + billable: e.billable === !0 + })) + }; +} +function ns(e) { + return { + currency_id: os(e.currency_id), + minutes: as(e.minutes), + amount: as(e.amount), + billable_minutes: as(e.billable_minutes), + billable_amount: as(e.billable_amount), + unbilled_amount: as(e.unbilled_amount) + }; +} +function rs(e) { + return Array.isArray(e) ? e.filter(is) : []; +} +function is(e) { + return typeof e == "object" && !!e; +} +function as(e) { + return typeof e == "number" && Number.isFinite(e) ? e : 0; +} +function os(e) { + return typeof e == "number" && Number.isFinite(e) ? e : null; +} +function ss(e, t) { + return typeof e == "string" && e.trim() !== "" ? e : t; +} +//#endregion +//#region resources/js/components/ReportBreakdownTable.vue?vue&type=script&setup=true&lang.ts +var cs = { class: "mt-6" }, ls = { class: "text-sm font-semibold tracking-wider text-muted uppercase" }, us = { class: "relative table-container" }, ds = { + key: 0, + class: "text-subtle" +}, fs = { key: 1 }, ps = /* @__PURE__ */ l({ + __name: "ReportBreakdownTable", + props: { + title: {}, + labelHeading: {}, + rows: {}, + showCurrency: { type: Boolean } + }, + setup(e) { + let t = e, i = B(), l = n(() => [ + { + key: "label", + label: t.labelHeading, + thClass: "extra", + tdClass: "font-medium text-heading" + }, + ...t.showCurrency ? [{ + key: "currency_id", + label: i("tasks_projects.reports.tables.currency") + }] : [], + { + key: "minutes", + label: i("tasks_projects.reports.tables.logged"), + dataType: "numeric" + }, + { + key: "billable_minutes", + label: i("tasks_projects.reports.tables.billable"), + dataType: "numeric" + }, + { + key: "amount", + label: i("tasks_projects.reports.tables.amount"), + dataType: "numeric" + }, + { + key: "unbilled_amount", + label: i("tasks_projects.reports.tables.unbilled"), + dataType: "numeric" + } + ]), u = n(() => t.showCurrency ? "currency" : "plain"); + return (t, n) => { + let i = S("BaseFormatMoney"), d = S("BaseTable"); + return v(), a("section", cs, [o("h3", ls, C(e.title), 1), o("div", us, [(v(), r(d, { + key: u.value, + data: e.rows, + columns: l.value, + class: "mt-2" + }, { + "cell-currency_id": D(({ row: e }) => [e.data.currency_id === null ? (v(), a("span", ds, "-")) : (v(), a("span", fs, "#" + C(e.data.currency_id), 1))]), + "cell-minutes": D(({ row: e }) => [s(C(w(Na)(e.data.minutes)), 1)]), + "cell-billable_minutes": D(({ row: e }) => [s(C(w(Na)(e.data.billable_minutes)), 1)]), + "cell-amount": D(({ row: e }) => [c(i, { amount: e.data.amount }, null, 8, ["amount"])]), + "cell-unbilled_amount": D(({ row: e }) => [c(i, { amount: e.data.unbilled_amount }, null, 8, ["amount"])]), + _: 1 + }, 8, ["data", "columns"]))])]); + }; + } +}), ms = 3; +function hs(e, t, n = /* @__PURE__ */ new Date()) { + let r = n.getFullYear(), i = n.getMonth(); + switch (e) { + case "THIS_WEEK": { + let e = Ba(n, t); + return _s(e, Ha(e, 6)); + } + case "LAST_MONTH": return _s(new Date(r, i - 1, 1), new Date(r, i, 0)); + case "THIS_QUARTER": { + let e = Math.floor(i / ms) * ms; + return _s(new Date(r, e, 1), new Date(r, e + ms, 0)); + } + case "THIS_YEAR": return _s(new Date(r, 0, 1), new Date(r, 12, 0)); + default: return _s(new Date(r, i, 1), new Date(r, i + 1, 0)); + } +} +function gs(e, t) { + return t <= 0 ? 0 : Math.min(100, Math.max(0, Math.round(e / t * 100))); +} +function _s(e, t) { + return { + from: Ua(e), + to: Ua(t) + }; +} +//#endregion +//#region resources/js/pages/ReportsPage.vue?vue&type=script&setup=true&lang.ts +var vs = { + key: 0, + class: "mt-2 text-sm text-muted" +}, ys = { class: "flex items-center justify-end space-x-5" }, bs = { class: "mt-4 flex flex-wrap gap-2" }, xs = ["onClick"], Ss = { + key: 0, + class: "flex justify-center py-16" +}, Cs = { + key: 0, + class: "text-xs font-medium tracking-wider text-muted uppercase" +}, ws = { class: "text-xs font-medium tracking-wider text-muted uppercase" }, Ts = { class: "mt-1 text-2xl font-semibold text-heading" }, Es = { class: "text-xs font-medium tracking-wider text-muted uppercase" }, Ds = { class: "mt-1 text-2xl font-semibold text-heading" }, Os = { class: "text-xs font-medium tracking-wider text-muted uppercase" }, ks = { class: "mt-1 text-2xl font-semibold text-heading" }, As = { class: "text-xs font-medium tracking-wider text-muted uppercase" }, js = { class: "mt-1 text-2xl font-semibold text-heading" }, Ms = { class: "mt-4 rounded-xl border border-line-default bg-surface p-5" }, Ns = { class: "text-xs font-medium tracking-wider text-muted uppercase" }, Ps = { class: "mt-3 flex h-2 w-full overflow-hidden rounded-full bg-surface-tertiary" }, Fs = { class: "mt-3 flex flex-wrap gap-6 text-sm" }, Is = { class: "inline-flex items-center text-body" }, Ls = { class: "ml-1 font-medium text-heading" }, Rs = { class: "ml-1 text-muted" }, zs = { class: "inline-flex items-center text-body" }, Bs = { class: "ml-1 font-medium text-heading" }, Vs = { class: "ml-1 text-muted" }, Hs = { + key: 1, + class: "mt-2 text-sm text-subtle" +}, Us = "THIS_MONTH", Ws = /* @__PURE__ */ l({ + __name: "ReportsPage", + props: { + client: { type: [Function, Object] }, + notify: { type: Function }, + router: {} + }, + setup(t) { + let l = t, u = B(), d = b(null), f = b([]), h = b(!0), _ = b(Us), y = b(""), T = b(""), E = n(() => [ + { + id: "THIS_WEEK", + label: u("tasks_projects.reports.range.this_week") + }, + { + id: "THIS_MONTH", + label: u("tasks_projects.reports.range.this_month") + }, + { + id: "LAST_MONTH", + label: u("tasks_projects.reports.range.last_month") + }, + { + id: "THIS_QUARTER", + label: u("tasks_projects.reports.range.this_quarter") + }, + { + id: "THIS_YEAR", + label: u("tasks_projects.reports.range.this_year") + }, + { + id: "CUSTOM", + label: u("tasks_projects.reports.range.custom") + } + ]), O = n(() => d.value?.totals ?? []), k = n(() => new Set(O.value.map((e) => e.currency_id)).size > 1), A = n(() => O.value.length > 0), j = n(() => (d.value?.by_project ?? []).map((e) => ({ + ...e, + id: `${e.project_id ?? "none"}-${e.currency_id ?? "base"}`, + label: e.project_id === null ? u("tasks_projects.reports.tables.no_project") : e.label || `#${e.project_id}` + }))), M = n(() => (d.value?.by_member ?? []).map((e) => ({ + ...e, + id: `${e.user_id ?? "none"}-${e.currency_id ?? "base"}`, + label: ie(e) + }))), N = n(() => (d.value?.by_customer ?? []).map((e) => ({ + ...e, + id: `${e.customer_id ?? "none"}-${e.currency_id ?? "base"}`, + label: e.customer_id === null ? u("tasks_projects.reports.tables.no_customer") : $t(e.customer_id) + }))), P = n(() => ae(!0)), F = n(() => ae(!1)), I = n(() => P.value + F.value), L = n(() => gs(P.value, I.value)); + g(() => { + R(Us), re(); + }); + function R(e) { + if (_.value = e, e !== "CUSTOM") { + let t = hs(e, G.settings.week_start); + y.value = t.from, T.value = t.to; + } + te(); + } + function z(e) { + y.value = e ? dt(e) : "", _.value = "CUSTOM", te(); + } + function ee(e) { + T.value = e ? dt(e) : "", _.value = "CUSTOM", te(); + } + async function te() { + h.value = !0; + try { + let e = await es(l.client, ne()); + d.value = e, e.by_customer.some((e) => e.customer_id !== null) && en(l.client); + } catch (e) { + d.value = null, l.notify("error", H(e, u("tasks_projects.reports.load_failed"))); + } finally { + h.value = !1; + } + } + function ne() { + let e = {}; + return y.value !== "" && (e.from = y.value), T.value !== "" && (e.to = T.value), e; + } + async function re() { + try { + f.value = await Jt(l.client); + } catch {} + } + function ie(e) { + let t = f.value.find((t) => t.id === e.user_id)?.name ?? ""; + return t === "" ? e.label === "" ? e.user_id === null ? u("tasks_projects.reports.tables.unknown_member") : `#${e.user_id}` : e.label : t; + } + function ae(e) { + return (d.value?.by_billable ?? []).filter((t) => t.billable === e).reduce((e, t) => e + t.minutes, 0); + } + function oe(e) { + return e === null ? u("tasks_projects.reports.summary.base_currency") : u("tasks_projects.reports.summary.currency", { id: e }); + } + function se(e) { + return _.value === e.id ? "border-primary-500 bg-primary-50 text-primary-500" : "border-line-default bg-surface text-muted hover:text-heading"; + } + function ce() { + R(Us); + } + return (t, n) => { + let l = S("BaseBreadcrumbItem"), f = S("BaseBreadcrumb"), g = S("BaseIcon"), _ = S("BaseButton"), b = S("router-link"), B = S("BasePageHeader"), te = S("BaseDatePicker"), ne = S("BaseInputGroup"), re = S("BaseFilterWrapper"), ie = S("BaseSpinner"), ae = S("BaseEmptyPlaceholder"), le = S("BaseFormatMoney"), ue = S("BasePage"); + return v(), r(ue, null, { + default: D(() => [ + c(B, { title: w(u)("tasks_projects.reports.title") }, { + actions: D(() => [o("div", ys, [ + c(b, { to: w(W).tasks }, { + default: D(() => [c(_, { variant: "white" }, { + left: D((e) => [c(g, { + name: "ClipboardDocumentListIcon", + class: p(e.class) + }, null, 8, ["class"])]), + default: D(() => [s(" " + C(w(u)("tasks_projects.tasks.title")), 1)]), + _: 1 + })]), + _: 1 + }, 8, ["to"]), + c(b, { to: w(W).projects }, { + default: D(() => [c(_, { variant: "white" }, { + left: D((e) => [c(g, { + name: "FolderIcon", + class: p(e.class) + }, null, 8, ["class"])]), + default: D(() => [s(" " + C(w(u)("tasks_projects.projects.title")), 1)]), + _: 1 + })]), + _: 1 + }, 8, ["to"]), + c(b, { to: w(W).billing }, { + default: D(() => [c(_, { variant: "primary-outline" }, { + left: D((e) => [c(g, { + name: "BanknotesIcon", + class: p(e.class) + }, null, 8, ["class"])]), + default: D(() => [s(" " + C(w(u)("tasks_projects.billing.title")), 1)]), + _: 1 + })]), + _: 1 + }, 8, ["to"]) + ])]), + default: D(() => [c(f, null, { + default: D(() => [ + c(l, { + title: w(u)("tasks_projects.general.home"), + to: "/admin/dashboard" + }, null, 8, ["title"]), + c(l, { + title: w(u)("tasks_projects.tasks.title"), + to: w(W).tasks + }, null, 8, ["title", "to"]), + c(l, { + title: w(u)("tasks_projects.reports.title"), + to: "#", + active: "" + }, null, 8, ["title"]) + ]), + _: 1 + }), d.value ? (v(), a("p", vs, C(w(ut)(d.value.from)) + " – " + C(w(ut)(d.value.to)), 1)) : i("", !0)]), + _: 1 + }, 8, ["title"]), + o("div", bs, [(v(!0), a(e, null, x(E.value, (e) => (v(), a("button", { + key: e.id, + type: "button", + class: p(["rounded-md border px-3 py-1.5 text-sm font-medium", se(e)]), + onClick: (t) => R(e.id) + }, C(e.label), 11, xs))), 128))]), + c(re, { + show: !0, + "row-on-xl": "", + class: "mt-3", + onClear: ce + }, { + default: D(() => [c(ne, { + label: w(u)("tasks_projects.reports.range.from"), + class: "mt-2 flex-1" + }, { + default: D(() => [c(te, { + "model-value": y.value, + "onUpdate:modelValue": z + }, null, 8, ["model-value"])]), + _: 1 + }, 8, ["label"]), c(ne, { + label: w(u)("tasks_projects.reports.range.to"), + class: "mt-2 flex-1" + }, { + default: D(() => [c(te, { + "model-value": T.value, + "onUpdate:modelValue": ee + }, null, 8, ["model-value"])]), + _: 1 + }, 8, ["label"])]), + _: 1 + }), + h.value && d.value === null ? (v(), a("div", Ss, [c(ie, { class: "h-8 w-8 text-primary-500" })])) : A.value ? (v(), a(e, { key: 2 }, [ + (v(!0), a(e, null, x(O.value, (e) => (v(), a("div", { + key: e.currency_id ?? "base", + class: "mt-4 rounded-xl border border-line-default bg-surface p-5" + }, [k.value ? (v(), a("p", Cs, C(oe(e.currency_id)), 1)) : i("", !0), o("div", { class: p(["grid grid-cols-2 gap-4 sm:grid-cols-4", k.value ? "mt-3" : ""]) }, [ + o("div", null, [o("p", ws, C(w(u)("tasks_projects.reports.summary.logged")), 1), o("p", Ts, C(w(Na)(e.minutes)), 1)]), + o("div", null, [o("p", Es, C(w(u)("tasks_projects.reports.summary.billable")), 1), o("p", Ds, C(w(Na)(e.billable_minutes)), 1)]), + o("div", null, [o("p", Os, C(w(u)("tasks_projects.reports.summary.amount")), 1), o("p", ks, [c(le, { amount: e.amount }, null, 8, ["amount"])])]), + o("div", null, [o("p", As, C(w(u)("tasks_projects.reports.summary.unbilled")), 1), o("p", js, [c(le, { amount: e.unbilled_amount }, null, 8, ["amount"])])]) + ], 2)]))), 128)), + o("section", Ms, [o("p", Ns, C(w(u)("tasks_projects.reports.split.title")), 1), I.value > 0 ? (v(), a(e, { key: 0 }, [o("div", Ps, [o("div", { + class: "h-2 bg-primary-500", + style: m({ width: `${L.value}%` }) + }, null, 4)]), o("div", Fs, [o("span", Is, [ + n[0] ||= o("span", { class: "mr-2 inline-block h-2.5 w-2.5 rounded-full bg-primary-500" }, null, -1), + s(" " + C(w(u)("tasks_projects.reports.split.billable")) + ": ", 1), + o("span", Ls, C(w(Na)(P.value)), 1), + o("span", Rs, "(" + C(L.value) + "%)", 1) + ]), o("span", zs, [ + n[1] ||= o("span", { class: "mr-2 inline-block h-2.5 w-2.5 rounded-full bg-surface-tertiary" }, null, -1), + s(" " + C(w(u)("tasks_projects.reports.split.non_billable")) + ": ", 1), + o("span", Bs, C(w(Na)(F.value)), 1), + o("span", Vs, "(" + C(100 - L.value) + "%)", 1) + ])])], 64)) : (v(), a("p", Hs, C(w(u)("tasks_projects.reports.split.nothing")), 1))]), + c(ps, { + title: w(u)("tasks_projects.reports.tables.by_project"), + "label-heading": w(u)("tasks_projects.reports.tables.project"), + rows: j.value, + "show-currency": k.value + }, null, 8, [ + "title", + "label-heading", + "rows", + "show-currency" + ]), + c(ps, { + title: w(u)("tasks_projects.reports.tables.by_member"), + "label-heading": w(u)("tasks_projects.reports.tables.member"), + rows: M.value, + "show-currency": k.value + }, null, 8, [ + "title", + "label-heading", + "rows", + "show-currency" + ]), + c(ps, { + title: w(u)("tasks_projects.reports.tables.by_customer"), + "label-heading": w(u)("tasks_projects.reports.tables.customer"), + rows: N.value, + "show-currency": k.value + }, null, 8, [ + "title", + "label-heading", + "rows", + "show-currency" + ]) + ], 64)) : (v(), r(ae, { + key: 1, + title: w(u)("tasks_projects.reports.empty_title"), + description: w(u)("tasks_projects.reports.empty_description") + }, { + default: D(() => [c(g, { + name: "ChartBarIcon", + class: "mt-5 mb-4 h-16 w-16 text-subtle" + })]), + _: 1 + }, 8, ["title", "description"])) + ]), + _: 1 + }); + }; + } +}), Gs = "tasks-projects"; +function Ks(e) { + e.addMessages(Qo), e.registerPage({ + id: "reports", + module: Gs, + path: "reports", + component: sn(e, Ws), + meta: { + ability: `${Gs}:view-own-time`, + title: "tasks_projects.reports.title" + } + }), e.on("company:changing", () => { + tn(); + }); +} +//#endregion +//#region resources/js/messages/tasks.ts +var qs = { en: { tasks_projects: { + board: { + title: "Board", + load_failed: "Unable to load the board.", + move_failed: "Unable to move the task.", + moved: "{name} moved to {status}.", + empty_column: "Nothing here yet", + hidden_invoiced: "Invoiced tasks are hidden on the board." + }, + tasks: { + title: "Tasks", + all_tasks: "All tasks", + new_task: "New task", + edit_task: "Edit task", + all_fields: "All fields", + fewer_fields: "Fewer fields", + search_placeholder: "Search by name or number", + empty_title: "No tasks yet", + empty_description: "Add a task to put work on the board.", + unassigned: "Unassigned", + billable: "Billable", + overdue: "Overdue", + none: "None", + no_project: "No project", + internal: "Internal", + invoiced: "Invoiced", + uninvoiced: "Unbilled", + invoice_task: "Invoice task", + already_invoiced: "This task is already on an invoice.", + nothing_to_invoice: "No unbilled billable time on this task.", + locked: "This task is on an invoice and cannot be changed.", + views: { + list: "List", + board: "Board", + week: "Week" + }, + filters: { + project: "Project", + all_projects: "All projects", + member: "Member", + all_members: "Everyone", + status: "Status", + all_statuses: "Any status", + invoicing: "Invoicing", + search: "Search" + }, + columns: { + number: "No.", + name: "Name", + project: "Project", + status: "Status", + assignee: "Assignee", + priority: "Priority", + due_date: "Due date", + logged: "Logged", + unbilled: "Unbilled", + invoiced: "Invoicing", + timer: "Timer" + }, + bulk: { + selected: "{count} selected", + select_page: "Select this page", + clear: "Clear", + change_status: "Move to", + delete: "Delete", + invoice: "Invoice", + delete_confirm: "Delete {count} task? Its time entries go with it. | Delete {count} tasks? Their time entries go with them.", + applied: "{count} task was updated. | {count} tasks were updated.", + deleted: "{count} task was deleted. | {count} tasks were deleted.", + partial: "{count} task was updated, {failed} refused: {ids}. | {count} tasks were updated, {failed} refused: {ids}.", + nothing: "No task was changed.", + failed: "Unable to apply the change." + }, + detail: { + estimate: "Estimate", + logged: "Logged", + unbilled: "Unbilled", + no_estimate: "No estimate", + project: "Project", + customer: "Customer", + status: "Status", + assignee: "Assignee", + priority: "Priority", + due_date: "Due date", + description: "Description", + no_description: "No description yet.", + status_saved: "The status was changed to {name}.", + status_failed: "Unable to change the status.", + not_found: "That task could not be loaded." + }, + time_log: { + title: "Time log", + add_item: "Add item", + add_disabled: "Stop the running timer to log an entry by hand.", + running: "Running", + empty: "No time logged against this task yet.", + load_failed: "Unable to load the time log.", + stamped: "Invoiced", + stamped_delete: "Invoiced time belongs to its invoice and cannot be deleted.", + columns: { + start_date: "Start date", + start_time: "Start", + end_date: "End date", + end_time: "End", + duration: "Duration", + description: "Description", + billable: "Billable", + member: "Member" + } + }, + created: "{name} was created.", + updated: "{name} was updated.", + deleted: "{name} was deleted.", + delete_confirm: "Delete {name}? Its time entries go with it.", + name_required: "Enter a task name.", + load_failed: "Unable to load the tasks.", + save_failed: "Unable to save the task.", + delete_failed: "Unable to delete the task.", + projects_failed: "Unable to load the projects.", + members_failed: "Unable to load the members.", + fields: { + name: "Name", + description: "Description", + project: "Project", + project_placeholder: "No project", + project_help: "Leave empty for a task that stands on its own.", + customer: "Customer", + customer_help: "Taken from the project.", + status: "Status", + assignee: "Assignee", + assignee_placeholder: "Nobody yet", + priority: "Priority", + priority_placeholder: "No priority", + due_date: "Due date", + estimate_hours: "Estimate (hours)", + billable: "Billable", + rate: "Rate override", + rate_help: "Per hour. Leave empty to use the project or member rate." + }, + priority: { + low: "Low", + normal: "Normal", + high: "High", + urgent: "Urgent" + } + }, + task_statuses: { + load_failed: "Unable to load the task statuses.", + none: "No board columns yet." + } +} } }, Js = { class: "mt-6 rounded-xl border border-line-default bg-surface" }, Ys = { class: "flex items-center justify-between border-b border-line-light px-5 py-3" }, Xs = { class: "text-sm font-semibold text-heading" }, Zs = ["title"], Qs = { class: "overflow-x-auto" }, $s = { class: "min-w-full text-sm" }, ec = { class: "bg-surface-secondary text-xs tracking-wide text-muted uppercase" }, tc = { class: "px-4 py-2 text-left font-medium" }, nc = { class: "px-4 py-2 text-left font-medium" }, rc = { class: "px-4 py-2 text-left font-medium" }, ic = { class: "px-4 py-2 text-left font-medium" }, ac = { class: "px-4 py-2 text-left font-medium" }, oc = { class: "px-4 py-2 text-left font-medium" }, sc = { class: "px-4 py-2 text-left font-medium" }, cc = { class: "px-4 py-2 text-left font-medium" }, lc = { class: "px-4 py-2 text-right font-medium" }, uc = ["onClick"], dc = { class: "px-4 py-2.5 whitespace-nowrap" }, fc = { class: "px-4 py-2.5 whitespace-nowrap tabular-nums" }, pc = { class: "px-4 py-2.5 whitespace-nowrap" }, mc = { + key: 0, + class: "text-primary-500" +}, hc = { key: 1 }, gc = { class: "px-4 py-2.5 whitespace-nowrap tabular-nums" }, _c = { class: "px-4 py-2.5 whitespace-nowrap tabular-nums" }, vc = { class: "max-w-64 truncate px-4 py-2.5" }, yc = { class: "px-4 py-2.5" }, bc = { + key: 1, + class: "text-subtle" +}, xc = { class: "px-4 py-2.5 whitespace-nowrap" }, Sc = ["title"], Cc = { + key: 3, + class: "text-xs text-primary-500" +}, wc = { key: 0 }, Tc = { + colspan: "9", + class: "px-4 py-8 text-center text-sm text-subtle" +}, Ec = /* @__PURE__ */ l({ + __name: "TimeLogGrid", + props: { + client: {}, + notify: {}, + task: {}, + members: { default: () => [] } + }, + setup(t) { + let l = t, u = B(), d = ro(), f = b([]), m = b(!1), h = b(!1), g = b(null), _ = n(() => ({ + id: l.task.id, + name: l.task.name, + number: l.task.number, + project_id: l.task.project_id, + billable: l.task.billable + })), y = n(() => q.isRunningOn(l.task.id)), T = n(() => ({ + notify: l.notify, + t: u + })); + function O(e) { + return e.is_running && q.running?.id === e.id; + } + function k() { + q.stopWithPrompt(l.client, T.value, { taskId: l.task.id }); + } + E(() => l.task.id, () => void j(), { immediate: !0 }), E(et, () => void j()); + async function j() { + m.value = !0; + try { + f.value = await mr(l.client, l.task.id); + } catch (e) { + f.value = [], l.notify("error", H(e, u("tasks_projects.tasks.time_log.load_failed"))); + } finally { + m.value = !1; + } + } + function M(e) { + return e.invoice_id !== null; + } + function N(e) { + return l.members.find((t) => t.id === e)?.name ?? `#${e}`; + } + function P(e) { + return e.is_running ? Ma(Ka(e.started_at, d.value)) : Na(e.duration_minutes); + } + function F() { + y.value || (g.value = null, h.value = !0); + } + function I(e) { + e.is_running || (g.value = e, h.value = !0); + } + function L() { + let e = g.value ? u("tasks_projects.time.updated") : u("tasks_projects.time.created"); + h.value = !1, g.value = null, l.notify("success", e), V(); + } + function R() { + h.value = !1, g.value = null, l.notify("success", u("tasks_projects.time.deleted")), V(); + } + async function z(e) { + if (M(e)) { + l.notify("warning", u("tasks_projects.tasks.time_log.stamped_delete")); + return; + } + if (window.confirm(u("tasks_projects.time.delete_confirm"))) try { + await je(l.client, e.id), l.notify("success", u("tasks_projects.time.deleted")), V(); + } catch (e) { + l.notify("error", H(e, u("tasks_projects.time.delete_failed"))); + } + } + return (n, l) => { + let d = S("BaseSpinner"), b = S("BaseIcon"), T = S("BaseButton"), E = S("BaseDropdownItem"), j = S("BaseDropdown"); + return v(), a("section", Js, [ + o("header", Ys, [o("h2", Xs, [s(C(w(u)("tasks_projects.tasks.time_log.title")) + " ", 1), m.value ? (v(), r(d, { + key: 0, + class: "ml-2 inline-block h-4 w-4 text-primary-500" + })) : i("", !0)]), o("span", { title: y.value ? w(u)("tasks_projects.tasks.time_log.add_disabled") : void 0 }, [c(T, { + variant: "primary-outline", + size: "sm", + disabled: y.value, + onClick: F + }, { + left: D((e) => [c(b, { + name: "PlusIcon", + class: p(e.class) + }, null, 8, ["class"])]), + default: D(() => [s(" " + C(w(u)("tasks_projects.tasks.time_log.add_item")), 1)]), + _: 1 + }, 8, ["disabled"])], 8, Zs)]), + o("div", Qs, [o("table", $s, [o("thead", ec, [o("tr", null, [ + o("th", tc, C(w(u)("tasks_projects.tasks.time_log.columns.start_date")), 1), + o("th", nc, C(w(u)("tasks_projects.tasks.time_log.columns.start_time")), 1), + o("th", rc, C(w(u)("tasks_projects.tasks.time_log.columns.end_date")), 1), + o("th", ic, C(w(u)("tasks_projects.tasks.time_log.columns.end_time")), 1), + o("th", ac, C(w(u)("tasks_projects.tasks.time_log.columns.duration")), 1), + o("th", oc, C(w(u)("tasks_projects.tasks.time_log.columns.description")), 1), + o("th", sc, C(w(u)("tasks_projects.tasks.time_log.columns.billable")), 1), + o("th", cc, C(w(u)("tasks_projects.tasks.time_log.columns.member")), 1), + o("th", lc, C(w(u)("tasks_projects.general.actions")), 1) + ])]), o("tbody", null, [(v(!0), a(e, null, x(f.value, (e) => (v(), a("tr", { + key: e.id, + class: p(["border-t border-line-light", e.is_running ? "bg-primary-50" : "cursor-pointer hover:bg-hover"]), + onClick: (t) => I(e) + }, [ + o("td", dc, C(w(ut)(w(Ia)(e.started_at)) || "-"), 1), + o("td", fc, C(w(La)(e.started_at) || "-"), 1), + o("td", pc, [e.is_running ? (v(), a("span", mc, C(w(u)("tasks_projects.tasks.time_log.running")), 1)) : (v(), a("span", hc, C(w(ut)(w(Ia)(e.ended_at)) || "-"), 1))]), + o("td", gc, C(e.is_running ? "-" : w(La)(e.ended_at) || "-"), 1), + o("td", _c, [o("span", { class: p(e.is_running ? "font-medium text-primary-500" : "") }, C(P(e)), 3)]), + o("td", vc, C(e.description || "-"), 1), + o("td", yc, [e.billable ? (v(), r(b, { + key: 0, + name: "CheckCircleIcon", + class: "h-5 w-5 text-status-green" + })) : (v(), a("span", bc, "-"))]), + o("td", xc, C(N(e.user_id)), 1), + o("td", { + class: "px-4 py-2.5 text-right whitespace-nowrap", + onClick: l[0] ||= A(() => {}, ["stop"]) + }, [M(e) ? (v(), a("span", { + key: 0, + class: "text-xs text-muted", + title: w(u)("tasks_projects.tasks.time_log.stamped_delete") + }, C(w(u)("tasks_projects.tasks.time_log.stamped")), 9, Sc)) : e.is_running ? O(e) ? (v(), r(T, { + key: 2, + variant: "white", + size: "sm", + disabled: w(q).busy, + onClick: k + }, { + left: D((e) => [c(b, { + name: "StopIcon", + class: p(e.class) + }, null, 8, ["class"])]), + default: D(() => [s(" " + C(w(u)("tasks_projects.timer.stop")), 1)]), + _: 1 + }, 8, ["disabled"])) : (v(), a("span", Cc, C(w(u)("tasks_projects.tasks.time_log.running")), 1)) : (v(), r(j, { key: 1 }, { + activator: D(() => [c(b, { + name: "EllipsisHorizontalIcon", + class: "h-5 text-muted" + })]), + default: D(() => [c(E, { onClick: (t) => I(e) }, { + default: D(() => [c(b, { + name: "PencilIcon", + class: "mr-3 h-5 w-5 text-subtle group-hover:text-muted" + }), s(" " + C(w(u)("tasks_projects.general.edit")), 1)]), + _: 1 + }, 8, ["onClick"]), c(E, { onClick: (t) => z(e) }, { + default: D(() => [c(b, { + name: "TrashIcon", + class: "mr-3 h-5 w-5 text-subtle group-hover:text-muted" + }), s(" " + C(w(u)("tasks_projects.general.delete")), 1)]), + _: 1 + }, 8, ["onClick"])]), + _: 2 + }, 1024))]) + ], 10, uc))), 128)), f.value.length === 0 && !m.value ? (v(), a("tr", wc, [o("td", Tc, C(w(u)("tasks_projects.tasks.time_log.empty")), 1)])) : i("", !0)])])]), + c(zo, { + show: h.value, + client: t.client, + notify: t.notify, + entry: g.value, + "default-task": _.value, + "lock-task": "", + onClose: l[1] ||= (e) => h.value = !1, + onSaved: L, + onDeleted: R + }, null, 8, [ + "show", + "client", + "notify", + "entry", + "default-task" + ]) + ]); + }; + } +}), Dc = { + key: 0, + class: "mt-2 flex flex-wrap items-center gap-3 text-sm text-muted" +}, Oc = { class: "rounded-sm bg-surface-tertiary px-2 py-0.5 text-body" }, kc = { + key: 1, + class: "text-subtle" +}, Ac = { + key: 0, + class: "flex flex-wrap items-center justify-end gap-3" +}, jc = ["title"], Mc = { + key: 0, + class: "flex justify-center py-16" +}, Nc = { class: "mt-6 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4" }, Pc = { class: "rounded-xl border border-line-default bg-surface p-5" }, Fc = { class: "text-xs font-medium tracking-wider text-muted uppercase" }, Ic = { class: "mt-2" }, Lc = { class: "mt-2 text-xs text-muted" }, Rc = { class: "text-body" }, zc = { class: "rounded-xl border border-line-default bg-surface p-5" }, Bc = { class: "text-xs font-medium tracking-wider text-muted uppercase" }, Vc = { class: "mt-2 text-2xl font-semibold text-heading" }, Hc = { class: "mt-1 text-xs text-muted" }, Uc = { class: "text-body" }, Wc = { class: "rounded-xl border border-line-default bg-surface p-5" }, Gc = { class: "text-xs font-medium tracking-wider text-muted uppercase" }, Kc = { class: "mt-2 text-2xl font-semibold text-heading" }, qc = { class: "mt-1 text-xs text-muted" }, Jc = { class: "rounded-xl border border-line-default bg-surface p-5" }, Yc = { class: "text-xs font-medium tracking-wider text-muted uppercase" }, Xc = { class: "mt-2 text-lg font-semibold text-heading" }, Zc = { class: "mt-1 text-xs text-muted" }, Qc = { class: "mt-4 rounded-xl border border-line-default bg-surface p-5" }, $c = { class: "text-xs font-medium tracking-wider text-muted uppercase" }, el = { + key: 0, + class: "mt-2 text-sm whitespace-pre-line text-body" +}, tl = { + key: 1, + class: "mt-2 text-sm text-subtle" +}, nl = 100, rl = /* @__PURE__ */ l({ + __name: "TaskPage", + props: { + id: {}, + client: { type: [Function, Object] }, + notify: { type: Function }, + router: {} + }, + setup(t) { + let l = t, u = { + LOW: "bg-surface-tertiary text-muted", + NORMAL: "bg-primary-50 text-primary-500", + HIGH: "bg-alert-warning-bg text-alert-warning-text", + URGENT: "bg-alert-error-bg text-alert-error-text" + }, d = B(), f = l.router, m = b(null), h = b([]), _ = b([]), y = b([]), x = b(!0), T = b(!1), O = b(!1), k = b(!1), A = n(() => Number(l.id)), j = n(() => nt(m.value)), N = n(() => m.value?.name ?? d("tasks_projects.tasks.title")), P = n(() => h.value.map((e) => ({ + id: e.id, + label: e.name + }))), F = n(() => y.value.map((e) => ({ + id: e.id, + label: e.name + }))), I = n(() => y.value.find((e) => e.id === m.value?.project_id)), L = n(() => { + let e = m.value?.assignee_id ?? null; + return e === null ? d("tasks_projects.tasks.unassigned") : _.value.find((t) => t.id === e)?.name ?? `#${e}`; + }), R = n({ + get: () => P.value.find((e) => e.id === m.value?.task_status_id) ?? null, + set: (e) => { + e !== null && se(e); + } + }), z = n(() => M.busy), ee = n(() => j.value.invoiced === "uninvoiced"), te = n(() => j.value.invoiced === "invoiced" ? d("tasks_projects.tasks.already_invoiced") : d("tasks_projects.tasks.nothing_to_invoice")), ne = n(() => m.value?.priority ? d(`tasks_projects.tasks.priority.${m.value.priority.toLowerCase()}`) : null); + E(A, () => void re()), E(et, () => void re(!0)), g(() => { + re(), ie(); + }); + async function re(e = !1) { + if (!(!Number.isInteger(A.value) || A.value <= 0)) { + x.value = !e; + try { + let e = await dr(l.client, A.value); + m.value = e, Qe(e), e.customer_id !== null && en(l.client); + } catch (t) { + e || l.notify("error", H(t, d("tasks_projects.tasks.detail.not_found"))); + } finally { + x.value = !1; + } + } + } + async function ie() { + try { + h.value = await or(l.client); + } catch (e) { + l.notify("error", H(e, d("tasks_projects.task_statuses.load_failed"))); + } + try { + _.value = await Jt(l.client); + } catch { + _.value = []; + } + try { + let e = await Ht(l.client, { + limit: nl, + status: "ACTIVE", + sort_by: "name" + }); + y.value = e.data ?? []; + } catch { + y.value = []; + } + } + function ae(e, t) { + return { + name: e.name, + task_status_id: e.task_status_id, + project_id: e.project_id, + customer_id: e.project_id === null ? e.customer_id : null, + description: e.description, + assignee_id: e.assignee_id, + priority: e.priority, + due_date: e.due_date, + estimated_minutes: e.estimated_minutes, + billable: e.billable, + rate: e.rate, + ...t + }; + } + function oe(e, t) { + return gt(e) === "task_locked" ? d("tasks_projects.tasks.locked") : H(e, d(t)); + } + async function se(e) { + let t = m.value; + if (t === null || T.value || t.task_status_id === e.id) return; + let n = t.task_status_id; + t.task_status_id = e.id, T.value = !0; + try { + m.value = await lr(l.client, t.id, ae(t, { task_status_id: e.id })), l.notify("success", d("tasks_projects.tasks.detail.status_saved", { name: e.label })), V(); + } catch (e) { + t.task_status_id = n, l.notify("error", oe(e, "tasks_projects.tasks.detail.status_failed")); + } finally { + T.value = !1; + } + } + async function ce() { + let e = m.value; + e !== null && ee.value && !z.value && await bt({ + client: l.client, + router: f, + notify: l.notify, + t: d + }, { taskIds: [e.id] }); + } + function le() { + f.push(W.tasks); + } + function ue(e) { + k.value = !1, m.value = e, Qe(e), l.notify("success", d("tasks_projects.tasks.updated", { name: e.name })), V(); + } + async function de() { + let e = m.value; + if (!(e === null || O.value) && window.confirm(d("tasks_projects.tasks.delete_confirm", { name: e.name }))) { + O.value = !0; + try { + await ur(l.client, e.id), l.notify("success", d("tasks_projects.tasks.deleted", { name: e.name })), V(), le(); + } catch (e) { + l.notify("error", oe(e, "tasks_projects.tasks.delete_failed")); + } finally { + O.value = !1; + } + } + } + return (n, l) => { + let f = S("BaseBreadcrumbItem"), g = S("BaseBreadcrumb"), y = S("router-link"), b = S("BaseIcon"), E = S("BaseButton"), A = S("BasePageHeader"), B = S("BaseSpinner"), re = S("BaseSelectInput"), ie = S("BaseFormatMoney"), ae = S("BasePage"); + return v(), r(ae, null, { + default: D(() => [ + c(A, { title: N.value }, { + actions: D(() => [m.value ? (v(), a("div", Ac, [ + c(go, { + client: t.client, + notify: t.notify, + task: m.value, + members: _.value, + size: "md" + }, null, 8, [ + "client", + "notify", + "task", + "members" + ]), + w(M).allowed ? (v(), a("span", { + key: 0, + class: "inline-flex", + title: ee.value ? void 0 : te.value + }, [c(E, { + variant: "white", + loading: z.value, + disabled: !ee.value || z.value, + onClick: ce + }, { + left: D((e) => [z.value ? i("", !0) : (v(), r(b, { + key: 0, + name: "BanknotesIcon", + class: p(e.class) + }, null, 8, ["class"]))]), + default: D(() => [s(" " + C(w(d)("tasks_projects.tasks.invoice_task")), 1)]), + _: 1 + }, 8, ["loading", "disabled"])], 8, jc)) : i("", !0), + c(E, { + variant: "primary-outline", + loading: O.value, + disabled: O.value, + onClick: de + }, { + default: D(() => [s(C(w(d)("tasks_projects.general.delete")), 1)]), + _: 1 + }, 8, ["loading", "disabled"]), + c(E, { + variant: "primary", + onClick: l[0] ||= (e) => k.value = !0 + }, { + left: D((e) => [c(b, { + name: "PencilIcon", + class: p(e.class) + }, null, 8, ["class"])]), + default: D(() => [s(" " + C(w(d)("tasks_projects.general.edit")), 1)]), + _: 1 + }) + ])) : i("", !0)]), + default: D(() => [c(g, null, { + default: D(() => [ + c(f, { + title: w(d)("tasks_projects.general.home"), + to: "/admin/dashboard" + }, null, 8, ["title"]), + c(f, { + title: w(d)("tasks_projects.tasks.title"), + to: w(W).tasks + }, null, 8, ["title", "to"]), + c(f, { + title: m.value ? `#${m.value.number}` : N.value, + to: "#", + active: "" + }, null, 8, ["title"]) + ]), + _: 1 + }), m.value ? (v(), a("div", Dc, [ + o("span", Oc, "#" + C(m.value.number), 1), + m.value.project_id ? (v(), r(y, { + key: 0, + class: "hover:text-primary-500", + to: w(W).project(m.value.project_id) + }, { + default: D(() => [s(C(I.value?.name ?? `#${m.value.project_id}`), 1)]), + _: 1 + }, 8, ["to"])) : (v(), a("span", kc, C(w(d)("tasks_projects.tasks.no_project")), 1)), + m.value.customer_id ? (v(), r(y, { + key: 2, + class: "hover:text-primary-500", + to: w(W).customer(m.value.customer_id) + }, { + default: D(() => [s(C(w($t)(m.value.customer_id)), 1)]), + _: 1 + }, 8, ["to"])) : i("", !0), + ne.value && m.value.priority ? (v(), a("span", { + key: 3, + class: p(["rounded-full px-2 py-0.5 text-xs font-medium", u[m.value.priority]]) + }, C(ne.value), 3)) : i("", !0), + c(da, { state: j.value.invoiced }, null, 8, ["state"]) + ])) : i("", !0)]), + _: 1 + }, 8, ["title"]), + c(Lt, { + client: t.client, + notify: t.notify + }, null, 8, ["client", "notify"]), + x.value && m.value === null ? (v(), a("div", Mc, [c(B, { class: "h-8 w-8 text-primary-500" })])) : m.value ? (v(), a(e, { key: 1 }, [ + o("div", Nc, [ + o("div", Pc, [ + o("p", Fc, C(w(d)("tasks_projects.tasks.detail.status")), 1), + o("div", Ic, [c(re, { + modelValue: R.value, + "onUpdate:modelValue": l[1] ||= (e) => R.value = e, + options: P.value, + disabled: T.value, + "label-key": "label" + }, null, 8, [ + "modelValue", + "options", + "disabled" + ])]), + o("p", Lc, [s(C(w(d)("tasks_projects.tasks.detail.assignee")) + ": ", 1), o("span", Rc, C(L.value), 1)]) + ]), + o("div", zc, [ + o("p", Bc, C(w(d)("tasks_projects.tasks.detail.logged")), 1), + o("p", Vc, C(w(ft)(j.value.logged_minutes)), 1), + o("p", Hc, [s(C(w(d)("tasks_projects.tasks.detail.estimate")) + ": ", 1), o("span", Uc, C(m.value.estimated_minutes ? w(ft)(m.value.estimated_minutes) : w(d)("tasks_projects.tasks.detail.no_estimate")), 1)]) + ]), + o("div", Wc, [ + o("p", Gc, C(w(d)("tasks_projects.tasks.detail.unbilled")), 1), + o("p", Kc, [c(ie, { amount: j.value.unbilled_amount }, null, 8, ["amount"])]), + o("p", qc, C(w(ft)(j.value.unbilled_minutes)), 1) + ]), + o("div", Jc, [ + o("p", Yc, C(w(d)("tasks_projects.tasks.detail.due_date")), 1), + o("p", Xc, C(m.value.due_date ? w(ut)(m.value.due_date) : "-"), 1), + o("p", Zc, C(m.value.billable ? w(d)("tasks_projects.tasks.billable") : w(d)("tasks_projects.time.non_billable")), 1) + ]) + ]), + o("div", Qc, [o("p", $c, C(w(d)("tasks_projects.tasks.detail.description")), 1), m.value.description ? (v(), a("p", el, C(m.value.description), 1)) : (v(), a("p", tl, C(w(d)("tasks_projects.tasks.detail.no_description")), 1))]), + c(Ec, { + client: t.client, + notify: t.notify, + task: m.value, + members: _.value + }, null, 8, [ + "client", + "notify", + "task", + "members" + ]) + ], 64)) : i("", !0), + m.value ? (v(), r(ya, { + key: 2, + show: k.value, + client: t.client, + notify: t.notify, + task: m.value, + statuses: h.value, + members: _.value, + projects: F.value, + onClose: l[2] ||= (e) => k.value = !1, + onSaved: ue, + onDeleted: le + }, null, 8, [ + "show", + "client", + "notify", + "task", + "statuses", + "members", + "projects" + ])) : i("", !0) + ]), + _: 1 + }); + }; + } +}), il = ["aria-label"], al = ["aria-current", "onClick"], ol = { class: "max-sm:hidden" }, sl = /* @__PURE__ */ l({ + __name: "ViewSwitcher", + props: { + active: {}, + query: {} + }, + emits: ["select"], + setup(t, { emit: r }) { + let i = t, s = r, l = B(), u = n(() => [ + { + id: "list", + name: on.list, + label: l("tasks_projects.tasks.views.list"), + icon: "ListBulletIcon" + }, + { + id: "board", + name: on.board, + label: l("tasks_projects.tasks.views.board"), + icon: "ViewColumnsIcon" + }, + { + id: "week", + name: on.week, + label: l("tasks_projects.tasks.views.week"), + icon: "CalendarDaysIcon" + } + ]); + function d(e) { + return i.active === e.name || e.id === "list" && i.active === on.tasks; + } + function f(e) { + d(e) || s("select", { + name: e.name, + query: i.query + }); + } + return (t, n) => { + let r = S("BaseIcon"); + return v(), a("nav", { + class: "inline-flex overflow-hidden rounded-lg border border-line-default", + "aria-label": w(l)("tasks_projects.tasks.title") + }, [(v(!0), a(e, null, x(u.value, (e) => (v(), a("button", { + key: e.id, + type: "button", + class: p(["flex items-center gap-1.5 border-r border-line-default px-3 py-1.5 text-sm font-medium last:border-r-0", d(e) ? "bg-primary-50 text-primary-500" : "bg-surface text-muted hover:bg-hover hover:text-heading"]), + "aria-current": d(e) ? "page" : void 0, + onClick: (t) => f(e) + }, [c(r, { + name: e.icon, + class: "h-4 w-4" + }, null, 8, ["name"]), o("span", ol, C(e.label), 1)], 10, al))), 128))], 8, il); + }; + } +}), cl = { class: "flex flex-wrap items-center justify-end gap-3" }, ll = 100, ul = /* @__PURE__ */ l({ + __name: "TasksPage", + props: { + client: { type: [Function, Object] }, + notify: { type: Function }, + router: {} + }, + setup(e) { + let t = e, i = B(), a = t.router, l = b([]), u = b([]), d = b([]), f = b(!1), m = n(() => t.router.currentRoute.value), h = n(() => Bi(m.value.query)), _ = n(() => Vi(h.value)), y = n(() => String(m.value.name ?? "")), x = n(() => d.value.map((e) => ({ + id: e.id, + label: e.name + }))); + E(y, (e) => T(e)), g(() => { + T(y.value), O(); + }); + function T(e) { + e === on.tasks && a.replace({ + name: on.list, + query: _.value + }); + } + async function O() { + try { + l.value = await or(t.client); + } catch (e) { + t.notify("error", H(e, i("tasks_projects.task_statuses.load_failed"))); + } + try { + u.value = await Jt(t.client); + } catch (e) { + t.notify("error", H(e, i("tasks_projects.tasks.members_failed"))); + } + try { + let e = await Ht(t.client, { + limit: ll, + status: "ACTIVE", + sort_by: "name" + }); + d.value = e.data ?? []; + } catch (e) { + t.notify("error", H(e, i("tasks_projects.tasks.projects_failed"))); + } + } + function k(e) { + Wi(e, h.value) || a.replace({ + name: y.value === on.tasks ? on.list : y.value, + query: Vi(e) + }); + } + function A(e) { + a.push(e); + } + function j(e) { + f.value = !1, t.notify("success", i("tasks_projects.tasks.created", { name: e.name })), V(); + } + return (t, n) => { + let a = S("BaseBreadcrumbItem"), m = S("BaseBreadcrumb"), g = S("BaseIcon"), b = S("BaseButton"), T = S("router-link"), E = S("BasePageHeader"), O = S("router-view"), M = S("BasePage"); + return v(), r(M, null, { + default: D(() => [ + c(E, { title: w(i)("tasks_projects.tasks.title") }, { + actions: D(() => [o("div", cl, [ + c(sl, { + active: y.value, + query: _.value, + onSelect: A + }, null, 8, ["active", "query"]), + c(T, { to: w(W).projects }, { + default: D(() => [c(b, { variant: "white" }, { + left: D((e) => [c(g, { + name: "FolderIcon", + class: p(e.class) + }, null, 8, ["class"])]), + default: D(() => [s(" " + C(w(i)("tasks_projects.projects.title")), 1)]), + _: 1 + })]), + _: 1 + }, 8, ["to"]), + c(T, { to: w(W).reports }, { + default: D(() => [c(b, { variant: "white" }, { + left: D((e) => [c(g, { + name: "ChartBarIcon", + class: p(e.class) + }, null, 8, ["class"])]), + default: D(() => [s(" " + C(w(i)("tasks_projects.reports.title")), 1)]), + _: 1 + })]), + _: 1 + }, 8, ["to"]), + c(b, { + variant: "primary", + onClick: n[0] ||= (e) => f.value = !0 + }, { + left: D((e) => [c(g, { + name: "PlusIcon", + class: p(e.class) + }, null, 8, ["class"])]), + default: D(() => [s(" " + C(w(i)("tasks_projects.tasks.new_task")), 1)]), + _: 1 + }) + ])]), + default: D(() => [c(m, null, { + default: D(() => [c(a, { + title: w(i)("tasks_projects.general.home"), + to: "/admin/dashboard" + }, null, 8, ["title"]), c(a, { + title: w(i)("tasks_projects.tasks.title"), + to: "#", + active: "" + }, null, 8, ["title"])]), + _: 1 + })]), + _: 1 + }, 8, ["title"]), + c(Lt, { + client: e.client, + notify: e.notify + }, null, 8, ["client", "notify"]), + c(oa, { + "model-value": h.value, + projects: d.value, + members: u.value, + statuses: l.value, + "onUpdate:modelValue": k + }, null, 8, [ + "model-value", + "projects", + "members", + "statuses" + ]), + c(O, { + filters: h.value, + statuses: l.value, + members: u.value, + projects: d.value + }, null, 8, [ + "filters", + "statuses", + "members", + "projects" + ]), + c(ya, { + show: f.value, + client: e.client, + notify: e.notify, + task: null, + statuses: l.value, + members: u.value, + projects: x.value, + defaults: { project_id: h.value.project === "" ? null : Number(h.value.project) }, + compact: "", + onClose: n[1] ||= (e) => f.value = !1, + onSaved: j + }, null, 8, [ + "show", + "client", + "notify", + "statuses", + "members", + "projects", + "defaults" + ]) + ]), + _: 1 + }); + }; + } +}); +//#endregion +//#region node_modules/.pnpm/sortablejs@1.15.7/node_modules/sortablejs/modular/sortable.esm.js +function dl(e, t, n) { + return (t = vl(t)) in e ? Object.defineProperty(e, t, { + value: n, + enumerable: !0, + configurable: !0, + writable: !0 + }) : e[t] = n, e; +} +function fl() { + return fl = Object.assign ? Object.assign.bind() : function(e) { + for (var t = 1; t < arguments.length; t++) { + var n = arguments[t]; + for (var r in n) ({}).hasOwnProperty.call(n, r) && (e[r] = n[r]); + } + return e; + }, fl.apply(null, arguments); +} +function pl(e, t) { + var n = Object.keys(e); + if (Object.getOwnPropertySymbols) { + var r = Object.getOwnPropertySymbols(e); + t && (r = r.filter(function(t) { + return Object.getOwnPropertyDescriptor(e, t).enumerable; + })), n.push.apply(n, r); + } + return n; +} +function ml(e) { + for (var t = 1; t < arguments.length; t++) { + var n = arguments[t] == null ? {} : arguments[t]; + t % 2 ? pl(Object(n), !0).forEach(function(t) { + dl(e, t, n[t]); + }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(e, Object.getOwnPropertyDescriptors(n)) : pl(Object(n)).forEach(function(t) { + Object.defineProperty(e, t, Object.getOwnPropertyDescriptor(n, t)); + }); + } + return e; +} +function hl(e, t) { + if (e == null) return {}; + var n, r, i = gl(e, t); + if (Object.getOwnPropertySymbols) { + var a = Object.getOwnPropertySymbols(e); + for (r = 0; r < a.length; r++) n = a[r], t.indexOf(n) === -1 && {}.propertyIsEnumerable.call(e, n) && (i[n] = e[n]); + } + return i; +} +function gl(e, t) { + if (e == null) return {}; + var n = {}; + for (var r in e) if ({}.hasOwnProperty.call(e, r)) { + if (t.indexOf(r) !== -1) continue; + n[r] = e[r]; + } + return n; +} +function _l(e, t) { + if (typeof e != "object" || !e) return e; + var n = e[Symbol.toPrimitive]; + if (n !== void 0) { + var r = n.call(e, t || "default"); + if (typeof r != "object") return r; + throw TypeError("@@toPrimitive must return a primitive value."); + } + return (t === "string" ? String : Number)(e); +} +function vl(e) { + var t = _l(e, "string"); + return typeof t == "symbol" ? t : t + ""; +} +function yl(e) { + "@babel/helpers - typeof"; + return yl = typeof Symbol == "function" && typeof Symbol.iterator == "symbol" ? function(e) { + return typeof e; + } : function(e) { + return e && typeof Symbol == "function" && e.constructor === Symbol && e !== Symbol.prototype ? "symbol" : typeof e; + }, yl(e); +} +var bl = "1.15.7"; +function xl(e) { + if (typeof window < "u" && window.navigator) return !!/*@__PURE__*/ navigator.userAgent.match(e); +} +var Sl = xl(/(?:Trident.*rv[ :]?11\.|msie|iemobile|Windows Phone)/i), Cl = xl(/Edge/i), wl = xl(/firefox/i), Tl = xl(/safari/i) && !xl(/chrome/i) && !xl(/android/i), El = xl(/iP(ad|od|hone)/i), Dl = xl(/chrome/i) && xl(/android/i), Ol = { + capture: !1, + passive: !1 +}; +function J(e, t, n) { + e.addEventListener(t, n, !Sl && Ol); +} +function Y(e, t, n) { + e.removeEventListener(t, n, !Sl && Ol); +} +function kl(e, t) { + if (t) { + if (t[0] === ">" && (t = t.substring(1)), e) try { + if (e.matches) return e.matches(t); + if (e.msMatchesSelector) return e.msMatchesSelector(t); + if (e.webkitMatchesSelector) return e.webkitMatchesSelector(t); + } catch { + return !1; + } + return !1; + } +} +function Al(e) { + return e.host && e !== document && e.host.nodeType && e.host !== e ? e.host : e.parentNode; +} +function jl(e, t, n, r) { + if (e) { + n ||= document; + do { + if (t != null && (t[0] === ">" ? e.parentNode === n && kl(e, t) : kl(e, t)) || r && e === n) return e; + if (e === n) break; + } while (e = Al(e)); + } + return null; +} +var Ml = /\s+/g; +function Nl(e, t, n) { + e && t && (e.classList ? e.classList[n ? "add" : "remove"](t) : e.className = ((" " + e.className + " ").replace(Ml, " ").replace(" " + t + " ", " ") + (n ? " " + t : "")).replace(Ml, " ")); +} +function X(e, t, n) { + var r = e && e.style; + if (r) { + if (n === void 0) return document.defaultView && document.defaultView.getComputedStyle ? n = document.defaultView.getComputedStyle(e, "") : e.currentStyle && (n = e.currentStyle), t === void 0 ? n : n[t]; + !(t in r) && t.indexOf("webkit") === -1 && (t = "-webkit-" + t), r[t] = n + (typeof n == "string" ? "" : "px"); + } +} +function Pl(e, t) { + var n = ""; + if (typeof e == "string") n = e; + else do { + var r = X(e, "transform"); + r && r !== "none" && (n = r + " " + n); + } while (!t && (e = e.parentNode)); + var i = window.DOMMatrix || window.WebKitCSSMatrix || window.CSSMatrix || window.MSCSSMatrix; + return i && new i(n); +} +function Fl(e, t, n) { + if (e) { + var r = e.getElementsByTagName(t), i = 0, a = r.length; + if (n) for (; i < a; i++) n(r[i], i); + return r; + } + return []; +} +function Il() { + return document.scrollingElement || document.documentElement; +} +function Ll(e, t, n, r, i) { + if (e.getBoundingClientRect || e === window) { + var a, o, s, c, l, u, d; + if (e !== window && e.parentNode && e !== Il() ? (a = e.getBoundingClientRect(), o = a.top, s = a.left, c = a.bottom, l = a.right, u = a.height, d = a.width) : (o = 0, s = 0, c = window.innerHeight, l = window.innerWidth, u = window.innerHeight, d = window.innerWidth), (t || n) && e !== window && (i ||= e.parentNode, !Sl)) do + if (i && i.getBoundingClientRect && (X(i, "transform") !== "none" || n && X(i, "position") !== "static")) { + var f = i.getBoundingClientRect(); + o -= f.top + parseInt(X(i, "border-top-width")), s -= f.left + parseInt(X(i, "border-left-width")), c = o + a.height, l = s + a.width; + break; + } + while (i = i.parentNode); + if (r && e !== window) { + var p = Pl(i || e), m = p && p.a, h = p && p.d; + p && (o /= h, s /= m, d /= m, u /= h, c = o + u, l = s + d); + } + return { + top: o, + left: s, + bottom: c, + right: l, + width: d, + height: u + }; + } +} +function Rl(e, t, n) { + for (var r = Wl(e, !0), i = Ll(e)[t]; r;) { + var a = Ll(r)[n], o = void 0; + if (o = n === "top" || n === "left" ? i >= a : i <= a, !o) return r; + if (r === Il()) break; + r = Wl(r, !1); + } + return !1; +} +function zl(e, t, n, r) { + for (var i = 0, a = 0, o = e.children; a < o.length;) { + if (o[a].style.display !== "none" && o[a] !== $.ghost && (r || o[a] !== $.dragged) && jl(o[a], n.draggable, e, !1)) { + if (i === t) return o[a]; + i++; + } + a++; + } + return null; +} +function Bl(e, t) { + for (var n = e.lastElementChild; n && (n === $.ghost || X(n, "display") === "none" || t && !kl(n, t));) n = n.previousElementSibling; + return n || null; +} +function Vl(e, t) { + var n = 0; + if (!e || !e.parentNode) return -1; + for (; e = e.previousElementSibling;) e.nodeName.toUpperCase() !== "TEMPLATE" && e !== $.clone && (!t || kl(e, t)) && n++; + return n; +} +function Hl(e) { + var t = 0, n = 0, r = Il(); + if (e) do { + var i = Pl(e), a = i.a, o = i.d; + t += e.scrollLeft * a, n += e.scrollTop * o; + } while (e !== r && (e = e.parentNode)); + return [t, n]; +} +function Ul(e, t) { + for (var n in e) if (e.hasOwnProperty(n)) { + for (var r in t) if (t.hasOwnProperty(r) && t[r] === e[n][r]) return Number(n); + } + return -1; +} +function Wl(e, t) { + if (!e || !e.getBoundingClientRect) return Il(); + var n = e, r = !1; + do + if (n.clientWidth < n.scrollWidth || n.clientHeight < n.scrollHeight) { + var i = X(n); + if (n.clientWidth < n.scrollWidth && (i.overflowX == "auto" || i.overflowX == "scroll") || n.clientHeight < n.scrollHeight && (i.overflowY == "auto" || i.overflowY == "scroll")) { + if (!n.getBoundingClientRect || n === document.body) return Il(); + if (r || t) return n; + r = !0; + } + } + while (n = n.parentNode); + return Il(); +} +function Gl(e, t) { + if (e && t) for (var n in t) t.hasOwnProperty(n) && (e[n] = t[n]); + return e; +} +function Kl(e, t) { + return Math.round(e.top) === Math.round(t.top) && Math.round(e.left) === Math.round(t.left) && Math.round(e.height) === Math.round(t.height) && Math.round(e.width) === Math.round(t.width); +} +var ql; +function Jl(e, t) { + return function() { + if (!ql) { + var n = arguments, r = this; + n.length === 1 ? e.call(r, n[0]) : e.apply(r, n), ql = setTimeout(function() { + ql = void 0; + }, t); + } + }; +} +function Yl() { + clearTimeout(ql), ql = void 0; +} +function Xl(e, t, n) { + e.scrollLeft += t, e.scrollTop += n; +} +function Zl(e) { + var t = window.Polymer, n = window.jQuery || window.Zepto; + return t && t.dom ? t.dom(e).cloneNode(!0) : n ? n(e).clone(!0)[0] : e.cloneNode(!0); +} +function Ql(e, t, n) { + var r = {}; + return Array.from(e.children).forEach(function(i) { + if (jl(i, t.draggable, e, !1) && !i.animated && i !== n) { + var a = Ll(i); + r.left = Math.min(r.left ?? Infinity, a.left), r.top = Math.min(r.top ?? Infinity, a.top), r.right = Math.max(r.right ?? -Infinity, a.right), r.bottom = Math.max(r.bottom ?? -Infinity, a.bottom); + } + }), r.width = r.right - r.left, r.height = r.bottom - r.top, r.x = r.left, r.y = r.top, r; +} +var $l = "Sortable" + (/* @__PURE__ */ new Date()).getTime(); +function eu() { + var e = [], t; + return { + captureAnimationState: function() { + e = [], this.options.animation && [].slice.call(this.el.children).forEach(function(t) { + if (X(t, "display") !== "none" && t !== $.ghost) { + e.push({ + target: t, + rect: Ll(t) + }); + var n = ml({}, e[e.length - 1].rect); + if (t.thisAnimationDuration) { + var r = Pl(t, !0); + r && (n.top -= r.f, n.left -= r.e); + } + t.fromRect = n; + } + }); + }, + addAnimationState: function(t) { + e.push(t); + }, + removeAnimationState: function(t) { + e.splice(Ul(e, { target: t }), 1); + }, + animateAll: function(n) { + var r = this; + if (!this.options.animation) { + clearTimeout(t), typeof n == "function" && n(); + return; + } + var i = !1, a = 0; + e.forEach(function(e) { + var t = 0, n = e.target, o = n.fromRect, s = Ll(n), c = n.prevFromRect, l = n.prevToRect, u = e.rect, d = Pl(n, !0); + d && (s.top -= d.f, s.left -= d.e), n.toRect = s, n.thisAnimationDuration && Kl(c, s) && !Kl(o, s) && (u.top - s.top) / (u.left - s.left) === (o.top - s.top) / (o.left - s.left) && (t = nu(u, c, l, r.options)), Kl(s, o) || (n.prevFromRect = o, n.prevToRect = s, t ||= r.options.animation, r.animate(n, u, s, t)), t && (i = !0, a = Math.max(a, t), clearTimeout(n.animationResetTimer), n.animationResetTimer = setTimeout(function() { + n.animationTime = 0, n.prevFromRect = null, n.fromRect = null, n.prevToRect = null, n.thisAnimationDuration = null; + }, t), n.thisAnimationDuration = t); + }), clearTimeout(t), i ? t = setTimeout(function() { + typeof n == "function" && n(); + }, a) : typeof n == "function" && n(), e = []; + }, + animate: function(e, t, n, r) { + if (r) { + X(e, "transition", ""), X(e, "transform", ""); + var i = Pl(this.el), a = i && i.a, o = i && i.d, s = (t.left - n.left) / (a || 1), c = (t.top - n.top) / (o || 1); + e.animatingX = !!s, e.animatingY = !!c, X(e, "transform", "translate3d(" + s + "px," + c + "px,0)"), this.forRepaintDummy = tu(e), X(e, "transition", "transform " + r + "ms" + (this.options.easing ? " " + this.options.easing : "")), X(e, "transform", "translate3d(0,0,0)"), typeof e.animated == "number" && clearTimeout(e.animated), e.animated = setTimeout(function() { + X(e, "transition", ""), X(e, "transform", ""), e.animated = !1, e.animatingX = !1, e.animatingY = !1; + }, r); + } + } + }; +} +function tu(e) { + return e.offsetWidth; +} +function nu(e, t, n, r) { + return Math.sqrt((t.top - e.top) ** 2 + (t.left - e.left) ** 2) / Math.sqrt((t.top - n.top) ** 2 + (t.left - n.left) ** 2) * r.animation; +} +var ru = [], iu = { initializeByDefault: !0 }, au = { + mount: function(e) { + for (var t in iu) iu.hasOwnProperty(t) && !(t in e) && (e[t] = iu[t]); + ru.forEach(function(t) { + if (t.pluginName === e.pluginName) throw `Sortable: Cannot mount plugin ${e.pluginName} more than once`; + }), ru.push(e); + }, + pluginEvent: function(e, t, n) { + var r = this; + this.eventCanceled = !1, n.cancel = function() { + r.eventCanceled = !0; + }; + var i = e + "Global"; + ru.forEach(function(r) { + t[r.pluginName] && (t[r.pluginName][i] && t[r.pluginName][i](ml({ sortable: t }, n)), t.options[r.pluginName] && t[r.pluginName][e] && t[r.pluginName][e](ml({ sortable: t }, n))); + }); + }, + initializePlugins: function(e, t, n, r) { + for (var i in ru.forEach(function(r) { + var i = r.pluginName; + if (e.options[i] || r.initializeByDefault) { + var a = new r(e, t, e.options); + a.sortable = e, a.options = e.options, e[i] = a, fl(n, a.defaults); + } + }), e.options) if (e.options.hasOwnProperty(i)) { + var a = this.modifyOption(e, i, e.options[i]); + a !== void 0 && (e.options[i] = a); + } + }, + getEventProperties: function(e, t) { + var n = {}; + return ru.forEach(function(r) { + typeof r.eventProperties == "function" && fl(n, r.eventProperties.call(t[r.pluginName], e)); + }), n; + }, + modifyOption: function(e, t, n) { + var r; + return ru.forEach(function(i) { + e[i.pluginName] && i.optionListeners && typeof i.optionListeners[t] == "function" && (r = i.optionListeners[t].call(e[i.pluginName], n)); + }), r; + } +}; +function ou(e) { + var t = e.sortable, n = e.rootEl, r = e.name, i = e.targetEl, a = e.cloneEl, o = e.toEl, s = e.fromEl, c = e.oldIndex, l = e.newIndex, u = e.oldDraggableIndex, d = e.newDraggableIndex, f = e.originalEvent, p = e.putSortable, m = e.extraEventProperties; + if (t ||= n && n[$l], t) { + var h, g = t.options, _ = "on" + r.charAt(0).toUpperCase() + r.substr(1); + window.CustomEvent && !Sl && !Cl ? h = new CustomEvent(r, { + bubbles: !0, + cancelable: !0 + }) : (h = document.createEvent("Event"), h.initEvent(r, !0, !0)), h.to = o || n, h.from = s || n, h.item = i || n, h.clone = a, h.oldIndex = c, h.newIndex = l, h.oldDraggableIndex = u, h.newDraggableIndex = d, h.originalEvent = f, h.pullMode = p ? p.lastPutMode : void 0; + var v = ml(ml({}, m), au.getEventProperties(r, t)); + for (var y in v) h[y] = v[y]; + n && n.dispatchEvent(h), g[_] && g[_].call(t, h); + } +} +var su = ["evt"], cu = function(e, t) { + var n = arguments.length > 2 && arguments[2] !== void 0 ? arguments[2] : {}, r = n.evt, i = hl(n, su); + au.pluginEvent.bind($)(e, t, ml({ + dragEl: Z, + parentEl: uu, + ghostEl: Q, + rootEl: du, + nextEl: fu, + lastDownEl: pu, + cloneEl: mu, + cloneHidden: hu, + dragStarted: ju, + putSortable: xu, + activeSortable: $.active, + originalEvent: r, + oldIndex: gu, + oldDraggableIndex: vu, + newIndex: _u, + newDraggableIndex: yu, + hideGhostForTarget: Xu, + unhideGhostForTarget: Zu, + cloneNowHidden: function() { + hu = !0; + }, + cloneNowShown: function() { + hu = !1; + }, + dispatchSortableEvent: function(e) { + lu({ + sortable: t, + name: e, + originalEvent: r + }); + } + }, i)); +}; +function lu(e) { + ou(ml({ + putSortable: xu, + cloneEl: mu, + targetEl: Z, + rootEl: du, + oldIndex: gu, + oldDraggableIndex: vu, + newIndex: _u, + newDraggableIndex: yu + }, e)); +} +var Z, uu, Q, du, fu, pu, mu, hu, gu, _u, vu, yu, bu, xu, Su = !1, Cu = !1, wu = [], Tu, Eu, Du, Ou, ku, Au, ju, Mu, Nu, Pu = !1, Fu = !1, Iu, Lu, Ru = [], zu = !1, Bu = [], Vu = typeof document < "u", Hu = El, Uu = Cl || Sl ? "cssFloat" : "float", Wu = Vu && !Dl && !El && "draggable" in document.createElement("div"), Gu = function() { + if (Vu) { + if (Sl) return !1; + var e = document.createElement("x"); + return e.style.cssText = "pointer-events:auto", e.style.pointerEvents === "auto"; + } +}(), Ku = function(e, t) { + var n = X(e), r = parseInt(n.width) - parseInt(n.paddingLeft) - parseInt(n.paddingRight) - parseInt(n.borderLeftWidth) - parseInt(n.borderRightWidth), i = zl(e, 0, t), a = zl(e, 1, t), o = i && X(i), s = a && X(a), c = o && parseInt(o.marginLeft) + parseInt(o.marginRight) + Ll(i).width, l = s && parseInt(s.marginLeft) + parseInt(s.marginRight) + Ll(a).width; + if (n.display === "flex") return n.flexDirection === "column" || n.flexDirection === "column-reverse" ? "vertical" : "horizontal"; + if (n.display === "grid") return n.gridTemplateColumns.split(" ").length <= 1 ? "vertical" : "horizontal"; + if (i && o.float && o.float !== "none") { + var u = o.float === "left" ? "left" : "right"; + return a && (s.clear === "both" || s.clear === u) ? "vertical" : "horizontal"; + } + return i && (o.display === "block" || o.display === "flex" || o.display === "table" || o.display === "grid" || c >= r && n[Uu] === "none" || a && n[Uu] === "none" && c + l > r) ? "vertical" : "horizontal"; +}, qu = function(e, t, n) { + var r = n ? e.left : e.top, i = n ? e.right : e.bottom, a = n ? e.width : e.height, o = n ? t.left : t.top, s = n ? t.right : t.bottom, c = n ? t.width : t.height; + return r === o || i === s || r + a / 2 === o + c / 2; +}, Ju = function(e, t) { + var n; + return wu.some(function(r) { + var i = r[$l].options.emptyInsertThreshold; + if (i && !Bl(r)) { + var a = Ll(r), o = e >= a.left - i && e <= a.right + i, s = t >= a.top - i && t <= a.bottom + i; + if (o && s) return n = r; + } + }), n; +}, Yu = function(e) { + function t(e, n) { + return function(r, i, a, o) { + var s = r.options.group.name && i.options.group.name && r.options.group.name === i.options.group.name; + if (e == null && (n || s)) return !0; + if (e == null || e === !1) return !1; + if (n && e === "clone") return e; + if (typeof e == "function") return t(e(r, i, a, o), n)(r, i, a, o); + var c = (n ? r : i).options.group.name; + return e === !0 || typeof e == "string" && e === c || e.join && e.indexOf(c) > -1; + }; + } + var n = {}, r = e.group; + (!r || yl(r) != "object") && (r = { name: r }), n.name = r.name, n.checkPull = t(r.pull, !0), n.checkPut = t(r.put), n.revertClone = r.revertClone, e.group = n; +}, Xu = function() { + !Gu && Q && X(Q, "display", "none"); +}, Zu = function() { + !Gu && Q && X(Q, "display", ""); +}; +Vu && !Dl && document.addEventListener("click", function(e) { + if (Cu) return e.preventDefault(), e.stopPropagation && e.stopPropagation(), e.stopImmediatePropagation && e.stopImmediatePropagation(), Cu = !1, !1; +}, !0); +var Qu = function(e) { + if (Z) { + e = e.touches ? e.touches[0] : e; + var t = Ju(e.clientX, e.clientY); + if (t) { + var n = {}; + for (var r in e) e.hasOwnProperty(r) && (n[r] = e[r]); + n.target = n.rootEl = t, n.preventDefault = void 0, n.stopPropagation = void 0, t[$l]._onDragOver(n); + } + } +}, $u = function(e) { + Z && Z.parentNode[$l]._isOutsideThisEl(e.target); +}; +function $(e, t) { + if (!(e && e.nodeType && e.nodeType === 1)) throw `Sortable: \`el\` must be an HTMLElement, not ${{}.toString.call(e)}`; + this.el = e, this.options = t = fl({}, t), e[$l] = this; + var n = { + group: null, + sort: !0, + disabled: !1, + store: null, + handle: null, + draggable: /^[uo]l$/i.test(e.nodeName) ? ">li" : ">*", + swapThreshold: 1, + invertSwap: !1, + invertedSwapThreshold: null, + removeCloneOnHide: !0, + direction: function() { + return Ku(e, this.options); + }, + ghostClass: "sortable-ghost", + chosenClass: "sortable-chosen", + dragClass: "sortable-drag", + ignore: "a, img", + filter: null, + preventOnFilter: !0, + animation: 0, + easing: null, + setData: function(e, t) { + e.setData("Text", t.textContent); + }, + dropBubble: !1, + dragoverBubble: !1, + dataIdAttr: "data-id", + delay: 0, + delayOnTouchOnly: !1, + touchStartThreshold: (Number.parseInt ? Number : window).parseInt(window.devicePixelRatio, 10) || 1, + forceFallback: !1, + fallbackClass: "sortable-fallback", + fallbackOnBody: !1, + fallbackTolerance: 0, + fallbackOffset: { + x: 0, + y: 0 + }, + supportPointer: $.supportPointer !== !1 && "PointerEvent" in window && (!Tl || El), + emptyInsertThreshold: 5 + }; + for (var r in au.initializePlugins(this, e, n), n) !(r in t) && (t[r] = n[r]); + for (var i in Yu(t), this) i.charAt(0) === "_" && typeof this[i] == "function" && (this[i] = this[i].bind(this)); + this.nativeDraggable = !t.forceFallback && Wu, this.nativeDraggable && (this.options.touchStartThreshold = 1), t.supportPointer ? J(e, "pointerdown", this._onTapStart) : (J(e, "mousedown", this._onTapStart), J(e, "touchstart", this._onTapStart)), this.nativeDraggable && (J(e, "dragover", this), J(e, "dragenter", this)), wu.push(this.el), t.store && t.store.get && this.sort(t.store.get(this) || []), fl(this, eu()); +} +$.prototype = { + constructor: $, + _isOutsideThisEl: function(e) { + !this.el.contains(e) && e !== this.el && (Mu = null); + }, + _getDirection: function(e, t) { + return typeof this.options.direction == "function" ? this.options.direction.call(this, e, t, Z) : this.options.direction; + }, + _onTapStart: function(e) { + if (e.cancelable) { + var t = this, n = this.el, r = this.options, i = r.preventOnFilter, a = e.type, o = e.touches && e.touches[0] || e.pointerType && e.pointerType === "touch" && e, s = (o || e).target, c = e.target.shadowRoot && (e.path && e.path[0] || e.composedPath && e.composedPath()[0]) || s, l = r.filter; + if (ld(n), !Z && !(/mousedown|pointerdown/.test(a) && e.button !== 0 || r.disabled) && !c.isContentEditable && !(!this.nativeDraggable && Tl && s && s.tagName.toUpperCase() === "SELECT") && (s = jl(s, r.draggable, n, !1), !(s && s.animated) && pu !== s)) { + if (gu = Vl(s), vu = Vl(s, r.draggable), typeof l == "function") { + if (l.call(this, e, s, this)) { + lu({ + sortable: t, + rootEl: c, + name: "filter", + targetEl: s, + toEl: n, + fromEl: n + }), cu("filter", t, { evt: e }), i && e.preventDefault(); + return; + } + } else if (l && (l = l.split(",").some(function(r) { + if (r = jl(c, r.trim(), n, !1), r) return lu({ + sortable: t, + rootEl: r, + name: "filter", + targetEl: s, + fromEl: n, + toEl: n + }), cu("filter", t, { evt: e }), !0; + }), l)) { + i && e.preventDefault(); + return; + } + (!r.handle || jl(c, r.handle, n, !1)) && this._prepareDragStart(e, o, s); + } + } + }, + _prepareDragStart: function(e, t, n) { + var r = this, i = r.el, a = r.options, o = i.ownerDocument, s; + if (n && !Z && n.parentNode === i) { + var c = Ll(n); + if (du = i, Z = n, uu = Z.parentNode, fu = Z.nextSibling, pu = n, bu = a.group, $.dragged = Z, Tu = { + target: Z, + clientX: (t || e).clientX, + clientY: (t || e).clientY + }, ku = Tu.clientX - c.left, Au = Tu.clientY - c.top, this._lastX = (t || e).clientX, this._lastY = (t || e).clientY, Z.style["will-change"] = "all", s = function() { + if (cu("delayEnded", r, { evt: e }), $.eventCanceled) { + r._onDrop(); + return; + } + r._disableDelayedDragEvents(), !wl && r.nativeDraggable && (Z.draggable = !0), r._triggerDragStart(e, t), lu({ + sortable: r, + name: "choose", + originalEvent: e + }), Nl(Z, a.chosenClass, !0); + }, a.ignore.split(",").forEach(function(e) { + Fl(Z, e.trim(), nd); + }), J(o, "dragover", Qu), J(o, "mousemove", Qu), J(o, "touchmove", Qu), a.supportPointer ? (J(o, "pointerup", r._onDrop), !this.nativeDraggable && J(o, "pointercancel", r._onDrop)) : (J(o, "mouseup", r._onDrop), J(o, "touchend", r._onDrop), J(o, "touchcancel", r._onDrop)), wl && this.nativeDraggable && (this.options.touchStartThreshold = 4, Z.draggable = !0), cu("delayStart", this, { evt: e }), a.delay && (!a.delayOnTouchOnly || t) && (!this.nativeDraggable || !(Cl || Sl))) { + if ($.eventCanceled) { + this._onDrop(); + return; + } + a.supportPointer ? (J(o, "pointerup", r._disableDelayedDrag), J(o, "pointercancel", r._disableDelayedDrag)) : (J(o, "mouseup", r._disableDelayedDrag), J(o, "touchend", r._disableDelayedDrag), J(o, "touchcancel", r._disableDelayedDrag)), J(o, "mousemove", r._delayedDragTouchMoveHandler), J(o, "touchmove", r._delayedDragTouchMoveHandler), a.supportPointer && J(o, "pointermove", r._delayedDragTouchMoveHandler), r._dragStartTimer = setTimeout(s, a.delay); + } else s(); + } + }, + _delayedDragTouchMoveHandler: function(e) { + var t = e.touches ? e.touches[0] : e; + Math.max(Math.abs(t.clientX - this._lastX), Math.abs(t.clientY - this._lastY)) >= Math.floor(this.options.touchStartThreshold / (this.nativeDraggable && window.devicePixelRatio || 1)) && this._disableDelayedDrag(); + }, + _disableDelayedDrag: function() { + Z && nd(Z), clearTimeout(this._dragStartTimer), this._disableDelayedDragEvents(); + }, + _disableDelayedDragEvents: function() { + var e = this.el.ownerDocument; + Y(e, "mouseup", this._disableDelayedDrag), Y(e, "touchend", this._disableDelayedDrag), Y(e, "touchcancel", this._disableDelayedDrag), Y(e, "pointerup", this._disableDelayedDrag), Y(e, "pointercancel", this._disableDelayedDrag), Y(e, "mousemove", this._delayedDragTouchMoveHandler), Y(e, "touchmove", this._delayedDragTouchMoveHandler), Y(e, "pointermove", this._delayedDragTouchMoveHandler); + }, + _triggerDragStart: function(e, t) { + t ||= e.pointerType == "touch" && e, !this.nativeDraggable || t ? this.options.supportPointer ? J(document, "pointermove", this._onTouchMove) : t ? J(document, "touchmove", this._onTouchMove) : J(document, "mousemove", this._onTouchMove) : (J(Z, "dragend", this), J(du, "dragstart", this._onDragStart)); + try { + document.selection ? ud(function() { + document.selection.empty(); + }) : window.getSelection().removeAllRanges(); + } catch {} + }, + _dragStarted: function(e, t) { + if (Su = !1, du && Z) { + cu("dragStarted", this, { evt: t }), this.nativeDraggable && J(document, "dragover", $u); + var n = this.options; + !e && Nl(Z, n.dragClass, !1), Nl(Z, n.ghostClass, !0), $.active = this, e && this._appendGhost(), lu({ + sortable: this, + name: "start", + originalEvent: t + }); + } else this._nulling(); + }, + _emulateDragOver: function() { + if (Eu) { + this._lastX = Eu.clientX, this._lastY = Eu.clientY, Xu(); + for (var e = document.elementFromPoint(Eu.clientX, Eu.clientY), t = e; e && e.shadowRoot && (e = e.shadowRoot.elementFromPoint(Eu.clientX, Eu.clientY), e !== t);) t = e; + if (Z.parentNode[$l]._isOutsideThisEl(e), t) do { + if (t[$l]) { + var n = void 0; + if (n = t[$l]._onDragOver({ + clientX: Eu.clientX, + clientY: Eu.clientY, + target: e, + rootEl: t + }), n && !this.options.dragoverBubble) break; + } + e = t; + } while (t = Al(t)); + Zu(); + } + }, + _onTouchMove: function(e) { + if (Tu) { + var t = this.options, n = t.fallbackTolerance, r = t.fallbackOffset, i = e.touches ? e.touches[0] : e, a = Q && Pl(Q, !0), o = Q && a && a.a, s = Q && a && a.d, c = Hu && Lu && Hl(Lu), l = (i.clientX - Tu.clientX + r.x) / (o || 1) + (c ? c[0] - Ru[0] : 0) / (o || 1), u = (i.clientY - Tu.clientY + r.y) / (s || 1) + (c ? c[1] - Ru[1] : 0) / (s || 1); + if (!$.active && !Su) { + if (n && Math.max(Math.abs(i.clientX - this._lastX), Math.abs(i.clientY - this._lastY)) < n) return; + this._onDragStart(e, !0); + } + if (Q) { + a ? (a.e += l - (Du || 0), a.f += u - (Ou || 0)) : a = { + a: 1, + b: 0, + c: 0, + d: 1, + e: l, + f: u + }; + var d = `matrix(${a.a},${a.b},${a.c},${a.d},${a.e},${a.f})`; + X(Q, "webkitTransform", d), X(Q, "mozTransform", d), X(Q, "msTransform", d), X(Q, "transform", d), Du = l, Ou = u, Eu = i; + } + e.cancelable && e.preventDefault(); + } + }, + _appendGhost: function() { + if (!Q) { + var e = this.options.fallbackOnBody ? document.body : du, t = Ll(Z, !0, Hu, !0, e), n = this.options; + if (Hu) { + for (Lu = e; X(Lu, "position") === "static" && X(Lu, "transform") === "none" && Lu !== document;) Lu = Lu.parentNode; + Lu !== document.body && Lu !== document.documentElement ? (Lu === document && (Lu = Il()), t.top += Lu.scrollTop, t.left += Lu.scrollLeft) : Lu = Il(), Ru = Hl(Lu); + } + Q = Z.cloneNode(!0), Nl(Q, n.ghostClass, !1), Nl(Q, n.fallbackClass, !0), Nl(Q, n.dragClass, !0), X(Q, "transition", ""), X(Q, "transform", ""), X(Q, "box-sizing", "border-box"), X(Q, "margin", 0), X(Q, "top", t.top), X(Q, "left", t.left), X(Q, "width", t.width), X(Q, "height", t.height), X(Q, "opacity", "0.8"), X(Q, "position", Hu ? "absolute" : "fixed"), X(Q, "zIndex", "100000"), X(Q, "pointerEvents", "none"), $.ghost = Q, e.appendChild(Q), X(Q, "transform-origin", ku / parseInt(Q.style.width) * 100 + "% " + Au / parseInt(Q.style.height) * 100 + "%"); + } + }, + _onDragStart: function(e, t) { + var n = this, r = e.dataTransfer, i = n.options; + if (cu("dragStart", this, { evt: e }), $.eventCanceled) { + this._onDrop(); + return; + } + cu("setupClone", this), $.eventCanceled || (mu = Zl(Z), mu.removeAttribute("id"), mu.draggable = !1, mu.style["will-change"] = "", this._hideClone(), Nl(mu, this.options.chosenClass, !1), $.clone = mu), n.cloneId = ud(function() { + cu("clone", n), !$.eventCanceled && (n.options.removeCloneOnHide || du.insertBefore(mu, Z), n._hideClone(), lu({ + sortable: n, + name: "clone" + })); + }), !t && Nl(Z, i.dragClass, !0), t ? (Cu = !0, n._loopId = setInterval(n._emulateDragOver, 50)) : (Y(document, "mouseup", n._onDrop), Y(document, "touchend", n._onDrop), Y(document, "touchcancel", n._onDrop), r && (r.effectAllowed = "move", i.setData && i.setData.call(n, r, Z)), J(document, "drop", n), X(Z, "transform", "translateZ(0)")), Su = !0, n._dragStartId = ud(n._dragStarted.bind(n, t, e)), J(document, "selectstart", n), ju = !0, window.getSelection().removeAllRanges(), Tl && X(document.body, "user-select", "none"); + }, + _onDragOver: function(e) { + var t = this.el, n = e.target, r, i, a, o = this.options, s = o.group, c = $.active, l = bu === s, u = o.sort, d = xu || c, f, p = this, m = !1; + if (zu) return; + function h(o, s) { + cu(o, p, ml({ + evt: e, + isOwner: l, + axis: f ? "vertical" : "horizontal", + revert: a, + dragRect: r, + targetRect: i, + canSort: u, + fromSortable: d, + target: n, + completed: _, + onMove: function(n, i) { + return td(du, t, Z, r, n, Ll(n), e, i); + }, + changed: v + }, s)); + } + function g() { + h("dragOverAnimationCapture"), p.captureAnimationState(), p !== d && d.captureAnimationState(); + } + function _(r) { + return h("dragOverCompleted", { insertion: r }), r && (l ? c._hideClone() : c._showClone(p), p !== d && (Nl(Z, xu ? xu.options.ghostClass : c.options.ghostClass, !1), Nl(Z, o.ghostClass, !0)), xu !== p && p !== $.active ? xu = p : p === $.active && xu && (xu = null), d === p && (p._ignoreWhileAnimating = n), p.animateAll(function() { + h("dragOverAnimationComplete"), p._ignoreWhileAnimating = null; + }), p !== d && (d.animateAll(), d._ignoreWhileAnimating = null)), (n === Z && !Z.animated || n === t && !n.animated) && (Mu = null), !o.dragoverBubble && !e.rootEl && n !== document && (Z.parentNode[$l]._isOutsideThisEl(e.target), !r && Qu(e)), !o.dragoverBubble && e.stopPropagation && e.stopPropagation(), m = !0; + } + function v() { + _u = Vl(Z), yu = Vl(Z, o.draggable), lu({ + sortable: p, + name: "change", + toEl: t, + newIndex: _u, + newDraggableIndex: yu, + originalEvent: e + }); + } + if (e.preventDefault !== void 0 && e.cancelable && e.preventDefault(), n = jl(n, o.draggable, t, !0), h("dragOver"), $.eventCanceled) return m; + if (Z.contains(e.target) || n.animated && n.animatingX && n.animatingY || p._ignoreWhileAnimating === n) return _(!1); + if (Cu = !1, c && !o.disabled && (l ? u || (a = uu !== du) : xu === this || (this.lastPutMode = bu.checkPull(this, c, Z, e)) && s.checkPut(this, c, Z, e))) { + if (f = this._getDirection(e, n) === "vertical", r = Ll(Z), h("dragOverValid"), $.eventCanceled) return m; + if (a) return uu = du, g(), this._hideClone(), h("revert"), $.eventCanceled || (fu ? du.insertBefore(Z, fu) : du.appendChild(Z)), _(!0); + var y = Bl(t, o.draggable); + if (!y || ad(e, f, this) && !y.animated) { + if (y === Z) return _(!1); + if (y && t === e.target && (n = y), n && (i = Ll(n)), td(du, t, Z, r, n, i, e, !!n) !== !1) return g(), y && y.nextSibling ? t.insertBefore(Z, y.nextSibling) : t.appendChild(Z), uu = t, v(), _(!0); + } else if (y && id(e, f, this)) { + var b = zl(t, 0, o, !0); + if (b === Z) return _(!1); + if (n = b, i = Ll(n), td(du, t, Z, r, n, i, e, !1) !== !1) return g(), t.insertBefore(Z, b), uu = t, v(), _(!0); + } else if (n.parentNode === t) { + i = Ll(n); + var x = 0, S, C = Z.parentNode !== t, w = !qu(Z.animated && Z.toRect || r, n.animated && n.toRect || i, f), T = f ? "top" : "left", E = Rl(n, "top", "top") || Rl(Z, "top", "top"), D = E ? E.scrollTop : void 0; + Mu !== n && (S = i[T], Pu = !1, Fu = !w && o.invertSwap || C), x = od(e, n, i, f, w ? 1 : o.swapThreshold, o.invertedSwapThreshold == null ? o.swapThreshold : o.invertedSwapThreshold, Fu, Mu === n); + var O; + if (x !== 0) { + var k = Vl(Z); + do + k -= x, O = uu.children[k]; + while (O && (X(O, "display") === "none" || O === Q)); + } + if (x === 0 || O === n) return _(!1); + Mu = n, Nu = x; + var A = n.nextElementSibling, j = !1; + j = x === 1; + var M = td(du, t, Z, r, n, i, e, j); + if (M !== !1) return (M === 1 || M === -1) && (j = M === 1), zu = !0, setTimeout(rd, 30), g(), j && !A ? t.appendChild(Z) : n.parentNode.insertBefore(Z, j ? A : n), E && Xl(E, 0, D - E.scrollTop), uu = Z.parentNode, S !== void 0 && !Fu && (Iu = Math.abs(S - Ll(n)[T])), v(), _(!0); + } + if (t.contains(Z)) return _(!1); + } + return !1; + }, + _ignoreWhileAnimating: null, + _offMoveEvents: function() { + Y(document, "mousemove", this._onTouchMove), Y(document, "touchmove", this._onTouchMove), Y(document, "pointermove", this._onTouchMove), Y(document, "dragover", Qu), Y(document, "mousemove", Qu), Y(document, "touchmove", Qu); + }, + _offUpEvents: function() { + var e = this.el.ownerDocument; + Y(e, "mouseup", this._onDrop), Y(e, "touchend", this._onDrop), Y(e, "pointerup", this._onDrop), Y(e, "pointercancel", this._onDrop), Y(e, "touchcancel", this._onDrop), Y(document, "selectstart", this); + }, + _onDrop: function(e) { + var t = this.el, n = this.options; + if (_u = Vl(Z), yu = Vl(Z, n.draggable), cu("drop", this, { evt: e }), uu = Z && Z.parentNode, _u = Vl(Z), yu = Vl(Z, n.draggable), $.eventCanceled) { + this._nulling(); + return; + } + Su = !1, Fu = !1, Pu = !1, clearInterval(this._loopId), clearTimeout(this._dragStartTimer), dd(this.cloneId), dd(this._dragStartId), this.nativeDraggable && (Y(document, "drop", this), Y(t, "dragstart", this._onDragStart)), this._offMoveEvents(), this._offUpEvents(), Tl && X(document.body, "user-select", ""), X(Z, "transform", ""), e && (ju && (e.cancelable && e.preventDefault(), !n.dropBubble && e.stopPropagation()), Q && Q.parentNode && Q.parentNode.removeChild(Q), (du === uu || xu && xu.lastPutMode !== "clone") && mu && mu.parentNode && mu.parentNode.removeChild(mu), Z && (this.nativeDraggable && Y(Z, "dragend", this), nd(Z), Z.style["will-change"] = "", ju && !Su && Nl(Z, xu ? xu.options.ghostClass : this.options.ghostClass, !1), Nl(Z, this.options.chosenClass, !1), lu({ + sortable: this, + name: "unchoose", + toEl: uu, + newIndex: null, + newDraggableIndex: null, + originalEvent: e + }), du === uu ? _u !== gu && _u >= 0 && (lu({ + sortable: this, + name: "update", + toEl: uu, + originalEvent: e + }), lu({ + sortable: this, + name: "sort", + toEl: uu, + originalEvent: e + })) : (_u >= 0 && (lu({ + rootEl: uu, + name: "add", + toEl: uu, + fromEl: du, + originalEvent: e + }), lu({ + sortable: this, + name: "remove", + toEl: uu, + originalEvent: e + }), lu({ + rootEl: uu, + name: "sort", + toEl: uu, + fromEl: du, + originalEvent: e + }), lu({ + sortable: this, + name: "sort", + toEl: uu, + originalEvent: e + })), xu && xu.save()), $.active && ((_u == null || _u === -1) && (_u = gu, yu = vu), lu({ + sortable: this, + name: "end", + toEl: uu, + originalEvent: e + }), this.save()))), this._nulling(); + }, + _nulling: function() { + cu("nulling", this), du = Z = uu = Q = fu = mu = pu = hu = Tu = Eu = ju = _u = yu = gu = vu = Mu = Nu = xu = bu = $.dragged = $.ghost = $.clone = $.active = null; + var e = this.el; + Bu.forEach(function(t) { + e.contains(t) && (t.checked = !0); + }), Bu.length = Du = Ou = 0; + }, + handleEvent: function(e) { + switch (e.type) { + case "drop": + case "dragend": + this._onDrop(e); + break; + case "dragenter": + case "dragover": + Z && (this._onDragOver(e), ed(e)); + break; + case "selectstart": e.preventDefault(); + } + }, + toArray: function() { + for (var e = [], t, n = this.el.children, r = 0, i = n.length, a = this.options; r < i; r++) t = n[r], jl(t, a.draggable, this.el, !1) && e.push(t.getAttribute(a.dataIdAttr) || cd(t)); + return e; + }, + sort: function(e, t) { + var n = {}, r = this.el; + this.toArray().forEach(function(e, t) { + var i = r.children[t]; + jl(i, this.options.draggable, r, !1) && (n[e] = i); + }, this), t && this.captureAnimationState(), e.forEach(function(e) { + n[e] && (r.removeChild(n[e]), r.appendChild(n[e])); + }), t && this.animateAll(); + }, + save: function() { + var e = this.options.store; + e && e.set && e.set(this); + }, + closest: function(e, t) { + return jl(e, t || this.options.draggable, this.el, !1); + }, + option: function(e, t) { + var n = this.options; + if (t === void 0) return n[e]; + var r = au.modifyOption(this, e, t); + n[e] = r === void 0 ? t : r, e === "group" && Yu(n); + }, + destroy: function() { + cu("destroy", this); + var e = this.el; + e[$l] = null, Y(e, "mousedown", this._onTapStart), Y(e, "touchstart", this._onTapStart), Y(e, "pointerdown", this._onTapStart), this.nativeDraggable && (Y(e, "dragover", this), Y(e, "dragenter", this)), Array.prototype.forEach.call(e.querySelectorAll("[draggable]"), function(e) { + e.removeAttribute("draggable"); + }), this._onDrop(), this._disableDelayedDragEvents(), wu.splice(wu.indexOf(this.el), 1), this.el = e = null; + }, + _hideClone: function() { + if (!hu) { + if (cu("hideClone", this), $.eventCanceled) return; + X(mu, "display", "none"), this.options.removeCloneOnHide && mu.parentNode && mu.parentNode.removeChild(mu), hu = !0; + } + }, + _showClone: function(e) { + if (e.lastPutMode !== "clone") { + this._hideClone(); + return; + } + if (hu) { + if (cu("showClone", this), $.eventCanceled) return; + Z.parentNode == du && !this.options.group.revertClone ? du.insertBefore(mu, Z) : fu ? du.insertBefore(mu, fu) : du.appendChild(mu), this.options.group.revertClone && this.animate(Z, mu), X(mu, "display", ""), hu = !1; + } + } +}; +function ed(e) { + e.dataTransfer && (e.dataTransfer.dropEffect = "move"), e.cancelable && e.preventDefault(); +} +function td(e, t, n, r, i, a, o, s) { + var c, l = e[$l], u = l.options.onMove, d; + return window.CustomEvent && !Sl && !Cl ? c = new CustomEvent("move", { + bubbles: !0, + cancelable: !0 + }) : (c = document.createEvent("Event"), c.initEvent("move", !0, !0)), c.to = t, c.from = e, c.dragged = n, c.draggedRect = r, c.related = i || t, c.relatedRect = a || Ll(t), c.willInsertAfter = s, c.originalEvent = o, e.dispatchEvent(c), u && (d = u.call(l, c, o)), d; +} +function nd(e) { + e.draggable = !1; +} +function rd() { + zu = !1; +} +function id(e, t, n) { + var r = Ll(zl(n.el, 0, n.options, !0)), i = Ql(n.el, n.options, Q), a = 10; + return t ? e.clientX < i.left - a || e.clientY < r.top && e.clientX < r.right : e.clientY < i.top - a || e.clientY < r.bottom && e.clientX < r.left; +} +function ad(e, t, n) { + var r = Ll(Bl(n.el, n.options.draggable)), i = Ql(n.el, n.options, Q), a = 10; + return t ? e.clientX > i.right + a || e.clientY > r.bottom && e.clientX > r.left : e.clientY > i.bottom + a || e.clientX > r.right && e.clientY > r.top; +} +function od(e, t, n, r, i, a, o, s) { + var c = r ? e.clientY : e.clientX, l = r ? n.height : n.width, u = r ? n.top : n.left, d = r ? n.bottom : n.right, f = !1; + if (!o) { + if (s && Iu < l * i) { + if (!Pu && (Nu === 1 ? c > u + l * a / 2 : c < d - l * a / 2) && (Pu = !0), Pu) f = !0; + else if (Nu === 1 ? c < u + Iu : c > d - Iu) return -Nu; + } else if (c > u + l * (1 - i) / 2 && c < d - l * (1 - i) / 2) return sd(t); + } + return f ||= o, f && (c < u + l * a / 2 || c > d - l * a / 2) ? c > u + l / 2 ? 1 : -1 : 0; +} +function sd(e) { + return Vl(Z) < Vl(e) ? 1 : -1; +} +function cd(e) { + for (var t = e.tagName + e.className + e.src + e.href + e.textContent, n = t.length, r = 0; n--;) r += t.charCodeAt(n); + return r.toString(36); +} +function ld(e) { + Bu.length = 0; + for (var t = e.getElementsByTagName("input"), n = t.length; n--;) { + var r = t[n]; + r.checked && Bu.push(r); + } +} +function ud(e) { + return setTimeout(e, 0); +} +function dd(e) { + return clearTimeout(e); +} +Vu && J(document, "touchmove", function(e) { + ($.active || Su) && e.cancelable && e.preventDefault(); +}), $.utils = { + on: J, + off: Y, + css: X, + find: Fl, + is: function(e, t) { + return !!jl(e, t, e, !1); + }, + extend: Gl, + throttle: Jl, + closest: jl, + toggleClass: Nl, + clone: Zl, + index: Vl, + nextTick: ud, + cancelNextTick: dd, + detectDirection: Ku, + getChild: zl, + expando: $l +}, $.get = function(e) { + return e[$l]; +}, $.mount = function() { + var e = [...arguments]; + e[0].constructor === Array && (e = e[0]), e.forEach(function(e) { + if (!e.prototype || !e.prototype.constructor) throw `Sortable: Mounted plugin must be a constructor function, not ${{}.toString.call(e)}`; + e.utils && ($.utils = ml(ml({}, $.utils), e.utils)), au.mount(e); + }); +}, $.create = function(e, t) { + return new $(e, t); +}, $.version = bl; +var fd = [], pd, md, hd = !1, gd, _d, vd, yd; +function bd() { + function e() { + for (var e in this.defaults = { + scroll: !0, + forceAutoScrollFallback: !1, + scrollSensitivity: 30, + scrollSpeed: 10, + bubbleScroll: !0 + }, this) e.charAt(0) === "_" && typeof this[e] == "function" && (this[e] = this[e].bind(this)); + } + return e.prototype = { + dragStarted: function(e) { + var t = e.originalEvent; + this.sortable.nativeDraggable ? J(document, "dragover", this._handleAutoScroll) : this.options.supportPointer ? J(document, "pointermove", this._handleFallbackAutoScroll) : t.touches ? J(document, "touchmove", this._handleFallbackAutoScroll) : J(document, "mousemove", this._handleFallbackAutoScroll); + }, + dragOverCompleted: function(e) { + var t = e.originalEvent; + !this.options.dragOverBubble && !t.rootEl && this._handleAutoScroll(t); + }, + drop: function() { + this.sortable.nativeDraggable ? Y(document, "dragover", this._handleAutoScroll) : (Y(document, "pointermove", this._handleFallbackAutoScroll), Y(document, "touchmove", this._handleFallbackAutoScroll), Y(document, "mousemove", this._handleFallbackAutoScroll)), Sd(), xd(), Yl(); + }, + nulling: function() { + vd = md = pd = hd = yd = gd = _d = null, fd.length = 0; + }, + _handleFallbackAutoScroll: function(e) { + this._handleAutoScroll(e, !0); + }, + _handleAutoScroll: function(e, t) { + var n = this, r = (e.touches ? e.touches[0] : e).clientX, i = (e.touches ? e.touches[0] : e).clientY, a = document.elementFromPoint(r, i); + if (vd = e, t || this.options.forceAutoScrollFallback || Cl || Sl || Tl) { + Cd(e, this.options, a, t); + var o = Wl(a, !0); + hd && (!yd || r !== gd || i !== _d) && (yd && Sd(), yd = setInterval(function() { + var a = Wl(document.elementFromPoint(r, i), !0); + a !== o && (o = a, xd()), Cd(e, n.options, a, t); + }, 10), gd = r, _d = i); + } else { + if (!this.options.bubbleScroll || Wl(a, !0) === Il()) { + xd(); + return; + } + Cd(e, this.options, Wl(a, !1), !1); + } + } + }, fl(e, { + pluginName: "scroll", + initializeByDefault: !0 + }); +} +function xd() { + fd.forEach(function(e) { + clearInterval(e.pid); + }), fd = []; +} +function Sd() { + clearInterval(yd); +} +var Cd = Jl(function(e, t, n, r) { + if (t.scroll) { + var i = (e.touches ? e.touches[0] : e).clientX, a = (e.touches ? e.touches[0] : e).clientY, o = t.scrollSensitivity, s = t.scrollSpeed, c = Il(), l = !1, u; + md !== n && (md = n, xd(), pd = t.scroll, u = t.scrollFn, pd === !0 && (pd = Wl(n, !0))); + var d = 0, f = pd; + do { + var p = f, m = Ll(p), h = m.top, g = m.bottom, _ = m.left, v = m.right, y = m.width, b = m.height, x = void 0, S = void 0, C = p.scrollWidth, w = p.scrollHeight, T = X(p), E = p.scrollLeft, D = p.scrollTop; + p === c ? (x = y < C && (T.overflowX === "auto" || T.overflowX === "scroll" || T.overflowX === "visible"), S = b < w && (T.overflowY === "auto" || T.overflowY === "scroll" || T.overflowY === "visible")) : (x = y < C && (T.overflowX === "auto" || T.overflowX === "scroll"), S = b < w && (T.overflowY === "auto" || T.overflowY === "scroll")); + var O = x && (Math.abs(v - i) <= o && E + y < C) - (Math.abs(_ - i) <= o && !!E), k = S && (Math.abs(g - a) <= o && D + b < w) - (Math.abs(h - a) <= o && !!D); + if (!fd[d]) for (var A = 0; A <= d; A++) fd[A] || (fd[A] = {}); + (fd[d].vx != O || fd[d].vy != k || fd[d].el !== p) && (fd[d].el = p, fd[d].vx = O, fd[d].vy = k, clearInterval(fd[d].pid), (O != 0 || k != 0) && (l = !0, fd[d].pid = setInterval(function() { + r && this.layer === 0 && $.active._onTouchMove(vd); + var t = fd[this.layer].vy ? fd[this.layer].vy * s : 0, n = fd[this.layer].vx ? fd[this.layer].vx * s : 0; + (typeof u != "function" || u.call($.dragged.parentNode[$l], n, t, e, vd, fd[this.layer].el) === "continue") && Xl(fd[this.layer].el, n, t); + }.bind({ layer: d }), 24))), d++; + } while (t.bubbleScroll && f !== c && (f = Wl(f, !1))); + hd = l; + } +}, 30), wd = function(e) { + var t = e.originalEvent, n = e.putSortable, r = e.dragEl, i = e.activeSortable, a = e.dispatchSortableEvent, o = e.hideGhostForTarget, s = e.unhideGhostForTarget; + if (t) { + var c = n || i; + o(); + var l = t.changedTouches && t.changedTouches.length ? t.changedTouches[0] : t, u = document.elementFromPoint(l.clientX, l.clientY); + s(), c && !c.el.contains(u) && (a("spill"), this.onSpill({ + dragEl: r, + putSortable: n + })); + } +}; +function Td() {} +Td.prototype = { + startIndex: null, + dragStart: function(e) { + var t = e.oldDraggableIndex; + this.startIndex = t; + }, + onSpill: function(e) { + var t = e.dragEl, n = e.putSortable; + this.sortable.captureAnimationState(), n && n.captureAnimationState(); + var r = zl(this.sortable.el, this.startIndex, this.options); + r ? this.sortable.el.insertBefore(t, r) : this.sortable.el.appendChild(t), this.sortable.animateAll(), n && n.animateAll(); + }, + drop: wd +}, fl(Td, { pluginName: "revertOnSpill" }); +function Ed() {} +Ed.prototype = { + onSpill: function(e) { + var t = e.dragEl, n = e.putSortable || this.sortable; + n.captureAnimationState(), t.parentNode && t.parentNode.removeChild(t), n.animateAll(); + }, + drop: wd +}, fl(Ed, { pluginName: "removeOnSpill" }), $.mount(new bd()), $.mount(Ed, Td); +//#endregion +//#region resources/js/components/TaskCard.vue?vue&type=script&setup=true&lang.ts +var Dd = { class: "flex items-start justify-between gap-2" }, Od = { class: "text-sm font-medium text-heading" }, kd = { class: "mt-2 flex flex-wrap items-center gap-2 text-xs text-muted" }, Ad = ["title"], jd = { class: "mt-3 flex items-center justify-between gap-2" }, Md = { + key: 0, + class: "text-xs tabular-nums text-muted" +}, Nd = { class: "mt-2 flex items-center justify-between" }, Pd = { + key: 1, + class: "text-xs text-subtle" +}, Fd = ["title"], Id = /* @__PURE__ */ l({ + __name: "TaskCard", + props: { + client: {}, + notify: {}, + task: {}, + projects: { default: () => [] }, + members: { default: () => [] } + }, + emits: ["open"], + setup(e, { emit: t }) { + let r = e, s = t, l = { + LOW: "bg-surface-tertiary text-muted", + NORMAL: "bg-primary-50 text-primary-500", + HIGH: "bg-alert-warning-bg text-alert-warning-text", + URGENT: "bg-alert-error-bg text-alert-error-text" + }, u = B(), d = n(() => nt(r.task)), f = n(() => r.projects.find((e) => e.id === r.task.project_id)), m = n(() => r.task.project_id === null ? null : f.value?.identifier || f.value?.name || null), h = n(() => [f.value?.name, $t(r.task.customer_id)].filter(Boolean).join(" · ")), g = n(() => r.members.find((e) => e.id === r.task.assignee_id)), _ = n(() => r.task.assignee_id === null ? null : g.value ? pt(g.value.name) : `#${r.task.assignee_id}`), y = n(() => g.value?.name ?? (r.task.assignee_id === null ? u("tasks_projects.tasks.unassigned") : `#${r.task.assignee_id}`)), b = n(() => Na(d.value.logged_minutes)); + function x(e) { + return u(`tasks_projects.tasks.priority.${e.toLowerCase()}`); + } + return (t, n) => (v(), a("article", { + class: "cursor-pointer rounded-lg border border-line-default bg-surface p-3 shadow-sm hover:bg-hover", + onClick: n[1] ||= (t) => s("open", e.task) + }, [ + o("div", Dd, [o("p", Od, C(e.task.name), 1), e.task.priority ? (v(), a("span", { + key: 0, + class: p(["shrink-0 rounded-full px-2 py-0.5 text-[11px] font-medium", l[e.task.priority]]) + }, C(x(e.task.priority)), 3)) : i("", !0)]), + o("div", kd, [ + o("span", null, "#" + C(e.task.number), 1), + m.value ? (v(), a("span", { + key: 0, + class: "rounded-sm bg-surface-tertiary px-1.5 py-0.5 text-[11px] text-body", + title: h.value + }, C(m.value), 9, Ad)) : i("", !0), + c(da, { state: d.value.invoiced }, null, 8, ["state"]) + ]), + o("div", jd, [c(go, { + client: e.client, + notify: e.notify, + task: e.task, + members: e.members, + onClick: n[0] ||= A(() => {}, ["stop"]) + }, null, 8, [ + "client", + "notify", + "task", + "members" + ]), d.value.logged_minutes > 0 ? (v(), a("span", Md, C(b.value), 1)) : i("", !0)]), + o("div", Nd, [e.task.due_date ? (v(), a("span", { + key: 0, + class: p(["text-xs", w(mt)(e.task.due_date) && !e.task.closed_at ? "font-medium text-status-red" : "text-muted"]) + }, C(w(ut)(e.task.due_date)), 3)) : (v(), a("span", Pd, "-")), _.value ? (v(), a("span", { + key: 2, + class: "flex h-6 w-6 items-center justify-center rounded-full bg-primary-50 text-[11px] font-semibold text-primary-500", + title: y.value + }, C(_.value), 9, Fd)) : i("", !0)]) + ])); + } +}), Ld = { class: "mt-4" }, Rd = { + key: 0, + class: "mb-3 text-xs text-subtle" +}, zd = { + key: 1, + class: "flex justify-center py-16" +}, Bd = { + key: 3, + class: "flex items-start gap-4 overflow-x-auto pb-4" +}, Vd = { class: "flex items-center justify-between border-b border-line-light px-3 py-2.5" }, Hd = { class: "flex items-center" }, Ud = { class: "text-sm font-semibold text-heading" }, Wd = { class: "ml-2 text-xs text-muted" }, Gd = [ + "aria-label", + "title", + "onClick" +], Kd = ["data-status-id"], qd = { + key: 0, + class: "px-3 pt-2 text-xs text-subtle" +}, Jd = { class: "px-3 pt-2 pb-3" }, Yd = ["onClick"], Xd = /* @__PURE__ */ l({ + __name: "TasksBoardView", + props: { + client: { type: [Function, Object] }, + notify: { type: Function }, + filters: {}, + statuses: {}, + members: {}, + projects: {} + }, + setup(t, { expose: s }) { + let l = t, u = B(), d = b([]), f = b(!0), g = b(!1), _ = b(null), y = b({}), T = /* @__PURE__ */ new Map(), O = /* @__PURE__ */ new Map(), k = !1, A = n(() => l.projects.map((e) => ({ + id: e.id, + label: e.name + }))), j = n(() => !f.value && d.value.length === 0), M = n(() => G.settings.hide_invoiced_on_board); + function N(e) { + return (e.tasks ?? []).filter((e) => { + let t = nt(e).invoiced; + return M.value && t === "invoiced" ? !1 : zi(l.filters.status) ? l.filters.status === "invoiced" ? t === "invoiced" : t !== "invoiced" : !0; + }); + } + E(() => `${l.filters.project}|${l.filters.user}`, () => void P(), { immediate: !0 }), E(et, () => void P()), h(() => { + for (let e of T.values()) e.destroy(); + T.clear(), O.clear(); + }); + async function P() { + let e = {}, t = Ki(l.filters.project), n = Ki(l.filters.user); + t !== null && (e.project_id = t), n !== null && (e.assignee_id = n), f.value = !0; + try { + d.value = await ar(l.client, e), d.value.some((e) => e.tasks.some((e) => e.customer_id !== null)) && en(l.client); + } catch (e) { + l.notify("error", H(e, u("tasks_projects.board.load_failed"))); + } finally { + f.value = !1; + } + } + function F(e, t) { + let n = t instanceof HTMLElement ? t : null; + O.get(e) !== n && (T.get(e)?.destroy(), T.delete(e), O.delete(e), n !== null && (O.set(e, n), T.set(e, $.create(n, { + group: "tasks", + animation: 150, + draggable: "[data-task-id]", + ghostClass: "opacity-40", + onStart: () => { + k = !0; + }, + onEnd: (e) => { + R(e), setTimeout(() => { + k = !1; + }); + } + })))); + } + function I(e) { + let t = e.item, n = e.oldIndex ?? 0; + t.parentNode?.removeChild(t), e.from.insertBefore(t, e.from.children[n] ?? null); + } + function L(e) { + return d.value.find((t) => t.status.id === e); + } + async function R(e) { + let t = Number(e.from.dataset.statusId), n = Number(e.to.dataset.statusId), r = e.oldIndex ?? 0, i = e.newIndex ?? 0; + if (I(e), Number.isNaN(t) || Number.isNaN(n) || t === n && r === i) return; + let a = L(t), o = L(n); + if (!a || !o) return; + let s = N(a)[r], c = s ? a.tasks.findIndex((e) => e.id === s.id) : -1; + if (!s || c === -1) return; + let d = { + from: [...a.tasks], + to: [...o.tasks] + }; + a.tasks.splice(c, 1); + let f = N(o), p = f[i - 1] ?? null, m = f[i] ?? null, h = p === null ? 0 : o.tasks.findIndex((e) => e.id === p.id) + 1; + o.tasks.splice(h, 0, s); + try { + let e = await gr(l.client, s.id, { + task_status_id: n, + before_id: p?.id ?? null, + after_id: m?.id ?? null + }); + Object.assign(s, e), t !== n && l.notify("success", u("tasks_projects.board.moved", { + name: s.name, + status: o.status.name + })); + } catch (e) { + a.tasks = d.from, o.tasks = d.to, l.notify("error", H(e, u("tasks_projects.board.move_failed"))); + } + } + function z(e) { + let t = e ?? l.statuses.find((e) => e.is_default) ?? l.statuses[0]; + _.value = null, y.value = { + task_status_id: t?.id ?? null, + project_id: Ki(l.filters.project) + }, g.value = !0; + } + function ee(e) { + k || (_.value = e, y.value = {}, g.value = !0); + } + function te(e) { + let t = _.value ? u("tasks_projects.tasks.updated", { name: e.name }) : u("tasks_projects.tasks.created", { name: e.name }); + g.value = !1, _.value = null, l.notify("success", t), V(); + } + function ne(e) { + g.value = !1, _.value = null, l.notify("success", u("tasks_projects.tasks.deleted", { name: e.name })), V(); + } + return s({ openCreate: () => z(null) }), (n, s) => { + let l = S("BaseSpinner"), h = S("BaseIcon"), b = S("BaseEmptyPlaceholder"); + return v(), a("section", Ld, [ + M.value ? (v(), a("p", Rd, C(w(u)("tasks_projects.board.hidden_invoiced")), 1)) : i("", !0), + f.value && d.value.length === 0 ? (v(), a("div", zd, [c(l, { class: "h-8 w-8 text-primary-500" })])) : j.value ? (v(), r(b, { + key: 2, + title: w(u)("tasks_projects.task_statuses.none"), + description: w(u)("tasks_projects.tasks.empty_description") + }, { + default: D(() => [c(h, { + name: "ViewColumnsIcon", + class: "mt-5 mb-4 h-16 w-16 text-subtle" + })]), + _: 1 + }, 8, ["title", "description"])) : (v(), a("div", Bd, [(v(!0), a(e, null, x(d.value, (n) => (v(), a("section", { + key: n.status.id, + class: "w-64 shrink-0 rounded-xl border border-line-default bg-surface-secondary" + }, [ + o("header", Vd, [o("div", Hd, [ + o("span", { + class: p(["mr-2 inline-block h-2.5 w-2.5 shrink-0 rounded-full", n.status.colour ? "" : "bg-line-default"]), + style: m(n.status.colour ? { backgroundColor: n.status.colour } : void 0) + }, null, 6), + o("h3", Ud, C(n.status.name), 1), + o("span", Wd, C(N(n).length), 1) + ]), o("button", { + type: "button", + class: "rounded-md p-1 text-subtle hover:bg-hover hover:text-body", + "aria-label": w(u)("tasks_projects.tasks.new_task"), + title: w(u)("tasks_projects.tasks.new_task"), + onClick: (e) => z(n.status) + }, [c(h, { + name: "PlusIcon", + class: "h-4 w-4" + })], 8, Gd)]), + o("div", { + ref_for: !0, + ref: (e) => F(n.status.id, e), + "data-status-id": n.status.id, + class: "min-h-20 space-y-2 px-3 pt-3" + }, [(v(!0), a(e, null, x(N(n), (e) => (v(), r(Id, { + key: e.id, + "data-task-id": e.id, + client: t.client, + notify: t.notify, + task: e, + projects: t.projects, + members: t.members, + onOpen: ee + }, null, 8, [ + "data-task-id", + "client", + "notify", + "task", + "projects", + "members" + ]))), 128))], 8, Kd), + N(n).length === 0 ? (v(), a("p", qd, C(w(u)("tasks_projects.board.empty_column")), 1)) : i("", !0), + o("div", Jd, [o("button", { + type: "button", + class: "w-full rounded-md border border-dashed border-line-default py-1.5 text-xs text-muted hover:bg-hover hover:text-body", + onClick: (e) => z(n.status) + }, " + " + C(w(u)("tasks_projects.tasks.new_task")), 9, Yd)]) + ]))), 128))])), + c(ya, { + show: g.value, + client: t.client, + notify: t.notify, + task: _.value, + statuses: t.statuses, + members: t.members, + projects: A.value, + defaults: y.value, + compact: _.value === null, + onClose: s[0] ||= (e) => g.value = !1, + onSaved: te, + onDeleted: ne + }, null, 8, [ + "show", + "client", + "notify", + "task", + "statuses", + "members", + "projects", + "defaults", + "compact" + ]) + ]); + }; + } +}), Zd = /* @__PURE__ */ l({ + __name: "TasksListView", + props: { + client: { type: [Function, Object] }, + notify: { type: Function }, + router: {}, + filters: {}, + statuses: {}, + members: {}, + projects: {} + }, + setup(e, { expose: t }) { + let n = b(null); + return t({ openCreate: () => n.value?.openCreate() }), (t, i) => (v(), r(To, { + ref_key: "listRef", + ref: n, + client: e.client, + notify: e.notify, + router: e.router, + filters: e.filters, + statuses: e.statuses, + members: e.members, + projects: e.projects + }, null, 8, [ + "client", + "notify", + "router", + "filters", + "statuses", + "members", + "projects" + ])); + } +}), Qd = { class: "relative table-container" }, $d = { class: "block max-w-64 truncate" }, ef = { class: "tabular-nums" }, tf = { + key: 1, + class: "text-subtle" +}, nf = /* @__PURE__ */ l({ + __name: "AllTimeTable", + props: { + client: { type: [Function, Object] }, + notify: { type: Function }, + members: {}, + memberId: {}, + projectId: {}, + reloadToken: {} + }, + emits: ["edit", "delete"], + setup(e, { emit: t }) { + let l = e, u = t, d = B(), f = b(null), m = y({ + from: "", + to: "", + billing: "ALL" + }), h = n(() => [ + { + id: "ALL", + label: d("tasks_projects.time.filters.all") + }, + { + id: "BILLED", + label: d("tasks_projects.time.billed") + }, + { + id: "UNBILLED", + label: d("tasks_projects.time.unbilled") + } + ]), g = n({ + get: () => x(h.value, m.billing), + set: (e) => { + m.billing = typeof e.id == "string" ? e.id : "ALL"; + } + }), _ = n(() => [ + { + key: "date", + label: d("tasks_projects.time.columns.date"), + sortable: !1 + }, + { + key: "member", + label: d("tasks_projects.time.columns.member"), + sortable: !1 + }, + { + key: "task", + label: d("tasks_projects.time.columns.task"), + sortable: !1, + tdClass: "font-medium text-heading" + }, + { + key: "description", + label: d("tasks_projects.time.columns.description"), + sortable: !1 + }, + { + key: "duration", + label: d("tasks_projects.time.columns.duration"), + sortable: !1 + }, + { + key: "billable", + label: d("tasks_projects.time.columns.billable"), + sortable: !1 + }, + { + key: "amount", + label: d("tasks_projects.time.columns.amount"), + sortable: !1 + }, + { + key: "actions", + label: d("tasks_projects.general.actions"), + sortable: !1, + tdClass: "text-right text-sm font-medium" + } + ]); + E(m, () => T()), E([() => l.memberId, () => l.projectId], () => T()), E(() => l.reloadToken, () => T(!0)); + function x(e, t) { + return e.find((e) => e.id === t) ?? e[0]; + } + function T(e = !1) { + f.value?.refresh(e); + } + function O() { + m.from = "", m.to = "", m.billing = "ALL"; + } + function k(e) { + m.from = e ? dt(e) : ""; + } + function A(e) { + m.to = e ? dt(e) : ""; + } + function j(e) { + let t = l.members.find((t) => t.id === e); + return t === void 0 ? l.members.length === 0 ? `#${e}` : d("tasks_projects.time.unknown_member") : t.name; + } + async function M({ page: e }) { + let t = { + page: e, + limit: 25 + }; + l.memberId !== null && (t.user_id = l.memberId), l.projectId !== null && (t.project_id = l.projectId), m.from !== "" && (t.from = m.from), m.to !== "" && (t.to = m.to), m.billing !== "ALL" && (t.billed = m.billing === "BILLED"); + try { + let e = await De(l.client, t), n = e.data ?? []; + return $e(l.client, n.map((e) => e.task_id).filter((e) => typeof e == "number")), { + data: n, + pagination: N(e.meta, n.length) + }; + } catch (e) { + return l.notify("error", H(e, d("tasks_projects.time.load_failed"))), { + data: [], + pagination: N(null, 0) + }; + } + } + function N(e, t) { + return { + totalPages: e?.last_page ?? 1, + currentPage: e?.current_page ?? 1, + totalCount: e?.total ?? t, + count: t, + limit: e?.per_page ?? 25 + }; + } + return (e, t) => { + let n = S("BaseDatePicker"), l = S("BaseInputGroup"), y = S("BaseSelectInput"), b = S("BaseFilterWrapper"), x = S("BaseBadge"), T = S("BaseFormatMoney"), E = S("BaseIcon"), N = S("BaseDropdownItem"), P = S("BaseDropdown"), F = S("BaseTable"); + return v(), a("section", null, [c(b, { + show: "", + "row-on-xl": "", + class: "mt-3", + onClear: O + }, { + default: D(() => [ + c(l, { + label: w(d)("tasks_projects.time.filters.from"), + class: "mt-2 flex-1" + }, { + default: D(() => [c(n, { + "model-value": m.from, + "onUpdate:modelValue": k + }, null, 8, ["model-value"])]), + _: 1 + }, 8, ["label"]), + c(l, { + label: w(d)("tasks_projects.time.filters.to"), + class: "mt-2 flex-1" + }, { + default: D(() => [c(n, { + "model-value": m.to, + "onUpdate:modelValue": A + }, null, 8, ["model-value"])]), + _: 1 + }, 8, ["label"]), + c(l, { + label: w(d)("tasks_projects.time.filters.billing"), + class: "mt-2 flex-1" + }, { + default: D(() => [c(y, { + modelValue: g.value, + "onUpdate:modelValue": t[0] ||= (e) => g.value = e, + options: h.value, + "label-key": "label" + }, null, 8, ["modelValue", "options"])]), + _: 1 + }, 8, ["label"]) + ]), + _: 1 + }), o("div", Qd, [c(F, { + ref_key: "tableRef", + ref: f, + data: M, + columns: _.value, + class: "mt-3" + }, { + "cell-date": D(({ row: e }) => [s(C(w(ut)(w(Ia)(e.data.started_at))), 1)]), + "cell-member": D(({ row: e }) => [s(C(j(e.data.user_id)), 1)]), + "cell-task": D(({ row: e }) => [s(C(w(Ze)(e.data.task_id)), 1)]), + "cell-description": D(({ row: e }) => [o("span", $d, C(e.data.description || "-"), 1)]), + "cell-duration": D(({ row: e }) => [o("span", ef, C(w(Na)(e.data.duration_minutes)), 1)]), + "cell-billable": D(({ row: e }) => [c(x, { class: p(["rounded-full", e.data.billable ? "bg-primary-50! text-primary-500!" : "bg-surface-tertiary! text-muted!"]) }, { + default: D(() => [s(C(e.data.billable ? w(d)("tasks_projects.time.billable") : w(d)("tasks_projects.time.non_billable")), 1)]), + _: 2 + }, 1032, ["class"])]), + "cell-amount": D(({ row: e }) => [e.data.billable ? (v(), r(T, { + key: 0, + amount: e.data.amount + }, null, 8, ["amount"])) : (v(), a("span", tf, "-"))]), + "cell-actions": D(({ row: e }) => [c(P, null, { + activator: D(() => [c(E, { + name: "EllipsisHorizontalIcon", + class: "h-5 text-muted" + })]), + default: D(() => [c(N, { onClick: (t) => u("edit", e.data) }, { + default: D(() => [c(E, { + name: "PencilIcon", + class: "mr-3 h-5 w-5 text-subtle group-hover:text-muted" + }), s(" " + C(w(d)("tasks_projects.general.edit")), 1)]), + _: 1 + }, 8, ["onClick"]), e.data.invoice_id === null ? (v(), r(N, { + key: 0, + onClick: (t) => u("delete", e.data) + }, { + default: D(() => [c(E, { + name: "TrashIcon", + class: "mr-3 h-5 w-5 text-subtle group-hover:text-muted" + }), s(" " + C(w(d)("tasks_projects.general.delete")), 1)]), + _: 1 + }, 8, ["onClick"])) : i("", !0)]), + _: 2 + }, 1024)]), + _: 1 + }, 8, ["columns"])])]); + }; + } +}), rf = { class: "mt-4 flex flex-wrap items-center justify-between gap-3" }, af = { class: "flex items-center gap-2" }, of = { class: "ml-1 text-sm text-muted" }, sf = { class: "flex items-center gap-2 text-sm" }, cf = { class: "text-muted" }, lf = { class: "text-lg font-semibold tabular-nums text-heading" }, uf = { + key: 0, + class: "mt-6 text-sm text-muted" +}, df = { + key: 1, + class: "mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-4 xl:grid-cols-7" +}, ff = { class: "flex items-baseline justify-between" }, pf = { class: "text-xs font-semibold tracking-wide text-heading uppercase" }, mf = { class: "text-xs text-muted" }, hf = { class: "text-sm font-medium tabular-nums text-heading" }, gf = { class: "mt-3 flex-1 space-y-2" }, _f = ["onClick"], vf = { class: "flex items-center justify-between gap-2" }, yf = { class: "truncate text-xs font-medium text-heading" }, bf = { class: "shrink-0 text-xs tabular-nums text-muted" }, xf = { + key: 0, + class: "mt-1 block truncate text-xs text-muted" +}, Sf = { class: "mt-1 flex items-center gap-1" }, Cf = { class: "text-[11px] text-subtle" }, wf = { + key: 0, + class: "text-[11px] text-subtle" +}, Tf = { + key: 0, + class: "py-2 text-xs text-subtle" +}, Ef = ["onClick"], Df = { + key: 2, + class: "mt-4 text-center text-sm text-subtle" +}, Of = /* @__PURE__ */ l({ + __name: "WeekTimesheet", + props: { + client: { type: [Function, Object] }, + notify: { type: Function }, + userId: {}, + projectId: {}, + weekStart: {}, + reloadToken: {} + }, + emits: ["add", "edit"], + setup(t, { emit: l }) { + let u = t, d = l, f = B(), m = b(Ba(/* @__PURE__ */ new Date(), u.weekStart)), h = b([]), g = b(!1), _ = n(() => Va(m.value)), y = n(() => { + let e = _.value[0], t = _.value[_.value.length - 1]; + return `${Wa(e).day} - ${Wa(t).day}`; + }), T = n(() => _.value.map((e) => { + let t = Ua(e), n = h.value.filter((e) => Ia(e.started_at) === t), r = Wa(e); + return { + key: t, + weekday: r.weekday, + day: r.day, + today: Ga(e), + entries: n, + minutes: A(n) + }; + })), O = n(() => A(h.value)), k = n(() => !g.value && h.value.length === 0); + E(() => u.weekStart, (e) => { + m.value = Ba(m.value, e); + }), E([ + m, + () => u.userId, + () => u.projectId, + () => u.reloadToken + ], () => void j(), { immediate: !0 }); + function A(e) { + return e.reduce((e, t) => e + (t.duration_minutes ?? 0), 0); + } + async function j() { + if (u.userId === null) { + h.value = []; + return; + } + g.value = !0; + try { + let e = { + user_id: u.userId, + from: Ua(_.value[0]), + to: Ua(_.value[_.value.length - 1]) + }; + u.projectId && (e.project_id = u.projectId); + let t = await Oe(u.client, e); + h.value = t, $e(u.client, t.map((e) => e.task_id).filter((e) => typeof e == "number")); + } catch (e) { + h.value = [], u.notify("error", H(e, f("tasks_projects.time.load_failed"))); + } finally { + g.value = !1; + } + } + function M(e) { + m.value = Ha(m.value, e * 7); + } + function N() { + m.value = Ba(/* @__PURE__ */ new Date(), u.weekStart); + } + return (n, l) => { + let u = S("BaseIcon"), m = S("BaseButton"), h = S("BaseSpinner"); + return v(), a("section", null, [ + o("header", rf, [o("div", af, [ + c(m, { + variant: "white", + size: "sm", + title: w(f)("tasks_projects.time.previous_week"), + onClick: l[0] ||= (e) => M(-1) + }, { + default: D(() => [c(u, { + name: "ChevronLeftIcon", + class: "h-4 w-4" + })]), + _: 1 + }, 8, ["title"]), + c(m, { + variant: "white", + size: "sm", + onClick: N + }, { + default: D(() => [s(C(w(f)("tasks_projects.time.this_week")), 1)]), + _: 1 + }), + c(m, { + variant: "white", + size: "sm", + title: w(f)("tasks_projects.time.next_week"), + onClick: l[1] ||= (e) => M(1) + }, { + default: D(() => [c(u, { + name: "ChevronRightIcon", + class: "h-4 w-4" + })]), + _: 1 + }, 8, ["title"]), + o("span", of, C(y.value), 1) + ]), o("div", sf, [ + o("span", cf, C(w(f)("tasks_projects.time.week_total")), 1), + o("span", lf, C(w(Na)(O.value)), 1), + g.value ? (v(), r(h, { + key: 0, + class: "h-4 w-4 text-primary-500" + })) : i("", !0) + ])]), + t.userId === null ? (v(), a("p", uf, C(w(f)("tasks_projects.time.unknown_user")), 1)) : (v(), a("div", df, [(v(!0), a(e, null, x(T.value, (t) => (v(), a("article", { + key: t.key, + class: p(["flex min-h-40 flex-col rounded-xl border bg-surface p-3", t.today ? "border-primary-400" : "border-line-default"]) + }, [ + o("header", ff, [o("div", null, [o("p", pf, C(t.weekday), 1), o("p", mf, C(t.day), 1)]), o("span", hf, C(w(Na)(t.minutes)), 1)]), + o("ul", gf, [(v(!0), a(e, null, x(t.entries, (e) => (v(), a("li", { key: e.id }, [o("button", { + type: "button", + class: "w-full rounded-md border border-line-light px-2 py-2 text-left hover:bg-hover", + onClick: (t) => d("edit", e) + }, [ + o("span", vf, [o("span", yf, C(w(Ze)(e.task_id)), 1), o("span", bf, C(w(Na)(e.duration_minutes)), 1)]), + e.description ? (v(), a("span", xf, C(e.description), 1)) : i("", !0), + o("span", Sf, [ + o("span", { class: p(["inline-block h-1.5 w-1.5 rounded-full", e.billable ? "bg-status-green" : "bg-line-strong"]) }, null, 2), + o("span", Cf, C(e.billable ? w(f)("tasks_projects.time.billable") : w(f)("tasks_projects.time.non_billable")), 1), + e.invoice_id === null ? i("", !0) : (v(), a("span", wf, " - " + C(w(f)("tasks_projects.time.billed")), 1)) + ]) + ], 8, _f)]))), 128)), t.entries.length === 0 ? (v(), a("li", Tf, C(w(f)("tasks_projects.time.no_entries")), 1)) : i("", !0)]), + o("button", { + type: "button", + class: "mt-2 flex items-center justify-center gap-1 rounded-md border border-dashed border-line-default py-1.5 text-xs text-muted hover:bg-hover hover:text-heading", + onClick: (e) => d("add", t.key) + }, [c(u, { + name: "PlusIcon", + class: "h-4 w-4" + }), s(" " + C(w(f)("tasks_projects.time.add_entry")), 1)], 8, Ef) + ], 2))), 128))])), + k.value && t.userId !== null ? (v(), a("p", Df, C(w(f)("tasks_projects.time.empty_description")), 1)) : i("", !0) + ]); + }; + } +}), kf = { + key: 0, + class: "mt-4 flex gap-6 border-b border-line-default" +}, Af = 5, jf = /* @__PURE__ */ l({ + __name: "TasksWeekView", + props: { + client: { type: [Function, Object] }, + notify: { type: Function }, + filters: {}, + statuses: {}, + members: {}, + projects: {} + }, + setup(e) { + let t = e, s = B(), l = b("MINE"), u = b(!1), d = b(!1), f = b(null), m = b(Ua(/* @__PURE__ */ new Date())), h = b(0), _ = n(() => G.settings.week_start), y = n(() => Ki(t.filters.project)), x = n(() => Ki(t.filters.user) ?? G.userId); + g(() => void S()), E(et, () => { + h.value += 1; + }); + async function S() { + G.userId === null && await xa(t.client), u.value = G.settings.members_see_all_time || await T(); + } + async function T() { + try { + return ((await De(t.client, { limit: Af })).data ?? []).some((e) => e.user_id !== G.userId); + } catch { + return !1; + } + } + function D(e) { + f.value = null, m.value = e ?? Ua(/* @__PURE__ */ new Date()), d.value = !0; + } + function O(e) { + f.value = e, d.value = !0; + } + function k() { + let e = f.value ? s("tasks_projects.time.updated") : s("tasks_projects.time.created"); + d.value = !1, f.value = null, t.notify("success", e), h.value += 1, V(); + } + function A() { + d.value = !1, f.value = null, t.notify("success", s("tasks_projects.time.deleted")), h.value += 1, V(); + } + async function j(e) { + if (window.confirm(s("tasks_projects.time.delete_confirm"))) try { + await je(t.client, e.id), t.notify("success", s("tasks_projects.time.deleted")), h.value += 1, V(); + } catch (e) { + t.notify("error", H(e, s("tasks_projects.time.delete_failed"))); + } + } + function M(e) { + return l.value === e ? "border-primary-500 text-primary-500" : "border-transparent text-muted hover:border-line-strong hover:text-heading"; + } + return (t, n) => (v(), a("section", null, [ + u.value ? (v(), a("nav", kf, [o("button", { + type: "button", + class: p(["-mb-px border-b-2 px-1 pb-3 text-sm font-medium", M("MINE")]), + onClick: n[0] ||= (e) => l.value = "MINE" + }, C(w(s)("tasks_projects.time.my_time")), 3), o("button", { + type: "button", + class: p(["-mb-px border-b-2 px-1 pb-3 text-sm font-medium", M("ALL")]), + onClick: n[1] ||= (e) => l.value = "ALL" + }, C(w(s)("tasks_projects.time.all_time")), 3)])) : i("", !0), + l.value === "MINE" ? (v(), r(Of, { + key: 1, + client: e.client, + notify: e.notify, + "user-id": x.value, + "project-id": y.value, + "week-start": _.value, + "reload-token": h.value, + onAdd: D, + onEdit: O + }, null, 8, [ + "client", + "notify", + "user-id", + "project-id", + "week-start", + "reload-token" + ])) : (v(), r(nf, { + key: 2, + client: e.client, + notify: e.notify, + members: e.members, + "member-id": w(Ki)(e.filters.user), + "project-id": y.value, + "reload-token": h.value, + onEdit: O, + onDelete: j + }, null, 8, [ + "client", + "notify", + "members", + "member-id", + "project-id", + "reload-token" + ])), + c(zo, { + show: d.value, + client: e.client, + notify: e.notify, + entry: f.value, + "default-date": m.value, + onClose: n[2] ||= (e) => d.value = !1, + onSaved: k, + onDeleted: A + }, null, 8, [ + "show", + "client", + "notify", + "entry", + "default-date" + ]) + ])); + } +}), Mf = { + viewTask: `${U}:view-task`, + viewOwnTime: `${U}:view-own-time` +}; +function Nf(e) { + e.addMessages(qs), e.registerPage({ + id: "tasks", + module: U, + path: "", + component: sn(e, ul), + meta: { + ability: Mf.viewTask, + title: "tasks_projects.tasks.title" + }, + children: [ + { + id: "list", + path: "", + component: sn(e, Zd), + meta: { + ability: Mf.viewTask, + title: "tasks_projects.tasks.views.list" + } + }, + { + id: "board", + path: "board", + component: sn(e, Xd), + meta: { + ability: Mf.viewTask, + title: "tasks_projects.board.title" + } + }, + { + id: "week", + path: "week", + component: sn(e, jf), + meta: { + ability: Mf.viewOwnTime, + title: "tasks_projects.time.title" + } + } + ] + }), e.registerPage({ + id: "task", + module: U, + path: "tasks/:id", + component: sn(e, rl), + meta: { + ability: Mf.viewTask, + title: "tasks_projects.tasks.title" + } + }), e.registerPage({ + id: "time", + module: U, + path: "time", + component: Pf(e), + meta: { + ability: Mf.viewOwnTime, + title: "tasks_projects.time.title" + } + }); +} +function Pf(e) { + return l({ setup: () => (g(() => { + e.router.replace(W.week); + }), () => null) }); +} +//#endregion +//#region resources/js/components/QuickStartOverlay.vue?vue&type=script&setup=true&lang.ts +var Ff = ["aria-label"], If = { class: "flex items-center justify-between border-b border-line-default px-4 py-3" }, Lf = { class: "text-sm font-semibold text-heading" }, Rf = ["aria-label"], zf = { class: "space-y-4 px-4 py-4" }, Bf = { class: "truncate text-sm font-medium text-heading" }, Vf = { class: "mt-1 text-2xl font-semibold tabular-nums text-primary-500" }, Hf = { + key: 0, + class: "mt-1 text-xs text-muted" +}, Uf = { class: "flex items-center justify-between" }, Wf = ["title", "aria-label"], Gf = { + key: 0, + class: "tabular-nums" +}, Kf = "[aria-label=\"Open AI Assistant\"]", qf = "/admin/settings", Jf = /* @__PURE__ */ l({ + __name: "QuickStartOverlay", + props: { + client: { type: [Function, Object] }, + notify: { type: Function }, + enabled: { type: Boolean }, + router: {} + }, + emits: ["open-task"], + setup(e, { emit: l }) { + let u = e, d = l, f = B(), m = b(!1), _ = b(window.location.pathname), y = b(!1), x, T = n(() => ({ + notify: u.notify, + t: f + })), O = n(() => Ze(q.running?.task_id ?? null)), A = n(() => Ma(q.elapsedSeconds)), j = n(() => q.stopPrompt !== null || q.startPrompt !== null), M = n(() => j.value || _.value.startsWith(qf)), N = n(() => y.value ? "bottom-24" : "bottom-5"); + function P() { + y.value = Array.from(document.querySelectorAll(Kf)).some((e) => window.getComputedStyle(e).position === "fixed"); + } + E(() => u.enabled, (e) => { + e || F(); + }), E(() => q.running, (e) => { + e === null && F(); + }), g(() => { + _.value = u.router.currentRoute.value.path, x = u.router.afterEach((e) => { + _.value = e.path, P(); + }), P(); + }), h(() => { + x?.(); + }); + function F() { + m.value = !1; + } + function I() { + if (q.running === null) { + q.startWithPrompt(u.client, T.value); + return; + } + m.value = !m.value; + } + function L() { + q.stopWithPrompt(u.client, T.value); + } + function R() { + F(), d("open-task"); + } + return (n, l) => { + let u = S("BaseIcon"), d = S("BaseButton"); + return v(), r(t, { to: "body" }, [e.enabled && !M.value ? (v(), a("div", { + key: 0, + class: p(["fixed right-5 z-40 flex flex-col items-end gap-3", N.value]) + }, [m.value && w(q).running !== null ? (v(), a("section", { + key: 0, + class: "w-80 max-w-[calc(100vw-3rem)] rounded-xl border border-line-default bg-surface shadow-2xl", + "aria-label": w(f)("tasks_projects.timer.panel_title"), + onKeydown: k(F, ["esc"]) + }, [o("header", If, [o("h2", Lf, C(w(f)("tasks_projects.timer.running")), 1), o("button", { + type: "button", + class: "rounded p-1 text-subtle hover:bg-hover hover:text-heading", + "aria-label": w(f)("tasks_projects.timer.close"), + onClick: F + }, [c(u, { + name: "XMarkIcon", + class: "h-5 w-5" + })], 8, Rf)]), o("div", zf, [o("div", null, [ + o("p", Bf, C(O.value), 1), + o("p", Vf, C(A.value), 1), + w(q).running.description ? (v(), a("p", Hf, C(w(q).running.description), 1)) : i("", !0) + ]), o("div", Uf, [o("button", { + type: "button", + class: "text-xs text-primary-500 hover:underline", + onClick: R + }, C(w(f)("tasks_projects.timer.open_task")), 1), c(d, { + variant: "primary", + disabled: w(q).busy, + onClick: L + }, { + left: D((e) => [c(u, { + name: "StopIcon", + class: p(e.class) + }, null, 8, ["class"])]), + default: D(() => [s(" " + C(w(f)("tasks_projects.timer.stop")), 1)]), + _: 1 + }, 8, ["disabled"])])])], 40, Ff)) : i("", !0), o("button", { + type: "button", + class: p(["flex items-center justify-center gap-2 rounded-full bg-btn-primary text-sm font-medium text-white shadow-lg hover:bg-btn-primary-hover", w(q).running === null ? "h-14 w-14 p-0" : "h-14 px-5"]), + title: w(f)("tasks_projects.timer.quick_start"), + "aria-label": w(f)("tasks_projects.timer.quick_start"), + onClick: I + }, [c(u, { + name: w(q).running === null ? "ClockIcon" : "StopIcon", + class: "h-5 w-5 text-white" + }, null, 8, ["name"]), w(q).running === null ? i("", !0) : (v(), a("span", Gf, C(A.value), 1))], 10, Wf)], 2)) : i("", !0)]); + }; + } +}), Yf = { class: "flex w-full items-center justify-between" }, Xf = { class: "space-y-5 px-6 py-6" }, Zf = { class: "flex justify-end space-x-3 border-t border-line-default px-6 py-4" }, Qf = 0, $f = -1, ep = 100, tp = 10, np = /* @__PURE__ */ l({ + __name: "StartTimerModal", + props: { + client: { type: [Function, Object] }, + notify: { type: Function } + }, + setup(e) { + let t = e, i = B(), a = b([]), l = b(Qf), u = b(null), d = b(""), m = b(!0), h = n(() => q.startPrompt !== null), g = n(() => [{ + id: Qf, + label: i("tasks_projects.timer.any_project") + }, ...a.value]), _ = n(() => g.value.find((e) => e.id === l.value) ?? null); + E(() => q.startPrompt, (e) => { + e !== null && (l.value = typeof e.projectId == "number" ? e.projectId : Qf, u.value = null, d.value = "", m.value = !0, y(), typeof e.taskId == "number" && x(e.taskId)); + }), E(l, () => { + u.value = null; + }); + async function y() { + try { + let e = await Ht(t.client, { + limit: ep, + status: "ACTIVE", + sort_by: "name" + }); + a.value = (e.data ?? []).map((e) => ({ + id: e.id, + label: e.name + })); + } catch (e) { + a.value = [], t.notify("error", H(e, i("tasks_projects.time.projects_failed"))); + } + } + async function x(e) { + try { + let n = await He(t.client, e); + Qe(n), typeof n.project_id == "number" && (l.value = n.project_id), await f(), j(T(n)); + } catch {} + } + function T(e) { + return { + id: e.id, + label: typeof e.number == "number" ? `#${e.number} ${e.name}` : e.name, + name: e.name, + billable: e.billable !== !1 + }; + } + async function O(e) { + let n = (e ?? "").trim(); + try { + let e = await Ve(t.client, n, { + projectId: l.value === Qf ? null : l.value, + invoiced: 0, + limit: tp + }); + e.forEach(Qe); + let r = e.map(T); + return r.length === 0 && n !== "" && r.push({ + id: $f, + label: i("tasks_projects.timer.create_and_start", { name: n }), + name: n, + billable: !0 + }), r; + } catch (e) { + return t.notify("error", H(e, i("tasks_projects.time.tasks_failed"))), []; + } + } + function k(e) { + l.value = e?.id ?? Qf; + } + function j(e) { + u.value = e, m.value = e === null || e.billable; + } + function M() { + let e = u.value; + if (e === null) return; + let t = d.value.trim() || null, n = l.value === Qf ? null : l.value; + q.answerStart(e.id === $f ? { + create: { + name: e.name, + projectId: n + }, + description: t, + billable: m.value + } : { + taskId: e.id, + description: t, + billable: m.value + }); + } + function N() { + q.answerStart(null); + } + return (e, t) => { + let n = S("BaseIcon"), a = S("BaseMultiselect"), f = S("BaseInputGroup"), y = S("BaseTextarea"), b = S("BaseSwitch"), x = S("BaseButton"), T = S("BaseModal"); + return v(), r(T, { + show: h.value, + onClose: N + }, { + header: D(() => [o("div", Yf, [o("span", null, C(w(i)("tasks_projects.timer.start_title")), 1), c(n, { + name: "XMarkIcon", + class: "h-6 w-6 cursor-pointer text-subtle hover:text-body", + onClick: N + })])]), + default: D(() => [o("form", { onSubmit: A(M, ["prevent"]) }, [o("div", Xf, [ + c(f, { label: w(i)("tasks_projects.time.filters.project") }, { + default: D(() => [c(a, { + "model-value": _.value, + options: g.value, + "can-clear": !1, + "value-prop": "id", + "track-by": "label", + label: "label", + object: "", + searchable: "", + "onUpdate:modelValue": t[0] ||= (e) => k(e) + }, null, 8, ["model-value", "options"])]), + _: 1 + }, 8, ["label"]), + c(f, { + label: w(i)("tasks_projects.time.fields.task"), + required: "" + }, { + default: D(() => [(v(), r(a, { + key: l.value, + "model-value": u.value, + options: O, + placeholder: w(i)("tasks_projects.timer.pick_task"), + "initial-search": u.value?.label ?? "", + "no-results-text": w(i)("tasks_projects.timer.no_matches"), + delay: 400, + "filter-results": !1, + "value-prop": "id", + "track-by": "label", + label: "label", + object: "", + searchable: "", + "preserve-search": "", + "resolve-on-load": "", + "onUpdate:modelValue": t[1] ||= (e) => j(e) + }, null, 8, [ + "model-value", + "placeholder", + "initial-search", + "no-results-text" + ]))]), + _: 1 + }, 8, ["label"]), + c(f, { label: w(i)("tasks_projects.time.fields.description") }, { + default: D(() => [c(y, { + modelValue: d.value, + "onUpdate:modelValue": t[2] ||= (e) => d.value = e, + row: 3 + }, null, 8, ["modelValue"])]), + _: 1 + }, 8, ["label"]), + c(f, { label: w(i)("tasks_projects.time.fields.billable") }, { + default: D(() => [c(b, { + modelValue: m.value, + "onUpdate:modelValue": t[3] ||= (e) => m.value = e, + class: "flex" + }, null, 8, ["modelValue"])]), + _: 1 + }, 8, ["label"]) + ]), o("div", Zf, [c(x, { + type: "button", + variant: "primary-outline", + onClick: N + }, { + default: D(() => [s(C(w(i)("tasks_projects.general.cancel")), 1)]), + _: 1 + }), c(x, { + type: "submit", + variant: "primary", + disabled: u.value === null || w(q).busy, + loading: w(q).busy + }, { + left: D((e) => [c(n, { + name: "PlayIcon", + class: p(e.class) + }, null, 8, ["class"])]), + default: D(() => [s(" " + C(w(i)("tasks_projects.timer.start")), 1)]), + _: 1 + }, 8, ["disabled", "loading"])])], 32)]), + _: 1 + }, 8, ["show"]); + }; + } +}), rp = { class: "flex w-full items-center justify-between" }, ip = { class: "space-y-5 px-6 py-6" }, ap = { class: "truncate text-sm font-medium text-heading" }, op = { class: "mt-1 text-3xl font-semibold tabular-nums text-primary-500" }, sp = { class: "mt-1 text-xs text-muted" }, cp = { class: "border-t border-line-default px-6 py-4" }, lp = { + key: 0, + class: "flex flex-wrap items-center justify-between gap-3" +}, up = { class: "text-sm text-body" }, dp = { class: "flex space-x-3" }, fp = { + key: 1, + class: "flex items-center justify-between" +}, pp = { class: "flex space-x-3" }, mp = /* @__PURE__ */ l({ + __name: "StopTimerModal", + setup(e) { + let t = B(), i = b(""), l = b(!0), u = b(!1), d = n(() => q.stopPrompt !== null), f = n(() => Ze(q.stopPrompt?.entry.task_id ?? null)), m = n(() => Ma(q.elapsedSeconds)), h = n(() => G.settings.rounding_minutes), g = n(() => Na(Fa(Math.round(q.elapsedSeconds / 60), h.value, G.settings.rounding_direction))), _ = n(() => h.value > 1 ? t("tasks_projects.timer.saved_as", { + duration: g.value, + increment: h.value + }) : t("tasks_projects.timer.saved_as_exact", { duration: g.value })); + E(() => q.stopPrompt, (e) => { + i.value = e?.entry.description ?? "", l.value = e?.entry.billable !== !1, u.value = !1; + }); + function y() { + q.answerStop({ + action: "save", + description: i.value.trim() || null, + billable: l.value + }); + } + function x() { + q.answerStop({ action: "discard" }); + } + function T() { + q.answerStop(null); + } + return (e, n) => { + let h = S("BaseIcon"), g = S("BaseTextarea"), b = S("BaseInputGroup"), E = S("BaseSwitch"), O = S("BaseButton"), k = S("BaseModal"); + return v(), r(k, { + show: d.value, + onClose: T + }, { + header: D(() => [o("div", rp, [o("span", null, C(w(t)("tasks_projects.timer.stop_title")), 1), c(h, { + name: "XMarkIcon", + class: "h-6 w-6 cursor-pointer text-subtle hover:text-body", + onClick: T + })])]), + default: D(() => [o("form", { onSubmit: A(y, ["prevent"]) }, [o("div", ip, [ + o("div", null, [ + o("p", ap, C(f.value), 1), + o("p", op, C(m.value), 1), + o("p", sp, C(_.value), 1) + ]), + c(b, { label: w(t)("tasks_projects.time.fields.description") }, { + default: D(() => [c(g, { + modelValue: i.value, + "onUpdate:modelValue": n[0] ||= (e) => i.value = e, + row: 3 + }, null, 8, ["modelValue"])]), + _: 1 + }, 8, ["label"]), + c(b, { label: w(t)("tasks_projects.time.fields.billable") }, { + default: D(() => [c(E, { + modelValue: l.value, + "onUpdate:modelValue": n[1] ||= (e) => l.value = e, + class: "flex" + }, null, 8, ["modelValue"])]), + _: 1 + }, 8, ["label"]) + ]), o("div", cp, [u.value ? (v(), a("div", lp, [o("p", up, C(w(t)("tasks_projects.timer.discard_ask", { duration: m.value })), 1), o("div", dp, [c(O, { + type: "button", + variant: "primary-outline", + onClick: n[2] ||= (e) => u.value = !1 + }, { + default: D(() => [s(C(w(t)("tasks_projects.general.cancel")), 1)]), + _: 1 + }), c(O, { + type: "button", + variant: "danger", + disabled: w(q).busy, + onClick: x + }, { + default: D(() => [s(C(w(t)("tasks_projects.timer.discard")), 1)]), + _: 1 + }, 8, ["disabled"])])])) : (v(), a("div", fp, [c(O, { + type: "button", + variant: "white", + disabled: w(q).busy, + onClick: n[3] ||= (e) => u.value = !0 + }, { + default: D(() => [s(C(w(t)("tasks_projects.timer.discard")), 1)]), + _: 1 + }, 8, ["disabled"]), o("div", pp, [c(O, { + type: "button", + variant: "primary-outline", + onClick: T + }, { + default: D(() => [s(C(w(t)("tasks_projects.general.cancel")), 1)]), + _: 1 + }), c(O, { + type: "submit", + variant: "primary", + loading: w(q).busy, + disabled: w(q).busy + }, { + left: D((e) => [c(h, { + name: "StopIcon", + class: p(e.class) + }, null, 8, ["class"])]), + default: D(() => [s(" " + C(w(t)("tasks_projects.timer.save_and_stop")), 1)]), + _: 1 + }, 8, ["loading", "disabled"])])]))])], 32)]), + _: 1 + }, 8, ["show"]); + }; + } +}), hp = { + key: 0, + class: "relative float-left m-0 ml-2" +}, gp = ["title"], _p = ["aria-label", "title"], vp = { class: "font-medium tabular-nums" }, yp = [ + "disabled", + "title", + "aria-label" +], bp = /* @__PURE__ */ l({ + __name: "TimerChip", + props: { + client: { type: [Function, Object] }, + notify: { type: Function } + }, + emits: ["open"], + setup(e, { emit: t }) { + let r = e, s = t, l = B(), u = n(() => Ze(q.running?.task_id ?? null)), d = n(() => Ma(q.elapsedSeconds)); + function f() { + q.stopWithPrompt(r.client, { + notify: r.notify, + t: l + }); + } + return (e, t) => { + let n = S("BaseIcon"); + return w(q).running === null ? i("", !0) : (v(), a("li", hp, [o("div", { + class: "flex h-8 items-center gap-2 rounded-lg bg-white/20 px-2 text-sm text-white md:h-9 md:px-3", + title: w(l)("tasks_projects.timer.running") + }, [ + t[1] ||= o("span", { class: "inline-block h-2 w-2 shrink-0 animate-pulse rounded-full bg-white" }, null, -1), + o("button", { + type: "button", + class: "hidden max-w-32 truncate hover:underline lg:block", + "aria-label": w(l)("tasks_projects.timer.open_task"), + title: w(l)("tasks_projects.timer.open_task"), + onClick: t[0] ||= (e) => s("open") + }, C(u.value), 9, _p), + o("span", vp, C(d.value), 1), + o("button", { + type: "button", + class: "rounded p-1 hover:bg-white/20 disabled:opacity-50", + disabled: w(q).busy, + title: w(l)("tasks_projects.timer.stop"), + "aria-label": w(l)("tasks_projects.timer.stop"), + onClick: f + }, [c(n, { + name: "StopIcon", + class: "h-4 w-4 text-white" + })], 8, yp) + ], 8, gp)])); + }; + } +}), xp = { en: { tasks_projects: { + time: { + title: "Time", + my_time: "My time", + all_time: "All time", + this_week: "This week", + previous_week: "Previous week", + next_week: "Next week", + week_total: "Week total", + day_total: "Total", + add_entry: "Add entry", + new_entry: "New time entry", + edit_entry: "Edit time entry", + view_entry: "Time entry", + no_entries: "Nothing logged.", + empty_title: "No time logged yet", + empty_description: "Log an entry by hand, or start the timer on a task.", + unknown_member: "Removed member", + unknown_user: "Unable to identify the signed-in user. Reload the page and try again.", + billable: "Billable", + non_billable: "Not billable", + billed: "Billed", + unbilled: "Unbilled", + stamped_notice: "This entry is already on an invoice. Invoiced time is history and cannot be changed.", + created: "The time entry was saved.", + updated: "The time entry was updated.", + deleted: "The time entry was deleted.", + delete_confirm: "Delete this time entry?", + load_failed: "Unable to load the time entries.", + save_failed: "Unable to save the time entry.", + delete_failed: "Unable to delete the time entry.", + members_failed: "Unable to load the members.", + projects_failed: "Unable to load the projects.", + tasks_failed: "Unable to load the tasks.", + columns: { + date: "Date", + member: "Member", + task: "Task", + description: "Description", + duration: "Duration", + billable: "Billable", + amount: "Amount" + }, + filters: { + member: "Member", + project: "Project", + from: "From", + to: "To", + billing: "Billing", + all: "All", + any_member: "Everyone", + any_project: "Any project" + }, + fields: { + task: "Task", + task_placeholder: "Search by task name or number", + date: "Date", + mode: "Entry", + duration: "Duration", + duration_help: "Hours and minutes, as 1:30 or 1.5.", + start: "Start", + end: "End", + description: "Description", + billable: "Billable" + }, + mode: { + duration: "Duration", + range: "Start and end" + }, + task_required: "Pick a task.", + date_required: "Pick a date.", + duration_invalid: "Enter a duration like 1:30 or 1.5.", + range_invalid: "Enter a start and an end time, with the end after the start." + }, + timer: { + running: "Timer running", + quick_start: "Start a timer", + panel_title: "Quick start", + start: "Start", + stop: "Stop", + discard: "Discard", + close: "Close", + open_timesheet: "Open my week", + open_task: "Open the running task", + start_on: "Start the timer on {name}", + stop_on: "Stop the timer on {name}", + busy_elsewhere: "Your timer is running on {name}.", + stop_and_start: "Stop and start", + running_by: "{name} has been running since {time}.", + mismatch: "Your timer is no longer on this task. It has been reloaded.", + elapsed: "Elapsed", + search_tasks: "Search tasks", + no_tasks: "No tasks match that search.", + description_placeholder: "What are you working on? (optional)", + started: "The timer is running on {name}.", + stopped: "Logged {duration} on {name}.", + discarded: "The running timer was discarded.", + discard_confirm: "Discard the running timer? The elapsed time is not saved.", + stop_title: "Save and stop", + save_and_stop: "Save and stop", + saved_as: "Saved as {duration} after rounding to {increment} min.", + saved_as_exact: "Saved as {duration}.", + discard_ask: "Discard {duration}? This cannot be undone.", + start_title: "Start a timer", + any_project: "Any project", + pick_task: "Search by task name or number", + create_and_start: "Create task \"{name}\" and start", + no_matches: "No tasks match that search. Keep typing to create one.", + already_running: "A timer is already running. It has been reloaded.", + start_failed: "Unable to start the timer.", + stop_failed: "Unable to stop the timer.", + discard_failed: "Unable to discard the timer." + }, + settings: { + title: "Tasks and Projects", + general_title: "General", + general_description: "The default hourly rate, the rounding increment, the first day of the week and who may see other members time.", + open_module_settings: "Open module settings", + default_rate: "Default rate / hour", + week_start: "First day of the week", + weekday_0: "Sunday", + weekday_1: "Monday", + weekday_2: "Tuesday", + weekday_3: "Wednesday", + weekday_4: "Thursday", + weekday_5: "Friday", + weekday_6: "Saturday", + members_see_all_time: "Members see other members' time", + behaviour_title: "Task behaviour", + behaviour_description: "What happens when a task is created, invoiced or shown on the board. Change these in the module settings form.", + rounding_direction: "Rounding", + rounding_direction_nearest: "To the nearest increment", + rounding_direction_up: "Up to the increment", + rounding_direction_down: "Down to the increment", + rounding_increment: "Increment", + rounding_increment_value: "{count} minute | {count} minutes", + auto_start_tasks: "Start the timer on a new task", + lock_invoiced_tasks: "Lock invoiced tasks", + hide_invoiced_on_board: "Hide invoiced tasks on the board", + invoice_title: "Invoice lines", + invoice_description: "What an invoice line built from a task carries. Change these in the module settings form.", + invoice_project_heading: "Project heading", + invoice_task_description: "Task description", + invoice_entry_dates: "Entry dates", + invoice_entry_times: "Entry times", + invoice_entry_hours: "Entry hours", + invoice_entry_descriptions: "Entry descriptions", + on: "On", + off: "Off", + statuses_title: "Task statuses", + statuses_description: "The columns of the board. One status is the default, where new tasks land; a closed status counts as done.", + status_name: "Name", + colour: "Colour", + colour_none: "None", + is_default: "Default", + is_closed: "Closed", + add_status: "Add status", + new_status: "New status", + move_up: "Move up", + move_down: "Move down", + no_statuses: "No statuses yet.", + status_created: "{name} was added.", + status_updated: "{name} was updated.", + status_deleted: "{name} was deleted.", + status_reordered: "The order was saved.", + status_delete_confirm: "Delete {name}?", + status_name_required: "Enter a status name.", + load_failed: "Unable to load the task statuses.", + save_failed: "Unable to save the task status.", + delete_failed: "Unable to delete the task status.", + reorder_failed: "Unable to save the new order.", + forbidden: "Your role does not allow managing the board columns." + } +} } }, Sp = { + key: 0, + class: "text-sm text-muted" +}, Cp = { key: 1 }, wp = { + key: 0, + class: "flex items-center gap-2 text-sm text-muted" +}, Tp = { + key: 1, + class: "text-sm text-muted" +}, Ep = { + key: 2, + class: "divide-y divide-line-light" +}, Dp = { + key: 0, + class: "space-y-3" +}, Op = { class: "flex flex-wrap items-center gap-2" }, kp = ["aria-label", "onClick"], Ap = { class: "flex flex-wrap items-center gap-6" }, jp = { class: "flex items-center gap-2 text-sm text-body" }, Mp = { class: "flex items-center gap-2 text-sm text-body" }, Np = { class: "flex gap-3" }, Pp = { + key: 1, + class: "flex items-center gap-3" +}, Fp = { class: "min-w-0 flex-1 truncate text-sm font-medium text-heading" }, Ip = { class: "flex items-center gap-1" }, Lp = [ + "disabled", + "title", + "aria-label", + "onClick" +], Rp = [ + "disabled", + "title", + "aria-label", + "onClick" +], zp = [ + "title", + "aria-label", + "onClick" +], Bp = [ + "disabled", + "title", + "aria-label", + "onClick" +], Vp = { + key: 3, + class: "mt-4 space-y-3 rounded-lg border border-line-default p-3" +}, Hp = { class: "flex flex-wrap items-center gap-2" }, Up = ["aria-label", "onClick"], Wp = { class: "flex flex-wrap items-center gap-6" }, Gp = { class: "flex items-center gap-2 text-sm text-body" }, Kp = { class: "flex items-center gap-2 text-sm text-body" }, qp = { class: "flex gap-3" }, Jp = /* @__PURE__ */ l({ + __name: "TaskStatusEditor", + props: { + client: { type: [Function, Object] }, + notify: { type: Function } + }, + setup(t) { + let l = t, u = [ + "#94a3b8", + "#3b82f6", + "#22c55e", + "#f59e0b", + "#ef4444", + "#a855f7", + "#0891b2", + "#64748b" + ], d = B(), f = b([]), h = b(!0), _ = b(!1), T = b(!1), E = b(null), O = b(!1), k = y({ + name: "", + colour: "", + is_default: !1, + is_closed: !1 + }), A = n(() => !h.value && f.value.length === 0); + g(() => void j()); + async function j() { + h.value = !0; + try { + f.value = await Ie(l.client), _.value = !1; + } catch (e) { + f.value = [], _.value = vt(e), _.value || l.notify("error", H(e, d("tasks_projects.settings.load_failed"))); + } finally { + h.value = !1; + } + } + function M(e) { + O.value = !1, E.value = e.id, k.name = e.name, k.colour = e.colour ?? "", k.is_default = e.is_default, k.is_closed = e.is_closed; + } + function N() { + E.value = null, O.value = !0, k.name = "", k.colour = u[0], k.is_default = !1, k.is_closed = !1; + } + function P() { + E.value = null, O.value = !1; + } + function F() { + return { + name: k.name.trim(), + colour: k.colour || null, + is_default: k.is_default, + is_closed: k.is_closed + }; + } + async function I() { + if (T.value) return; + if (k.name.trim() === "") { + l.notify("error", d("tasks_projects.settings.status_name_required")); + return; + } + let e = E.value, t = k.name.trim(); + T.value = !0; + try { + e === null ? (await Le(l.client, F()), l.notify("success", d("tasks_projects.settings.status_created", { name: t }))) : (await Re(l.client, e, F()), l.notify("success", d("tasks_projects.settings.status_updated", { name: t }))), P(), await j(); + } catch (e) { + l.notify("error", H(e, d("tasks_projects.settings.save_failed"))); + } finally { + T.value = !1; + } + } + async function L(e) { + if (!T.value && window.confirm(d("tasks_projects.settings.status_delete_confirm", { name: e.name }))) { + T.value = !0; + try { + await ze(l.client, e.id), l.notify("success", d("tasks_projects.settings.status_deleted", { name: e.name })), P(), await j(); + } catch (e) { + l.notify("error", H(e, d("tasks_projects.settings.delete_failed"))); + } finally { + T.value = !1; + } + } + } + async function R(e, t) { + let n = e + t; + if (T.value || n < 0 || n >= f.value.length) return; + let r = [...f.value]; + r.splice(n, 0, ...r.splice(e, 1)), f.value = r, T.value = !0; + try { + f.value = await Be(l.client, r.map((e) => e.id)), l.notify("success", d("tasks_projects.settings.status_reordered")); + } catch (e) { + l.notify("error", H(e, d("tasks_projects.settings.reorder_failed"))), await j(); + } finally { + T.value = !1; + } + } + return (t, n) => { + let l = S("BaseSpinner"), g = S("BaseInput"), y = S("BaseInputGroup"), b = S("BaseSwitch"), j = S("BaseButton"), F = S("BaseBadge"), z = S("BaseIcon"); + return v(), a("div", null, [_.value ? (v(), a("p", Sp, C(w(d)("tasks_projects.settings.forbidden")), 1)) : (v(), a("div", Cp, [h.value ? (v(), a("div", wp, [c(l, { class: "h-4 w-4 text-primary-500" })])) : A.value ? (v(), a("p", Tp, C(w(d)("tasks_projects.settings.no_statuses")), 1)) : (v(), a("ul", Ep, [(v(!0), a(e, null, x(f.value, (t, l) => (v(), a("li", { + key: t.id, + class: "py-3" + }, [E.value === t.id ? (v(), a("div", Dp, [ + c(y, { + label: w(d)("tasks_projects.settings.status_name"), + required: "" + }, { + default: D(() => [c(g, { + modelValue: k.name, + "onUpdate:modelValue": n[0] ||= (e) => k.name = e, + type: "text", + maxlength: "255" + }, null, 8, ["modelValue"])]), + _: 1 + }, 8, ["label"]), + c(y, { label: w(d)("tasks_projects.settings.colour") }, { + default: D(() => [o("div", Op, [(v(), a(e, null, x(u, (e) => o("button", { + key: e, + type: "button", + class: p(["h-7 w-7 rounded-full border-2 transition", k.colour === e ? "border-heading" : "border-line-default"]), + style: m({ backgroundColor: e }), + "aria-label": e, + onClick: (t) => k.colour = e + }, null, 14, kp)), 64)), o("button", { + type: "button", + class: "rounded-md border border-line-default px-2 py-1 text-xs text-muted hover:bg-hover", + onClick: n[1] ||= (e) => k.colour = "" + }, C(w(d)("tasks_projects.settings.colour_none")), 1)])]), + _: 1 + }, 8, ["label"]), + o("div", Ap, [o("label", jp, [c(b, { + modelValue: k.is_default, + "onUpdate:modelValue": n[2] ||= (e) => k.is_default = e, + class: "flex" + }, null, 8, ["modelValue"]), s(" " + C(w(d)("tasks_projects.settings.is_default")), 1)]), o("label", Mp, [c(b, { + modelValue: k.is_closed, + "onUpdate:modelValue": n[3] ||= (e) => k.is_closed = e, + class: "flex" + }, null, 8, ["modelValue"]), s(" " + C(w(d)("tasks_projects.settings.is_closed")), 1)])]), + o("div", Np, [c(j, { + variant: "primary", + size: "sm", + disabled: T.value, + onClick: I + }, { + default: D(() => [s(C(w(d)("tasks_projects.general.save")), 1)]), + _: 1 + }, 8, ["disabled"]), c(j, { + variant: "primary-outline", + size: "sm", + onClick: P + }, { + default: D(() => [s(C(w(d)("tasks_projects.general.cancel")), 1)]), + _: 1 + })]) + ])) : (v(), a("div", Pp, [ + o("span", { + class: p(["inline-block h-3 w-3 shrink-0 rounded-full", t.colour ? "" : "bg-line-default"]), + style: m(t.colour ? { backgroundColor: t.colour } : void 0) + }, null, 6), + o("span", Fp, C(t.name), 1), + t.is_default ? (v(), r(F, { + key: 0, + class: "rounded-full bg-primary-50! text-primary-500!" + }, { + default: D(() => [s(C(w(d)("tasks_projects.settings.is_default")), 1)]), + _: 1 + })) : i("", !0), + t.is_closed ? (v(), r(F, { + key: 1, + class: "rounded-full bg-surface-tertiary! text-muted!" + }, { + default: D(() => [s(C(w(d)("tasks_projects.settings.is_closed")), 1)]), + _: 1 + })) : i("", !0), + o("div", Ip, [ + o("button", { + type: "button", + class: "rounded p-1 text-subtle hover:bg-hover hover:text-heading disabled:opacity-40", + disabled: T.value || l === 0, + title: w(d)("tasks_projects.settings.move_up"), + "aria-label": w(d)("tasks_projects.settings.move_up"), + onClick: (e) => R(l, -1) + }, [c(z, { + name: "ChevronUpIcon", + class: "h-4 w-4" + })], 8, Lp), + o("button", { + type: "button", + class: "rounded p-1 text-subtle hover:bg-hover hover:text-heading disabled:opacity-40", + disabled: T.value || l === f.value.length - 1, + title: w(d)("tasks_projects.settings.move_down"), + "aria-label": w(d)("tasks_projects.settings.move_down"), + onClick: (e) => R(l, 1) + }, [c(z, { + name: "ChevronDownIcon", + class: "h-4 w-4" + })], 8, Rp), + o("button", { + type: "button", + class: "rounded p-1 text-subtle hover:bg-hover hover:text-heading", + title: w(d)("tasks_projects.general.edit"), + "aria-label": w(d)("tasks_projects.general.edit"), + onClick: (e) => M(t) + }, [c(z, { + name: "PencilIcon", + class: "h-4 w-4" + })], 8, zp), + o("button", { + type: "button", + class: "rounded p-1 text-subtle hover:bg-hover hover:text-alert-error-text", + disabled: T.value, + title: w(d)("tasks_projects.general.delete"), + "aria-label": w(d)("tasks_projects.general.delete"), + onClick: (e) => L(t) + }, [c(z, { + name: "TrashIcon", + class: "h-4 w-4" + })], 8, Bp) + ]) + ]))]))), 128))])), O.value ? (v(), a("div", Vp, [ + c(y, { + label: w(d)("tasks_projects.settings.status_name"), + required: "" + }, { + default: D(() => [c(g, { + modelValue: k.name, + "onUpdate:modelValue": n[4] ||= (e) => k.name = e, + type: "text", + maxlength: "255" + }, null, 8, ["modelValue"])]), + _: 1 + }, 8, ["label"]), + c(y, { label: w(d)("tasks_projects.settings.colour") }, { + default: D(() => [o("div", Hp, [(v(), a(e, null, x(u, (e) => o("button", { + key: e, + type: "button", + class: p(["h-7 w-7 rounded-full border-2 transition", k.colour === e ? "border-heading" : "border-line-default"]), + style: m({ backgroundColor: e }), + "aria-label": e, + onClick: (t) => k.colour = e + }, null, 14, Up)), 64))])]), + _: 1 + }, 8, ["label"]), + o("div", Wp, [o("label", Gp, [c(b, { + modelValue: k.is_default, + "onUpdate:modelValue": n[5] ||= (e) => k.is_default = e, + class: "flex" + }, null, 8, ["modelValue"]), s(" " + C(w(d)("tasks_projects.settings.is_default")), 1)]), o("label", Kp, [c(b, { + modelValue: k.is_closed, + "onUpdate:modelValue": n[6] ||= (e) => k.is_closed = e, + class: "flex" + }, null, 8, ["modelValue"]), s(" " + C(w(d)("tasks_projects.settings.is_closed")), 1)])]), + o("div", qp, [c(j, { + variant: "primary", + size: "sm", + disabled: T.value, + onClick: I + }, { + default: D(() => [s(C(w(d)("tasks_projects.general.save")), 1)]), + _: 1 + }, 8, ["disabled"]), c(j, { + variant: "primary-outline", + size: "sm", + onClick: P + }, { + default: D(() => [s(C(w(d)("tasks_projects.general.cancel")), 1)]), + _: 1 + })]) + ])) : h.value ? i("", !0) : (v(), r(j, { + key: 4, + variant: "primary-outline", + size: "sm", + class: "mt-4", + onClick: N + }, { + left: D((e) => [c(z, { + name: "PlusIcon", + class: p(e.class) + }, null, 8, ["class"])]), + default: D(() => [s(" " + C(w(d)("tasks_projects.settings.add_status")), 1)]), + _: 1 + }))]))]); + }; + } +}), Yp = { class: "space-y-6" }, Xp = { class: "divide-y divide-line-light" }, Zp = { class: "text-sm text-muted" }, Qp = { class: "text-sm font-medium text-heading" }, $p = { class: "divide-y divide-line-light" }, em = { class: "text-sm text-muted" }, tm = { class: "text-sm font-medium text-heading" }, nm = { class: "divide-y divide-line-light" }, rm = { class: "text-sm text-muted" }, im = { class: "text-sm font-medium text-heading" }, am = /* @__PURE__ */ l({ + __name: "TimeSettingsPage", + props: { + client: { type: [Function, Object] }, + notify: { type: Function }, + router: {} + }, + setup(t) { + let r = B(), i = n(() => G.settings); + function l(e) { + return r(e ? "tasks_projects.settings.on" : "tasks_projects.settings.off"); + } + let u = n(() => [ + { + key: "default_rate", + label: r("tasks_projects.settings.default_rate"), + value: ot(i.value.default_rate) + }, + { + key: "week_start", + label: r("tasks_projects.settings.week_start"), + value: r(`tasks_projects.settings.weekday_${i.value.week_start}`) + }, + { + key: "members_see_all_time", + label: r("tasks_projects.settings.members_see_all_time"), + value: l(i.value.members_see_all_time) + } + ]), d = n(() => [ + { + key: "rounding_direction", + label: r("tasks_projects.settings.rounding_direction"), + value: r(`tasks_projects.settings.rounding_direction_${i.value.rounding_direction}`) + }, + { + key: "rounding_minutes", + label: r("tasks_projects.settings.rounding_increment"), + value: r("tasks_projects.settings.rounding_increment_value", { count: i.value.rounding_minutes }) + }, + { + key: "auto_start_tasks", + label: r("tasks_projects.settings.auto_start_tasks"), + value: l(i.value.auto_start_tasks) + }, + { + key: "lock_invoiced_tasks", + label: r("tasks_projects.settings.lock_invoiced_tasks"), + value: l(i.value.lock_invoiced_tasks) + }, + { + key: "hide_invoiced_on_board", + label: r("tasks_projects.settings.hide_invoiced_on_board"), + value: l(i.value.hide_invoiced_on_board) + } + ]), f = n(() => [ + "invoice_project_heading", + "invoice_task_description", + "invoice_entry_dates", + "invoice_entry_times", + "invoice_entry_hours", + "invoice_entry_descriptions" + ].map((e) => ({ + key: e, + label: r(`tasks_projects.settings.${e}`), + value: l(i.value[e]) + }))); + return (n, i) => { + let l = S("BaseIcon"), m = S("BaseButton"), h = S("router-link"), g = S("BaseSettingCard"); + return v(), a("div", Yp, [ + c(g, { + title: w(r)("tasks_projects.settings.general_title"), + description: w(r)("tasks_projects.settings.general_description") + }, { + action: D(() => [c(h, { to: w(W).settings }, { + default: D(() => [c(m, { + variant: "primary-outline", + size: "sm" + }, { + right: D((e) => [c(l, { + name: "ArrowTopRightOnSquareIcon", + class: p(e.class) + }, null, 8, ["class"])]), + default: D(() => [s(" " + C(w(r)("tasks_projects.settings.open_module_settings")), 1)]), + _: 1 + })]), + _: 1 + }, 8, ["to"])]), + default: D(() => [o("dl", Xp, [(v(!0), a(e, null, x(u.value, (e) => (v(), a("div", { + key: e.key, + class: "flex justify-between gap-4 py-2.5" + }, [o("dt", Zp, C(e.label), 1), o("dd", Qp, C(e.value), 1)]))), 128))])]), + _: 1 + }, 8, ["title", "description"]), + c(g, { + title: w(r)("tasks_projects.settings.behaviour_title"), + description: w(r)("tasks_projects.settings.behaviour_description") + }, { + default: D(() => [o("dl", $p, [(v(!0), a(e, null, x(d.value, (e) => (v(), a("div", { + key: e.key, + class: "flex justify-between gap-4 py-2.5" + }, [o("dt", em, C(e.label), 1), o("dd", tm, C(e.value), 1)]))), 128))])]), + _: 1 + }, 8, ["title", "description"]), + c(g, { + title: w(r)("tasks_projects.settings.invoice_title"), + description: w(r)("tasks_projects.settings.invoice_description") + }, { + default: D(() => [o("dl", nm, [(v(!0), a(e, null, x(f.value, (e) => (v(), a("div", { + key: e.key, + class: "flex justify-between gap-4 py-2.5" + }, [o("dt", rm, C(e.label), 1), o("dd", im, C(e.value), 1)]))), 128))])]), + _: 1 + }, 8, ["title", "description"]), + c(g, { + title: w(r)("tasks_projects.settings.statuses_title"), + description: w(r)("tasks_projects.settings.statuses_description") + }, { + default: D(() => [c(Jp, { + client: t.client, + notify: t.notify + }, null, 8, ["client", "notify"])]), + _: 1 + }, 8, ["title", "description"]) + ]); + }; + } +}); +//#endregion +//#region resources/js/registrations/time.ts +function om(e) { + e.addMessages(xp); + let t = (t, n) => { + e.notify(t, n); + }, n = () => { + let t = q.runningTaskId; + e.router.push(t === null ? W.week : W.task(t)); + }; + e.registerHeaderAction({ + id: `${U}.timer-chip`, + priority: 30, + visible: () => q.running !== null, + component: l({ setup: () => () => d(bp, { + client: e.client, + notify: t, + onOpen: n + }) }) + }), e.registerCompanyLayoutOverlay({ + id: `${U}.quick-start`, + component: l({ setup: () => () => d(Jf, { + key: G.companySession, + client: e.client, + notify: t, + enabled: !G.adminMode, + router: e.router, + onOpenTask: n + }) }) + }), e.registerCompanyLayoutOverlay({ + id: `${U}.stop-timer`, + component: l({ setup: () => () => d(mp, { key: G.companySession }) }) + }), e.registerCompanyLayoutOverlay({ + id: `${U}.start-timer`, + component: l({ setup: () => () => d(np, { + key: G.companySession, + client: e.client, + notify: t + }) }) + }), e.registerCompanySettingsPage({ + id: `${U}.settings`, + title: "tasks_projects.settings.title", + icon: "ClockIcon", + path: U, + priority: 70, + component: sn(e, am) + }), e.on("bootstrap:completed", ({ adminMode: t }) => { + sm(e, t); + }), e.on("company:changing", () => { + cm(); + }), e.on("company:changed", ({ companyId: t }) => { + sm(e, t === null); + }); +} +async function sm(e, t) { + if (Ca(t), t) { + cm(); + return; + } + await xa(e.client), await q.refresh(e.client); +} +function cm() { + q.reset(), rt(), Sa(); +} +//#endregion //#region resources/js/init.ts window.InvoiceShelf.booting((e, t, n) => { - n.addMessages({ en: { tasks_projects: { title: "Projects" } } }); + n.addMessages(j), Nf(n), Zo(n), om(n), nr(n), Ks(n); }); //#endregion diff --git a/dist/style.css b/dist/style.css index e698e04..ee1c7ee 100644 --- a/dist/style.css +++ b/dist/style.css @@ -1,3 +1,3 @@ /*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */ -@layer utilities{.visible{visibility:visible}.absolute{position:absolute}.fixed{position:fixed}.static{position:static}.contents{display:contents}.table{display:table}} +@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-space-y-reverse:0;--tw-space-x-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial}}}@layer utilities{.visible{visibility:visible}.fixed{position:fixed}.relative{position:relative}.right-5{right:calc(var(--spacing,.25rem) * 5)}.bottom-5{bottom:calc(var(--spacing,.25rem) * 5)}.bottom-24{bottom:calc(var(--spacing,.25rem) * 24)}.z-40{z-index:40}.float-left{float:left}.m-0{margin:0}.mt-0\.5{margin-top:calc(var(--spacing,.25rem) * .5)}.mt-1{margin-top:var(--spacing,.25rem)}.mt-2{margin-top:calc(var(--spacing,.25rem) * 2)}.mt-3{margin-top:calc(var(--spacing,.25rem) * 3)}.mt-4{margin-top:calc(var(--spacing,.25rem) * 4)}.mt-5{margin-top:calc(var(--spacing,.25rem) * 5)}.mt-6{margin-top:calc(var(--spacing,.25rem) * 6)}.mr-2{margin-right:calc(var(--spacing,.25rem) * 2)}.mr-3{margin-right:calc(var(--spacing,.25rem) * 3)}.-mb-px{margin-bottom:-1px}.mb-1{margin-bottom:var(--spacing,.25rem)}.mb-3{margin-bottom:calc(var(--spacing,.25rem) * 3)}.mb-4{margin-bottom:calc(var(--spacing,.25rem) * 4)}.ml-1{margin-left:var(--spacing,.25rem)}.ml-2{margin-left:calc(var(--spacing,.25rem) * 2)}.ml-auto{margin-left:auto}.block{display:block}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline-block{display:inline-block}.inline-flex{display:inline-flex}.table{display:table}.h-1\.5{height:calc(var(--spacing,.25rem) * 1.5)}.h-2{height:calc(var(--spacing,.25rem) * 2)}.h-2\.5{height:calc(var(--spacing,.25rem) * 2.5)}.h-3{height:calc(var(--spacing,.25rem) * 3)}.h-3\.5{height:calc(var(--spacing,.25rem) * 3.5)}.h-4{height:calc(var(--spacing,.25rem) * 4)}.h-5{height:calc(var(--spacing,.25rem) * 5)}.h-6{height:calc(var(--spacing,.25rem) * 6)}.h-7{height:calc(var(--spacing,.25rem) * 7)}.h-8{height:calc(var(--spacing,.25rem) * 8)}.h-14{height:calc(var(--spacing,.25rem) * 14)}.h-16{height:calc(var(--spacing,.25rem) * 16)}.min-h-20{min-height:calc(var(--spacing,.25rem) * 20)}.min-h-40{min-height:calc(var(--spacing,.25rem) * 40)}.w-1\.5{width:calc(var(--spacing,.25rem) * 1.5)}.w-2{width:calc(var(--spacing,.25rem) * 2)}.w-2\.5{width:calc(var(--spacing,.25rem) * 2.5)}.w-3{width:calc(var(--spacing,.25rem) * 3)}.w-3\.5{width:calc(var(--spacing,.25rem) * 3.5)}.w-4{width:calc(var(--spacing,.25rem) * 4)}.w-5{width:calc(var(--spacing,.25rem) * 5)}.w-6{width:calc(var(--spacing,.25rem) * 6)}.w-7{width:calc(var(--spacing,.25rem) * 7)}.w-8{width:calc(var(--spacing,.25rem) * 8)}.w-10{width:calc(var(--spacing,.25rem) * 10)}.w-14{width:calc(var(--spacing,.25rem) * 14)}.w-16{width:calc(var(--spacing,.25rem) * 16)}.w-64{width:calc(var(--spacing,.25rem) * 64)}.w-80{width:calc(var(--spacing,.25rem) * 80)}.w-full{width:100%}.max-w-32{max-width:calc(var(--spacing,.25rem) * 32)}.max-w-64{max-width:calc(var(--spacing,.25rem) * 64)}.max-w-\[calc\(100vw-3rem\)\]{max-width:calc(100vw - 3rem)}.min-w-0{min-width:0}.min-w-44{min-width:calc(var(--spacing,.25rem) * 44)}.min-w-48{min-width:calc(var(--spacing,.25rem) * 48)}.min-w-full{min-width:100%}.flex-1{flex:1}.shrink-0{flex-shrink:0}.table-auto{table-layout:auto}.animate-pulse{animation:var(--animate-pulse,pulse 2s cubic-bezier(.4, 0, .6, 1) infinite)}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-baseline{align-items:baseline}.items-center{align-items:center}.items-end{align-items:flex-end}.items-start{align-items:flex-start}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}.gap-1{gap:var(--spacing,.25rem)}.gap-1\.5{gap:calc(var(--spacing,.25rem) * 1.5)}.gap-2{gap:calc(var(--spacing,.25rem) * 2)}.gap-3{gap:calc(var(--spacing,.25rem) * 3)}.gap-4{gap:calc(var(--spacing,.25rem) * 4)}.gap-6{gap:calc(var(--spacing,.25rem) * 6)}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing,.25rem) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing,.25rem) * 2) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing,.25rem) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing,.25rem) * 3) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing,.25rem) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing,.25rem) * 4) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing,.25rem) * 5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing,.25rem) * 5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-6>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing,.25rem) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing,.25rem) * 6) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-x-3>:not(:last-child)){--tw-space-x-reverse:0;margin-inline-start:calc(calc(var(--spacing,.25rem) * 3) * var(--tw-space-x-reverse));margin-inline-end:calc(calc(var(--spacing,.25rem) * 3) * calc(1 - var(--tw-space-x-reverse)))}:where(.space-x-5>:not(:last-child)){--tw-space-x-reverse:0;margin-inline-start:calc(calc(var(--spacing,.25rem) * 5) * var(--tw-space-x-reverse));margin-inline-end:calc(calc(var(--spacing,.25rem) * 5) * calc(1 - var(--tw-space-x-reverse)))}:where(.divide-y>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px * var(--tw-divide-y-reverse));border-bottom-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)))}:where(.divide-line-default>:not(:last-child)){border-color:var(--color-line-default)}:where(.divide-line-light>:not(:last-child)){border-color:var(--color-line-light)}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.rounded{border-radius:.25rem}.rounded-full{border-radius:2147483647px}.rounded-lg{border-radius:var(--radius-lg,.5rem)}.rounded-md{border-radius:var(--radius-md,.375rem)}.rounded-sm{border-radius:var(--radius-sm,.25rem)}.rounded-xl{border-radius:var(--radius-xl,.75rem)}.border{border-style:var(--tw-border-style);border-width:1px}.border-2{border-style:var(--tw-border-style);border-width:2px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-r{border-right-style:var(--tw-border-style);border-right-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-b-2{border-bottom-style:var(--tw-border-style);border-bottom-width:2px}.border-dashed{--tw-border-style:dashed;border-style:dashed}.border-heading{border-color:var(--color-heading)}.border-line-default{border-color:var(--color-line-default)}.border-line-light{border-color:var(--color-line-light)}.border-line-strong{border-color:var(--color-line-strong)}.border-primary-200{border-color:var(--color-primary-200)}.border-primary-400{border-color:var(--color-primary-400)}.border-primary-500{border-color:var(--color-primary-500)}.border-status-yellow{border-color:var(--color-status-yellow)}.border-transparent{border-color:#0000}.bg-alert-error-bg{background-color:var(--color-alert-error-bg)}.bg-alert-success-bg\!{background-color:var(--color-alert-success-bg)!important}.bg-alert-warning-bg{background-color:var(--color-alert-warning-bg)}.bg-alert-warning-bg\!{background-color:var(--color-alert-warning-bg)!important}.bg-btn-primary{background-color:var(--color-btn-primary)}.bg-line-default{background-color:var(--color-line-default)}.bg-line-strong{background-color:var(--color-line-strong)}.bg-primary-50{background-color:var(--color-primary-50)}.bg-primary-50\!{background-color:var(--color-primary-50)!important}.bg-primary-500{background-color:var(--color-primary-500)}.bg-status-green{background-color:var(--color-status-green)}.bg-status-red{background-color:var(--color-status-red)}.bg-surface{background-color:var(--color-surface)}.bg-surface-secondary{background-color:var(--color-surface-secondary)}.bg-surface-tertiary{background-color:var(--color-surface-tertiary)}.bg-surface-tertiary\!{background-color:var(--color-surface-tertiary)!important}.bg-white{background-color:var(--color-white,#fff)}.bg-white\/20{background-color:#fff3}@supports (color:color-mix(in lab, red, red)){.bg-white\/20{background-color:color-mix(in oklab, var(--color-white,#fff) 20%, transparent)}}.p-0{padding:0}.p-1{padding:var(--spacing,.25rem)}.p-1\.5{padding:calc(var(--spacing,.25rem) * 1.5)}.p-2{padding:calc(var(--spacing,.25rem) * 2)}.p-3{padding:calc(var(--spacing,.25rem) * 3)}.p-5{padding:calc(var(--spacing,.25rem) * 5)}.px-1{padding-inline:var(--spacing,.25rem)}.px-1\.5{padding-inline:calc(var(--spacing,.25rem) * 1.5)}.px-2{padding-inline:calc(var(--spacing,.25rem) * 2)}.px-3{padding-inline:calc(var(--spacing,.25rem) * 3)}.px-4{padding-inline:calc(var(--spacing,.25rem) * 4)}.px-5{padding-inline:calc(var(--spacing,.25rem) * 5)}.px-6{padding-inline:calc(var(--spacing,.25rem) * 6)}.py-0\.5{padding-block:calc(var(--spacing,.25rem) * .5)}.py-1{padding-block:var(--spacing,.25rem)}.py-1\.5{padding-block:calc(var(--spacing,.25rem) * 1.5)}.py-2{padding-block:calc(var(--spacing,.25rem) * 2)}.py-2\.5{padding-block:calc(var(--spacing,.25rem) * 2.5)}.py-3{padding-block:calc(var(--spacing,.25rem) * 3)}.py-4{padding-block:calc(var(--spacing,.25rem) * 4)}.py-6{padding-block:calc(var(--spacing,.25rem) * 6)}.py-8{padding-block:calc(var(--spacing,.25rem) * 8)}.py-10{padding-block:calc(var(--spacing,.25rem) * 10)}.py-16{padding-block:calc(var(--spacing,.25rem) * 16)}.pt-2{padding-top:calc(var(--spacing,.25rem) * 2)}.pt-3{padding-top:calc(var(--spacing,.25rem) * 3)}.pb-2{padding-bottom:calc(var(--spacing,.25rem) * 2)}.pb-3{padding-bottom:calc(var(--spacing,.25rem) * 3)}.pb-4{padding-bottom:calc(var(--spacing,.25rem) * 4)}.pl-4{padding-left:calc(var(--spacing,.25rem) * 4)}.text-center{text-align:center}.text-left{text-align:left}.text-right{text-align:right}.text-2xl{font-size:var(--text-2xl,1.5rem);line-height:var(--tw-leading,var(--text-2xl--line-height,calc(2 / 1.5)))}.text-3xl{font-size:var(--text-3xl,1.875rem);line-height:var(--tw-leading,var(--text-3xl--line-height,calc(2.25 / 1.875)))}.text-base{font-size:var(--text-base,1rem);line-height:var(--tw-leading,var(--text-base--line-height,calc(1.5 / 1)))}.text-lg{font-size:var(--text-lg,1.125rem);line-height:var(--tw-leading,var(--text-lg--line-height,calc(1.75 / 1.125)))}.text-sm{font-size:var(--text-sm,.875rem);line-height:var(--tw-leading,var(--text-sm--line-height,calc(1.25 / .875)))}.text-xl{font-size:var(--text-xl,1.25rem);line-height:var(--tw-leading,var(--text-xl--line-height,calc(1.75 / 1.25)))}.text-xs{font-size:var(--text-xs,.75rem);line-height:var(--tw-leading,var(--text-xs--line-height,calc(1 / .75)))}.text-\[11px\]{font-size:11px}.leading-5{--tw-leading:calc(var(--spacing,.25rem) * 5);line-height:calc(var(--spacing,.25rem) * 5)}.font-medium{--tw-font-weight:var(--font-weight-medium,500);font-weight:var(--font-weight-medium,500)}.font-normal{--tw-font-weight:var(--font-weight-normal,400);font-weight:var(--font-weight-normal,400)}.font-semibold{--tw-font-weight:var(--font-weight-semibold,600);font-weight:var(--font-weight-semibold,600)}.tracking-wide{--tw-tracking:var(--tracking-wide,.025em);letter-spacing:var(--tracking-wide,.025em)}.tracking-wider{--tw-tracking:var(--tracking-wider,.05em);letter-spacing:var(--tracking-wider,.05em)}.whitespace-nowrap{white-space:nowrap}.whitespace-pre-line{white-space:pre-line}.text-alert-error-text{color:var(--color-alert-error-text)}.text-alert-success-text\!{color:var(--color-alert-success-text)!important}.text-alert-warning-text{color:var(--color-alert-warning-text)}.text-alert-warning-text\!{color:var(--color-alert-warning-text)!important}.text-body{color:var(--color-body)}.text-heading{color:var(--color-heading)}.text-muted{color:var(--color-muted)}.text-muted\!{color:var(--color-muted)!important}.text-primary-500{color:var(--color-primary-500)}.text-primary-500\!{color:var(--color-primary-500)!important}.text-primary-600{color:var(--color-primary-600)}.text-primary-700{color:var(--color-primary-700)}.text-status-green{color:var(--color-status-green)}.text-status-red{color:var(--color-status-red)}.text-subtle{color:var(--color-subtle)}.text-white{color:var(--color-white,#fff)}.uppercase{text-transform:uppercase}.tabular-nums{--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.opacity-40{opacity:.4}.shadow-2xl{--tw-shadow:0 25px 50px -12px var(--tw-shadow-color,#00000040);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a), 0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function,cubic-bezier(.4, 0, .2, 1)));transition-duration:var(--tw-duration,var(--default-transition-duration,.15s))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function,cubic-bezier(.4, 0, .2, 1)));transition-duration:var(--tw-duration,var(--default-transition-duration,.15s))}@media (hover:hover){.group-hover\:text-muted:is(:where(.group):hover *){color:var(--color-muted)}}.last\:border-r-0:last-child{border-right-style:var(--tw-border-style);border-right-width:0}@media (hover:hover){.hover\:border-line-strong:hover{border-color:var(--color-line-strong)}.hover\:border-primary-500:hover{border-color:var(--color-primary-500)}.hover\:bg-btn-primary-hover:hover{background-color:var(--color-btn-primary-hover)}.hover\:bg-hover:hover{background-color:var(--color-hover)}.hover\:bg-white\/20:hover{background-color:#fff3}@supports (color:color-mix(in lab, red, red)){.hover\:bg-white\/20:hover{background-color:color-mix(in oklab, var(--color-white,#fff) 20%, transparent)}}.hover\:text-alert-error-text:hover{color:var(--color-alert-error-text)}.hover\:text-body:hover{color:var(--color-body)}.hover\:text-heading:hover{color:var(--color-heading)}.hover\:text-primary-500:hover{color:var(--color-primary-500)}.hover\:underline:hover{text-decoration-line:underline}}.focus\:outline-hidden:focus{--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.focus\:outline-hidden:focus{outline-offset:2px;outline:2px solid #0000}}.disabled\:opacity-40:disabled{opacity:.4}.disabled\:opacity-50:disabled{opacity:.5}@media not all and (width>=40rem){.max-sm\:hidden{display:none}}@media (width>=40rem){.sm\:w-48{width:calc(var(--spacing,.25rem) * 48)}.sm\:w-56{width:calc(var(--spacing,.25rem) * 56)}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}}@media (width>=48rem){.md\:h-9{height:calc(var(--spacing,.25rem) * 9)}.md\:px-3{padding-inline:calc(var(--spacing,.25rem) * 3)}}@media (width>=64rem){.lg\:mb-1{margin-bottom:var(--spacing,.25rem)}.lg\:block{display:block}.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.lg\:flex-row{flex-direction:row}.lg\:items-end{align-items:flex-end}}@media (width>=80rem){.xl\:grid-cols-7{grid-template-columns:repeat(7,minmax(0,1fr))}}}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-space-x-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-divide-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-ordinal{syntax:"*";inherits:false}@property --tw-slashed-zero{syntax:"*";inherits:false}@property --tw-numeric-figure{syntax:"*";inherits:false}@property --tw-numeric-spacing{syntax:"*";inherits:false}@property --tw-numeric-fraction{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@keyframes pulse{50%{opacity:.5}} /*$vite$:1*/ \ No newline at end of file diff --git a/package.json b/package.json index 10e67d9..1cde4fc 100644 --- a/package.json +++ b/package.json @@ -8,8 +8,12 @@ "build": "vite build", "lint": "eslint resources/js --ext .ts,.vue --max-warnings 0" }, + "dependencies": { + "sortablejs": "^1.15.7" + }, "devDependencies": { "@tailwindcss/vite": "^4.0.0", + "@types/sortablejs": "^1.15.9", "@vitejs/plugin-vue": "^6.0.5", "axios": "^1.13.6", "eslint": "^9.39.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8d8740d..868f3c2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,10 +7,17 @@ settings: importers: .: + dependencies: + sortablejs: + specifier: ^1.15.7 + version: 1.15.7 devDependencies: '@tailwindcss/vite': specifier: ^4.0.0 version: 4.3.3(vite@8.3.0(jiti@2.7.0)) + '@types/sortablejs': + specifier: ^1.15.9 + version: 1.15.9 '@vitejs/plugin-vue': specifier: ^6.0.5 version: 6.0.8(vite@8.3.0(jiti@2.7.0))(vue@3.5.42(typescript@6.0.3)) @@ -340,6 +347,9 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/sortablejs@1.15.9': + resolution: {integrity: sha512-7HP+rZGE2p886PKV9c9OJzLBI6BBJu1O7lJGYnPyG3fS4/duUCcngkNCjsLwIMV+WMqANe3tt4irrXHSIe68OQ==} + '@typescript-eslint/eslint-plugin@8.70.0': resolution: {integrity: sha512-/v8HZt6RlyIZxB3ntehELOcUcfxKPVGWXnQdJuHRmzrqgF8nQypcC/oxGW+Ot4VGKDq81XugPKxx0n5PBtf9PA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -1057,6 +1067,9 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} + sortablejs@1.15.7: + resolution: {integrity: sha512-Kk8wLQPlS+yi1ZEf48a4+fzHa4yxjC30M/Sr2AnQu+f/MPwvvX9XjZ6OWejiz8crBsLwSq8GHqaxaET7u6ux0A==} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -1406,6 +1419,8 @@ snapshots: '@types/json-schema@7.0.15': {} + '@types/sortablejs@1.15.9': {} + '@typescript-eslint/eslint-plugin@8.70.0(@typescript-eslint/parser@8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3)': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -2122,6 +2137,8 @@ snapshots: shebang-regex@3.0.0: {} + sortablejs@1.15.7: {} + source-map-js@1.2.1: {} strip-json-comments@3.1.1: {} diff --git a/resources/css/module.css b/resources/css/module.css index ab0062c..71370d0 100644 --- a/resources/css/module.css +++ b/resources/css/module.css @@ -1,5 +1,13 @@ @reference "tailwindcss/theme.css"; -@import "tailwindcss/utilities" layer(utilities); + +/* + * `source(none)` turns off Tailwind's automatic content detection, which would + * otherwise scan the whole repository and scrape utility-looking words out of + * PHP, Markdown and config files. Only the module's own components feed the + * scanner, so the compiled stylesheet depends on the frontend alone and a + * backend change can never move dist/style.css. + */ +@import "tailwindcss/utilities" layer(utilities) source(none); @source "../js/**/*.vue"; @source "../js/**/*.ts"; diff --git a/resources/js/api.ts b/resources/js/api.ts new file mode 100644 index 0000000..0f52b22 --- /dev/null +++ b/resources/js/api.ts @@ -0,0 +1,139 @@ +import type { AxiosInstance } from 'axios' +import type { Customer, Paginated, Wrapped } from '@/types/api' +import type { CompanyMember } from '@/types/member' +import type { ModuleSettings } from '@/types/settings' +import type { Project, ProjectInput, ProjectListParams } from '@/types/project' + +/** Every module path hangs off the slug prefix, so a core route can never collide. */ +export const BASE = '/api/v1/tasks-projects' + +/** Every endpoint the module owns. */ +export const TASKS_PROJECTS_API = { + projects: `${BASE}/projects`, + project: (id: number): string => `${BASE}/projects/${id}`, + archiveProject: (id: number): string => `${BASE}/projects/${id}/archive`, + unarchiveProject: (id: number): string => `${BASE}/projects/${id}/unarchive`, + members: `${BASE}/members`, + settings: `${BASE}/settings`, +} as const + +/** Host endpoints the module reads through the same client. */ +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 & SortParams, +): Promise> { + const { data } = await client.get>(TASKS_PROJECTS_API.projects, { params }) + + return data +} + +export async function createProject(client: AxiosInstance, input: ProjectInput): Promise { + const { data } = await client.post>(TASKS_PROJECTS_API.projects, input) + + return data.data +} + +export async function updateProject( + client: AxiosInstance, + id: number, + input: ProjectInput, +): Promise { + const { data } = await client.put>(TASKS_PROJECTS_API.project(id), input) + + return data.data +} + +export async function archiveProject(client: AxiosInstance, id: number): Promise { + const { data } = await client.post>(TASKS_PROJECTS_API.archiveProject(id)) + + return data.data +} + +export async function unarchiveProject(client: AxiosInstance, id: number): Promise { + const { data } = await client.post>(TASKS_PROJECTS_API.unarchiveProject(id)) + + return data.data +} + +export async function deleteProject(client: AxiosInstance, id: number): Promise { + await client.delete(TASKS_PROJECTS_API.project(id)) +} + +/** The company's members, for the assignee and project member pickers. */ +export async function listMembers(client: AxiosInstance): Promise { + const { data } = await client.get>(TASKS_PROJECTS_API.members) + + return data.data +} + +export async function fetchSettings(client: AxiosInstance): Promise { + const { data } = await client.get>(TASKS_PROJECTS_API.settings) + + return data.data +} + +/** + * The company's contacts, for the project form's customer picker. This is a + * host endpoint, scoped by the same `company` header the client already sends. + */ +export async function listCustomers(client: AxiosInstance, limit = 100): Promise { + const { data } = await client.get>(HOST_API.customers, { params: { limit } }) + + return data.data +} diff --git a/resources/js/api/billing.ts b/resources/js/api/billing.ts new file mode 100644 index 0000000..baee174 --- /dev/null +++ b/resources/js/api/billing.ts @@ -0,0 +1,236 @@ +import type { AxiosInstance } from 'axios' +import type { Wrapped } from '@/types/api' +import type { + BillingCustomer, + BillingSelection, + CompanyInvoiceDefaults, + ConfirmItem, + CreatedInvoice, + CurrencyFormat, + InvoicePayload, + InvoiceTemplate, + PreparedInvoice, + UnbilledCustomer, + UnbilledTime, +} from '@/types/billing' + +const BASE = '/api/v1/tasks-projects' + +/** The module endpoints the invoicing flow talks to. */ +export const BILLING_API = { + customers: `${BASE}/billing/customers`, + unbilled: `${BASE}/billing/unbilled`, + prepare: `${BASE}/billing/prepare`, + confirm: `${BASE}/billing/confirm`, +} as const + +/** + * Host endpoints, reached through the same session client. + * + * The invoice itself is written by the host's own endpoint rather than by the + * module, which is what keeps the module out of the invoice tables: same + * session, same permission checks, same validation and same numbering. + */ +export const HOST_BILLING_API = { + bootstrap: '/api/v1/bootstrap', + customer: (customerId: number): string => `/api/v1/customers/${customerId}`, + invoices: '/api/v1/invoices', + invoiceTemplates: '/api/v1/invoices/templates', + nextNumber: '/api/v1/next-number', + exchangeRate: (currencyId: number): string => `/api/v1/currencies/${currencyId}/exchange-rate`, +} as const + +export interface UnbilledRange { + /** `Y-m-d`, inclusive. */ + from?: string + /** `Y-m-d`, inclusive. */ + to?: string +} + +/** Who has billable time waiting, and how much of it. */ +export async function listUnbilledCustomers( + client: AxiosInstance, + range: UnbilledRange = {}, +): Promise { + const { data } = await client.get>(BILLING_API.customers, { + params: range, + }) + + return data.data ?? [] +} + +/** One customer's unbilled entries, with the four grouped views over them. */ +export async function fetchUnbilledTime( + client: AxiosInstance, + customerId: number, + range: UnbilledRange = {}, +): Promise { + const { data } = await client.get>(BILLING_API.unbilled, { + params: { customer_id: customerId, ...range }, + }) + + return data.data +} + +/** + * The invoice body for a selection, plus the entries behind each line. + * + * The selection arrives in whichever of the three shapes the calling screen + * knows and leaves as the one key the endpoint expects, because the rules + * refuse a body that names two of them. The grouping rides along only when the + * caller chose one, so the server's own default stays the default. + */ +export async function prepareInvoice( + client: AxiosInstance, + selection: BillingSelection, +): Promise { + const { data } = await client.post>( + BILLING_API.prepare, + prepareBody(selection), + ) + + return data.data +} + +/** The one selection key the request carries, plus the grouping when set. */ +function prepareBody(selection: BillingSelection): Record { + const body: Record = + 'taskIds' in selection + ? { task_ids: selection.taskIds } + : 'projectId' in selection + ? { project_id: selection.projectId } + : { entry_ids: selection.entryIds } + + if (selection.grouping !== undefined) { + body.grouping = selection.grouping + } + + return body +} + +/** + * Stamp the entries with the ids the host handed back. + * + * Idempotent, so a flow that created the invoice and then lost the stamp can + * offer the same call again rather than a second invoice. + */ +export async function confirmInvoice( + client: AxiosInstance, + invoiceId: number, + items: ConfirmItem[], +): Promise { + const { data } = await client.post<{ stamped?: number }>(BILLING_API.confirm, { + invoice_id: invoiceId, + items, + }) + + return data?.stamped ?? 0 +} + +/** + * One contact, read for the currency it settles in. + * + * The host's invoice endpoint compares the *contact's* currency with the + * company setting to decide whether an exchange rate is required, so the + * answer has to come from the contact rather than from the currency the time + * happened to be logged in. + */ +export async function fetchBillingCustomer( + client: AxiosInstance, + customerId: number, +): Promise { + const { data } = await client.get>( + HOST_BILLING_API.customer(customerId), + ) + + return data?.data ?? null +} + +/** Create the draft invoice with the session's own client. */ +export async function createInvoice( + client: AxiosInstance, + payload: InvoicePayload, +): Promise { + const { data } = await client.post>(HOST_BILLING_API.invoices, payload) + + return data.data +} + +export async function listInvoiceTemplates(client: AxiosInstance): Promise { + const { data } = await client.get<{ invoiceTemplates?: InvoiceTemplate[] }>( + HOST_BILLING_API.invoiceTemplates, + ) + + return data?.invoiceTemplates ?? [] +} + +/** + * The number the next invoice would carry. + * + * The customer goes along as `userId`, which is what the host's serial + * numbering calls it, so a per-customer number format resolves the same way it + * does on the host's own form. + */ +export async function fetchNextInvoiceNumber( + client: AxiosInstance, + customerId?: number, +): Promise { + const params: Record = { key: 'invoice' } + + if (customerId !== undefined) { + params.userId = customerId + } + + const { data } = await client.get<{ success?: boolean; nextNumber?: string }>( + HOST_BILLING_API.nextNumber, + { params }, + ) + + return data?.success && typeof data.nextNumber === 'string' ? data.nextNumber : null +} + +/** + * The rate from a customer's currency into the company's own. + * + * The endpoint answers a bare number, a one-element array or an error object + * depending on whether a live provider, a logged rate or nothing at all + * supplied it, so all three are read here and anything else becomes null. + */ +export async function fetchExchangeRate( + client: AxiosInstance, + currencyId: number, +): Promise { + const { data } = await client.get<{ exchangeRate?: unknown }>( + HOST_BILLING_API.exchangeRate(currencyId), + ) + const rate = Array.isArray(data?.exchangeRate) ? data.exchangeRate[0] : data?.exchangeRate + const value = Number(rate) + + return Number.isFinite(value) && value > 0 ? value : null +} + +/** The company's invoice defaults, read from the host bootstrap payload. */ +export async function fetchCompanyInvoiceDefaults( + client: AxiosInstance, +): Promise { + const { data } = await client.get<{ + current_company_settings?: Record + current_company_currency?: CurrencyFormat | null + current_user_settings?: Record + }>(HOST_BILLING_API.bootstrap) + + const settings = data?.current_company_settings ?? {} + const userSettings = data?.current_user_settings ?? {} + const days = Number(settings.invoice_due_date_days) + const template = userSettings.default_invoice_template + + return { + currency: data?.current_company_currency ?? null, + dueDateDays: Number.isFinite(days) && days >= 0 ? days : 0, + setDueDateAutomatically: settings.invoice_set_due_date_automatically === 'YES', + // Anything but an explicit NO leaves the host numbering the invoice, which + // is what an older company with no stored value expects. + autoGenerateNumber: settings.invoice_auto_generate !== 'NO', + defaultTemplate: typeof template === 'string' && template !== '' ? template : null, + } +} diff --git a/resources/js/api/board.ts b/resources/js/api/board.ts new file mode 100644 index 0000000..437e553 --- /dev/null +++ b/resources/js/api/board.ts @@ -0,0 +1,234 @@ +import type { AxiosInstance } from 'axios' +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' +import type { + Task, + TaskBulkInput, + TaskBulkResult, + TaskInput, + TaskListParams, + TaskMoveInput, + TaskQuickInput, +} from '@/types/task' +import type { TaskStatus } from '@/types/task-status' +import type { TimeEntry, TimeEntryListParams } from '@/types/time-entry' +import type { StopTimerInput } from '@/types/timer' + +/** 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`, + tasks: `${BASE}/tasks`, + task: (id: number): string => `${BASE}/tasks/${id}`, + moveTask: (id: number): string => `${BASE}/tasks/${id}/move`, + startTask: (id: number): string => `${BASE}/tasks/${id}/start`, + stopTask: (id: number): string => `${BASE}/tasks/${id}/stop`, + taskTimeLog: (id: number): string => `${BASE}/tasks/${id}/time-log`, + bulkTasks: `${BASE}/tasks/bulk`, + taskStatuses: `${BASE}/task-statuses`, + timeEntries: `${BASE}/time-entries`, + projectMembers: (projectId: number): string => `${BASE}/projects/${projectId}/members`, + projectMember: (projectId: number, userId: number): string => + `${BASE}/projects/${projectId}/members/${userId}`, +} as const + +/** Every column of the company with its tasks, in one request. */ +export async function fetchBoard( + client: AxiosInstance, + params: BoardParams, +): Promise { + const { data } = await client.get>(BOARD_API.board, { params }) + + return data.data +} + +/** The board columns on their own, for the drawer's status picker. */ +export async function listTaskStatuses(client: AxiosInstance): Promise { + const { data } = await client.get>(BOARD_API.taskStatuses) + + return data.data +} + +export async function listTasks( + client: AxiosInstance, + params: TaskListParams & SortParams, +): Promise> { + const { data } = await client.get>(BOARD_API.tasks, { params }) + + return data +} + +/** + * Create a task. + * + * The quick shape is the whole of what the start dialog knows: the server + * picks the default column and the rest of the defaults, so starting the clock + * on something new never means filling in a form first. + */ +export async function createTask( + client: AxiosInstance, + input: TaskInput | TaskQuickInput, +): Promise { + const { data } = await client.post>(BOARD_API.tasks, input) + + return data.data +} + +export async function updateTask( + client: AxiosInstance, + id: number, + input: TaskInput, +): Promise { + const { data } = await client.put>(BOARD_API.task(id), input) + + return data.data +} + +export async function deleteTask(client: AxiosInstance, id: number): Promise { + await client.delete(BOARD_API.task(id)) +} + +/** One task with the time summary the list carries, for the task page. */ +export async function fetchTaskDetail(client: AxiosInstance, id: number): Promise { + const { data } = await client.get>(BOARD_API.task(id)) + + return data.data +} + +/** + * Put the caller's clock on a task. + * + * Answers 409 `timer_already_running` when their timer is on another task, + * which is a question for the caller rather than a failure: the run control + * offers to stop the other one first. + */ +export async function startTask( + client: AxiosInstance, + id: number, + description: string | null = null, + billable?: boolean, +): Promise { + const body: { description?: string | null; billable?: boolean } = {} + + if (description !== null) { + body.description = description + } + + if (billable !== undefined) { + body.billable = billable + } + + const { data } = await client.post>(BOARD_API.startTask(id), body) + + return data.data +} + +/** Close the caller's running entry on a task. 409 `timer_mismatch` if it moved. */ +export async function stopTask( + client: AxiosInstance, + id: number, + input: StopTimerInput = {}, +): Promise { + const { data } = await client.post>(BOARD_API.stopTask(id), input) + + return data.data +} + +/** + * Every entry logged against one task, running first and then newest. + * + * A caller who may not see other members' time gets their own rows, so the + * grid renders either way and never has to ask which case it is in. + */ +export async function fetchTaskTimeLog(client: AxiosInstance, id: number): Promise { + const { data } = await client.get>(BOARD_API.taskTimeLog(id)) + + return data.data ?? [] +} + +/** + * Apply one action to a selection of tasks. + * + * The endpoint is partial by design: it reports how many it changed and names + * the ones it refused, so a locked task in the selection does not sink the + * rest of it. + */ +export async function bulkTasks( + client: AxiosInstance, + input: TaskBulkInput, +): Promise { + const { data } = await client.post(BOARD_API.bulkTasks, input) + + return { updated: data?.updated ?? [], failed: data?.failed ?? [] } +} + +/** + * Drop a task between two neighbours of a column. + * + * The server owns the ordering: it returns the task with the `board_position` + * it settled on, which the board applies rather than guessing one itself. + */ +export async function moveTask( + client: AxiosInstance, + id: number, + input: TaskMoveInput, +): Promise { + const { data } = await client.post>(BOARD_API.moveTask(id), input) + + return data.data +} + +/** One project with the totals only the detail endpoint carries. */ +export async function fetchProject(client: AxiosInstance, id: number): Promise { + const { data } = await client.get>(TASKS_PROJECTS_API.project(id)) + + return data.data +} + +export async function listProjectMembers( + client: AxiosInstance, + projectId: number, +): Promise { + const { data } = await client.get>(BOARD_API.projectMembers(projectId)) + + return data.data +} + +export async function attachProjectMember( + client: AxiosInstance, + projectId: number, + input: ProjectMemberInput, +): Promise { + const { data } = await client.post>( + BOARD_API.projectMembers(projectId), + input, + ) + + return data.data +} + +export async function detachProjectMember( + client: AxiosInstance, + projectId: number, + userId: number, +): Promise { + await client.delete(BOARD_API.projectMember(projectId, userId)) +} + +/** The time logged against one project, for the read-only detail tab. */ +export async function listProjectTime( + client: AxiosInstance, + params: TimeEntryListParams, +): Promise> { + const { data } = await client.get>(BOARD_API.timeEntries, { params }) + + return data +} 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/api/time.ts b/resources/js/api/time.ts new file mode 100644 index 0000000..a5676a9 --- /dev/null +++ b/resources/js/api/time.ts @@ -0,0 +1,233 @@ +import type { AxiosInstance } from 'axios' +import type { Paginated, Wrapped } from '@/types/api' +import type { CompanyMember } from '@/types/member' +import type { ModuleSettings } from '@/types/settings' +import type { TaskStatus, TaskStatusInput } from '@/types/task-status' +import type { TaskSummary } from '@/types/task-summary' +import type { TimeEntry, TimeEntryInput, TimeEntryListParams } from '@/types/time-entry' +import type { RunningTimer, StartTimerInput, StopTimerInput } from '@/types/timer' + +const BASE = '/api/v1/tasks-projects' + +/** The endpoints the time screens talk to. */ +export const TIME_API = { + timeEntries: `${BASE}/time-entries`, + timeEntry: (id: number): string => `${BASE}/time-entries/${id}`, + timer: `${BASE}/timer`, + timerStart: `${BASE}/timer/start`, + timerStop: `${BASE}/timer/stop`, + taskStatuses: `${BASE}/task-statuses`, + taskStatus: (id: number): string => `${BASE}/task-statuses/${id}`, + reorderTaskStatuses: `${BASE}/task-statuses/reorder`, + tasks: `${BASE}/tasks`, + task: (id: number): string => `${BASE}/tasks/${id}`, + members: `${BASE}/members`, + settings: `${BASE}/settings`, +} as const + +/** Host endpoints the module reads through the same session client. */ +export const HOST_TIME_API = { + bootstrap: '/api/v1/bootstrap', +} as const + +/** How many rows one list request asks for, and how many it may ever ask for. */ +export const TIME_PAGE_SIZE = 25 + +const WEEK_PAGE_SIZE = 100 +const MAX_WEEK_PAGES = 5 +const TASK_SEARCH_LIMIT = 10 + +export async function listTimeEntries( + client: AxiosInstance, + params: TimeEntryListParams, +): Promise> { + const { data } = await client.get>(TIME_API.timeEntries, { params }) + + return data +} + +/** + * Every entry of one range, rather than one page of them. + * + * The week grid has to show whole days, so it follows the paginator instead of + * cutting the last day in half. The page walk is bounded: a week with more + * than five hundred entries is a data problem, not a view to render. + */ +export async function listAllTimeEntries( + client: AxiosInstance, + params: TimeEntryListParams, +): Promise { + const entries: TimeEntry[] = [] + + for (let page = 1; page <= MAX_WEEK_PAGES; page += 1) { + const response = await listTimeEntries(client, { ...params, page, limit: WEEK_PAGE_SIZE }) + + entries.push(...(response.data ?? [])) + + if (!response.meta || page >= response.meta.last_page) { + break + } + } + + return entries +} + +export async function createTimeEntry( + client: AxiosInstance, + input: TimeEntryInput, +): Promise { + const { data } = await client.post>(TIME_API.timeEntries, input) + + return data.data +} + +export async function updateTimeEntry( + client: AxiosInstance, + id: number, + input: TimeEntryInput, +): Promise { + const { data } = await client.put>(TIME_API.timeEntry(id), input) + + return data.data +} + +export async function deleteTimeEntry(client: AxiosInstance, id: number): Promise { + await client.delete(TIME_API.timeEntry(id)) +} + +/** The caller's running entry, or null when the clock is not running. */ +export async function fetchTimer(client: AxiosInstance): Promise { + const { data } = await client.get(TIME_API.timer) + + return data?.data ?? null +} + +export async function startTimer( + client: AxiosInstance, + input: StartTimerInput, +): Promise { + const { data } = await client.post>(TIME_API.timerStart, input) + + return data.data +} + +/** Close the running entry, with whatever the stop dialog collected. */ +export async function stopTimer( + client: AxiosInstance, + input: StopTimerInput = {}, +): Promise { + const { data } = await client.post>(TIME_API.timerStop, input) + + return data.data +} + +export async function discardTimer(client: AxiosInstance): Promise { + await client.delete(TIME_API.timer) +} + +export async function listTaskStatuses(client: AxiosInstance): Promise { + const { data } = await client.get>(TIME_API.taskStatuses) + + return data.data ?? [] +} + +export async function createTaskStatus( + client: AxiosInstance, + input: TaskStatusInput, +): Promise { + const { data } = await client.post>(TIME_API.taskStatuses, input) + + return data.data +} + +export async function updateTaskStatus( + client: AxiosInstance, + id: number, + input: TaskStatusInput, +): Promise { + const { data } = await client.put>(TIME_API.taskStatus(id), input) + + return data.data +} + +export async function deleteTaskStatus(client: AxiosInstance, id: number): Promise { + await client.delete(TIME_API.taskStatus(id)) +} + +/** Apply the wanted column order; the endpoint answers with the new list. */ +export async function reorderTaskStatuses( + client: AxiosInstance, + ids: number[], +): Promise { + const { data } = await client.post>(TIME_API.reorderTaskStatuses, { ids }) + + return data.data ?? [] +} + +/** How a picker narrows the task search beyond the typed text. */ +export interface TaskSearchOptions { + /** Only this project's tasks; omit or null to search every task. */ + projectId?: number | null + /** `0` for tasks not yet on an invoice; omit to search either way. */ + invoiced?: 0 | 1 + limit?: number +} + +/** Tasks matching what the picker has typed so far. */ +export async function searchTasks( + client: AxiosInstance, + search: string, + options: TaskSearchOptions = {}, +): Promise { + const params: Record = { limit: options.limit ?? TASK_SEARCH_LIMIT } + + if (search.trim() !== '') { + params.search = search.trim() + } + + if (typeof options.projectId === 'number') { + params.project_id = options.projectId + } + + if (options.invoiced !== undefined) { + params.invoiced = options.invoiced + } + + const { data } = await client.get>(TIME_API.tasks, { params }) + + return data.data ?? [] +} + +export async function fetchTask(client: AxiosInstance, id: number): Promise { + const { data } = await client.get>(TIME_API.task(id)) + + return data.data +} + +/** The company's members, for the member filter and the "who logged it" column. */ +export async function listTimeMembers(client: AxiosInstance): Promise { + const { data } = await client.get>(TIME_API.members) + + return data.data ?? [] +} + +export async function fetchTimeSettings(client: AxiosInstance): Promise { + const { data } = await client.get>(TIME_API.settings) + + return data.data +} + +/** + * The signed-in user's id, read from the host bootstrap payload. + * + * A module bundle has no access to the host's user store, and the time + * endpoints answer "my time" only when they are asked for a specific + * `user_id`, so the id is fetched once per company session from the same + * round trip the shell itself uses. + */ +export async function fetchCurrentUserId(client: AxiosInstance): Promise { + const { data } = await client.get<{ current_user?: { id?: unknown } }>(HOST_TIME_API.bootstrap) + const id = data?.current_user?.id + + return typeof id === 'number' ? id : null +} diff --git a/resources/js/components/AllTimeTable.vue b/resources/js/components/AllTimeTable.vue new file mode 100644 index 0000000..cd0e7a2 --- /dev/null +++ b/resources/js/components/AllTimeTable.vue @@ -0,0 +1,279 @@ + + + diff --git a/resources/js/components/BulkActionBar.vue b/resources/js/components/BulkActionBar.vue new file mode 100644 index 0000000..fce0df8 --- /dev/null +++ b/resources/js/components/BulkActionBar.vue @@ -0,0 +1,104 @@ + + + diff --git a/resources/js/components/InvoiceNumberModal.vue b/resources/js/components/InvoiceNumberModal.vue new file mode 100644 index 0000000..a3fccc1 --- /dev/null +++ b/resources/js/components/InvoiceNumberModal.vue @@ -0,0 +1,81 @@ + + + diff --git a/resources/js/components/InvoiceRetryBanner.vue b/resources/js/components/InvoiceRetryBanner.vue new file mode 100644 index 0000000..0f65299 --- /dev/null +++ b/resources/js/components/InvoiceRetryBanner.vue @@ -0,0 +1,91 @@ + + + diff --git a/resources/js/components/InvoicedBadge.vue b/resources/js/components/InvoicedBadge.vue new file mode 100644 index 0000000..9aff817 --- /dev/null +++ b/resources/js/components/InvoicedBadge.vue @@ -0,0 +1,46 @@ + + + diff --git a/resources/js/components/ProjectFormModal.vue b/resources/js/components/ProjectFormModal.vue new file mode 100644 index 0000000..bb3ab64 --- /dev/null +++ b/resources/js/components/ProjectFormModal.vue @@ -0,0 +1,284 @@ + + + diff --git a/resources/js/components/QuickStartOverlay.vue b/resources/js/components/QuickStartOverlay.vue new file mode 100644 index 0000000..714d58b --- /dev/null +++ b/resources/js/components/QuickStartOverlay.vue @@ -0,0 +1,221 @@ + + + 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/components/StartTimerModal.vue b/resources/js/components/StartTimerModal.vue new file mode 100644 index 0000000..c0b1325 --- /dev/null +++ b/resources/js/components/StartTimerModal.vue @@ -0,0 +1,297 @@ + + + diff --git a/resources/js/components/StopTimerModal.vue b/resources/js/components/StopTimerModal.vue new file mode 100644 index 0000000..84b1488 --- /dev/null +++ b/resources/js/components/StopTimerModal.vue @@ -0,0 +1,173 @@ + + + diff --git a/resources/js/components/TaskCard.vue b/resources/js/components/TaskCard.vue new file mode 100644 index 0000000..ac5cbd2 --- /dev/null +++ b/resources/js/components/TaskCard.vue @@ -0,0 +1,153 @@ + + + diff --git a/resources/js/components/TaskFilters.vue b/resources/js/components/TaskFilters.vue new file mode 100644 index 0000000..d2939a5 --- /dev/null +++ b/resources/js/components/TaskFilters.vue @@ -0,0 +1,158 @@ + + + diff --git a/resources/js/components/TaskFormModal.vue b/resources/js/components/TaskFormModal.vue new file mode 100644 index 0000000..6a3bbb9 --- /dev/null +++ b/resources/js/components/TaskFormModal.vue @@ -0,0 +1,446 @@ + + + diff --git a/resources/js/components/TaskList.vue b/resources/js/components/TaskList.vue new file mode 100644 index 0000000..5097192 --- /dev/null +++ b/resources/js/components/TaskList.vue @@ -0,0 +1,567 @@ + + + diff --git a/resources/js/components/TaskRunControl.vue b/resources/js/components/TaskRunControl.vue new file mode 100644 index 0000000..ad215dc --- /dev/null +++ b/resources/js/components/TaskRunControl.vue @@ -0,0 +1,182 @@ + + + diff --git a/resources/js/components/TaskStatusEditor.vue b/resources/js/components/TaskStatusEditor.vue new file mode 100644 index 0000000..26dbb01 --- /dev/null +++ b/resources/js/components/TaskStatusEditor.vue @@ -0,0 +1,364 @@ + + + diff --git a/resources/js/components/TimeEntryModal.vue b/resources/js/components/TimeEntryModal.vue new file mode 100644 index 0000000..73a7f3c --- /dev/null +++ b/resources/js/components/TimeEntryModal.vue @@ -0,0 +1,469 @@ + + + diff --git a/resources/js/components/TimeLogGrid.vue b/resources/js/components/TimeLogGrid.vue new file mode 100644 index 0000000..7197b6c --- /dev/null +++ b/resources/js/components/TimeLogGrid.vue @@ -0,0 +1,329 @@ + + + diff --git a/resources/js/components/TimerChip.vue b/resources/js/components/TimerChip.vue new file mode 100644 index 0000000..511ec5b --- /dev/null +++ b/resources/js/components/TimerChip.vue @@ -0,0 +1,70 @@ + + + diff --git a/resources/js/components/ViewSwitcher.vue b/resources/js/components/ViewSwitcher.vue new file mode 100644 index 0000000..2e6b077 --- /dev/null +++ b/resources/js/components/ViewSwitcher.vue @@ -0,0 +1,85 @@ + + + diff --git a/resources/js/components/WeekTimesheet.vue b/resources/js/components/WeekTimesheet.vue new file mode 100644 index 0000000..314b5f9 --- /dev/null +++ b/resources/js/components/WeekTimesheet.vue @@ -0,0 +1,267 @@ + + + diff --git a/resources/js/init.ts b/resources/js/init.ts index 46c7742..a194170 100644 --- a/resources/js/init.ts +++ b/resources/js/init.ts @@ -1,11 +1,25 @@ import '../css/module.css' +import { messages } from './messages' +import { registerBillingPages } from './registrations/billing' +import { registerProjectPages } from './registrations/projects' +import { registerReportPages } from './registrations/reports' +import { registerTaskPages } from './registrations/tasks' +import { registerTimeTracking } from './registrations/time' +/** + * Everything the module contributes to the host, one slice per line. + * + * Each slice owns its own file: its pages, its strings and whatever lifecycle + * it needs. Adding a screen never means editing the lines another slice is + * editing, and the shared page wrapper lives in `support/page.ts` so no slice + * carries its own copy. + */ window.InvoiceShelf.booting((_app, _router, extensions) => { - extensions.addMessages({ - en: { - tasks_projects: { - title: 'Projects', - }, - }, - }) + extensions.addMessages(messages) + + registerTaskPages(extensions) + registerProjectPages(extensions) + registerTimeTracking(extensions) + registerBillingPages(extensions) + registerReportPages(extensions) }) diff --git a/resources/js/messages.ts b/resources/js/messages.ts new file mode 100644 index 0000000..0116430 --- /dev/null +++ b/resources/js/messages.ts @@ -0,0 +1,73 @@ +/** + * Every string the module renders, in one bundle. + * + * The host merges these into its own i18n catalogue, so templates reach them + * with `$t('tasks_projects....')` and a later locale only has to add a sibling + * key here. + */ +export const messages = { + en: { + tasks_projects: { + general: { + home: 'Home', + filter: 'Filter', + search: 'Search', + actions: 'Actions', + edit: 'Edit', + delete: 'Delete', + cancel: 'Cancel', + save: 'Save', + update: 'Update', + }, + projects: { + title: 'Projects', + new_project: 'New project', + edit_project: 'Edit project', + internal: 'Internal', + archive: 'Archive', + unarchive: 'Restore', + search_placeholder: 'Search by name or identifier', + empty_title: 'No projects yet', + empty_description: 'Create a project to group its tasks, time and billing.', + status: { + active: 'Active', + archived: 'Archived', + all: 'All', + }, + columns: { + name: 'Name', + status: 'Status', + customer: 'Customer', + default_rate: 'Rate / hour', + due_date: 'Due date', + }, + fields: { + name: 'Name', + identifier: 'Identifier', + identifier_help: 'A short code, used as the task number prefix.', + customer: 'Customer', + customer_help: 'Leave empty for an internal project.', + customer_placeholder: 'No customer', + due_date: 'Due date', + default_rate: 'Default rate', + default_rate_help: 'Per hour, in the customer currency.', + budget_hours: 'Budget (hours)', + colour: 'Colour', + colour_none: 'None', + description: 'Description', + }, + created: '{name} was created.', + updated: '{name} was updated.', + archived: '{name} was archived.', + unarchived: '{name} was restored.', + deleted: '{name} was deleted.', + delete_confirm: 'Delete {name}? Its tasks and time entries go with it.', + name_required: 'Enter a project name.', + load_failed: 'Unable to load the projects.', + save_failed: 'Unable to save the project.', + delete_failed: 'Unable to delete the project.', + customers_failed: 'Unable to load the customers.', + }, + }, + }, +} diff --git a/resources/js/messages/billing.ts b/resources/js/messages/billing.ts new file mode 100644 index 0000000..b8027c2 --- /dev/null +++ b/resources/js/messages/billing.ts @@ -0,0 +1,99 @@ +/** + * Every string invoicing renders, wherever it is started from. + * + * Kept beside the slice that owns it rather than in `messages.ts`, so two + * slices of the module never edit the same catalogue. The host merges each + * bundle recursively, so these land under the same `tasks_projects` namespace + * as the rest. + */ +export const billingMessages = { + en: { + tasks_projects: { + billing: { + title: 'Unbilled time', + subtitle: 'Time that has not reached an invoice yet, by customer.', + back: 'Back to customers', + create: 'Create invoice', + busy: 'Creating the invoice', + + // What the sequence says when it cannot finish. + prepare_failed: 'Unable to prepare the invoice.', + create_failed: 'Unable to create the invoice.', + rate_failed: 'Unable to read the exchange rate; the invoice was created without one.', + forbidden: 'You are not allowed to invoice time.', + nothing_to_invoice: 'No unbilled billable time on the selected tasks.', + mixed_customers: 'Select tasks of one customer. This selection spans {count} customers.', + mixed_selection: 'One invoice covers one customer in one currency. Narrow the selection.', + pending_stamp: + 'Finish marking the last invoice as billed before creating another one.', + created: 'Invoice {number} was created.', + stamped: + '{count} time entry was marked as invoiced. | {count} time entries were marked as invoiced.', + stamp_failed: 'Unable to mark the time as invoiced.', + stamp_failed_notice: + 'Invoice {number} was created, but its time is not marked as invoiced yet.', + stamp_unmatched: + 'Invoice {number} was created, but its lines could not be matched back to the time behind them.', + + number: { + title: 'Invoice number', + description: + 'This company numbers its invoices by hand, so the draft needs a number before it can be created.', + label: 'Number', + save: 'Create invoice', + }, + + retry: { + title: 'The invoice was created, but the time is not marked yet', + description: + 'Invoice {number} exists. Its time entries still count as unbilled until they are marked, which is safe to run again.', + action: 'Retry stamping', + open_invoice: 'Open the invoice', + dismiss: 'Forget this invoice', + dismiss_confirm: + 'Forget this invoice? Its time stays unbilled and can reach a second invoice.', + }, + + customer: { + title: 'Who has time waiting?', + description: 'Customers with billable time that has not reached an invoice yet.', + entries: '{count} entry | {count} entries', + empty_title: 'Nothing to invoice', + empty_description: + 'Billable time appears here once it has been logged against a task that belongs to a customer.', + load_failed: 'Unable to load the customers with unbilled time.', + from: 'From', + to: 'To', + clear_range: 'Clear dates', + }, + + entries: { + title: 'Which time goes on the invoice?', + grouping: 'Group lines by', + group_by: { + task: 'Task', + project: 'Project', + member: 'Member', + summary: 'One summary line', + }, + select_all: 'Select all', + selected: '{count} of {total} entries selected', + selected_total: 'Selected: {hours}', + no_description: 'No description', + columns: { + date: 'Date', + task: 'Task', + project: 'Project', + member: 'Member', + duration: 'Duration', + amount: 'Amount', + }, + empty_title: 'No unbilled time', + empty_description: 'This customer has nothing waiting to be invoiced in this range.', + load_failed: 'Unable to load the unbilled time.', + none_selected: 'Select at least one entry.', + }, + }, + }, + }, +} diff --git a/resources/js/messages/projects.ts b/resources/js/messages/projects.ts new file mode 100644 index 0000000..d9c906c --- /dev/null +++ b/resources/js/messages/projects.ts @@ -0,0 +1,75 @@ +/** + * Strings for the project index and the project detail tabs. + * + * 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.project` here and + * `tasks_projects.tasks` there end up in one namespace. + */ +export const projectMessages = { + en: { + tasks_projects: { + project: { + load_failed: 'Unable to load the project.', + customer: 'Customer', + identifier: 'Identifier', + due_date: 'Due date', + board: 'Board', + tasks: 'Tasks', + invoice_project: 'Invoice project', + tabs: { + overview: 'Overview', + tasks: 'Tasks', + time: 'Time', + members: 'Members', + }, + overview: { + tasks: 'Tasks', + open_tasks: '{count} open', + closed_tasks: '{count} done', + logged: 'Logged', + billable: 'Billable', + billable_amount: 'Billable value', + unbilled_amount: 'Unbilled', + budget: 'Budget', + budget_used: '{used} of {total}', + budget_over: 'Over budget by {amount}', + no_budget: 'No budget set.', + description: 'Description', + no_description: 'No description yet.', + }, + time: { + title: 'Time log', + add_entry: 'Add entry', + columns: { + date: 'Date', + member: 'Member', + task: 'Task', + minutes: 'Duration', + billable: 'Billable', + amount: 'Amount', + }, + running: 'Running', + removed_member: 'Removed member', + load_failed: 'Unable to load the time entries.', + }, + members: { + title: 'Members', + member: 'Member', + rate: 'Rate / hour', + rate_help: 'Per hour on this project. Leave empty to use the project default.', + attach: 'Add member', + attach_placeholder: 'Choose a member', + attached: '{name} was added to the project.', + detached: '{name} was removed from the project.', + detach_confirm: 'Remove {name} from this project? Their time entries stay.', + empty: 'Nobody is on this project yet.', + all_attached: 'Every company member is already on this project.', + load_failed: 'Unable to load the project members.', + attach_failed: 'Unable to add the member.', + detach_failed: 'Unable to remove the member.', + }, + }, + }, + }, +} 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/messages/tasks.ts b/resources/js/messages/tasks.ts new file mode 100644 index 0000000..2797f63 --- /dev/null +++ b/resources/js/messages/tasks.ts @@ -0,0 +1,165 @@ +/** + * Every string the Tasks screen, its three views and the task page render. + * + * 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.tasks` here and + * `tasks_projects.projects` there end up in one namespace. + */ +export const taskMessages = { + en: { + tasks_projects: { + board: { + title: 'Board', + load_failed: 'Unable to load the board.', + move_failed: 'Unable to move the task.', + moved: '{name} moved to {status}.', + empty_column: 'Nothing here yet', + hidden_invoiced: 'Invoiced tasks are hidden on the board.', + }, + tasks: { + title: 'Tasks', + all_tasks: 'All tasks', + new_task: 'New task', + edit_task: 'Edit task', + all_fields: 'All fields', + fewer_fields: 'Fewer fields', + search_placeholder: 'Search by name or number', + empty_title: 'No tasks yet', + empty_description: 'Add a task to put work on the board.', + unassigned: 'Unassigned', + billable: 'Billable', + overdue: 'Overdue', + none: 'None', + no_project: 'No project', + internal: 'Internal', + invoiced: 'Invoiced', + uninvoiced: 'Unbilled', + invoice_task: 'Invoice task', + already_invoiced: 'This task is already on an invoice.', + nothing_to_invoice: 'No unbilled billable time on this task.', + locked: 'This task is on an invoice and cannot be changed.', + views: { + list: 'List', + board: 'Board', + week: 'Week', + }, + filters: { + project: 'Project', + all_projects: 'All projects', + member: 'Member', + all_members: 'Everyone', + status: 'Status', + all_statuses: 'Any status', + invoicing: 'Invoicing', + search: 'Search', + }, + columns: { + number: 'No.', + name: 'Name', + project: 'Project', + status: 'Status', + assignee: 'Assignee', + priority: 'Priority', + due_date: 'Due date', + logged: 'Logged', + unbilled: 'Unbilled', + invoiced: 'Invoicing', + timer: 'Timer', + }, + bulk: { + selected: '{count} selected', + select_page: 'Select this page', + clear: 'Clear', + change_status: 'Move to', + delete: 'Delete', + invoice: 'Invoice', + delete_confirm: + 'Delete {count} task? Its time entries go with it. | Delete {count} tasks? Their time entries go with them.', + applied: '{count} task was updated. | {count} tasks were updated.', + deleted: '{count} task was deleted. | {count} tasks were deleted.', + partial: + '{count} task was updated, {failed} refused: {ids}. | {count} tasks were updated, {failed} refused: {ids}.', + nothing: 'No task was changed.', + failed: 'Unable to apply the change.', + }, + detail: { + estimate: 'Estimate', + logged: 'Logged', + unbilled: 'Unbilled', + no_estimate: 'No estimate', + project: 'Project', + customer: 'Customer', + status: 'Status', + assignee: 'Assignee', + priority: 'Priority', + due_date: 'Due date', + description: 'Description', + no_description: 'No description yet.', + status_saved: 'The status was changed to {name}.', + status_failed: 'Unable to change the status.', + not_found: 'That task could not be loaded.', + }, + time_log: { + title: 'Time log', + add_item: 'Add item', + add_disabled: 'Stop the running timer to log an entry by hand.', + running: 'Running', + empty: 'No time logged against this task yet.', + load_failed: 'Unable to load the time log.', + stamped: 'Invoiced', + stamped_delete: 'Invoiced time belongs to its invoice and cannot be deleted.', + columns: { + start_date: 'Start date', + start_time: 'Start', + end_date: 'End date', + end_time: 'End', + duration: 'Duration', + description: 'Description', + billable: 'Billable', + member: 'Member', + }, + }, + created: '{name} was created.', + updated: '{name} was updated.', + deleted: '{name} was deleted.', + delete_confirm: 'Delete {name}? Its time entries go with it.', + name_required: 'Enter a task name.', + load_failed: 'Unable to load the tasks.', + save_failed: 'Unable to save the task.', + delete_failed: 'Unable to delete the task.', + projects_failed: 'Unable to load the projects.', + members_failed: 'Unable to load the members.', + fields: { + name: 'Name', + description: 'Description', + project: 'Project', + project_placeholder: 'No project', + project_help: 'Leave empty for a task that stands on its own.', + customer: 'Customer', + customer_help: 'Taken from the project.', + status: 'Status', + assignee: 'Assignee', + assignee_placeholder: 'Nobody yet', + priority: 'Priority', + priority_placeholder: 'No priority', + due_date: 'Due date', + estimate_hours: 'Estimate (hours)', + billable: 'Billable', + rate: 'Rate override', + rate_help: 'Per hour. Leave empty to use the project or member rate.', + }, + priority: { + low: 'Low', + normal: 'Normal', + high: 'High', + urgent: 'Urgent', + }, + }, + task_statuses: { + load_failed: 'Unable to load the task statuses.', + none: 'No board columns yet.', + }, + }, + }, +} diff --git a/resources/js/messages/time.ts b/resources/js/messages/time.ts new file mode 100644 index 0000000..30326ef --- /dev/null +++ b/resources/js/messages/time.ts @@ -0,0 +1,190 @@ +/** + * Every string the time screens render. + * + * Kept apart from the projects bundle so the two slices never edit the same + * file; the host merges both into one catalogue, so a template still reaches + * these with `$t('tasks_projects.time....')`. + */ +export const timeMessages = { + en: { + tasks_projects: { + time: { + title: 'Time', + my_time: 'My time', + all_time: 'All time', + this_week: 'This week', + previous_week: 'Previous week', + next_week: 'Next week', + week_total: 'Week total', + day_total: 'Total', + add_entry: 'Add entry', + new_entry: 'New time entry', + edit_entry: 'Edit time entry', + view_entry: 'Time entry', + no_entries: 'Nothing logged.', + empty_title: 'No time logged yet', + empty_description: 'Log an entry by hand, or start the timer on a task.', + unknown_member: 'Removed member', + unknown_user: 'Unable to identify the signed-in user. Reload the page and try again.', + billable: 'Billable', + non_billable: 'Not billable', + billed: 'Billed', + unbilled: 'Unbilled', + stamped_notice: + 'This entry is already on an invoice. Invoiced time is history and cannot be changed.', + created: 'The time entry was saved.', + updated: 'The time entry was updated.', + deleted: 'The time entry was deleted.', + delete_confirm: 'Delete this time entry?', + load_failed: 'Unable to load the time entries.', + save_failed: 'Unable to save the time entry.', + delete_failed: 'Unable to delete the time entry.', + members_failed: 'Unable to load the members.', + projects_failed: 'Unable to load the projects.', + tasks_failed: 'Unable to load the tasks.', + columns: { + date: 'Date', + member: 'Member', + task: 'Task', + description: 'Description', + duration: 'Duration', + billable: 'Billable', + amount: 'Amount', + }, + filters: { + member: 'Member', + project: 'Project', + from: 'From', + to: 'To', + billing: 'Billing', + all: 'All', + any_member: 'Everyone', + any_project: 'Any project', + }, + fields: { + task: 'Task', + task_placeholder: 'Search by task name or number', + date: 'Date', + mode: 'Entry', + duration: 'Duration', + duration_help: 'Hours and minutes, as 1:30 or 1.5.', + start: 'Start', + end: 'End', + description: 'Description', + billable: 'Billable', + }, + mode: { + duration: 'Duration', + range: 'Start and end', + }, + task_required: 'Pick a task.', + date_required: 'Pick a date.', + duration_invalid: 'Enter a duration like 1:30 or 1.5.', + range_invalid: 'Enter a start and an end time, with the end after the start.', + }, + timer: { + running: 'Timer running', + quick_start: 'Start a timer', + panel_title: 'Quick start', + start: 'Start', + stop: 'Stop', + discard: 'Discard', + close: 'Close', + open_timesheet: 'Open my week', + open_task: 'Open the running task', + start_on: 'Start the timer on {name}', + stop_on: 'Stop the timer on {name}', + busy_elsewhere: 'Your timer is running on {name}.', + stop_and_start: 'Stop and start', + running_by: '{name} has been running since {time}.', + mismatch: 'Your timer is no longer on this task. It has been reloaded.', + elapsed: 'Elapsed', + search_tasks: 'Search tasks', + no_tasks: 'No tasks match that search.', + description_placeholder: 'What are you working on? (optional)', + started: 'The timer is running on {name}.', + stopped: 'Logged {duration} on {name}.', + discarded: 'The running timer was discarded.', + discard_confirm: 'Discard the running timer? The elapsed time is not saved.', + stop_title: 'Save and stop', + save_and_stop: 'Save and stop', + saved_as: 'Saved as {duration} after rounding to {increment} min.', + saved_as_exact: 'Saved as {duration}.', + discard_ask: 'Discard {duration}? This cannot be undone.', + start_title: 'Start a timer', + any_project: 'Any project', + pick_task: 'Search by task name or number', + create_and_start: 'Create task "{name}" and start', + no_matches: 'No tasks match that search. Keep typing to create one.', + already_running: 'A timer is already running. It has been reloaded.', + start_failed: 'Unable to start the timer.', + stop_failed: 'Unable to stop the timer.', + discard_failed: 'Unable to discard the timer.', + }, + settings: { + title: 'Tasks and Projects', + general_title: 'General', + general_description: + 'The default hourly rate, the rounding increment, the first day of the week and who may see other members time.', + open_module_settings: 'Open module settings', + default_rate: 'Default rate / hour', + week_start: 'First day of the week', + weekday_0: 'Sunday', + weekday_1: 'Monday', + weekday_2: 'Tuesday', + weekday_3: 'Wednesday', + weekday_4: 'Thursday', + weekday_5: 'Friday', + weekday_6: 'Saturday', + members_see_all_time: "Members see other members' time", + behaviour_title: 'Task behaviour', + behaviour_description: + 'What happens when a task is created, invoiced or shown on the board. Change these in the module settings form.', + rounding_direction: 'Rounding', + rounding_direction_nearest: 'To the nearest increment', + rounding_direction_up: 'Up to the increment', + rounding_direction_down: 'Down to the increment', + rounding_increment: 'Increment', + rounding_increment_value: '{count} minute | {count} minutes', + auto_start_tasks: 'Start the timer on a new task', + lock_invoiced_tasks: 'Lock invoiced tasks', + hide_invoiced_on_board: 'Hide invoiced tasks on the board', + invoice_title: 'Invoice lines', + invoice_description: + 'What an invoice line built from a task carries. Change these in the module settings form.', + invoice_project_heading: 'Project heading', + invoice_task_description: 'Task description', + invoice_entry_dates: 'Entry dates', + invoice_entry_times: 'Entry times', + invoice_entry_hours: 'Entry hours', + invoice_entry_descriptions: 'Entry descriptions', + on: 'On', + off: 'Off', + statuses_title: 'Task statuses', + statuses_description: + 'The columns of the board. One status is the default, where new tasks land; a closed status counts as done.', + status_name: 'Name', + colour: 'Colour', + colour_none: 'None', + is_default: 'Default', + is_closed: 'Closed', + add_status: 'Add status', + new_status: 'New status', + move_up: 'Move up', + move_down: 'Move down', + no_statuses: 'No statuses yet.', + status_created: '{name} was added.', + status_updated: '{name} was updated.', + status_deleted: '{name} was deleted.', + status_reordered: 'The order was saved.', + status_delete_confirm: 'Delete {name}?', + status_name_required: 'Enter a status name.', + load_failed: 'Unable to load the task statuses.', + save_failed: 'Unable to save the task status.', + delete_failed: 'Unable to delete the task status.', + reorder_failed: 'Unable to save the new order.', + forbidden: 'Your role does not allow managing the board columns.', + }, + }, + }, +} diff --git a/resources/js/pages/ProjectDetailPage.vue b/resources/js/pages/ProjectDetailPage.vue new file mode 100644 index 0000000..0d4135c --- /dev/null +++ b/resources/js/pages/ProjectDetailPage.vue @@ -0,0 +1,346 @@ + + + diff --git a/resources/js/pages/ProjectsIndexPage.vue b/resources/js/pages/ProjectsIndexPage.vue new file mode 100644 index 0000000..5c7b79d --- /dev/null +++ b/resources/js/pages/ProjectsIndexPage.vue @@ -0,0 +1,423 @@ + + + diff --git a/resources/js/pages/ReportsPage.vue b/resources/js/pages/ReportsPage.vue new file mode 100644 index 0000000..b5ddfd2 --- /dev/null +++ b/resources/js/pages/ReportsPage.vue @@ -0,0 +1,409 @@ + + + diff --git a/resources/js/pages/TaskPage.vue b/resources/js/pages/TaskPage.vue new file mode 100644 index 0000000..0fbfdb1 --- /dev/null +++ b/resources/js/pages/TaskPage.vue @@ -0,0 +1,496 @@ + + + diff --git a/resources/js/pages/TasksPage.vue b/resources/js/pages/TasksPage.vue new file mode 100644 index 0000000..aeced60 --- /dev/null +++ b/resources/js/pages/TasksPage.vue @@ -0,0 +1,210 @@ + + + diff --git a/resources/js/pages/TimeSettingsPage.vue b/resources/js/pages/TimeSettingsPage.vue new file mode 100644 index 0000000..9bdc23b --- /dev/null +++ b/resources/js/pages/TimeSettingsPage.vue @@ -0,0 +1,170 @@ + + + diff --git a/resources/js/pages/UnbilledTimePage.vue b/resources/js/pages/UnbilledTimePage.vue new file mode 100644 index 0000000..7cb54b4 --- /dev/null +++ b/resources/js/pages/UnbilledTimePage.vue @@ -0,0 +1,537 @@ + + + diff --git a/resources/js/pages/project/ProjectMembersTab.vue b/resources/js/pages/project/ProjectMembersTab.vue new file mode 100644 index 0000000..fb45d8b --- /dev/null +++ b/resources/js/pages/project/ProjectMembersTab.vue @@ -0,0 +1,203 @@ + + + diff --git a/resources/js/pages/project/ProjectOverviewTab.vue b/resources/js/pages/project/ProjectOverviewTab.vue new file mode 100644 index 0000000..9692c2b --- /dev/null +++ b/resources/js/pages/project/ProjectOverviewTab.vue @@ -0,0 +1,204 @@ + + + diff --git a/resources/js/pages/project/ProjectTasksTab.vue b/resources/js/pages/project/ProjectTasksTab.vue new file mode 100644 index 0000000..ae64a38 --- /dev/null +++ b/resources/js/pages/project/ProjectTasksTab.vue @@ -0,0 +1,107 @@ + + + diff --git a/resources/js/pages/project/ProjectTimeTab.vue b/resources/js/pages/project/ProjectTimeTab.vue new file mode 100644 index 0000000..77e7e98 --- /dev/null +++ b/resources/js/pages/project/ProjectTimeTab.vue @@ -0,0 +1,280 @@ + + + diff --git a/resources/js/pages/tasks/TasksBoardView.vue b/resources/js/pages/tasks/TasksBoardView.vue new file mode 100644 index 0000000..135d522 --- /dev/null +++ b/resources/js/pages/tasks/TasksBoardView.vue @@ -0,0 +1,404 @@ + + + diff --git a/resources/js/pages/tasks/TasksListView.vue b/resources/js/pages/tasks/TasksListView.vue new file mode 100644 index 0000000..87f406f --- /dev/null +++ b/resources/js/pages/tasks/TasksListView.vue @@ -0,0 +1,46 @@ + + + diff --git a/resources/js/pages/tasks/TasksWeekView.vue b/resources/js/pages/tasks/TasksWeekView.vue new file mode 100644 index 0000000..b555592 --- /dev/null +++ b/resources/js/pages/tasks/TasksWeekView.vue @@ -0,0 +1,201 @@ + + + diff --git a/resources/js/registrations/billing.ts b/resources/js/registrations/billing.ts new file mode 100644 index 0000000..0b1aa69 --- /dev/null +++ b/resources/js/registrations/billing.ts @@ -0,0 +1,52 @@ +import { defineComponent, h } from 'vue' +import type { InvoiceShelfExtensionApi } from '@invoiceshelf/modules/frontend' +import InvoiceNumberModal from '@/components/InvoiceNumberModal.vue' +import { billingMessages } from '@/messages/billing' +import UnbilledTimePage from '@/pages/UnbilledTimePage.vue' +import { resetInvoicing } from '@/stores/invoicing' +import { MODULE, injectedPage } from '@/support/page' + +/** + * What invoicing contributes to the host. + * + * Invoicing itself has no screen: it is an action on a task row, a bulk bar, a + * task page and a project header, and `support/invoicing.ts` is the whole of + * it. Two things still have to be mounted somewhere, so they are mounted here: + * + * - The unbilled time page, for the month-end question the task screens cannot + * answer. It keeps the `billing` id and path the wizard had, so a bookmark + * and the module's own registered ability both still resolve. + * - The invoice number dialog, once, in the company layout. The sequence can + * run from any screen, and none of them should carry a dialog for a question + * only a hand-numbering company is ever asked. + * + * 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 lines another + * slice is editing. + */ +export function registerBillingPages(extensions: InvoiceShelfExtensionApi): void { + extensions.addMessages(billingMessages) + + extensions.registerPage({ + id: 'billing', + module: MODULE, + path: 'billing', + component: injectedPage(extensions, UnbilledTimePage), + meta: { + ability: `${MODULE}:invoice-tasks`, + title: 'tasks_projects.billing.title', + }, + }) + + extensions.registerCompanyLayoutOverlay({ + id: `${MODULE}.invoice-number`, + component: defineComponent({ + setup: () => () => h(InvoiceNumberModal), + }), + }) + + // A half-finished invoice and a refused ability both belong to one company. + extensions.on('company:changing', () => { + resetInvoicing() + }) +} diff --git a/resources/js/registrations/projects.ts b/resources/js/registrations/projects.ts new file mode 100644 index 0000000..9c44207 --- /dev/null +++ b/resources/js/registrations/projects.ts @@ -0,0 +1,93 @@ +import type { InvoiceShelfExtensionApi } from '@invoiceshelf/modules/frontend' +import { projectMessages } from '@/messages/projects' +import ProjectDetailPage from '@/pages/ProjectDetailPage.vue' +import ProjectsIndexPage from '@/pages/ProjectsIndexPage.vue' +import ProjectMembersTab from '@/pages/project/ProjectMembersTab.vue' +import ProjectOverviewTab from '@/pages/project/ProjectOverviewTab.vue' +import ProjectTasksTab from '@/pages/project/ProjectTasksTab.vue' +import ProjectTimeTab from '@/pages/project/ProjectTimeTab.vue' +import { MODULE, injectedPage } from '@/support/page' + +const ability = { + viewProject: `${MODULE}:view-project`, + editProject: `${MODULE}:edit-project`, + viewTask: `${MODULE}:view-task`, + viewOwnTime: `${MODULE}:view-own-time`, +} as const + +/** + * The project index and one project's detail tabs. + * + * Projects sits beside Tasks rather than above it: a project groups work, + * bills it and holds a budget, but the day's question is "what am I doing", + * which is a task. So the index moved off the module root and onto its own + * path, and the sidebar carries both. + * + * 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. + */ +export function registerProjectPages(extensions: InvoiceShelfExtensionApi): void { + extensions.addMessages(projectMessages) + + extensions.registerPage({ + id: 'projects', + module: MODULE, + path: 'projects', + component: injectedPage(extensions, ProjectsIndexPage), + meta: { + ability: ability.viewProject, + title: 'tasks_projects.projects.title', + }, + }) + + extensions.registerPage({ + id: 'project', + module: MODULE, + path: 'projects/:id', + component: injectedPage(extensions, ProjectDetailPage), + meta: { + ability: ability.viewProject, + title: 'tasks_projects.projects.title', + }, + children: [ + { + id: 'overview', + path: '', + component: injectedPage(extensions, ProjectOverviewTab), + meta: { + ability: ability.viewProject, + title: 'tasks_projects.project.tabs.overview', + }, + }, + { + id: 'tasks', + path: 'tasks', + component: injectedPage(extensions, ProjectTasksTab), + meta: { + ability: ability.viewTask, + title: 'tasks_projects.project.tabs.tasks', + }, + }, + { + id: 'time', + path: 'time', + component: injectedPage(extensions, ProjectTimeTab), + meta: { + ability: ability.viewOwnTime, + title: 'tasks_projects.project.tabs.time', + }, + }, + { + id: 'members', + path: 'members', + component: injectedPage(extensions, ProjectMembersTab), + meta: { + ability: ability.editProject, + title: 'tasks_projects.project.tabs.members', + }, + }, + ], + }) +} 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/registrations/tasks.ts b/resources/js/registrations/tasks.ts new file mode 100644 index 0000000..9438595 --- /dev/null +++ b/resources/js/registrations/tasks.ts @@ -0,0 +1,113 @@ +import { defineComponent, onMounted } from 'vue' +import type { InvoiceShelfExtensionApi } from '@invoiceshelf/modules/frontend' +import { taskMessages } from '@/messages/tasks' +import TaskPage from '@/pages/TaskPage.vue' +import TasksPage from '@/pages/TasksPage.vue' +import TasksBoardView from '@/pages/tasks/TasksBoardView.vue' +import TasksListView from '@/pages/tasks/TasksListView.vue' +import TasksWeekView from '@/pages/tasks/TasksWeekView.vue' +import { MODULE, PATHS, injectedPage } from '@/support/page' + +const ability = { + viewTask: `${MODULE}:view-task`, + viewOwnTime: `${MODULE}:view-own-time`, +} as const + +/** + * The Tasks screen, its three views and one task's own page. + * + * Tasks is the module root: it is the screen people open to see what there is + * to do, and List, Board and Week are three ways of reading the same filtered + * set rather than three destinations. They are children of one route so the + * header, the filters and the pickers are loaded once and a view switch costs + * one request. + * + * 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. + */ +export function registerTaskPages(extensions: InvoiceShelfExtensionApi): void { + extensions.addMessages(taskMessages) + + extensions.registerPage({ + id: 'tasks', + module: MODULE, + path: '', + component: injectedPage(extensions, TasksPage), + meta: { + ability: ability.viewTask, + title: 'tasks_projects.tasks.title', + }, + children: [ + { + id: 'list', + path: '', + component: injectedPage(extensions, TasksListView), + meta: { + ability: ability.viewTask, + title: 'tasks_projects.tasks.views.list', + }, + }, + { + id: 'board', + path: 'board', + component: injectedPage(extensions, TasksBoardView), + meta: { + ability: ability.viewTask, + title: 'tasks_projects.board.title', + }, + }, + { + id: 'week', + path: 'week', + component: injectedPage(extensions, TasksWeekView), + meta: { + ability: ability.viewOwnTime, + title: 'tasks_projects.time.title', + }, + }, + ], + }) + + extensions.registerPage({ + id: 'task', + module: MODULE, + path: 'tasks/:id', + component: injectedPage(extensions, TaskPage), + meta: { + ability: ability.viewTask, + title: 'tasks_projects.tasks.title', + }, + }) + + extensions.registerPage({ + id: 'time', + module: MODULE, + path: 'time', + component: timeRedirect(extensions), + meta: { + ability: ability.viewOwnTime, + title: 'tasks_projects.time.title', + }, + }) +} + +/** + * The old Time page, kept as a forward for one release. + * + * The timesheet is the Week view of Tasks now. Bookmarks, the release notes + * and anything that linked to `/time` keep working rather than landing on a + * "page not found" the host would blame the module for. + */ +function timeRedirect(extensions: InvoiceShelfExtensionApi): ReturnType { + return defineComponent({ + setup: () => { + onMounted(() => { + void extensions.router.replace(PATHS.week) + }) + + return () => null + }, + }) +} diff --git a/resources/js/registrations/time.ts b/resources/js/registrations/time.ts new file mode 100644 index 0000000..ef7cc5f --- /dev/null +++ b/resources/js/registrations/time.ts @@ -0,0 +1,146 @@ +import { defineComponent, h } from 'vue' +import type { InvoiceShelfExtensionApi } from '@invoiceshelf/modules/frontend' +import QuickStartOverlay from '@/components/QuickStartOverlay.vue' +import StartTimerModal from '@/components/StartTimerModal.vue' +import StopTimerModal from '@/components/StopTimerModal.vue' +import TimerChip from '@/components/TimerChip.vue' +import { timeMessages } from '@/messages/time' +import TimeSettingsPage from '@/pages/TimeSettingsPage.vue' +import { refreshSession, resetSession, session, setAdminMode } from '@/stores/session' +import { resetTaskNames } from '@/stores/tasks' +import { timerStore } from '@/stores/timer' +import { MODULE, PATHS, injectedPage } from '@/support/page' +import type { NotifyType } from '@/support/page' + +/** + * What the time-tracking slice contributes to the host. + * + * The timesheet itself is the Week view of the Tasks screen now, so this file + * keeps what has no screen of its own: the header chip, the quick-start + * launcher, the two timer dialogs, the settings page and the lifecycle wiring. + * + * The dialogs are mounted once here rather than by each control that opens + * one, because a timer is started and stopped from a header, a launcher, a + * row, a card, a task page and a time log, and the question each of them asks + * is the same one. Unlike the launcher they are not hidden on any path: a + * clock running while someone is in the settings still has to be stoppable. + * + * Nothing in this function talks to the network. Pinia is not installed when + * the boot callback runs, so the first read waits for `bootstrap:completed`, + * and every later company switch clears the previous company's answers before + * asking again. + */ +export function registerTimeTracking(extensions: InvoiceShelfExtensionApi): void { + extensions.addMessages(timeMessages) + + const notify = (type: NotifyType, message: string): void => { + extensions.notify(type, message) + } + + /** + * The chip leads to the work, not to the timesheet. + * + * What someone wants when they look at a running clock is the thing it is + * running on: the task, its time log and the stop button beside them. The + * week grid is one click further, on the same screen's Week view. + */ + const openRunningTask = (): void => { + const taskId = timerStore.runningTaskId + + void extensions.router.push(taskId === null ? PATHS.week : PATHS.task(taskId)) + } + + extensions.registerHeaderAction({ + id: `${MODULE}.timer-chip`, + priority: 30, + visible: (): boolean => timerStore.running !== null, + component: defineComponent({ + setup: () => () => + h(TimerChip, { + client: extensions.client, + notify, + onOpen: openRunningTask, + }), + }), + }) + + extensions.registerCompanyLayoutOverlay({ + id: `${MODULE}.quick-start`, + component: defineComponent({ + setup: () => () => + h(QuickStartOverlay, { + // A company switch starts the launcher clean rather than carrying + // the panel of the workspace the user just left, and it remounts the + // component so the AI assistant's launcher is looked for again on + // the new company's layout. + key: session.companySession, + client: extensions.client, + notify, + enabled: !session.adminMode, + router: extensions.router, + onOpenTask: openRunningTask, + }), + }), + }) + + extensions.registerCompanyLayoutOverlay({ + id: `${MODULE}.stop-timer`, + component: defineComponent({ + setup: () => () => h(StopTimerModal, { key: session.companySession }), + }), + }) + + extensions.registerCompanyLayoutOverlay({ + id: `${MODULE}.start-timer`, + component: defineComponent({ + setup: () => () => + h(StartTimerModal, { + key: session.companySession, + client: extensions.client, + notify, + }), + }), + }) + + extensions.registerCompanySettingsPage({ + id: `${MODULE}.settings`, + title: 'tasks_projects.settings.title', + icon: 'ClockIcon', + path: MODULE, + priority: 70, + component: injectedPage(extensions, TimeSettingsPage), + }) + + extensions.on('bootstrap:completed', ({ adminMode }) => { + void enter(extensions, adminMode) + }) + + extensions.on('company:changing', () => { + leave() + }) + + extensions.on('company:changed', ({ companyId }) => { + void enter(extensions, companyId === null) + }) +} + +/** Read the company's settings and the caller's running timer. */ +async function enter(extensions: InvoiceShelfExtensionApi, adminMode: boolean): Promise { + setAdminMode(adminMode) + + if (adminMode) { + leave() + + return + } + + await refreshSession(extensions.client) + await timerStore.refresh(extensions.client) +} + +/** Forget the previous company: its timer, its task names and its settings. */ +function leave(): void { + timerStore.reset() + resetTaskNames() + resetSession() +} diff --git a/resources/js/stores/customers.ts b/resources/js/stores/customers.ts new file mode 100644 index 0000000..34b7c59 --- /dev/null +++ b/resources/js/stores/customers.ts @@ -0,0 +1,108 @@ +import { reactive } from 'vue' +import type { AxiosInstance } from 'axios' +import { listCustomers } from '@/api' +import type { Customer } from '@/types/api' + +/** + * A display name for every contact id the module renders. + * + * Projects, tasks and time all carry a bare `customer_id`, so the lists would + * otherwise read `#42`. The names come from one host request per company + * session, kept here where the projects table, the board and the reports page + * all reach them. + * + * Everything degrades rather than throws: reading contacts needs a host + * ability the caller may not have, and a contact deleted since keeps its id as + * its label rather than blanking the row. + */ + +/** + * How many contacts one lookup asks the host for. + * + * The map exists to label ids the module already holds, not to browse the + * address book, so it is deliberately capped: a company with more contacts + * than this still gets names for most rows, and anything past the cap falls + * back to `#id`. Forms keep their search-based picker, which is not capped and + * is the right tool for choosing a contact. + */ +export const CUSTOMER_LIMIT = 200 + +const names = reactive>({}) + +let loaded = false +let inFlight: Promise | null = null + +/** The cached name, or a stable `#id` placeholder to render meanwhile. */ +export function customerName(id: number | null): string { + if (id === null) { + return '' + } + + return names[id] ?? `#${id}` +} + +/** Whether a name for this id has arrived, for a caller that hides the rest. */ +export function hasCustomerName(id: number | null): boolean { + return id !== null && names[id] !== undefined +} + +/** + * Read the company's contacts, once. + * + * Several views ask on the same page, so the first call owns the request and + * the rest wait on it. A failed read leaves the map empty and unlatched, so + * the next view to need a name tries again. + */ +export async function ensureLoaded(client: AxiosInstance): Promise { + if (loaded) { + return + } + + inFlight ??= load(client) + + await inFlight +} + +/** Forget the previous company's contacts: ids belong to one company. */ +export function resetCustomers(): void { + for (const key of Object.keys(names)) { + delete names[Number(key)] + } + + loaded = false + inFlight = null +} + +async function load(client: AxiosInstance): Promise { + try { + for (const customer of await listCustomers(client, CUSTOMER_LIMIT)) { + const id = customer?.id + + if (typeof id === 'number') { + names[id] = labelFor(customer) + } + } + + loaded = true + } catch { + // The caller may not read contacts; the ids stay as their own labels. + } finally { + inFlight = null + } +} + +/** + * What a contact is called, checked rather than trusted: this is host data + * crossing a module boundary, and `display_name` is not on every host version. + */ +function labelFor(customer: Customer): string { + const display = typeof customer.display_name === 'string' ? customer.display_name.trim() : '' + + if (display !== '') { + return display + } + + const name = typeof customer.name === 'string' ? customer.name.trim() : '' + + return name !== '' ? name : `#${customer.id}` +} diff --git a/resources/js/stores/invoicing.ts b/resources/js/stores/invoicing.ts new file mode 100644 index 0000000..ca09d66 --- /dev/null +++ b/resources/js/stores/invoicing.ts @@ -0,0 +1,118 @@ +import { reactive } from 'vue' +import type { ConfirmItem } from '@/types/billing' + +/** + * What the invoicing sequence has in flight, shared by every screen that can + * start one. + * + * A module bundle has no Pinia, so this is a plain reactive singleton. Three + * facts live here rather than in a component, because all three outlive the + * screen that caused them: + * + * - `busy` is one lock for the whole module. Invoicing is four requests long, + * and a second click on another row while the first is still running would + * race the same time entries onto two invoices. + * - `pending` is an invoice the host created whose entries were not stamped. + * It is the one state the user has to resolve, because the invoice exists + * and the time still reads as unbilled, so it is kept until the retry works + * rather than lost with the page. + * - `allowed` is what a 403 taught us. The module settings endpoint does not + * say whether the caller may invoice, so the first refusal does, and the + * actions stop offering themselves for the rest of the session. + */ + +/** An invoice that exists, with the stamp that did not land on its entries. */ +export interface PendingStamp { + invoiceId: number + /** For the banner, so it names the invoice the user is looking at. */ + invoiceNumber: string + items: ConfirmItem[] +} + +/** A number the sequence is waiting for the user to type. */ +interface NumberPrompt { + suggested: string + resolve: (value: string | null) => void +} + +interface InvoicingState { + /** True while a prepare-create-confirm sequence is running. */ + busy: boolean + pending: PendingStamp | null + /** False once the server has refused the ability once. */ + allowed: boolean + prompt: NumberPrompt | null +} + +export const invoicingStore = reactive({ + busy: false, + pending: null, + allowed: true, + prompt: null, +}) + +/** Take the module-wide lock, or say that someone else holds it. */ +export function lockInvoicing(): boolean { + if (invoicingStore.busy) { + return false + } + + invoicingStore.busy = true + + return true +} + +export function unlockInvoicing(): void { + invoicingStore.busy = false +} + +/** Remember an invoice whose entries are still unstamped. */ +export function holdStamp(pending: PendingStamp): void { + invoicingStore.pending = pending +} + +export function clearStamp(): void { + invoicingStore.pending = null +} + +/** The server refused the ability, so stop offering the action. */ +export function denyInvoicing(): void { + invoicingStore.allowed = false +} + +/** + * Ask the user for the invoice number and wait for the answer. + * + * The modal that answers is mounted once in the company layout rather than by + * every screen that can invoice, so the sequence can ask from anywhere without + * each caller carrying a dialog of its own. A second ask while one is open + * cancels the first, which cannot happen while `busy` holds but keeps the + * promise from being dropped if it ever did. + */ +export function askInvoiceNumber(suggested: string): Promise { + answerInvoiceNumber(null) + + return new Promise((resolve) => { + invoicingStore.prompt = { suggested, resolve } + }) +} + +/** Hand the sequence the number, or null when the user backed out. */ +export function answerInvoiceNumber(value: string | null): void { + const prompt = invoicingStore.prompt + + if (prompt === null) { + return + } + + invoicingStore.prompt = null + prompt.resolve(value) +} + +/** Forget the previous company: invoices and abilities belong to one. */ +export function resetInvoicing(): void { + answerInvoiceNumber(null) + invoicingStore.busy = false + invoicingStore.pending = null + invoicingStore.allowed = true +} diff --git a/resources/js/stores/session.ts b/resources/js/stores/session.ts new file mode 100644 index 0000000..4705614 --- /dev/null +++ b/resources/js/stores/session.ts @@ -0,0 +1,158 @@ +import { reactive } from 'vue' +import type { AxiosInstance } from 'axios' +import { fetchCurrentUserId, fetchTimeSettings } from '@/api/time' +import type { ModuleSettings, RoundingDirection } from '@/types/settings' + +/** + * What the time screens need to know about the current session. + * + * A module bundle runs on the host's Vue instance but not on its Pinia, so it + * cannot read the user or company stores. The two facts the timesheet cannot + * work without, the signed-in user's id and the company's module settings, are + * fetched once per company and kept here, where the page, the overlay and the + * settings editor all reach them. + * + * Everything degrades rather than throws: a member who may not read the module + * settings still gets the defaults, and a missing user id only means the "My + * time" view asks the caller to reload. + */ + +export const DEFAULT_SETTINGS: ModuleSettings = { + default_rate: 0, + rounding_minutes: 1, + rounding_direction: 'nearest', + week_start: 1, + members_see_all_time: false, + auto_start_tasks: false, + lock_invoiced_tasks: false, + hide_invoiced_on_board: false, + invoice_project_heading: false, + invoice_task_description: true, + invoice_entry_dates: true, + invoice_entry_times: false, + invoice_entry_hours: true, + invoice_entry_descriptions: false, + rounding_increments: [1, 5, 6, 15, 30, 60], +} + +interface SessionState { + /** True while the shell is in platform administration, where no company is active. */ + adminMode: boolean + userId: number | null + settings: ModuleSettings + /** Bumped on every company change so views can key off it and start clean. */ + companySession: number + loading: boolean +} + +export const session = reactive({ + adminMode: false, + userId: null, + settings: { ...DEFAULT_SETTINGS }, + companySession: 0, + loading: false, +}) + +/** Read the user id and the company settings for the active company. */ +export async function refreshSession(client: AxiosInstance): Promise { + if (session.adminMode) { + return + } + + session.loading = true + + const [userId, settings] = await Promise.all([ + fetchCurrentUserId(client).catch((): null => null), + fetchTimeSettings(client).catch((): null => null), + ]) + + session.userId = userId + session.settings = normaliseSettings(settings) + session.loading = false +} + +/** Forget the previous company's answers before the next one loads. */ +export function resetSession(): void { + session.userId = null + session.settings = { ...DEFAULT_SETTINGS } + session.companySession += 1 + session.loading = false +} + +export function setAdminMode(adminMode: boolean): void { + session.adminMode = adminMode +} + +/** + * Settings as the module promises them, whatever the endpoint answered. + * + * The payload is host data crossing a module boundary, so every field is + * checked rather than trusted: a missing settings endpoint, an older module + * version or a 403 all end up as the documented defaults. + */ +function normaliseSettings(settings: ModuleSettings | null): ModuleSettings { + if (settings === null || typeof settings !== 'object') { + return { ...DEFAULT_SETTINGS } + } + + const increments = Array.isArray(settings.rounding_increments) + ? settings.rounding_increments.filter((value): value is number => typeof value === 'number') + : DEFAULT_SETTINGS.rounding_increments + + return { + default_rate: numberOr(settings.default_rate, DEFAULT_SETTINGS.default_rate), + rounding_minutes: numberOr(settings.rounding_minutes, DEFAULT_SETTINGS.rounding_minutes), + rounding_direction: directionOr(settings.rounding_direction), + week_start: weekStartOr(settings.week_start), + members_see_all_time: settings.members_see_all_time === true, + auto_start_tasks: flagOr(settings.auto_start_tasks, DEFAULT_SETTINGS.auto_start_tasks), + lock_invoiced_tasks: flagOr(settings.lock_invoiced_tasks, DEFAULT_SETTINGS.lock_invoiced_tasks), + hide_invoiced_on_board: flagOr( + settings.hide_invoiced_on_board, + DEFAULT_SETTINGS.hide_invoiced_on_board, + ), + invoice_project_heading: flagOr( + settings.invoice_project_heading, + DEFAULT_SETTINGS.invoice_project_heading, + ), + invoice_task_description: flagOr( + settings.invoice_task_description, + DEFAULT_SETTINGS.invoice_task_description, + ), + invoice_entry_dates: flagOr(settings.invoice_entry_dates, DEFAULT_SETTINGS.invoice_entry_dates), + invoice_entry_times: flagOr(settings.invoice_entry_times, DEFAULT_SETTINGS.invoice_entry_times), + invoice_entry_hours: flagOr(settings.invoice_entry_hours, DEFAULT_SETTINGS.invoice_entry_hours), + invoice_entry_descriptions: flagOr( + settings.invoice_entry_descriptions, + DEFAULT_SETTINGS.invoice_entry_descriptions, + ), + rounding_increments: increments.length > 0 ? increments : DEFAULT_SETTINGS.rounding_increments, + } +} + +function numberOr(value: unknown, fallback: number): number { + return typeof value === 'number' && Number.isFinite(value) ? value : fallback +} + +/** + * A toggle the server sent, or the documented default. + * + * An older server omits these keys entirely, which is not the same answer as + * "off": a missing `invoice_task_description` still means the description is + * written, because that is what the module promises when nobody has chosen. + */ +function flagOr(value: unknown, fallback: boolean): boolean { + return typeof value === 'boolean' ? value : fallback +} + +function directionOr(value: unknown): RoundingDirection { + return value === 'up' || value === 'down' || value === 'nearest' + ? value + : DEFAULT_SETTINGS.rounding_direction +} + +function weekStartOr(value: unknown): number { + return typeof value === 'number' && Number.isInteger(value) && value >= 0 && value <= 6 + ? value + : DEFAULT_SETTINGS.week_start +} diff --git a/resources/js/stores/tasks.ts b/resources/js/stores/tasks.ts new file mode 100644 index 0000000..d68f4cf --- /dev/null +++ b/resources/js/stores/tasks.ts @@ -0,0 +1,152 @@ +import { reactive, ref } from 'vue' +import type { Ref } from 'vue' +import type { AxiosInstance } from 'axios' +import { fetchTask } from '@/api/time' +import type { Task, TaskTime } from '@/types/task' +import type { TaskSummary } from '@/types/task-summary' + +/** + * A name for every task id the time screens display. + * + * `TimeEntryResource` carries `task_id` and nothing else, so a timesheet row, + * the header chip and the entry editor would all show a bare number. The cache + * fills in the names, once per task per company session, and a task that + * cannot be read keeps its id as the label rather than blanking the row. + */ + +const names = reactive>({}) +const pending = new Set() + +/** How many name lookups may be in flight at once. */ +const BATCH_SIZE = 5 + +/** + * What a task's time block says when the server did not send one. + * + * Every reader goes through `taskTime()`, so a row from an older server still + * renders: it just reports nothing logged rather than blanking the column. + */ +const EMPTY_TIME: TaskTime = { + logged_minutes: 0, + billable_minutes: 0, + unbilled_minutes: 0, + unbilled_amount: 0, + invoiced: 'none', + running: [], +} + +/** + * How many writes have happened, for the lists and boards to watch. + * + * A task written on one screen changes what another shows: starting a clock on + * the task page changes the row on the list behind it, and a bulk status + * change moves cards on the board. Rather than wiring every screen to every + * other, each one watches this counter and refetches. + */ +const version = ref(0) + +/** + * Time blocks a screen has written ahead of the server's answer. + * + * Pressing play has to look instant, but the row the button sits in came from + * a list request that will not be repeated for a second or two. The override + * is merged over whatever the payload carried and is dropped when the fresh + * answer arrives. + */ +const timePatches = reactive>>({}) + +/** The cached name, or a stable `#id` placeholder to render meanwhile. */ +export function taskLabel(id: number | null): string { + if (id === null) { + return '' + } + + return names[id] ?? `#${id}` +} + +/** Remember a task the caller already holds, so no lookup is needed. */ +export function rememberTask(task: TaskSummary | Task | null | undefined): void { + if (task && typeof task.id === 'number' && typeof task.name === 'string') { + names[task.id] = task.name + } +} + +/** + * Make sure every id given has a name, fetching the ones that do not. + * + * Failures are swallowed on purpose: a missing name is cosmetic, and the + * timesheet must render even when one task has been deleted under it. + */ +export async function ensureTaskNames(client: AxiosInstance, ids: number[]): Promise { + const wanted = [...new Set(ids)].filter( + (id) => typeof id === 'number' && names[id] === undefined && !pending.has(id), + ) + + for (const id of wanted) { + pending.add(id) + } + + for (let index = 0; index < wanted.length; index += BATCH_SIZE) { + await Promise.all( + wanted.slice(index, index + BATCH_SIZE).map(async (id) => { + try { + rememberTask(await fetchTask(client, id)) + } catch { + // A task that cannot be read keeps its id as its label. + } finally { + pending.delete(id) + } + }), + ) + } +} + +/** The counter every list and board watches to know a refetch is due. */ +export const taskVersion: Ref = version + +/** Say that a task was written, so every open list and board reloads. */ +export function bumpTaskVersion(): void { + version.value += 1 +} + +/** Show a task's time as something else until the server confirms it. */ +export function patchTime(taskId: number, partial: Partial): void { + timePatches[taskId] = { ...(timePatches[taskId] ?? {}), ...partial } +} + +/** Forget one optimistic patch, because a fresh payload has replaced it. */ +export function clearTimePatch(taskId: number): void { + delete timePatches[taskId] +} + +/** + * A task's time summary: what the payload carried, under what a screen has + * written optimistically, over the empty summary an older server implies. + */ +export function taskTime(task: Pick | null | undefined): TaskTime { + if (!task || typeof task.id !== 'number') { + return EMPTY_TIME + } + + const sent = task.time ?? EMPTY_TIME + + return { + ...EMPTY_TIME, + ...sent, + running: Array.isArray(sent.running) ? sent.running : [], + ...(timePatches[task.id] ?? {}), + } +} + +/** Drop everything: task ids belong to one company. */ +export function resetTaskNames(): void { + for (const key of Object.keys(names)) { + delete names[Number(key)] + } + + for (const key of Object.keys(timePatches)) { + delete timePatches[Number(key)] + } + + pending.clear() +} diff --git a/resources/js/stores/timer.ts b/resources/js/stores/timer.ts new file mode 100644 index 0000000..db7d3f0 --- /dev/null +++ b/resources/js/stores/timer.ts @@ -0,0 +1,596 @@ +import { computed, onScopeDispose, reactive } from 'vue' +import type { ComputedRef } from 'vue' +import type { AxiosInstance } from 'axios' +import { createTask, startTask, stopTask } from '@/api/board' +import { discardTimer, fetchTimer, startTimer, stopTimer } from '@/api/time' +import { errorMessage } from '@/support/errors' +import { errorCode, isConflict } from '@/support/http' +import { formatDuration, secondsBetween } from '@/support/time' +import type { Translate } from '@/support/i18n' +import type { TimeEntry } from '@/types/time-entry' +import type { + StartAnswer, + StartPreset, + StopAnswer, + StopPrompt, + StopTimerInput, +} from '@/types/timer' +import { bumpTaskVersion, ensureTaskNames, patchTime, rememberTask, taskLabel } from './tasks' + +/** + * The running timer, shared by the header chip, the quick-start launcher, the + * task rows and the time log. + * + * A module bundle has no Pinia, so this is a plain reactive singleton. The + * elapsed time is recomputed from `started_at` on every tick rather than + * counted up, so a throttled background tab, a sleeping laptop and a clock + * correction all land on the right number at the next tick. + * + * Nothing here throws at a caller: a failed refresh leaves the chip hidden + * rather than breaking the header it renders in. + */ + +type NotifyType = 'success' | 'error' | 'warning' | 'info' + +/** + * How a caller wants failures reported. The translator comes from the calling + * component, because a store cannot reach the host's i18n on its own. + */ +export interface TimerFeedback { + notify: (type: NotifyType, message: string) => void + t: Translate +} + +/** The running entry the stop dialog is asking about, and who is waiting. */ +interface StopPromptState extends StopPrompt { + resolve: (answer: StopAnswer | null) => void +} + +/** What the start dialog opened with, and who is waiting for its answer. */ +interface StartPromptState extends StartPreset { + resolve: (answer: StartAnswer | null) => void +} + +interface TimerState { + running: TimeEntry | null + /** True while a start, stop or discard is in flight, to disable the buttons. */ + busy: boolean + /** + * The two questions the timer asks, promise-driven like the invoicing store. + * + * Every stop control on the screen asks the same question, and the dialog + * that answers is mounted once in the company layout rather than by each of + * them, so a header chip, a task row and a time log row all reach it without + * carrying a dialog of their own. + */ + stopPrompt: StopPromptState | null + startPrompt: StartPromptState | null +} + +const state = reactive({ + running: null, + busy: false, + stopPrompt: null, + startPrompt: null, +}) + +/** + * One clock for every live duration on the screen. + * + * A board can show a dozen running rows; one interval driving one ref keeps + * that at a single wake-up per second, and only the components that read it + * re-render. It runs while anything is subscribed and while the caller's own + * timer is going, and stops as soon as neither is true. + */ +const clock = reactive({ now: Date.now() }) + +let ticker: ReturnType | undefined +let subscribers = 0 + +function tick(): void { + clock.now = Date.now() +} + +function subscribeClock(): void { + subscribers += 1 + + if (ticker === undefined) { + tick() + ticker = setInterval(tick, 1000) + } +} + +function unsubscribeClock(): void { + subscribers = Math.max(0, subscribers - 1) + + if (subscribers === 0 && ticker !== undefined) { + clearInterval(ticker) + ticker = undefined + } +} + +/** + * Read the shared clock for as long as this scope lives. + * + * Call it from `setup` in any component that renders a live duration; the + * subscription is released when the component goes away. + */ +export function useNow(): ComputedRef { + subscribeClock() + onScopeDispose(unsubscribeClock, true) + + return computed(() => clock.now) +} + +/** Whether the store itself is holding the clock open for its own entry. */ +let holdingClock = false + +/** Adopt a payload as the running entry, or clear the clock when it is null. */ +function adopt(entry: TimeEntry | null, client?: AxiosInstance): void { + state.running = entry && typeof entry.id === 'number' ? entry : null + + if (state.running === null) { + if (holdingClock) { + holdingClock = false + unsubscribeClock() + } + + return + } + + if (!holdingClock) { + holdingClock = true + subscribeClock() + } + + if (client && typeof state.running.task_id === 'number') { + void ensureTaskNames(client, [state.running.task_id]) + } +} + +function report(feedback: TimerFeedback | undefined, error: unknown, key: string): void { + feedback?.notify('error', errorMessage(error, feedback.t(key))) +} + +/** Say that the caller's clock is now on this task, before the list agrees. */ +function claim(entry: TimeEntry): void { + patchTime(entry.task_id, { + running: [{ entry_id: entry.id, user_id: entry.user_id, started_at: entry.started_at }], + }) +} + +/** + * Write down a task the start dialog only has a name for. + * + * The clock is started separately rather than relying on `auto_start_tasks`, + * because the company may not have that switch on; when it does, the start + * that follows lands on the timer the server already opened. + */ +async function createTaskToTime( + client: AxiosInstance, + create: { name: string; projectId: number | null }, + feedback?: TimerFeedback, +): Promise { + try { + const task = await createTask(client, { name: create.name, project_id: create.projectId }) + + rememberTask(task) + bumpTaskVersion() + + return typeof task.id === 'number' ? task.id : null + } catch (error: unknown) { + report(feedback, error, 'tasks_projects.tasks.save_failed') + + return null + } +} + +export const timerStore = { + /** The running entry, or null when the clock is not running. */ + get running(): TimeEntry | null { + return state.running + }, + + /** The task the caller's clock is on, or null when it is not running. */ + get runningTaskId(): number | null { + const taskId = state.running?.task_id + + return typeof taskId === 'number' ? taskId : null + }, + + /** Seconds since the running entry started, recomputed every second. */ + get elapsedSeconds(): number { + return state.running === null ? 0 : secondsBetween(state.running.started_at, clock.now) + }, + + /** True while a timer request is in flight. */ + get busy(): boolean { + return state.busy + }, + + /** The running entry the stop dialog is open on, or null when it is closed. */ + get stopPrompt(): StopPromptState | null { + return state.stopPrompt + }, + + /** What the start dialog is open with, or null when it is closed. */ + get startPrompt(): StartPromptState | null { + return state.startPrompt + }, + + /** Whether the caller's own clock is on this task. */ + isRunningOn(taskId: number): boolean { + return state.running !== null && state.running.task_id === taskId + }, + + /** Read the caller's running entry from the server. */ + async refresh(client: AxiosInstance): Promise { + try { + adopt(await fetchTimer(client), client) + } catch { + // The header chip stays hidden rather than reporting a background read. + adopt(null) + } + }, + + /** + * Start the clock on a task. + * + * A 409 means another tab got there first, which is not an error the user + * caused: it is reported and the real running entry is read back. + */ + async start( + client: AxiosInstance, + taskId: number, + description: string | null = null, + feedback?: TimerFeedback, + ): Promise { + if (state.busy) { + return null + } + + state.busy = true + + try { + const entry = await startTimer(client, { task_id: taskId, description }) + + adopt(entry, client) + claim(entry) + bumpTaskVersion() + + return entry + } catch (error: unknown) { + if (isConflict(error)) { + feedback?.notify('warning', feedback.t('tasks_projects.timer.already_running')) + await this.refresh(client) + } else { + report(feedback, error, 'tasks_projects.timer.start_failed') + } + + return null + } finally { + state.busy = false + } + }, + + /** + * Start the clock through the task's own route. + * + * Same effect as `start`, but the server answers `timer_already_running` + * when the caller's clock is on a different task, which the run control + * turns into "stop that one and start this one" rather than a dead end. + */ + async startOnTask( + client: AxiosInstance, + taskId: number, + description: string | null = null, + feedback?: TimerFeedback, + billable?: boolean, + ): Promise { + if (state.busy) { + return null + } + + state.busy = true + + try { + const entry = await startTask(client, taskId, description, billable) + + adopt(entry, client) + claim(entry) + bumpTaskVersion() + + return entry + } catch (error: unknown) { + if (errorCode(error) === 'timer_already_running') { + feedback?.notify('warning', feedback.t('tasks_projects.timer.already_running')) + await this.refresh(client) + } else { + report(feedback, error, 'tasks_projects.timer.start_failed') + } + + return null + } finally { + state.busy = false + } + }, + + /** + * Close the running entry and answer the completed one. + * + * `details` is what the stop dialog collected. What it leaves out the server + * leaves alone, so a stop with nothing to say keeps the start's own note. + */ + async stop( + client: AxiosInstance, + feedback?: TimerFeedback, + details?: StopTimerInput, + ): Promise { + if (state.busy || state.running === null) { + return null + } + + const taskId = state.running.task_id + + state.busy = true + + try { + const entry = await stopTimer(client, details) + + adopt(null) + patchTime(taskId, { running: [] }) + bumpTaskVersion() + + return entry + } catch (error: unknown) { + report(feedback, error, 'tasks_projects.timer.stop_failed') + await this.refresh(client) + + return null + } finally { + state.busy = false + } + }, + + /** + * Close the caller's entry on one named task. + * + * The task is named so a stale row cannot stop a timer that has since moved + * elsewhere: the server answers `timer_mismatch` and the row reloads. + */ + async stopOnTask( + client: AxiosInstance, + taskId: number, + feedback?: TimerFeedback, + details?: StopTimerInput, + ): Promise { + if (state.busy) { + return null + } + + state.busy = true + + try { + const entry = await stopTask(client, taskId, details) + + adopt(null) + patchTime(taskId, { running: [] }) + bumpTaskVersion() + + return entry + } catch (error: unknown) { + if (errorCode(error) === 'timer_mismatch') { + feedback?.notify('warning', feedback.t('tasks_projects.timer.mismatch')) + } else { + report(feedback, error, 'tasks_projects.timer.stop_failed') + } + + await this.refresh(client) + + return null + } finally { + state.busy = false + } + }, + + /** + * Ask what to do with the running timer and wait for the answer. + * + * A second ask cancels the first, which cannot happen while one dialog is + * mounted but keeps the promise from being dropped if it ever did. + */ + askStop(): Promise { + this.answerStop(null) + + const entry = state.running + + if (entry === null) { + return Promise.resolve(null) + } + + return new Promise((resolve) => { + state.stopPrompt = { entry, resolve } + }) + }, + + /** Hand the waiting stop its answer, or null when the user backed out. */ + answerStop(answer: StopAnswer | null): void { + const prompt = state.stopPrompt + + if (prompt === null) { + return + } + + state.stopPrompt = null + prompt.resolve(answer) + }, + + /** Ask which task to start on, and with what, then wait for the answer. */ + askStart(preset: StartPreset = {}): Promise { + this.answerStart(null) + + return new Promise((resolve) => { + state.startPrompt = { ...preset, resolve } + }) + }, + + /** Hand the waiting start its answer, or null when the user backed out. */ + answerStart(answer: StartAnswer | null): void { + const prompt = state.startPrompt + + if (prompt === null) { + return + } + + state.startPrompt = null + prompt.resolve(answer) + }, + + /** + * The one way a timer is ever stopped: ask, then do what was asked. + * + * Every stop control goes through here, so the dialog, the discard and the + * one success message are written once rather than by each caller. Cancel is + * a real answer: the clock keeps running and nothing is written. + * + * `taskId` names the task the caller believes is running, so a stale row + * cannot stop a clock that has since moved; the mismatch is reported before + * the dialog opens rather than after the user has typed into it. + */ + async stopWithPrompt( + client: AxiosInstance, + feedback?: TimerFeedback, + opts: { taskId?: number } = {}, + ): Promise { + const running = state.running + + if (running === null) { + return null + } + + const taskId = opts.taskId + + if (typeof taskId === 'number' && running.task_id !== taskId) { + feedback?.notify('warning', feedback.t('tasks_projects.timer.mismatch')) + await this.refresh(client) + + return null + } + + // Read before the stop, because the entry is gone by the time it lands. + const name = taskLabel(running.task_id) + const answer = await this.askStop() + + if (answer === null) { + return null + } + + if (answer.action === 'discard') { + if (await this.discard(client, feedback)) { + feedback?.notify('success', feedback.t('tasks_projects.timer.discarded')) + } + + return null + } + + const details: StopTimerInput = { + description: answer.description, + billable: answer.billable, + } + + const entry = + typeof taskId === 'number' + ? await this.stopOnTask(client, taskId, feedback, details) + : await this.stop(client, feedback, details) + + if (entry !== null) { + feedback?.notify( + 'success', + feedback.t('tasks_projects.timer.stopped', { + name, + duration: formatDuration(entry.duration_minutes), + }), + ) + } + + return entry + }, + + /** + * The one way a timer is ever started from a launcher: ask, then start. + * + * A task the dialog had to create is created first and timed second, so the + * answer "start on something I have just thought of" costs one dialog rather + * than a form and a search. + */ + async startWithPrompt( + client: AxiosInstance, + feedback?: TimerFeedback, + preset: StartPreset = {}, + ): Promise { + const answer = await this.askStart(preset) + + if (answer === null) { + return null + } + + const taskId = + 'taskId' in answer ? answer.taskId : await createTaskToTime(client, answer.create, feedback) + + if (taskId === null) { + return null + } + + const entry = await this.startOnTask( + client, + taskId, + answer.description, + feedback, + answer.billable, + ) + + if (entry !== null) { + feedback?.notify( + 'success', + feedback.t('tasks_projects.timer.started', { name: taskLabel(taskId) }), + ) + } + + return entry + }, + + /** Throw the running entry away without recording any time. */ + async discard(client: AxiosInstance, feedback?: TimerFeedback): Promise { + if (state.busy || state.running === null) { + return false + } + + const taskId = state.running.task_id + + state.busy = true + + try { + await discardTimer(client) + adopt(null) + patchTime(taskId, { running: [] }) + bumpTaskVersion() + + return true + } catch (error: unknown) { + report(feedback, error, 'tasks_projects.timer.discard_failed') + await this.refresh(client) + + return false + } finally { + state.busy = false + } + }, + + /** Forget the clock, for a company switch or a sign-out. */ + reset(): void { + this.answerStop(null) + this.answerStart(null) + state.busy = false + adopt(null) + }, +} diff --git a/resources/js/support/errors.ts b/resources/js/support/errors.ts new file mode 100644 index 0000000..b08c8cf --- /dev/null +++ b/resources/js/support/errors.ts @@ -0,0 +1,51 @@ +/** + * Reading a Laravel error response without importing axios at runtime. + * + * The module bundle runs inside the host page and receives the host's own + * axios instance, so errors are checked structurally rather than with + * `axios.isAxiosError`. + */ + +interface ApiErrorBody { + message?: string + errors?: Record +} + +function responseBody(error: unknown): ApiErrorBody | null { + if (typeof error !== 'object' || error === null) { + return null + } + + const response = (error as { response?: { data?: unknown } }).response + + if (typeof response?.data !== 'object' || response.data === null) { + return null + } + + return response.data as ApiErrorBody +} + +/** The server's message, or the caller's fallback when there is none. */ +export function errorMessage(error: unknown, fallback: string): string { + const message = responseBody(error)?.message + + return typeof message === 'string' && message !== '' ? message : fallback +} + +/** The first validation message per field of a 422 response. */ +export function fieldErrors(error: unknown): Record { + const errors = responseBody(error)?.errors + const messages: Record = {} + + if (typeof errors !== 'object' || errors === null) { + return messages + } + + for (const [field, list] of Object.entries(errors)) { + if (Array.isArray(list) && typeof list[0] === 'string') { + messages[field] = list[0] + } + } + + return messages +} diff --git a/resources/js/support/filters.ts b/resources/js/support/filters.ts new file mode 100644 index 0000000..b4472fd --- /dev/null +++ b/resources/js/support/filters.ts @@ -0,0 +1,158 @@ +import type { LocationQuery, LocationQueryRaw } from 'vue-router' +import type { SortParams } from '@/api' +import type { TaskSortKey } from '@/api/board' +import type { TaskListParams } from '@/types/task' + +/** + * The Tasks screen's filters, as the address bar carries them. + * + * Everything is a string because that is what a query string holds and what a + * link has to round-trip: a view switch, a reload and a bookmark all go + * through the URL, so keeping one representation removes every conversion but + * the one at the edge. + */ +export interface TaskFilterState { + /** A project id, or '' for every project. */ + project: string + /** A member id, or '' for everyone. */ + user: string + /** A status id, or the pseudo values below, or '' for every status. */ + status: string + search: string +} + +/** The two status values that are not a board column. */ +export const INVOICED_FILTERS = ['uninvoiced', 'invoiced'] as const + +export type InvoicedFilter = (typeof INVOICED_FILTERS)[number] + +export const EMPTY_FILTERS: TaskFilterState = { + project: '', + user: '', + status: '', + search: '', +} + +/** Whether a status filter names the invoicing state rather than a column. */ +export function isInvoicedFilter(status: string): status is InvoicedFilter { + return status === 'uninvoiced' || status === 'invoiced' +} + +/** The filters a route carries, with anything unrecognised dropped. */ +export function readFilters(query: LocationQuery): TaskFilterState { + return { + project: numeric(query.project), + user: numeric(query.user), + status: statusOf(query.status), + search: single(query.search).slice(0, 200), + } +} + +/** + * The query a link should carry. + * + * Empty filters are left out rather than written as blanks, so an unfiltered + * Tasks screen has a clean URL and two links to the same view compare equal. + */ +export function filterQuery(filters: TaskFilterState): LocationQueryRaw { + const query: LocationQueryRaw = {} + + for (const key of ['project', 'user', 'status', 'search'] as const) { + if (filters[key] !== '') { + query[key] = filters[key] + } + } + + return query +} + +/** Whether anything is filtered, for the "clear" affordance. */ +export function hasFilters(filters: TaskFilterState): boolean { + return filters.project !== '' || filters.user !== '' || filters.status !== '' || filters.search !== '' +} + +/** + * The filters as one comparable string. + * + * A watcher on the object itself would fire on every route change, because the + * screen above rebuilds it from the query each time. Comparing the four values + * fires when they really differ, which is when a list is worth asking for + * again. + */ +export function filterKey(filters: TaskFilterState): string { + return [filters.project, filters.user, filters.status, filters.search].join('|') +} + +export function sameFilters(left: TaskFilterState, right: TaskFilterState): boolean { + return ( + left.project === right.project && + left.user === right.user && + left.status === right.status && + left.search === right.search + ) +} + +/** + * The filters as `GET tasks` takes them. + * + * `project` is overridden by a project page, which fixes the list to its own + * project whatever the address bar says. + */ +export function taskListParams( + filters: TaskFilterState, + overrides: { projectId?: number | null } = {}, +): TaskListParams & SortParams { + const params: TaskListParams & SortParams = {} + const projectId = overrides.projectId ?? idOf(filters.project) + + if (projectId !== null) { + params.project_id = projectId + } + + const assigneeId = idOf(filters.user) + + if (assigneeId !== null) { + params.assignee_id = assigneeId + } + + if (isInvoicedFilter(filters.status)) { + params.invoiced = filters.status === 'invoiced' ? 1 : 0 + } else { + const statusId = idOf(filters.status) + + if (statusId !== null) { + params.task_status_id = statusId + } + } + + if (filters.search !== '') { + params.search = filters.search + } + + return params +} + +/** A filter value as the id it names, or null when it names nothing. */ +export function idOf(value: string): number | null { + const id = Number(value) + + return value !== '' && Number.isInteger(id) && id > 0 ? id : null +} + +function single(value: LocationQuery[string]): string { + const first = Array.isArray(value) ? value[0] : value + + return typeof first === 'string' ? first.trim() : '' +} + +function numeric(value: LocationQuery[string]): string { + const text = single(value) + + return idOf(text) === null ? '' : text +} + +function statusOf(value: LocationQuery[string]): string { + const text = single(value) + + return isInvoicedFilter(text) ? text : numeric(value) +} diff --git a/resources/js/support/format.ts b/resources/js/support/format.ts new file mode 100644 index 0000000..76bd4ab --- /dev/null +++ b/resources/js/support/format.ts @@ -0,0 +1,102 @@ +/** + * Conversions between what the API stores and what a form shows. + * + * The API keeps money in integer minor units and durations in minutes; the + * form asks for major units and hours, which is what people type. + */ + +/** Minor units to the major-unit string a number input shows. */ +export function minorToMajor(amount: number | null): string { + return amount === null ? '' : String(amount / 100) +} + +/** A typed major-unit amount back to integer minor units. */ +export function majorToMinor(value: string): number | null { + const amount = Number(value) + + return value.trim() === '' || Number.isNaN(amount) ? null : Math.round(amount * 100) +} + +export function minutesToHours(minutes: number | null): string { + return minutes === null ? '' : String(minutes / 60) +} + +export function hoursToMinutes(value: string): number | null { + const hours = Number(value) + + return value.trim() === '' || Number.isNaN(hours) ? null : Math.round(hours * 60) +} + +/** + * A `Y-m-d` date in the viewer's locale. The date is a calendar date rather + * than an instant, so it is read and printed in UTC and never shifts a day. + */ +export function formatDate(value: string | null): string { + if (!value) { + return '' + } + + const [year, month, day] = value.slice(0, 10).split('-').map(Number) + + if (!year || !month || !day) { + return value + } + + return new Date(Date.UTC(year, month - 1, day)).toLocaleDateString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + timeZone: 'UTC', + }) +} + +/** What a date picker hands back, normalised to the `Y-m-d` the API takes. */ +export function toDateString(value: string | Date): string { + if (typeof value === 'string') { + return value.slice(0, 10) + } + + const month = String(value.getMonth() + 1).padStart(2, '0') + const day = String(value.getDate()).padStart(2, '0') + + return `${value.getFullYear()}-${month}-${day}` +} + +/** Minutes as the hours and minutes a timesheet reads back, such as "2h 30m". */ +export function formatMinutes(minutes: number | null): string { + const total = Math.max(0, Math.round(minutes ?? 0)) + const hours = Math.floor(total / 60) + const rest = total % 60 + + if (hours === 0) { + return `${rest}m` + } + + return rest === 0 ? `${hours}h` : `${hours}h ${rest}m` +} + +/** The one or two letters an avatar chip shows for a person. */ +export function initials(name: string): string { + const parts = name.trim().split(/\s+/).filter(Boolean) + + if (parts.length === 0) { + return '?' + } + + const first = parts[0].charAt(0) + const last = parts.length > 1 ? parts[parts.length - 1].charAt(0) : '' + + return (first + last).toUpperCase() +} + +/** Whether a `Y-m-d` date has already passed, compared in the viewer's day. */ +export function isOverdue(value: string | null): boolean { + if (!value) { + return false + } + + const today = new Date() + const stamp = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}` + + return value.slice(0, 10) < stamp +} diff --git a/resources/js/support/http.ts b/resources/js/support/http.ts new file mode 100644 index 0000000..e907f07 --- /dev/null +++ b/resources/js/support/http.ts @@ -0,0 +1,50 @@ +/** + * The status of a failed request, read structurally. + * + * The module bundle runs on the host's axios instance and never imports axios + * at runtime, so the error is inspected rather than narrowed with + * `axios.isAxiosError`. A missing or unparseable response answers null, which + * callers treat as "some other failure". + */ +export function errorStatus(error: unknown): number | null { + if (typeof error !== 'object' || error === null) { + return null + } + + const status = (error as { response?: { status?: unknown } }).response?.status + + return typeof status === 'number' ? status : null +} + +/** + * The machine-readable `error` key of a failed request. + * + * Every module endpoint answers a refusal as `{ message, error }`, so the + * caller can tell `timer_already_running` from `timer_mismatch` without + * matching on the human sentence, which is translated and may change. + */ +export function errorCode(error: unknown): string | null { + if (typeof error !== 'object' || error === null) { + return null + } + + const data = (error as { response?: { data?: unknown } }).response?.data + + if (typeof data !== 'object' || data === null) { + return null + } + + const code = (data as { error?: unknown }).error + + return typeof code === 'string' && code !== '' ? code : null +} + +/** A 409 from `timer/start`: someone else's tab already started the clock. */ +export function isConflict(error: unknown): boolean { + return errorStatus(error) === 409 +} + +/** A 403: the caller lacks the ability the endpoint asks for. */ +export function isForbidden(error: unknown): boolean { + return errorStatus(error) === 403 +} diff --git a/resources/js/support/i18n.ts b/resources/js/support/i18n.ts new file mode 100644 index 0000000..2a1f01b --- /dev/null +++ b/resources/js/support/i18n.ts @@ -0,0 +1,18 @@ +import { getCurrentInstance } from 'vue' + +export type Translate = (key: string, named?: Record) => string + +/** + * The host's `$t` for use outside a template. + * + * A module bundle cannot call `useI18n()`, because it would look the composer + * up through its own injection symbols, so the translator is read off the host + * app's global properties instead. Call this during `setup`. + */ +export function useTranslate(): Translate { + const translate = getCurrentInstance()?.appContext.config.globalProperties.$t as + | Translate + | undefined + + return translate ?? ((key: string): string => key) +} diff --git a/resources/js/support/invoicing.ts b/resources/js/support/invoicing.ts new file mode 100644 index 0000000..791925b --- /dev/null +++ b/resources/js/support/invoicing.ts @@ -0,0 +1,459 @@ +import type { AxiosInstance } from 'axios' +import type { Router } from 'vue-router' +import { + confirmInvoice, + createInvoice, + fetchBillingCustomer, + fetchCompanyInvoiceDefaults, + fetchExchangeRate, + fetchNextInvoiceNumber, + listInvoiceTemplates, + prepareInvoice, +} from '@/api/billing' +import { + askInvoiceNumber, + clearStamp, + denyInvoicing, + holdStamp, + invoicingStore, + lockInvoicing, + unlockInvoicing, +} from '@/stores/invoicing' +import { bumpTaskVersion } from '@/stores/tasks' +import { errorMessage } from '@/support/errors' +import { toDateString } from '@/support/format' +import { errorCode, errorStatus } from '@/support/http' +import type { Translate } from '@/support/i18n' +import type { Notify } from '@/support/page' +import type { + BillingSelection, + CompanyInvoiceDefaults, + ConfirmItem, + CreatedInvoice, + InvoicePayload, + PreparedInvoice, +} from '@/types/billing' + +/** + * Turning a selection of work into a draft invoice, in one sequence. + * + * Every entry point in the module, a task row, the bulk bar, a task page, a + * project header and the unbilled time page, runs this and nothing else, so + * "invoice this" means the same four steps wherever it is pressed: + * + * 1. `billing/prepare` turns the selection into the body the host takes. + * 2. The host's own defaults fill in what its create form would have: the due + * date, the template, the number and an exchange rate when the contact does + * not settle in the company currency. + * 3. The host's `POST /api/v1/invoices` writes the invoice, with the session's + * own client, so the module never touches the host invoice tables. + * 4. `billing/confirm` stamps the entries with the line ids that came back, + * and the user lands on the host's edit page for the draft. + * + * Nothing here throws at a caller: every refusal becomes a notification, and + * the one failure that leaves work behind, a created invoice whose entries were + * not stamped, is parked in the store for `InvoiceRetryBanner` to finish. + */ + +/** Where the host mounts the invoice screens. */ +const INVOICES = '/admin/invoices' + +/** Flip to trace the sequence in the console while working on it. */ +const DEBUG = false + +export interface InvoicingDeps { + client: AxiosInstance + /** The host router, which module pages receive as a prop. */ + router: Router + notify: Notify + t: Translate +} + +/** + * Invoice a selection and open the draft. + * + * Answers whether an invoice was created and stamped, so a caller that wants + * to refresh something of its own knows whether anything changed. The task + * lists refresh themselves: the sequence bumps the shared task version. + */ +export async function invoiceTasks( + deps: InvoicingDeps, + selection: BillingSelection, +): Promise { + const { notify, t } = deps + + // An invoice that exists but whose time still reads as unbilled has to be + // finished before another is started, or the same hours reach two invoices. + if (invoicingStore.pending !== null) { + notify('warning', t('tasks_projects.billing.pending_stamp')) + + return false + } + + if (!lockInvoicing()) { + return false + } + + try { + return await run(deps, selection) + } finally { + unlockInvoicing() + } +} + +/** + * Stamp the entries of an invoice that was created but never confirmed. + * + * `billing/confirm` is idempotent, so this is always safe to press again, and + * it is the only way back from a half-finished invoice that does not risk a + * second one. + */ +export async function retryStamp( + client: AxiosInstance, + notify: Notify, + t: Translate, +): Promise { + const pending = invoicingStore.pending + + if (pending === null || !lockInvoicing()) { + return false + } + + try { + const stamped = await confirmInvoice(client, pending.invoiceId, pending.items) + + debug('stamped on retry', stamped) + clearStamp() + bumpTaskVersion() + notify('success', t('tasks_projects.billing.stamped', { count: stamped })) + + return true + } catch (error: unknown) { + notify('error', errorMessage(error, t('tasks_projects.billing.stamp_failed'))) + + return false + } finally { + unlockInvoicing() + } +} + +/** Where the host shows an invoice that exists, for links out of the module. */ +export function invoiceViewPath(invoiceId: number): string { + return `${INVOICES}/${invoiceId}/view` +} + +async function run(deps: InvoicingDeps, selection: BillingSelection): Promise { + const { client, notify, t } = deps + + let prepared: PreparedInvoice + + try { + prepared = await prepareInvoice(client, selection) + } catch (error: unknown) { + reportPrepareFailure(deps, error) + + return false + } + + debug('prepared', prepared) + + if (!Array.isArray(prepared.items) || prepared.items.length === 0) { + notify('warning', t('tasks_projects.billing.nothing_to_invoice')) + + return false + } + + // All three are the host's own answers and none of them is fatal: a company + // whose bootstrap or template list cannot be read still gets an invoice, + // with the same fields its create form would have left blank. + const [defaults, templates, customer] = await Promise.all([ + fetchCompanyInvoiceDefaults(client).catch((): null => null), + listInvoiceTemplates(client).catch((): [] => []), + fetchBillingCustomer(client, prepared.customer_id).catch((): null => null), + ]) + + const currencyId = customer?.currency_id ?? customer?.currency?.id ?? prepared.currency_id + const homeCurrencyId = defaults?.currency?.id ?? null + const foreign = homeCurrencyId !== null && currencyId !== null && currencyId !== homeCurrencyId + + const invoiceNumber = await resolveNumber(deps, defaults, prepared.customer_id) + + if (invoiceNumber === null) { + return false + } + + let exchangeRate: number | null = null + + if (foreign && currencyId !== null) { + exchangeRate = await fetchExchangeRate(client, currencyId).catch((): null => null) + + if (exchangeRate === null) { + notify('warning', t('tasks_projects.billing.rate_failed')) + } + } + + const payload = invoicePayload(prepared, { + invoiceNumber, + currencyId, + exchangeRate, + dueDate: defaultDueDate(prepared.invoice_date, defaults), + templateName: defaults?.defaultTemplate ?? templates[0]?.name ?? '', + }) + + debug('creating', payload) + + let invoice: CreatedInvoice + + try { + invoice = await createInvoice(client, payload) + } catch (error: unknown) { + notify('error', errorMessage(error, t('tasks_projects.billing.create_failed'))) + + return false + } + + debug('created', invoice) + + const stamped = await stamp(deps, invoice, prepared) + + bumpTaskVersion() + + if (!stamped) { + return false + } + + notify('success', t('tasks_projects.billing.created', { number: invoice.invoice_number })) + await openInvoice(deps.router, invoice.id) + + return true +} + +/** + * Say why `prepare` refused, in the words of the screen that asked. + * + * The three answers worth naming are the ones a person can act on: a selection + * spanning two customers, a selection with no money in it, and an ability the + * caller does not have. Everything else keeps the server's own message. + */ +function reportPrepareFailure(deps: InvoicingDeps, error: unknown): void { + const { notify, t } = deps + const code = errorCode(error) + + if (code === 'mixed_billing_selection') { + const customers = customerCount(error) + + // The same refusal covers two customers and two currencies, and only the + // first names ids, so the counted sentence is used only when it is true. + notify( + 'error', + customers > 1 + ? t('tasks_projects.billing.mixed_customers', { count: customers }) + : errorMessage(error, t('tasks_projects.billing.mixed_selection')), + ) + + return + } + + if (code === 'nothing_to_invoice') { + notify('warning', t('tasks_projects.billing.nothing_to_invoice')) + + return + } + + if (errorStatus(error) === 403) { + denyInvoicing() + notify('error', t('tasks_projects.billing.forbidden')) + + return + } + + notify('error', errorMessage(error, t('tasks_projects.billing.prepare_failed'))) +} + +/** How many customers the refused selection spanned, as the body reported. */ +function customerCount(error: unknown): number { + if (typeof error !== 'object' || error === null) { + return 0 + } + + const data = (error as { response?: { data?: unknown } }).response?.data + + if (typeof data !== 'object' || data === null) { + return 0 + } + + const ids = (data as { customer_ids?: unknown }).customer_ids + + return Array.isArray(ids) ? ids.length : 0 +} + +/** + * The number the invoice will carry. + * + * A company that lets the host number its invoices gets the next one without + * being asked. One that numbers them by hand, or a host that could not answer, + * is asked, with whatever the endpoint did say filled in. Backing out of that + * question is a cancelled invoice, not an invoice with a blank number. + */ +async function resolveNumber( + deps: InvoicingDeps, + defaults: CompanyInvoiceDefaults | null, + customerId: number, +): Promise { + const suggested = await fetchNextInvoiceNumber(deps.client, customerId).catch((): null => null) + + if (defaults?.autoGenerateNumber !== false && suggested !== null) { + return suggested + } + + return askInvoiceNumber(suggested ?? '') +} + +/** The due date the host's own form would have filled in, or none. */ +function defaultDueDate(invoiceDate: string, defaults: CompanyInvoiceDefaults | null): string | null { + if (defaults === null || !defaults.setDueDateAutomatically) { + return null + } + + const due = new Date(`${invoiceDate}T00:00:00`) + + if (Number.isNaN(due.getTime())) { + return null + } + + due.setDate(due.getDate() + defaults.dueDateDays) + + return toDateString(due) +} + +interface InvoiceFields { + invoiceNumber: string + currencyId: number | null + exchangeRate: number | null + dueDate: string | null + templateName: string +} + +/** + * The body the host invoice endpoint takes. + * + * Only the keys it validates or stores: the lines arrive with their zeroed + * discount and tax fields so the host's item writer never reaches for a + * missing index, and the totals are the module's arithmetic, which the host + * recomputes from the same lines before it saves anything. + */ +function invoicePayload(payload: PreparedInvoice, fields: InvoiceFields): InvoicePayload { + return { + invoice_date: payload.invoice_date, + due_date: fields.dueDate, + customer_id: payload.customer_id, + invoice_number: fields.invoiceNumber, + // The host stores the contact's currency whatever is sent, and reads this + // only to decide whether the rate applies, so the contact's is what goes. + currency_id: fields.currencyId, + exchange_rate: fields.exchangeRate, + discount: payload.discount, + discount_type: payload.discount_type, + discount_val: payload.discount_val, + tax: payload.tax, + sub_total: payload.sub_total, + total: payload.total, + tax_included: false, + notes: payload.notes, + template_name: fields.templateName, + items: payload.items.map((item) => ({ ...item })), + taxes: [], + } +} + +/** + * Hand the created line ids back to the module. + * + * `groups[i]` was produced alongside `items[i]`, and the host writes the lines + * in the order they were posted, so zipping them positionally pairs each line + * with the entries behind it. A failure here leaves a live invoice and unbilled + * time, which the banner offers to fix: the call is idempotent. + */ +async function stamp( + deps: InvoicingDeps, + invoice: CreatedInvoice, + payload: PreparedInvoice, +): Promise { + const { client, notify, t } = deps + const items = confirmItems(invoice, payload) + + if (items.length === 0) { + // Nothing to retry with: the host answered without the line ids, so the + // invoice is real and the time behind it can only be matched by hand. + notify('error', t('tasks_projects.billing.stamp_unmatched', { number: invoice.invoice_number })) + await openInvoice(deps.router, invoice.id) + + return false + } + + try { + const stamped = await confirmInvoice(client, invoice.id, items) + + debug('stamped', stamped) + + return true + } catch (error: unknown) { + holdStamp({ invoiceId: invoice.id, invoiceNumber: invoice.invoice_number, items }) + notify( + 'error', + errorMessage( + error, + t('tasks_projects.billing.stamp_failed_notice', { number: invoice.invoice_number }), + ), + ) + + return false + } +} + +/** Each created line paired with the entries that produced it. */ +function confirmItems(invoice: CreatedInvoice, payload: PreparedInvoice): ConfirmItem[] { + const lines = Array.isArray(invoice.items) ? invoice.items : [] + const groups = Array.isArray(payload.groups) ? payload.groups : [] + const items: ConfirmItem[] = [] + + groups.forEach((group, index) => { + const line = lines[index] + + if (line && typeof line.id === 'number' && group.entry_ids.length > 0) { + items.push({ invoice_item_id: line.id, entry_ids: group.entry_ids }) + } + }) + + return items +} + +/** + * Land on the host's edit page, or its view page when the guard refuses. + * + * Editing an invoice is its own host ability, and the module's own one does + * not imply it, so a caller who may invoice but not edit still gets taken to + * the invoice rather than left on the screen they pressed. + */ +async function openInvoice(router: Router, invoiceId: number): Promise { + if (await push(router, `${INVOICES}/${invoiceId}/edit`)) { + return + } + + await push(router, invoiceViewPath(invoiceId)) +} + +/** Whether the navigation actually landed. */ +async function push(router: Router, path: string): Promise { + try { + return !(await router.push(path)) + } catch { + return false + } +} + +function debug(label: string, value: unknown): void { + if (DEBUG) { + console.debug(`[tasks-projects] invoicing: ${label}`, value) + } +} diff --git a/resources/js/support/page.ts b/resources/js/support/page.ts new file mode 100644 index 0000000..8010b15 --- /dev/null +++ b/resources/js/support/page.ts @@ -0,0 +1,66 @@ +import { defineComponent, h } from 'vue' +import type { Component } from 'vue' +import type { InvoiceShelfExtensionApi } from '@invoiceshelf/modules/frontend' + +export type NotifyType = 'success' | 'error' | 'warning' | 'info' + +export type Notify = (type: NotifyType, message: string) => void + +/** The module.json slug, which every registered path and route name hangs off. */ +export const MODULE = 'tasks-projects' + +const ROOT = `/admin/modules/${MODULE}` + +/** + * Where each screen lives. + * + * Breadcrumbs and cross-screen links are absolute, because a module page is + * mounted under the host's `admin` route and a relative link would resolve + * against whatever the user happened to arrive from. + */ +export const PATHS = { + tasks: ROOT, + board: `${ROOT}/board`, + week: `${ROOT}/week`, + task: (id: number | string): string => `${ROOT}/tasks/${id}`, + projects: `${ROOT}/projects`, + project: (id: number | string): string => `${ROOT}/projects/${id}`, + reports: `${ROOT}/reports`, + billing: `${ROOT}/billing`, + settings: '/admin/settings/modules', + customer: (id: number): string => `/admin/customers/${id}/view`, +} as const + +/** The names the host gives the module's routes, for navigating by name. */ +export const ROUTES = { + tasks: `extension.page.${MODULE}.tasks`, + list: `extension.page.${MODULE}.tasks.list`, + board: `extension.page.${MODULE}.tasks.board`, + week: `extension.page.${MODULE}.tasks.week`, + task: `extension.page.${MODULE}.task`, + projects: `extension.page.${MODULE}.projects`, + project: `extension.page.${MODULE}.project`, +} as const + +/** + * Hand a page the host services it cannot reach on its own. + * + * A module bundle runs on the host's Vue instance but not on its Pinia or + * router injections, so the client, the notifier and the router arrive as + * props. Route params arrive as attrs, because the host registers module pages + * with `props: true`, and a tab page also receives whatever its parent passes + * through ``. + */ +export function injectedPage(extensions: InvoiceShelfExtensionApi, page: Component): Component { + return defineComponent({ + setup: (_props, { attrs }) => () => + h(page, { + ...attrs, + client: extensions.client, + notify: (type: NotifyType, message: string): void => { + extensions.notify(type, message) + }, + router: extensions.router, + }), + }) +} 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/support/time.ts b/resources/js/support/time.ts new file mode 100644 index 0000000..f4e4e18 --- /dev/null +++ b/resources/js/support/time.ts @@ -0,0 +1,246 @@ +/** + * Clocks, durations and weeks. + * + * The API stores instants in UTC and durations in whole minutes; the timesheet + * talks in the viewer's own day, so every conversion here goes through the + * browser's local time zone and never through a string comparison of two + * differently offset timestamps. + */ + +import type { RoundingDirection } from '@/types/settings' + +const MINUTES_PER_HOUR = 60 +const SECONDS_PER_MINUTE = 60 +const DAYS_PER_WEEK = 7 + +/** Seconds as `h:mm:ss`, which is what a running timer shows. */ +export function formatClock(seconds: number): string { + const total = Number.isFinite(seconds) && seconds > 0 ? Math.floor(seconds) : 0 + const hours = Math.floor(total / (SECONDS_PER_MINUTE * MINUTES_PER_HOUR)) + const minutes = Math.floor((total % (SECONDS_PER_MINUTE * MINUTES_PER_HOUR)) / SECONDS_PER_MINUTE) + const rest = total % SECONDS_PER_MINUTE + + return `${hours}:${pad(minutes)}:${pad(rest)}` +} + +/** Minutes as `h:mm`, which is how a logged duration is written and typed. */ +export function formatDuration(minutes: number | null): string { + const total = minutes !== null && Number.isFinite(minutes) && minutes > 0 ? Math.round(minutes) : 0 + + return `${Math.floor(total / MINUTES_PER_HOUR)}:${pad(total % MINUTES_PER_HOUR)}` +} + +/** + * A typed duration back to minutes. + * + * Both notations people actually use are accepted: `1:30` and the decimal + * `1.5`. Anything else answers null so the caller can mark the field invalid + * rather than silently logging zero. + */ +export function parseDuration(value: string): number | null { + const text = value.trim() + + if (text === '') { + return null + } + + const clock = /^(\d+):([0-5]?\d)$/.exec(text) + + if (clock) { + return Number(clock[1]) * MINUTES_PER_HOUR + Number(clock[2]) + } + + if (!/^\d+([.,]\d+)?$/.test(text)) { + return null + } + + const hours = Number(text.replace(',', '.')) + + return Number.isNaN(hours) ? null : Math.round(hours * MINUTES_PER_HOUR) +} + +/** + * A duration rounded the way the server will round it. + * + * This mirrors `Application\Rounding::roundMinutes` so the stop dialog can + * promise what the entry is about to say. Zero stays zero whichever way the + * company rounds; `nearest` bills a spell shorter than one increment as a + * whole one, and `down` is the one direction that may answer zero for real + * work. An increment that is not a positive number rounds to the minute, which + * is the server's own fallback rather than a crash on the screen. + */ +export function roundMinutes( + minutes: number, + increment: number, + direction: RoundingDirection = 'nearest', +): number { + const step = Number.isFinite(increment) && increment >= 1 ? Math.floor(increment) : 1 + const total = Number.isFinite(minutes) ? Math.floor(minutes) : 0 + + if (total <= 0) { + return 0 + } + + if (direction === 'up') { + return Math.ceil(total / step) * step + } + + if (direction === 'down') { + return Math.floor(total / step) * step + } + + return total < step ? step : Math.round(total / step) * step +} + +/** The local calendar date of an instant, as the `Y-m-d` the API takes. */ +export function localDateOf(instant: string | null): string { + const date = parseInstant(instant) + + return date === null ? '' : formatLocalDate(date) +} + +/** The local wall-clock time of an instant, as the `HH:MM` an input shows. */ +export function localTimeOf(instant: string | null): string { + const date = parseInstant(instant) + + return date === null ? '' : `${pad(date.getHours())}:${pad(date.getMinutes())}` +} + +/** + * A local date and an optional `HH:MM` back to the instant the API stores. + * + * The pair is read as local wall-clock time, so an entry typed as "the 3rd, + * 09:00" stays on the 3rd at 09:00 for the person who typed it whatever their + * offset is. A duration-only entry gets a default hour rather than midnight, + * which would land on the previous day for anyone east of UTC. + */ +export function localInstant(date: string, time = '09:00'): string | null { + const day = parseDateString(date) + const clock = /^(\d{1,2}):([0-5]\d)$/.exec(time.trim()) + + if (day === null || clock === null) { + return null + } + + const hours = Number(clock[1]) + + if (hours > 23) { + return null + } + + day.setHours(hours, Number(clock[2]), 0, 0) + + return day.toISOString() +} + +/** The same instant moved by whole minutes, for the end of a typed duration. */ +export function addMinutes(instant: string, minutes: number): string { + const date = new Date(instant) + + date.setTime(date.getTime() + minutes * SECONDS_PER_MINUTE * 1000) + + return date.toISOString() +} + +/** + * The first day of the week `date` falls in. + * + * `weekStart` is the company setting, 0 for Sunday through 6 for Saturday; an + * out-of-range value falls back to Monday rather than shifting the grid. + */ +export function startOfWeek(date: Date, weekStart: number): Date { + const first = Number.isInteger(weekStart) && weekStart >= 0 && weekStart <= 6 ? weekStart : 1 + const start = startOfDay(date) + const shift = (start.getDay() - first + DAYS_PER_WEEK) % DAYS_PER_WEEK + + start.setDate(start.getDate() - shift) + + return start +} + +/** The seven days of the week beginning at `start`. */ +export function weekDays(start: Date): Date[] { + return Array.from({ length: DAYS_PER_WEEK }, (_unused, index) => addDays(start, index)) +} + +export function addDays(date: Date, days: number): Date { + const shifted = startOfDay(date) + + shifted.setDate(shifted.getDate() + days) + + return shifted +} + +/** A `Date` as the `Y-m-d` the API takes, in local time. */ +export function formatLocalDate(date: Date): string { + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` +} + +/** The weekday and day-of-month a column of the week grid is labelled with. */ +export function dayLabel(date: Date): { weekday: string; day: string } { + return { + weekday: date.toLocaleDateString(undefined, { weekday: 'short' }), + day: date.toLocaleDateString(undefined, { day: 'numeric', month: 'short' }), + } +} + +/** Whether a date is today, so the grid can mark the column. */ +export function isToday(date: Date): boolean { + return formatLocalDate(date) === formatLocalDate(new Date()) +} + +/** Seconds elapsed since an instant, never negative and never NaN. */ +export function secondsSince(instant: string | null): number { + return secondsBetween(instant, Date.now()) +} + +/** + * The same, measured against a caller-supplied instant. + * + * Every live clock on a screen reads one shared "now", so the rows tick + * together and one timer drives the whole page instead of one per row. + */ +export function secondsBetween(instant: string | null, now: number): number { + const start = parseInstant(instant) + + if (start === null) { + return 0 + } + + return Math.max(0, Math.floor((now - start.getTime()) / 1000)) +} + +function parseInstant(instant: string | null): Date | null { + if (!instant) { + return null + } + + const date = new Date(instant) + + return Number.isNaN(date.getTime()) ? null : date +} + +/** A `Y-m-d` at local midnight. Anything else answers null. */ +function parseDateString(value: string): Date | null { + const match = /^(\d{4})-(\d{2})-(\d{2})/.exec(value.trim()) + + if (match === null) { + return null + } + + const date = new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]), 0, 0, 0, 0) + + return Number.isNaN(date.getTime()) ? null : date +} + +function startOfDay(date: Date): Date { + const copy = new Date(date.getTime()) + + copy.setHours(0, 0, 0, 0) + + return copy +} + +function pad(value: number): string { + return String(value).padStart(2, '0') +} diff --git a/resources/js/types/api.ts b/resources/js/types/api.ts new file mode 100644 index 0000000..11083c7 --- /dev/null +++ b/resources/js/types/api.ts @@ -0,0 +1,30 @@ +/** Shapes the host and the module share on every list endpoint. */ + +export interface PaginationMeta { + current_page: number + last_page: number + per_page: number + total: number +} + +/** A Laravel resource collection over a paginator. */ +export interface Paginated { + data: T[] + meta: PaginationMeta +} + +/** A single Laravel resource, which the host always wraps in `data`. */ +export interface Wrapped { + data: T +} + +/** + * A host contact, as `/api/v1/customers` renders it. Only the fields the + * project form needs are typed; the endpoint returns many more. + */ +export interface Customer { + id: number + name: string | null + display_name?: string | null + currency_id: number | null +} diff --git a/resources/js/types/billing.ts b/resources/js/types/billing.ts new file mode 100644 index 0000000..358c648 --- /dev/null +++ b/resources/js/types/billing.ts @@ -0,0 +1,216 @@ +/** + * Everything the invoicing flow passes between the module and the host. + * + * Money is integer minor units on both sides of the boundary and `quantity` is + * decimal hours, which is what the host's own invoice form posts. The shapes + * under "module" come from `billing/*`; the ones under "host" are the host's + * own invoice, template, number and currency endpoints, typed here only as far + * as the flow reads them. + */ + +import type { Customer } from '@/types/api' + +/** How a selection is collapsed into invoice lines. */ +export type BillingGrouping = 'task' | 'project' | 'member' | 'summary' + +/** + * What to invoice, in exactly one of the three shapes the endpoint takes. + * + * The screens name whichever they know: the unbilled time page ticks entries + * off, a row, a task page or a bulk selection names tasks, and a project + * header names a project. They are mutually exclusive, which is what the + * request rules enforce, so the union is spelled out rather than left as one + * object with three optional keys. + */ +export interface EntryIdSelection { + entryIds: number[] + grouping?: BillingGrouping +} + +export interface TaskIdSelection { + taskIds: number[] + grouping?: BillingGrouping +} + +export interface ProjectSelection { + projectId: number + grouping?: BillingGrouping +} + +export type BillingSelection = EntryIdSelection | TaskIdSelection | ProjectSelection + +/** A customer with time waiting to be invoiced, in one currency. */ +export interface UnbilledCustomer { + customer_id: number + entries: number + minutes: number + amount: number + currency_id: number | null +} + +/** One unbilled entry, with the names the review step shows. */ +export interface UnbilledEntry { + id: number + task_id: number + task_name: string + project_id: number | null + project_name: string | null + user_id: number + user_name: string + date: string | null + minutes: number + amount: number + rate: number + currency_id: number | null + description: string | null +} + +/** One row of a grouped view, with the entries behind it. */ +export interface UnbilledGroup { + key: number | null + label: string + currency_id: number | null + rate: number | null + entry_ids: number[] + minutes: number + amount: number + description: string | null +} + +export interface UnbilledCurrencyTotal { + currency_id: number | null + minutes: number + amount: number +} + +/** What `billing/unbilled` answers for one customer. */ +export interface UnbilledTime { + customer_id: number + from: string | null + to: string | null + entry_ids: number[] + minutes: number + currencies: UnbilledCurrencyTotal[] + entries: UnbilledEntry[] + groups: Record +} + +/** One prepared invoice line. */ +export interface PreparedItem { + name: string + description: string | null + quantity: number + price: number + discount_type: string + discount: number + discount_val: number + tax: number + taxes: unknown[] + total: number +} + +/** + * What `billing/prepare` answers: the invoice body, plus the entries behind + * each line. `groups[i]` belongs to `items[i]`, which is what lets the created + * line ids be zipped back onto the entries positionally. + */ +export interface PreparedInvoice { + invoice_date: string + customer_id: number + currency_id: number | null + discount: number + discount_type: string + discount_val: number + tax: number + sub_total: number + total: number + notes: string | null + template_name: string | null + taxes: unknown[] + items: PreparedItem[] + groups: { entry_ids: number[] }[] +} + +/** One line of the body the host invoice endpoint takes. */ +export interface InvoicePayloadItem extends PreparedItem { + taxes: unknown[] +} + +/** + * The body posted to the host's `POST /api/v1/invoices`. + * + * Only the keys the host validates or stores: it recomputes the totals from + * the lines, so what is sent here is the module's arithmetic offered for + * checking rather than a figure the host trusts. + */ +export interface InvoicePayload { + invoice_date: string + due_date: string | null + customer_id: number + invoice_number: string + currency_id: number | null + exchange_rate: number | null + discount: number + discount_type: string + discount_val: number + tax: number + sub_total: number + total: number + tax_included: boolean + notes: string | null + template_name: string + items: InvoicePayloadItem[] + taxes: unknown[] +} + +/** The invoice the host answers with, as far as the module reads it. */ +export interface CreatedInvoice { + id: number + invoice_number: string + total: number + items: { id: number }[] +} + +/** A PDF template, as `GET /api/v1/invoices/templates` lists them. */ +export interface InvoiceTemplate { + name: string + path: string +} + +/** Enough of a host currency to render an amount in it. */ +export interface CurrencyFormat { + id: number + code: string + symbol: string + precision: number + thousand_separator: string + decimal_separator: string + swap_currency_symbol?: boolean +} + +/** A host contact, with the currency the customer list carries. */ +export interface BillingCustomer extends Customer { + currency?: CurrencyFormat | null +} + +/** + * The company's own invoice defaults, read from the host bootstrap payload. + * + * A module bundle cannot reach the host's company store, so the module asks + * the same endpoint the shell does and keeps only the settings a draft needs: + * the home currency, the due-date rule, whether numbers generate themselves + * and which template the user last defaulted to. + */ +export interface CompanyInvoiceDefaults { + currency: CurrencyFormat | null + dueDateDays: number + setDueDateAutomatically: boolean + autoGenerateNumber: boolean + defaultTemplate: string | null +} + +/** One line of the confirmation, pairing a created line with its entries. */ +export interface ConfirmItem { + invoice_item_id: number + entry_ids: number[] +} diff --git a/resources/js/types/board.ts b/resources/js/types/board.ts new file mode 100644 index 0000000..c47cf9e --- /dev/null +++ b/resources/js/types/board.ts @@ -0,0 +1,22 @@ +import type { Task } from '@/types/task' +import type { TaskStatus } from '@/types/task-status' + +/** One column of `GET board`: a status with its tasks in board order. */ +export interface BoardColumn { + status: TaskStatus + tasks: Task[] +} + +export interface BoardParams { + project_id?: number + assignee_id?: number +} + +/** + * What the host select inputs bind. They hand back the whole option object + * rather than an id, so every picker works on this shape. + */ +export interface SelectOption { + id: number + label: string +} diff --git a/resources/js/types/member.ts b/resources/js/types/member.ts new file mode 100644 index 0000000..1d2067b --- /dev/null +++ b/resources/js/types/member.ts @@ -0,0 +1,7 @@ +/** A user of the active company, as the module's members endpoint renders it. */ +export interface CompanyMember { + id: number + name: string + email: string + avatar: string | null +} diff --git a/resources/js/types/project-member.ts b/resources/js/types/project-member.ts new file mode 100644 index 0000000..7feaf59 --- /dev/null +++ b/resources/js/types/project-member.ts @@ -0,0 +1,21 @@ +/** + * A user attached to a project, as `ProjectMemberResource` renders it. + * + * `user_id` points at a host user without a foreign key, so a member who has + * left the company still renders here. + */ +export interface ProjectMember { + id: number + company_id: number + project_id: number + user_id: number + /** Minor units per hour for this member on this project. */ + rate: number | null + created_at: string | null + updated_at: string | null +} + +export interface ProjectMemberInput { + user_id: number + rate: number | null +} diff --git a/resources/js/types/project.ts b/resources/js/types/project.ts new file mode 100644 index 0000000..8253c90 --- /dev/null +++ b/resources/js/types/project.ts @@ -0,0 +1,61 @@ +/** The project as `ProjectResource` renders it. Money is integer minor units. */ + +export type ProjectStatus = 'ACTIVE' | 'ARCHIVED' + +export interface ProjectTaskTotals { + total: number + open: number + closed: number +} + +export interface ProjectTotals { + tasks: ProjectTaskTotals + logged_minutes: number + billable_minutes: number + billable_amount: number + unbilled_amount: number + currency_id: number | null +} + +export interface Project { + id: number + company_id: number + customer_id: number | null + name: string + identifier: string | null + description: string | null + colour: string | null + status: ProjectStatus + currency_id: number | null + /** Minor units per hour. */ + default_rate: number | null + budget_minutes: number | null + due_date: string | null + creator_id: number | null + is_internal: boolean + created_at: string | null + updated_at: string | null + /** Only the detail endpoint carries these. */ + totals?: ProjectTotals +} + +/** What the create and update endpoints accept. */ +export interface ProjectInput { + name: string + customer_id: number | null + identifier: string | null + description: string | null + colour: string | null + currency_id?: number | null + default_rate: number | null + budget_minutes: number | null + due_date: string | null +} + +export interface ProjectListParams { + page?: number + limit?: number + /** Omitted when the filter is "all". */ + status?: ProjectStatus + search?: string +} 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 +} diff --git a/resources/js/types/settings.ts b/resources/js/types/settings.ts new file mode 100644 index 0000000..e141c9f --- /dev/null +++ b/resources/js/types/settings.ts @@ -0,0 +1,26 @@ +/** How a stopped entry's minutes are rounded to the increment. */ +export type RoundingDirection = 'nearest' | 'up' | 'down' + +/** The module's per-company settings, as the settings endpoint renders them. */ +export interface ModuleSettings { + /** Minor units per hour. */ + default_rate: number + rounding_minutes: number + rounding_direction: RoundingDirection + week_start: number + members_see_all_time: boolean + /** Start the creator's timer as soon as a task is created. */ + auto_start_tasks: boolean + /** Refuse edits to a task whose time is already on an invoice. */ + lock_invoiced_tasks: boolean + /** Keep invoiced tasks off the board. */ + hide_invoiced_on_board: boolean + /** What an invoice line built from a task carries. */ + invoice_project_heading: boolean + invoice_task_description: boolean + invoice_entry_dates: boolean + invoice_entry_times: boolean + invoice_entry_hours: boolean + invoice_entry_descriptions: boolean + rounding_increments: number[] +} diff --git a/resources/js/types/task-status.ts b/resources/js/types/task-status.ts new file mode 100644 index 0000000..55130af --- /dev/null +++ b/resources/js/types/task-status.ts @@ -0,0 +1,23 @@ +/** One board column, as `TaskStatusResource` renders it. */ +export interface TaskStatus { + id: number + company_id: number + name: string + colour: string | null + /** Column order on the board. */ + position: number + /** Where a task lands when none is named. */ + is_default: boolean + /** Counts as done, and stamps the task's `closed_at`. */ + is_closed: boolean + created_at: string | null + updated_at: string | null +} + +/** What the create and update endpoints accept. */ +export interface TaskStatusInput { + name?: string + colour?: string | null + is_default?: boolean + is_closed?: boolean +} diff --git a/resources/js/types/task-summary.ts b/resources/js/types/task-summary.ts new file mode 100644 index 0000000..7b4e224 --- /dev/null +++ b/resources/js/types/task-summary.ts @@ -0,0 +1,11 @@ +/** + * The few fields of a task the time screens need: enough to search for one, + * label an entry and know whether logging against it is billable by default. + */ +export interface TaskSummary { + id: number + name: string + number: number | null + project_id: number | null + billable: boolean +} diff --git a/resources/js/types/task.ts b/resources/js/types/task.ts new file mode 100644 index 0000000..f2f4d7a --- /dev/null +++ b/resources/js/types/task.ts @@ -0,0 +1,133 @@ +/** A task as `TaskResource` renders it. Money is integer minor units. */ + +export const TASK_PRIORITIES = ['LOW', 'NORMAL', 'HIGH', 'URGENT'] as const + +export type TaskPriority = (typeof TASK_PRIORITIES)[number] + +/** Whether any of a task's billable time has reached an invoice. */ +export type TaskInvoiceState = 'none' | 'uninvoiced' | 'invoiced' + +/** One entry whose clock is running right now, whoever started it. */ +export interface TaskRunningEntry { + entry_id: number + user_id: number + started_at: string | null +} + +/** + * The time summary the API attaches to a task. + * + * Every screen that shows a task shows its time, so the totals ride along with + * the row rather than costing a request each. An older server answers without + * the block, so every read of it is guarded. + */ +export interface TaskTime { + logged_minutes: number + billable_minutes: number + unbilled_minutes: number + /** Minor units, in the currency the entries were logged in. */ + unbilled_amount: number + invoiced: TaskInvoiceState + running: TaskRunningEntry[] +} + +export interface Task { + id: number + company_id: number + project_id: number | null + /** Denormalised from the project, or set directly on a standalone task. */ + customer_id: number | null + task_status_id: number + /** A per-company sequence, for referring to a task in an email. */ + number: number + name: string + description: string | null + assignee_id: number | null + priority: TaskPriority | null + due_date: string | null + estimated_minutes: number | null + billable: boolean + /** Minor units per hour, overriding the member and project rates. */ + rate: number | null + /** Fractional board order, kept as a string so no float rewrites it. */ + board_position: string + closed_at: string | null + creator_id: number | null + created_at: string | null + updated_at: string | null + /** Absent on a server that predates the time summary. */ + time?: TaskTime +} + +/** + * What the create and update endpoints accept. + * + * `task_status_id` is never null: the update rule takes an integer, and the + * form always has a column selected. + */ +export interface TaskInput { + name: string + task_status_id: number + project_id: number | null + customer_id: number | null + description: string | null + assignee_id: number | null + priority: TaskPriority | null + due_date: string | null + estimated_minutes: number | null + billable: boolean + rate: number | null +} + +/** + * The least a task can be created with. + * + * The start dialog creates a task out of the name someone typed into its + * search box, so everything else is left to the server: the default column, + * the billable flag and the number all come from the company's own settings. + */ +export interface TaskQuickInput { + name: string + project_id: number | null +} + +export interface TaskListParams { + page?: number + limit?: number + project_id?: number + assignee_id?: number + task_status_id?: number + customer_id?: number + search?: string + /** 1 for tasks already on an invoice, 0 for the ones still waiting. */ + invoiced?: 0 | 1 +} + +/** Where a dragged card landed: its new column and the two tasks around it. */ +export interface TaskMoveInput { + task_status_id: number + before_id: number | null + after_id: number | null +} + +/** What `POST tasks/bulk` does to the selection. */ +export type TaskBulkAction = 'status' | 'delete' + +export interface TaskBulkInput { + action: TaskBulkAction + ids: number[] + /** Required by the `status` action, ignored by the others. */ + task_status_id?: number +} + +/** A task the bulk endpoint refused, and why. */ +export interface TaskBulkFailure { + id: number + reason: string +} + +/** The ids the bulk endpoint actually changed. */ +export interface TaskBulkResult { + updated: number[] + failed: TaskBulkFailure[] +} diff --git a/resources/js/types/time-entry.ts b/resources/js/types/time-entry.ts new file mode 100644 index 0000000..ea1c01b --- /dev/null +++ b/resources/js/types/time-entry.ts @@ -0,0 +1,54 @@ +/** + * Logged time, as `TimeEntryResource` renders it. + * + * Durations are minutes, `rate` is minor units per hour and `amount` is the + * money frozen on the entry when it was saved, also in minor units. An entry + * carrying an `invoice_id` is stamped: it belongs to an invoice and the API + * refuses to delete it. + */ +export interface TimeEntry { + id: number + company_id: number + task_id: number + project_id: number | null + user_id: number + started_at: string | null + ended_at: string | null + duration_minutes: number + description: string | null + billable: boolean + rate: number + amount: number + currency_id: number | null + is_running: boolean + invoice_id: number | null + invoice_item_id: number | null + invoiced_at: string | null + created_at: string | null + updated_at: string | null +} + +/** What the create and update endpoints accept. */ +export interface TimeEntryInput { + task_id: number + user_id?: number | null + started_at?: string | null + ended_at?: string | null + duration_minutes?: number | null + description?: string | null + billable?: boolean +} + +export interface TimeEntryListParams { + page?: number + limit?: number + user_id?: number + project_id?: number + task_id?: number + /** `Y-m-d`, inclusive. */ + from?: string + /** `Y-m-d`, inclusive. */ + to?: string + billable?: boolean + billed?: boolean +} diff --git a/resources/js/types/timer.ts b/resources/js/types/timer.ts new file mode 100644 index 0000000..4b5ef77 --- /dev/null +++ b/resources/js/types/timer.ts @@ -0,0 +1,58 @@ +import type { TimeEntry } from './time-entry' + +/** + * The timer endpoint answers with the caller's running entry or with null, so + * the payload is wrapped rather than a bare resource. + */ +export interface RunningTimer { + data: TimeEntry | null +} + +/** What `timer/start` accepts. `billable` overrides the task's own flag. */ +export interface StartTimerInput { + task_id: number + description?: string | null + billable?: boolean +} + +/** + * What a stop may carry. + * + * Every key is optional and an omitted one is left alone by the server, so a + * stop with nothing to say keeps whatever the start recorded. + */ +export interface StopTimerInput { + description?: string | null + billable?: boolean +} + +/** What the caller wanted a running timer to do, once the dialog answered. */ +export type StopAnswer = + | { action: 'save'; description: string | null; billable: boolean } + | { action: 'discard' } + +/** The running entry the stop dialog is asking about. */ +export interface StopPrompt { + entry: TimeEntry +} + +/** + * What the start dialog answered: an existing task, or a task to create first. + * + * Creating is part of the answer rather than a separate step, because "start + * the clock on something I have not written down yet" is one intention and the + * dialog should not make the user leave to satisfy it. + */ +export type StartAnswer = + | { taskId: number; description: string | null; billable: boolean } + | { + create: { name: string; projectId: number | null } + description: string | null + billable: boolean + } + +/** What a caller already knows when it opens the start dialog. */ +export interface StartPreset { + taskId?: number + projectId?: number +} From e1db978d52c456efb0b6b00575b98ca7d56d7d1c Mon Sep 17 00:00:00 2001 From: Darko Gjorgjijoski Date: Wed, 16 Sep 2026 02:05:36 +0200 Subject: [PATCH 3/4] docs: README and agent guidance for the task-centric module --- AGENTS.md | 10 ++++++--- README.md | 62 +++++++++++++++++++++++++++++++++++++++++-------------- 2 files changed, 53 insertions(+), 19 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 363ab4c..28d266e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,12 +10,16 @@ the InvoiceShelf 3.x `AGENTS.md` before making changes. routes must disappear when disabled. - Every module table is prefixed `tp_`, and every table carries a `company_id` column. Scope every query to the company from the `company` header, never from a request parameter. -- Abilities are namespaced `tasks-projects:` (see `app/Support/Abilities.php`). Until the host - ability catalogue is open to modules, gate through `Contracts\Host\ModuleAuthorization` against - existing host abilities. +- Abilities are namespaced `tasks-projects:` by the SDK: register them with + `Registry::registerAbility()` and build ids with `Registry::abilityId()`. The bare names live in + `app/Support/Abilities.php`; dependencies on host abilities stay un-namespaced. - Money is stored and compared as integer minor units, matching the host's `invoices.total` convention. Rates are minor units per hour. - Migrations are reversible: one concrete class per file, a non-empty `up()` and `down()`, and no `drop*`, `rename*`, `raw`, or `statement` calls in `up()`. - Run `composer run lint`, `composer run test`, `pnpm run build`, and package validation before release. +- `composer.json` pins `invoiceshelf/modules` 3.4.0 to an unreleased SDK commit through an inline + `package` repository, because `registerAbility`, `registerPage` and the `CompanyDataReader` + member and invoice readers are not tagged yet. Replace the whole `repositories` block with the + plain `vcs` entry once the SDK tags 3.4.0; the `^3.4.0` constraint already matches. diff --git a/README.md b/README.md index e935989..49fabf1 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,9 @@ # InvoiceShelf Tasks and Projects -The official Tasks, Projects and Time Tracking module for InvoiceShelf 3.x. It adds projects hung -off a customer, tasks that move across a configurable Kanban board, time recorded by hand or by a -running timer, and a billing wizard that turns unbilled hours into invoice lines. +The official Tasks, Projects and Time Tracking module for InvoiceShelf 3.x. It adds two sidebar +entries, Projects and Tasks, and builds invoicing the way Invoice Ninja does it: pick the work +(a task, a selection of tasks, or a whole project) and land on a draft invoice, rather than +picking a customer first and hunting for what to bill. The module is `AGPL-3.0-only`. @@ -15,17 +16,45 @@ The module is `AGPL-3.0-only`. ## What it adds - **Projects.** Name, optional customer, description, colour, status, default billable rate, - budget and due date. A project without a customer is internal and never reaches the billing - screen. -- **Tasks and a Kanban board.** Tasks belong to a project or stand alone against a customer, and - move across per-company task statuses with drag ordering. -- **Time tracking.** Manual time entries or a running timer, one per user per company, with a - header chip showing elapsed time. -- **Rate resolution.** Task rate, then the assignee's project rate, then the project default, then - the company default from module settings, written onto the time entry so a later rate change - never rewrites history. -- **Task to invoice.** Review unbilled billable entries, choose a grouping, and produce a draft - invoice through the host's own invoice endpoint. + budget and due date, reachable from its own sidebar entry. A project without a customer is + internal and never reaches the billing screens. +- **Tasks, with three views over one filter.** The Tasks screen is the module's sidebar root: a + view switcher moves between a sortable **List**, a **Board** with drag ordering across the + company's task statuses, and a **Week** timesheet, and the project, member and status filters + in the address bar survive every switch. A task also has its own page, with the time log + underneath it. +- **Time tracking, everywhere a task appears.** Start or stop a task's timer from its row in the + list, its card on the board, its own page, or the floating quick-start button that stays + reachable from any screen (it search-picks a task by name and starts or stops on it without + leaving the page you are on). Only one timer runs per user per company; starting a second one + offers to stop the first. A header chip shows the elapsed time and opens the running task. +- **The task time log.** A task's own page lists every interval logged against it, hand-entered + or from the timer: start, end, duration, description, billable, and who logged it. A row + already on an invoice is marked and its time, billable flag and task cannot change; its + description still can. +- **Rate resolution.** Task rate, then the assignee's project rate, then the project default, + then the company default from module settings, written onto the time entry so a later rate + change never rewrites history. +- **Invoicing from the work, not from a wizard.** "Invoice" on a task row, the bulk selection + bar, a task's own page, or a project's header prepares a draft invoice, one line per task, and + opens it on the host's own invoice edit screen, ready to review and send. A selection spanning + two customers or two currencies is refused with a clear message instead of guessing. The + **Unbilled time** page (linked from Reports and from the Projects header) answers the + month-end question across every project and customer at once, and is where a single entry can + still be left off an invoice on purpose. +- **Settings**, under **Company Settings → Tasks and Projects**: the default hourly rate, the + rounding increment and whether a stopped entry rounds to the nearest increment, up, or down, + the first day of the week, whether members see each other's time, whether creating a task + starts its creator's timer, whether an invoiced task locks against further edits, whether an + invoiced task drops off the board, and which parts of an invoice line an invoiced task writes + (a project heading, the task's own description, and each entry's date, time range, hours and + description). The module's own settings page under the module menu shows the current value of + every one of these next to a link to the form that edits them. +- **Abilities.** `view-project`, `create-project`, `edit-project` and `delete-project`; + `view-task`, `create-task`, `edit-task`, `delete-task` and `manage-task-status`; `view-own-time`, + `view-all-time` and `edit-all-time`; and `invoice-tasks`, which also requires the host's own + `create-invoice` and `edit-invoice` abilities, because invoicing a task ends on the host's + invoice edit page. See [`specs/tasks-projects.md`](../specs/tasks-projects.md) in the private specs repository for the full scope and data model. @@ -35,8 +64,9 @@ full scope and data model. 1. Sign in as a super administrator and open **Administration → Modules**. 2. Pair the application with the InvoiceShelf marketplace if it is not already paired, then install and enable **Tasks and Projects**. -3. Open **Company Settings → Tasks and Projects** to set the default hourly rate, rounding - increment, week start day, and whether non-owners may see other members' time. +3. Open **Company Settings → Tasks and Projects** to set the default hourly rate, the rounding + increment and direction, the first day of the week, who may see other members' time, and the + task and invoice-line behaviour described above. ## Disable and uninstall From effa6a96442f567d67fb7c522c5198a8c1f82c6c Mon Sep 17 00:00:00 2001 From: Darko Gjorgjijoski Date: Wed, 16 Sep 2026 04:32:53 +0200 Subject: [PATCH 4/4] build: depend on the tagged SDK 3.4.0 The package stub that pointed at the SDK development branch gives way to the VCS repository and a ^3.4 constraint now that 3.4.0 is tagged. --- composer.json | 36 +++--------------------------------- 1 file changed, 3 insertions(+), 33 deletions(-) diff --git a/composer.json b/composer.json index acba5ea..6cb719c 100644 --- a/composer.json +++ b/composer.json @@ -11,7 +11,7 @@ "require": { "php": "^8.4", "ext-json": "*", - "invoiceshelf/modules": "^3.4.0" + "invoiceshelf/modules": "^3.4" }, "require-dev": { "laravel/pint": "^1.26", @@ -42,38 +42,8 @@ "prefer-stable": true, "repositories": { "invoiceshelf-modules": { - "type": "package", - "package": { - "name": "invoiceshelf/modules", - "version": "3.4.0", - "type": "library", - "license": "MIT", - "source": { - "type": "git", - "url": "https://github.com/InvoiceShelf/modules.git", - "reference": "fb7b62961153a42a7c12aabed795cd4107e4ad0d" - }, - "require": { - "php": "^8.3", - "nikic/php-parser": "^5.0", - "nwidart/laravel-modules": "^13.0" - }, - "autoload": { - "psr-4": { - "InvoiceShelf\\Modules\\": "src/" - } - }, - "bin": [ - "bin/invoiceshelf-module" - ], - "extra": { - "laravel": { - "providers": [ - "InvoiceShelf\\Modules\\InvoiceShelfModulesServiceProvider" - ] - } - } - } + "type": "vcs", + "url": "https://github.com/InvoiceShelf/modules.git" } } }