admin-reports
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.zipInstalls 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.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
.screcords this is distilled from:admin-reports-wave-1,liquidity-bids-per-job-report,admin-worker-supply-report,ratings-quality-report(inworkflows/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
createZodDtofromnestjs-zod— not promoted to@lunaticwithaduck/schemaswhile the consumer monorepo is in flight. Query params usez.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:
PrismaServicecomes from a@Global()PrismaModule — do NOT import a Prisma module; just injectPrismaService. - Response shape: list envelope
{ items, total, page, pageSize }, plus asummary(KPI tiles) and/or a chart-series object.
Aggregation decision rule
- Single-dimension counts →
prisma.x.groupBy({ by, _count, where })+Promise.allofcount/aggregate. Lists via$transaction([count, findMany]). - Multiple derived metrics per parent (avg bids/job, % with ≥1 bid, mean
child amount, …) → a single
groupBycan't express it.findManythe parents with a minimal childselect, then aggregate in aMap<key, bucket>in memory. The aggregated row set (one per category/city, ~tiny) sorts + slice-paginates in memory; tiebreak on key for stable pagination. Decimal→Number(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: daycreatedAt.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
mixedCurrencyboolean; 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 / dso the FE never sees NaN/Infinity. Round ratios/means to 2dp before the wire. Aging buckets = 5 parallelaggregateoverdueAtranges forstatus in (sent, overdue).
2. Frontend — RTK local-stopgap endpoint
src/api/admin-<name>-endpoints.tsexportsexport const adminXEndpoints = (build: Build) => ({ ... })whereBuildisEndpointBuilder<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 theadmin-jobsprecedent (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 noReportmember. 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.tsinjectEndpointsand re-exports the hook, and ownsroutes.ts, nav constants, andapp.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 params→setRequestLocale(locale)→ render one'use client'body inside<Suspense>(required — the body callsuseSearchParams). R6: no shim logic.- Body is
'use client', colocated undercomponents/<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.tsxrendering a'use client'tab strip (webuiTabsbacked by<Link>+usePathname) +{children}; the base segment page IS the first tab (nooverview/subfolder). - URL state:
useReportQuery(pageKey)from@/lib/report-query.utils—get/getNumber/set;setauto-resets page→1 on any non-page change and strips empty keys. Server-side sort =SortHeader(ghost-button column header togglingsortBy/sortDirquery args). - Composed building blocks (all in
src/ui/components/composed/):StatTileRow/StatTile— KPI tiles.ReportChart— discriminatedkind:line | area | bar | donut. Bar wantsdata: {label,value}[]+ariaLabel; donutdata: {label,value}[](label e.g."5★") +ariaLabel. Renders<svg role="img" aria-label=…>→ Playwright targetsgetByRole('img', { name: /…/i }).DataTable<T>— tanstackColumnDef[], server-side paging viatotal/page/pageSize/onPageChange, plusfiltersandactionsslots.ReportFilters— wrapsPeriodSelect; extra<Select>filters go as children (rendered in a slot next to PeriodSelect). webuiSelecttakeslabel/value/onValueChange/size; options are<SelectItem value=…>.
- CSV export:
toCsv/downloadCsv/csvFilenamefrom@/lib/export.utils, fired from a<Button>in the DataTableactionsslot (client-side over the current page until BE export lands). - Derive row types from the hook to dodge bracketed-
[locale]-path.d.tsresolution 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?: Twill not acceptT | undefined. Either annotatefoo?: T | undefined, or spread conditionally{...(x ? { foo: x } : {})}. Bit everyStatTile 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 defensivelyMAP[row.status] ?? row.status. Typing asRecord<SomeEnum, X>then indexing with a plain-string field throws TS7053. WithnoUncheckedIndexedAccess, indexing returnsX | 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, runtsc --noEmit, then revert all three edits exactly (git diffto confirmstore.tsis byte-identical).
5. Schema facts that aren't where you'd expect (majstorbg prisma)
Worker supply / demand
Workerhas nocityName/categorycolumn. City =Worker.serviceCity(nullable) → fall back toWorker.serviceArea(required) when null. No soft-delete flag → "active worker" == everyWorkerrow.- A worker's skills/categories live in
WorkerSkill, joined via the SHAREDuserId(Worker.userId == WorkerSkill.userId).WorkerSkillhas two FKs:categoryId → SkillCategory(15-value profession taxonomy) andjobCategoryId → JobCategory(10-value). UsejobCategoryIdto line workers up against jobs. Job.categoryis a plainString, NOT a relation — but it stores aJobCategory.id. So demand (job.groupBy({ by:['category'] })) and supply (WorkerSkill.jobCategoryId) match on the sameJobCategory.id. Labels fromJobCategory.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/towindow onJob.createdAt. **State this in the query
Content truncated.