Revised v1 · internal-tools · 13 July 2026

On-demand ClickUp customer export

One customer-scoped Excel report, generated from fresh ClickUp data when an internal user clicks download. The HTTP request fetches, builds, and streams the workbook directly to the browser—without scheduled jobs, cached results, report records, or files on an application disk.

The customer connection replaces the old Excel filtering step.

The first “total overview” workbook was only an intermediate artifact of the manual workflow. Internal Tools already knows which ClickUp sources belong to a customer, so v1 produces only the final, customer-filtered detail workbook.

1workbook per click
0stored report files
0background jobs
LiveClickUp data source
01

Revised decision

Keep

The existing integration boundary

Reuse ClickUpTimeEntriesRequest, ClickUpTimeEntriesService, database-backed ClickUp user IDs, customer time-entry sources, the existing authenticated customer area, and ClickUp configuration.

low churnone source of truth
Add

A thin synchronous export slice

Add one download route/controller, one validated month input, a normalized time-entry DTO, one period query, one concrete XLSX exporter, and a compact report card on the customer overview.

request → responseno orchestration layer
Exclude from v1

Everything that creates report state

No overview workbook, report table, filesystem disk, cache key, queue job, event/listener, command, retention policy, email delivery, or automatic generation.

removed scopeno stale artifacts
Freshness contract

Every click issues a new bounded ClickUp query for the selected calendar month. Repeating the same download later can legitimately produce different content because ClickUp remains the source of truth.

02

Existing seams and remaining gaps

AreaStatusWhat existsv1 proposal
User identityreuseusers.click_up_user_id is nullable, cast, and editable.Select all non-null IDs once and send them as ClickUp’s comma-separated assignee filter. No IDs in source code.
Customer scopereuseCustomer::clickUpTimeEntrySources() already maps a customer to Space, Folder, List, or Task sources.This relation is the report boundary. Query only configured sources for the route-bound customer.
Request buildertightenDate bounds, assignees, location names, and all source filters are supported.Build a fresh request per source so a previous location filter cannot leak into the next API call.
Time-entry serviceextendThe service fetches one day, loops users × sources, and returns raw arrays grouped by user.Add forCustomerPeriod(); batch all DB users per source, normalize results, and deduplicate by ClickUp entry ID.
Customer viewreusecustomers/Edit.vue is the customer overview inside CustomersLayout.vue.Add a report card above the edit form. Keep reporting contextual; do not add a new navigation section for one action.
Download endpointaddNo customer report route or controller exists.Add an authenticated customer-bound GET route with a required month=YYYY-MM query parameter.
XLSX generationaddNo spreadsheet writer is installed.Add phpoffice/phpspreadsheet and keep one concrete exporter; no general report framework.
PersistencenonePrivate filesystem conventions exist, but they are not needed for this flow.Never call Storage or cache the payload. Write the workbook to php://output inside streamDownload().
03

Customer view placement

internal-tools

Sauels

Kunden Daten und Datei-Uploads verwalten

ClickUp-Zeitreport

Aktuelle ClickUp-Zeiteinträge für diesen Kunden als Excel-Datei herunterladen.

2026-07
Excel herunterladen ↓
Recommended placement

First card on the “Kunde” overview

The report is customer-level and operational, so it belongs above CustomerForm on customers/Edit.vue. A dedicated report page or permanent sidebar item would add navigation without adding capability.

  • Native month input defaults to the current month.
  • Use a normal anchor/form navigation—not Inertia XHR—because the response is a file download.
  • Disable the button when the customer has no time-entry source and link to “Zeiterfassungs-Quellen”.
  • Show a busy state after click to discourage accidental duplicate requests.
Backend enforcement still matters

The disabled UI is guidance only. The controller must independently reject a customer without a configured source and must authorize access to the customer.

04

Target request / response flow

01 · UIChoose monthCustomer overview
defaults to now
02 · HTTPGET downloadcustomer route binding
validated YYYY-MM
03 · DBResolve scopecustomer sources
configured user IDs
04 · CLICKUPFetch live periodone request per source
all assignees
05 · MAPNormalize rowsDTO + dedupe
local user lookup
06 · XLSXBuild workbooksort + group
subtotal + format
07 · RESPONSEStream downloadphp://output
no-store headers
SourceFresh ClickUp request
TransportStreamedResponse
PersistenceNone
Cache policyprivate, no-store
Why the synchronous path is the simplest correct v1

