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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
62 changes: 46 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
@@ -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`.

Expand All @@ -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.
Expand All @@ -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

Expand Down
83 changes: 83 additions & 0 deletions app/Application/BillingSelection.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
<?php

declare(strict_types=1);

namespace Modules\TasksProjects\Application;

use InvalidArgumentException;

/**
* What the caller asked to invoice, before it becomes a list of time entries.
*
* Three shapes reach `billing/prepare`: the explicit entries the unbilled time
* page ticks off, the tasks a row or a bulk selection names, and a whole
* project. They differ only in how the entries are found, so the difference is
* carried here rather than in three overloads of the service, and
* `BillingService::resolveEntries()` is the single place that turns any of
* them into the same ordered collection.
*/
final class BillingSelection
{
/** Entry ids the caller listed itself. */
public const ENTRIES = 'entries';

/** Tasks whose unbilled billable time is wanted. */
public const TASKS = 'tasks';

/** One project, meaning every task filed under it. */
public const PROJECT = 'project';

/** @var list<string> */
public const KINDS = [self::ENTRIES, self::TASKS, self::PROJECT];

/** @param list<int> $ids */
private function __construct(
public readonly string $kind,
public readonly array $ids,
) {}

/** @param list<int> $entryIds */
public static function fromEntryIds(array $entryIds): self
{
return new self(self::ENTRIES, self::normalise($entryIds));
}

/** @param list<int> $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<int> $ids
* @return list<int>
*/
private static function normalise(array $ids): array
{
$ids = array_values(array_unique(array_map(intval(...), $ids)));
sort($ids);

return $ids;
}
}
Loading
Loading