Leantime/leantime · error · Exception

Task has timesheets attached, delete all timesheets first or

Error message

Task has timesheets attached, delete all timesheets first or consider archiving the task

What it means

The second guard in Tickets::canDelete($id): the ticket exists, but timesheetsRepo->getTimesheetsByTicket($id) returns rows, so deleting would orphan tracked time in zp_timesheets. Leantime blocks the delete and instructs: remove all logged hours first, or archive the task instead. This protects reporting/invoicing integrity - archived tasks keep their history, deleted ones do not.

Source

Thrown at app/Domain/Tickets/Services/Tickets.php:3717

            return true;
        }

        return false;
    }

    public function canDelete($id)
    {

        $ticket = $this->getTicket($id);

        if (empty($ticket)) {
            throw new \Exception('Task does not exist');
        }

        $hasLoggedHours = $this->timesheetsRepo->getTimesheetsByTicket($id);

        if ($hasLoggedHours) {
            throw new \Exception('Task has timesheets attached, delete all timesheets first or consider archiving the task');
        }

        return true;

    }

    /**
     * @return bool|string[]
     *
     * @throws BindingResolutionException
     *
     * @api
     */
    #[RequiresPermission(TicketsPermissions::DELETE, entityScoped: true)]
    public function deleteMilestone($id): array|bool
    {

        $ticket = $this->getTicket($id);

View on GitHub (pinned to 9a9f49f100)

Solutions

  1. Archive the task instead of deleting it - this is the intended path and preserves the time history.
  2. Delete the timesheet entries first (Timesheets UI / the row set in zp_timesheets WHERE ticket_id = {id}), then retry the delete.
  3. If the hours are simply wrong, edit/correct them via the timesheet functionality rather than destroying the task.
  4. As a last resort with a DB backup in hand: DELETE FROM zp_timesheets WHERE ticket_id = {id}, then delete the task.

Example fix

// before: plain delete attempt on a task with logged time
$this->ticketService->canDelete($id);        // throws 'Task has timesheets attached...'
$this->ticketService->delete($id);

// after: archive when time is logged, delete only when clean
if (! empty($this->timesheetsRepo->getTimesheetsByTicket($id))) {
    $this->ticketService->updateTicket(['id' => $id, 'tags' => 'archived']); // or set an archived-type status
    return redirect()->back()->with('message', 'Task archived because timesheets exist');
}
$this->ticketService->canDelete($id);
$this->ticketService->delete($id);
Defensive patterns

Strategy: validation

Validate before calling

if (! empty($timesheetsRepo->getTimesheetsByTicket($ticketId))) {
    // time is booked: archive instead of delete, preserving reporting history
    $ticketService->updateTicket(['id' => $ticketId, 'editorType' => 'task', 'tags' => 'archived']);
    return redirect()->back()->with('message', 'Task archived because timesheets are attached');
}
$ticketService->canDelete($ticketId);

Type guard

/** True when the ticket can be safely deleted (exists and has no logged hours). */
function isTicketDeletable(\Leantime\Domain\Tickets\Services\Tickets $tickets, mixed $id): bool
{
    try {
        return $tickets->canDelete($id) === true;
    } catch (\Exception) {
        return false;
    }
}

Try / catch

try {
    $ticketService->canDelete($id);
    $ticketService->delete($id);
} catch (\Exception $e) {
    if (str_contains($e->getMessage(), 'timesheets attached')) {
        return back()->with('message', 'This task has logged hours - it was archived instead of deleted.');
        // archive flow: $ticketService->updateTicket(['id' => $id, ...archived status...]);
    }
    throw $e;
}

Prevention

When it happens

Trigger: Deleting any task with at least one timesheet row via /tickets/delTicket/{id} or an RPC flow that calls canDelete first: cleaning up subtasks that received tracked work, deleting finished tasks at project close, bulk-cleanup scripts that assume empty tasks.

Common situations: End-of-project cleanup where tasks carry historical logged hours; users unaware that archive is the intended path for worked-on tasks; mistakenly logged hours on a task someone then wants gone.

Related errors


AI-assisted analysis of Leantime/leantime@9a9f49f100 (2026-08-21). Data as JSON: /api/errors/d5c18a7fff553327. Report an issue: GitHub.