The existing integration boundary
Reuse ClickUpTimeEntriesRequest, ClickUpTimeEntriesService, database-backed ClickUp user IDs, customer time-entry sources, the existing authenticated customer area, and ClickUp configuration.
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.
Reuse ClickUpTimeEntriesRequest, ClickUpTimeEntriesService, database-backed ClickUp user IDs, customer time-entry sources, the existing authenticated customer area, and ClickUp configuration.
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.
No overview workbook, report table, filesystem disk, cache key, queue job, event/listener, command, retention policy, email delivery, or automatic generation.
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.
| Area | Status | What exists | v1 proposal |
|---|---|---|---|
| User identity | reuse | users.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 scope | reuse | Customer::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 builder | tighten | Date 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 service | extend | The 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 view | reuse | customers/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 endpoint | add | No customer report route or controller exists. | Add an authenticated customer-bound GET route with a required month=YYYY-MM query parameter. |
| XLSX generation | add | No spreadsheet writer is installed. | Add phpoffice/phpspreadsheet and keep one concrete exporter; no general report framework. |
| Persistence | none | Private 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(). |
Kunden Daten und Datei-Uploads verwalten
Aktuelle ClickUp-Zeiteinträge für diesen Kunden als Excel-Datei herunterladen.
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.
The disabled UI is guidance only. The controller must independently reject a customer without a configured source and must authorize access to the customer.
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.
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.
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.
| Column | Value source | Formatting / policy |
|---|---|---|
| Kunde | Customer::name | Fixed for every row; prevents source/location names from crossing customer boundaries. |
| Mitarbeiter | Internal User matched by click_up_user_id | Use the local display name; an unmapped ClickUp assignee is an explicit export error. |
| Projekt | ClickUp List name | Requires include_location_names=true. |
| Aufgabe | ClickUp task name | Empty fallback only when ClickUp truly returns no task name. |
| Beschreibung | Time-entry description | Plain text; preserve user-entered content. |
| Start / Ende | ClickUp millisecond timestamps | Europe/Berlin Excel datetimes; running entries have a blank end. |
| Dauer | ClickUp duration | Numeric hours for completed entries. Running entries remain visible but are excluded from subtotals. |
| Status / subtotal | Derived by exporter | Mark running rows; add explicit styled per-user subtotals and a grand total. |
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.
Route::get(
'{customer:id}/clickup-time-report',
CustomerClickUpTimeReportController::class,
)->name('customers.clickup-time-report.download');
final class DownloadCustomerClickUpTimeReportRequest
extends FormRequest
{
public function rules(): array;
public function month(): CarbonImmutable;
}
// month: required, date_format:Y-m
final readonly class CustomerClickUpTimeReportController
{
public function __invoke(
Customer $customer,
DownloadCustomerClickUpTimeReportRequest $request,
ClickUpTimeEntriesService $entries,
ClickUpTimeReportExporter $exporter,
): StreamedResponse;
}
// Orchestrates one request; owns HTTP headers.
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;
}
/** @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.
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.
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.
YYYY-MM with ordinary browser navigation.php://output with download and no-store headers.->throw() prevents a partial success path.| File | Change | Responsibility |
|---|---|---|
composer.json | dependency | Add phpoffice/phpspreadsheet. |
routes/web.php | extend | Add the authenticated, customer-bound download route inside the existing customer group. |
app/Http/Requests/DownloadCustomerClickUpTimeReportRequest.php | new | Authorize and validate month; expose start/end period. |
app/Http/Controllers/CustomerClickUpTimeReportController.php | new | Coordinate the live query and exporter; return the uncached streamed response. |
app/Data/ClickUpTimeEntryData.php | new | Normalize the ClickUp payload once, including nullable end/duration for running timers. |
app/Services/ClickUpTimeEntriesService.php | extend | Add period fetch; batch assignees; create a fresh request per source; deduplicate. |
app/Reports/ClickUpTimeReportExporter.php | new | Build the single customer workbook in memory. |
app/Http/Controllers/CustomerController.php | small | Expose click_up_time_entry_sources_exists to support the enabled/disabled UI state. |
resources/js/pages/customers/Edit.vue | extend | Place the month selector and download button above CustomerForm. |
tests/Feature/CustomerClickUpTimeReportControllerTest.php | new | Cover authorization, validation, scope, headers, failures, and streamed XLSX content. |
tests/Unit/Reports/ClickUpTimeReportExporterTest.php | new | Cover columns, ordering, dates, totals, running entries, and sample parity. |
users.click_up_user_id rows.Cache-Control: private, no-store, max-age=0.Storage, cache, queue, or a report model.| Risk | Level | Mitigation |
|---|---|---|
| Customer scope leaks unrelated entries | high | Route-bound customer relation, fresh request per source, local customer name, cross-customer fixture test. |
| Long month exceeds a normal request timeout | medium | Batch assignees per source, constrain to one month, set an explicit ClickUp timeout, and measure representative customers before introducing async complexity. |
| Browser/proxy caches sensitive response | medium | Private no-store headers plus authenticated route; do not expose a public signed URL. |
| Running timer makes totals misleading | medium | Keep it visible, label it as running, leave its duration blank, and exclude it from totals. |
| Large XLSX consumes PHP memory | low | One customer/month is bounded and the observed samples are small; measure memory before adding writer cell caching. |
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.
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.
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.
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.
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.