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 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..6cb719c 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" }, "require-dev": { "laravel/pint": "^1.26", 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/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/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/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 +} 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); + } +}