agentskills.codes
AD

This skill should be used when building or modifying a BO-style admin analytics report in majstorbg-admin (FE) backed by majstorbg-backend (BE) — "admin report", "reports module", "report screen", "StatTile", "ReportChart", "ReportFilters", "PeriodSelect", "DataTable report", "aggregation endpoint",

Install

mkdir -p .claude/skills/admin-reports && curl -L -o skill.zip "https://agentskills.codes/api/skills/download/14648" && unzip -o skill.zip -d .claude/skills/admin-reports && rm skill.zip

Installs to .claude/skills/admin-reports

Activation

This is the description your AI agent reads to decide when to run this skill — the better it matches your request, the more reliably it fires.

This skill should be used when building or modifying a BO-style admin analytics report in majstorbg-admin (FE) backed by majstorbg-backend (BE) — "admin report", "reports module", "report screen", "StatTile", "ReportChart", "ReportFilters", "PeriodSelect", "DataTable report", "aggregation endpoint", "createZodDto report", "groupBy report", "supply/demand report", "ratings report", "report stopgap endpoint", or wiring a new `/admin/reports/*` route end-to-end.
463 chars✓ has a “when” triggerlonger than Claude Code's old 250-char listing cap (fine on current versions)

About this skill

Admin Reports Skill

End-to-end recipe for adding a BO-style analytics report to the majstorbg-admin / majstorbg-backend repo pair. One report = a new BE NestJS module (aggregation) + a FE RTK local-stopgap endpoint + a FE App-Router screen. All FE code obeys R1–R10 (see CLAUDE.md).

Companion .sc records this is distilled from: admin-reports-wave-1, liquidity-bids-per-job-report, admin-worker-supply-report, ratings-quality-report (in workflows/done/).


1. Backend — NestJS module (Prisma + nestjs-zod)

One module per report under src/modules/admin-<name>-reports/:

<name>.module.ts      # @Module exporting the class (lists only its own ctrl+svc)
<name>.controller.ts
<name>.service.ts
dto/<name>.query.dto.ts
dto/<name>.response.dto.ts