The user is already waiting for a download, the requested period is bounded, and there is no later retrieval workflow. A queue would require job state, status UI, report storage, cleanup, and a second request to fetch the result—all specifically unnecessary for this iteration.

05

Single workbook contract

The deliverable

Customer-filtered detail report only

The retained workbook is the useful end product from the old flow. Its rows come only from the current customer’s configured ClickUp sources. The customer name comes from Customer::name; it is not trusted from a cross-customer ClickUp location label.

one sheetone customerone selected month
Removed artifact

No total overview workbook

Internal Tools performs the customer filter before export, so the raw cross-customer overview has no role in generation, delivery, or fixture parity. It may remain only as historical evidence of column meanings.

do not reproducedo not expose
ColumnValue sourceFormatting / policy
KundeCustomer::nameFixed for every row; prevents source/location names from crossing customer boundaries.
MitarbeiterInternal User matched by click_up_user_idUse the local display name; an unmapped ClickUp assignee is an explicit export error.
ProjektClickUp List nameRequires include_location_names=true.
AufgabeClickUp task nameEmpty fallback only when ClickUp truly returns no task name.
BeschreibungTime-entry descriptionPlain text; preserve user-entered content.
Start / EndeClickUp millisecond timestampsEurope/Berlin Excel datetimes; running entries have a blank end.
DauerClickUp durationNumeric hours for completed entries. Running entries remain visible but are excluded from subtotals.
Status / subtotalDerived by exporterMark running rows; add explicit styled per-user subtotals and a grand total.
Filename

Use a response-only deterministic name such as sauels-clickup-zeiten-2026-07.xlsx. Deterministic here improves user recognition; it does not imply a stored server artifact.

06

Main types and signatures

routes/web.php · extend
Route::get(
  '{customer:id}/clickup-time-report',
  CustomerClickUpTimeReportController::class,
)->name('customers.clickup-time-report.download');
app/Http/Requests/DownloadCustomerClickUpTimeReportRequest.php · new
final class DownloadCustomerClickUpTimeReportRequest
  extends FormRequest
{
  public function rules(): array;

  public function month(): CarbonImmutable;
}

// month: required, date_format:Y-m
app/Http/Controllers/CustomerClickUpTimeReportController.php · new
final readonly class CustomerClickUpTimeReportController
{
  public function __invoke(
    Customer $customer,
    DownloadCustomerClickUpTimeReportRequest $request,
    ClickUpTimeEntriesService $entries,
    ClickUpTimeReportExporter $exporter,
  ): StreamedResponse;
}

// Orchestrates one request; owns HTTP headers.
app/Data/ClickUpTimeEntryData.php · new
final readonly class ClickUpTimeEntryData
{
  public function __construct(
    public string $id,
    public int $clickUpUserId,
    public ?string $taskName,
    public ?string $description,
    public ?string $listName,
    public CarbonImmutable $startedAt,
    public ?CarbonImmutable $endedAt,
    public ?int $durationMs,
  ) {}

  public static function fromClickUp(array $payload): self;
}
app/Services/ClickUpTimeEntriesService.php · extend
/** @return Collection<int, ClickUpTimeEntryData> */
public function forCustomerPeriod(
  Customer $customer,
  CarbonImmutable $from,
  CarbonImmutable $through,
): Collection;

// Fresh request per source.
// Assignees come from users.click_up_user_id.
// Results deduplicate by ClickUp entry id.
app/Reports/ClickUpTimeReportExporter.php · new
final readonly class ClickUpTimeReportExporter
{
  /** @param Collection<int, ClickUpTimeEntryData> $entries */
  public function workbook(
    Customer $customer,
    CarbonImmutable $month,
    Collection $entries,
  ): Spreadsheet;
}

// Controller streams Xlsx($spreadsheet) to php://output.
No extra action object in v1

The invokable controller coordinates one service call and one exporter call. Introducing GenerateReportAction would only rename that two-line orchestration. Extract it later if another delivery path needs the same use case.

07

Call stacks

Successful download
  1. Customer report cardSubmits selected YYYY-MM with ordinary browser navigation.
  2. Download requestAuthorizes the customer and parses the month into start/end bounds.
  3. ControllerRejects missing sources, then asks the service for live period entries.
  4. ClickUp serviceLoads DB users and customer sources; fetches, normalizes, and deduplicates.
  5. ExporterBuilds one in-memory spreadsheet with detail rows and totals.
  6. Streamed responseWrites XLSX bytes to php://output with download and no-store headers.