Register the module in src/app.module.ts. Routes live under /admin/reports/* (plus /admin/disputes, /admin/invoices for those two).

  • DTOs are LOCAL zod schemas wrapped with createZodDto from nestjs-zodnot promoted to @lunaticwithaduck/schemas while the consumer monorepo is in flight. Query params use z.coerce.number()/boolean() (they arrive as strings); page/pageSize/sort-enums/thresholds get .default()s.
  • Auth: EVERY handler gets @AllowAnonymous() (from ../../auth/decorators/allow-anonymous.decorator.js) + literal // TODO(auth): require admin role once BE adds 'admin' to UserRole.
  • Prisma: PrismaService comes from a @Global() PrismaModule — do NOT import a Prisma module; just inject PrismaService.
  • Response shape: list envelope { items, total, page, pageSize }, plus a summary (KPI tiles) and/or a chart-series object.

Aggregation decision rule

  • Single-dimension countsprisma.x.groupBy({ by, _count, where }) + Promise.all of count/aggregate. Lists via $transaction([count, findMany]).
  • Multiple derived metrics per parent (avg bids/job, % with ≥1 bid, mean child amount, …) → a single groupBy can't express it. findMany the parents with a minimal child select, then aggregate in a Map<key, bucket> in memory. The aggregated row set (one per category/city, ~tiny) sorts + slice-paginates in memory; tiebreak on key for stable pagination.
  • DecimalNumber(row.amount) / .toNumber() / ._sum.amount?.toNumber() ?? 0.
  • Date windows are half-open [from, to): where.createdAt = { gte: new Date(from), lt: new Date(to) }, both optional. In-memory bucketing: day createdAt.toISOString().slice(0,10), month .slice(0,7), week via a UTC-Monday helper. No $queryRaw.
  • Unnormalized currency — NEVER sum money across rows/groups. Report each group's dominant currency (most rows) + a mixedCurrency boolean; summary reports counts/ratios only. (Invoice has no currency column → EUR-assumed; Job/Bid are BGN.)
  • Divide-by-zero guard: ratio(n, d) => d === 0 ? 0 : n / d so the FE never sees NaN/Infinity. Round ratios/means to 2dp before the wire. Aging buckets = 5 parallel aggregate over dueAt ranges for status in (sent, overdue).

2. Frontend — RTK local-stopgap endpoint

  • src/api/admin-<name>-endpoints.ts exports export const adminXEndpoints = (build: Build) => ({ ... }) where Build is EndpointBuilder<BaseQueryFn<AxiosBaseQueryArgs, unknown, AxiosBaseQueryError>, ApiTag, 'api'>. File header: // TODO: replace with @lunaticwithaduck/api … once BE lands.
  • Why local-stopgap: the published endpoints .parse() responses (zod strips unknown keys), so additive BE fields are invisible to published consumers. Mirrors the admin-jobs precedent (DTOs kept local, published packages untouched).
  • Hook names are derived by RTK: getLiquidity → useGetLiquidityQuery, listX → useListXQuery. This name is the hard contract with the wiring agent (below) — the component name is yours, the hook name is not.
  • Tags: API_TAGS (from @lunaticwithaduck/api) has no Report member. Reuse the nearest existing tag with a sentinel id: { type: API_TAGS.Job, id: 'LIQUIDITY' } (mirrors funnel's 'FUNNEL').
  • Wiring boundary: a dedicated wiring agent spreads the builder into store.ts injectEndpoints and re-exports the hook, and owns routes.ts, nav constants, and app.module.ts. Do not edit those files from a screen agent — disjoint file ownership = no write races.

3. Frontend — the screen (App Router + composed components)

  • (admin)/reports/<slug>/page.tsx = thin async server wrapper: await paramssetRequestLocale(locale) → render one 'use client' body inside <Suspense> (required — the body calls useSearchParams). R6: no shim logic.
  • Body is 'use client', colocated under components/<Pascal>Report/ with a sibling .styles.ts (default-exported plain object of className strings) and ../../config/constants.ts (ALL_CAPS_OBJECTS for every label/copy/PAGE_SIZE/ param-key).
  • Tabbed report group = a layout.tsx rendering a 'use client' tab strip (webui Tabs backed by <Link> + usePathname) + {children}; the base segment page IS the first tab (no overview/ subfolder).
  • URL state: useReportQuery(pageKey) from @/lib/report-query.utilsget/getNumber/set; set auto-resets page→1 on any non-page change and strips empty keys. Server-side sort = SortHeader (ghost-button column header toggling sortBy/sortDir query args).
  • Composed building blocks (all in src/ui/components/composed/):
    • StatTileRow / StatTile — KPI tiles.
    • ReportChart — discriminated kind: line | area | bar | donut. Bar wants data: {label,value}[] + ariaLabel; donut data: {label,value}[] (label e.g. "5★") + ariaLabel. Renders <svg role="img" aria-label=…> → Playwright targets getByRole('img', { name: /…/i }).
    • DataTable<T> — tanstack ColumnDef[], server-side paging via total/page/pageSize/onPageChange, plus filters and actions slots.
    • ReportFilters — wraps PeriodSelect; extra <Select> filters go as children (rendered in a slot next to PeriodSelect). webui Select takes label/value/onValueChange/size; options are <SelectItem value=…>.
  • CSV export: toCsv/downloadCsv/csvFilename from @/lib/export.utils, fired from a <Button> in the DataTable actions slot (client-side over the current page until BE export lands).
  • Derive row types from the hook to dodge bracketed-[locale]-path .d.ts resolution issues:
    type Resp = NonNullable<ReturnType<typeof useGetXQuery>['data']>;
    type Row  = Resp['items'][number];
    // also: type Summary = …['data']>; type Bucket = Summary['starDistribution'][number];
    

Custom SVG charts (no webui chart primitive)

ReportChart is a dispatcher over dependency-free SVG siblings (LineAreaChart/BarBreakdownChart/DonutChart). R1 only bans style={ — SVG geometry attrs (x/y/d/points) are fine. R4 forbids hex literals → source colors from the webui colors token (import { colors } from '@lunaticwithaduck/webui') into a CHART_PALETTE const. DonutDatum.label is a required string (?? '').


4. Strict-tsconfig gotchas (these bit every prior wave)

tsconfig has strict + exactOptionalPropertyTypes + noUncheckedIndexedAccess.

  • An optional prop foo?: T will not accept T | undefined. Either annotate foo?: T | undefined, or spread conditionally {...(x ? { foo: x } : {})}. Bit every StatTile value={maybeUndefined} / TextPrice currency={…} call — pass concrete values: value={x ?? 0} or a formatted string.
  • Status/label/badge-variant maps MUST be Record<string, X> (loose key) and indexed defensively MAP[row.status] ?? row.status. Typing as Record<SomeEnum, X> then indexing with a plain-string field throws TS7053. With noUncheckedIndexedAccess, indexing returns X | undefined → always ?? fallback.

Pre-wiring typecheck (hook not in store.ts yet)

The hook (useGetXQuery) doesn't exist in store.ts until the wiring agent adds it, so ReturnType<typeof useGetXQuery>['data'] resolves to any and .map(...) trips noImplicitAny (TS7006). Fix without touching store.ts:

  • Import the row/point TYPES from your own endpoints file and make the derived Row fall back: type Row = Resp extends { items: readonly unknown[] } ? Resp['items'][number] : EndpointRow;
  • Cast map sources: ((data?.chart ?? []) as ChartPoint[]).map((point) => …).
  • To self-verify the screen compiles, TEMPORARILY add the import/spread/ hook-export to store.ts, run tsc --noEmit, then revert all three edits exactly (git diff to confirm store.ts is byte-identical).

5. Schema facts that aren't where you'd expect (majstorbg prisma)

Worker supply / demand

  • Worker has no cityName/category column. City = Worker.serviceCity (nullable) → fall back to Worker.serviceArea (required) when null. No soft-delete flag → "active worker" == every Worker row.
  • A worker's skills/categories live in WorkerSkill, joined via the SHARED userId (Worker.userId == WorkerSkill.userId). WorkerSkill has two FKs: categoryId → SkillCategory (15-value profession taxonomy) and jobCategoryId → JobCategory (10-value). Use jobCategoryId to line workers up against jobs.
  • Job.category is a plain String, NOT a relation — but it stores a JobCategory.id. So demand (job.groupBy({ by:['category'] })) and supply (WorkerSkill.jobCategoryId) match on the same JobCategory.id. Labels from JobCategory.nameEn/nameBg.
  • JobStatus = open | accepted | in_progress | awaiting_confirmation | completed | cancelled. Open-job demand = status: 'open'.
  • Windowing split: supply (Worker/WorkerSkill) is a CURRENT SNAPSHOT — never time-filter it. Only DEMAND (open Jobs) takes the from/to window on Job.createdAt. **State this in the query

Content truncated.

Search skills

Search the agent skills registry