ClickUp failure
  1. HTTP client throws->throw() prevents a partial success path.
  2. No workbook is streamedThe exporter is never called when source data is incomplete.
  3. User sees an errorReturn a translated 502/503 page or redirect with a clear retry message.
  4. Nothing to clean upNo file, cache entry, report record, or queued work exists.
Invalid configuration
  1. No customer sourceUI disables download; backend rejects direct route access.
  2. No mapped usersFail with a configuration message instead of generating an empty, misleading report.
  3. Unknown assigneeFail explicitly; never silently attribute a row to the wrong employee.
  4. Running entryInclude the row, leave end/duration blank, and exclude it from totals.
08

Implementation file map

FileChangeResponsibility
composer.jsondependencyAdd phpoffice/phpspreadsheet.
routes/web.phpextendAdd the authenticated, customer-bound download route inside the existing customer group.
app/Http/Requests/DownloadCustomerClickUpTimeReportRequest.phpnewAuthorize and validate month; expose start/end period.
app/Http/Controllers/CustomerClickUpTimeReportController.phpnewCoordinate the live query and exporter; return the uncached streamed response.
app/Data/ClickUpTimeEntryData.phpnewNormalize the ClickUp payload once, including nullable end/duration for running timers.
app/Services/ClickUpTimeEntriesService.phpextendAdd period fetch; batch assignees; create a fresh request per source; deduplicate.
app/Reports/ClickUpTimeReportExporter.phpnewBuild the single customer workbook in memory.
app/Http/Controllers/CustomerController.phpsmallExpose click_up_time_entry_sources_exists to support the enabled/disabled UI state.
resources/js/pages/customers/Edit.vueextendPlace the month selector and download button above CustomerForm.
tests/Feature/CustomerClickUpTimeReportControllerTest.phpnewCover authorization, validation, scope, headers, failures, and streamed XLSX content.
tests/Unit/Reports/ClickUpTimeReportExporterTest.phpnewCover columns, ordering, dates, totals, running entries, and sample parity.
09

Verification and risk controls

Request boundary

Prove the customer scope

  • Every ClickUp call contains exactly one location filter from the route-bound customer.
  • All assignee IDs originate from non-null users.click_up_user_id rows.
  • Multiple configured sources cannot duplicate an entry ID in the workbook.
  • A source from another customer can never enter the query.
  • ClickUp failures yield no partial XLSX response.
Download boundary

Prove there is no server artifact

  • Response is an XLSX attachment with a safe deterministic filename.
  • Headers include Cache-Control: private, no-store, max-age=0.
  • The implementation never calls Storage, cache, queue, or a report model.
  • A second request hits the mocked ClickUp API again and reflects changed data.
  • Generated bytes can be reopened by PhpSpreadsheet and inspected in tests.
RiskLevelMitigation
Customer scope leaks unrelated entrieshighRoute-bound customer relation, fresh request per source, local customer name, cross-customer fixture test.
Long month exceeds a normal request timeoutmediumBatch assignees per source, constrain to one month, set an explicit ClickUp timeout, and measure representative customers before introducing async complexity.
Browser/proxy caches sensitive responsemediumPrivate no-store headers plus authenticated route; do not expose a public signed URL.
Running timer makes totals misleadingmediumKeep it visible, label it as running, leave its duration blank, and exclude it from totals.
Large XLSX consumes PHP memorylowOne customer/month is bounded and the observed samples are small; measure memory before adding writer cell caching.
Meaning of “no disk”

The application must not persist or cache a report. The response writer targets php://output. If the requirement also forbids transient temp files inside the XLSX library/ZIP implementation, verify PhpSpreadsheet’s runtime behavior in the deployment environment before implementation; that stricter constraint may require a different streaming writer.

10

Implementation sequence

Phase 1

Stabilize the live ClickUp query

Add the DTO and period method, source-isolated requests, batched DB assignees, deduplication, running-entry handling, and HTTP-faked service coverage. Keep the existing daily aggregate method working.

Phase 2

Build the one customer workbook

Add PhpSpreadsheet and the concrete exporter. Reconcile the retained detail sample’s column meaning, row ordering, employee groups, dates, and totals; intentionally exclude the overview sample from the output contract.

Phase 3

Expose the streamed download

Add request validation, controller, route, no-store headers, error behavior, and the customer overview card. Exercise the response end-to-end without touching storage, cache, or queues.

Done

Ship the smallest useful loop

An internal user selects a month, clicks once, and receives a fresh customer-scoped workbook. Revisit asynchronous generation only if measured request duration makes the synchronous UX unacceptable.