From a76cc39494313414124ccd944f0e4b83b97b6ae1 Mon Sep 17 00:00:00 2001 From: Brian Fertig Date: Thu, 9 Jul 2026 12:46:19 -0600 Subject: [PATCH] Added Charts, Graphs, Types, Categories, etc. --- .gitignore | 1 + README.md | 69 +++ docker-compose.yml | 7 + docker/app/Dockerfile | 9 + docker/app/entrypoint.sh | 11 + docker/app/php-perf.ini | 16 + src/app/Enums/KpiEvaluation.php | 21 + src/app/Enums/KpiValueType.php | 32 ++ src/app/Enums/RagStatus.php | 27 ++ .../Http/Controllers/Api/KpiApiController.php | 60 +++ .../Controllers/Api/KpiEntryApiController.php | 73 +++ .../Http/Controllers/Api/MeApiController.php | 19 + .../Controllers/Api/ProjectApiController.php | 60 +++ .../Api/ProjectEntryApiController.php | 55 +++ src/app/Http/Controllers/ApiKeyController.php | 62 +++ .../Controllers/BowlerChartController.php | 123 ++++++ .../Http/Controllers/CategoryController.php | 62 +++ .../Concerns/SyncsChartExclusions.php | 30 ++ src/app/Http/Controllers/Controller.php | 4 +- src/app/Http/Controllers/KpiController.php | 86 ++++ .../Http/Controllers/KpiEntryController.php | 71 +++ .../Http/Controllers/ProjectController.php | 86 ++++ .../Controllers/ProjectEntryController.php | 70 +++ src/app/Http/Controllers/TeamController.php | 62 +++ src/app/Models/BowlerChart.php | 55 +++ src/app/Models/Category.php | 27 ++ src/app/Models/Group.php | 6 + src/app/Models/Kpi.php | 142 ++++++ src/app/Models/KpiEntry.php | 50 +++ src/app/Models/Project.php | 68 +++ src/app/Models/ProjectEntry.php | 52 +++ src/app/Models/Team.php | 27 ++ src/app/Models/User.php | 45 +- src/app/Policies/BowlerChartPolicy.php | 31 ++ src/app/Policies/KpiPolicy.php | 30 ++ src/app/Policies/ProjectPolicy.php | 30 ++ src/app/Support/BowlerGrid.php | 153 +++++++ src/app/Support/KpiChart.php | 315 +++++++++++++ src/bootstrap/app.php | 1 + src/composer.json | 9 +- .../0001_01_01_000005_create_kpis_tables.php | 43 ++ ...01_01_01_000006_create_projects_tables.php | 39 ++ ...07_create_personal_access_tokens_table.php | 27 ++ ...intext_token_to_personal_access_tokens.php | 23 + ..._01_000009_create_bowler_charts_tables.php | 40 ++ ...010_create_teams_and_categories_tables.php | 53 +++ src/public/api-docs/openapi.json | 418 ++++++++++++++++++ src/public/css/app.css | 2 +- src/resources/views/api-docs.blade.php | 30 ++ src/resources/views/api-keys/index.blade.php | 93 ++++ src/resources/views/categories/edit.blade.php | 28 ++ .../views/categories/index.blade.php | 65 +++ src/resources/views/charts/_grid.blade.php | 182 ++++++++ .../views/charts/_kpi-chart.blade.php | 80 ++++ src/resources/views/charts/create.blade.php | 57 +++ src/resources/views/charts/edit.blade.php | 38 ++ src/resources/views/charts/index.blade.php | 58 +++ src/resources/views/charts/personal.blade.php | 18 + src/resources/views/charts/show.blade.php | 28 ++ .../views/kpis/_entry-value-field.blade.php | 37 ++ src/resources/views/kpis/_form.blade.php | 141 ++++++ src/resources/views/kpis/create.blade.php | 22 + src/resources/views/kpis/edit.blade.php | 19 + src/resources/views/kpis/entries.blade.php | 111 +++++ src/resources/views/kpis/entry-edit.blade.php | 42 ++ src/resources/views/kpis/index.blade.php | 70 +++ src/resources/views/layouts/app.blade.php | 6 + .../views/partials/chart-sharing.blade.php | 33 ++ .../views/partials/confirm-script.blade.php | 9 + .../views/partials/status-badge.blade.php | 4 + .../views/partials/taxonomy-cta.blade.php | 16 + .../partials/team-category-selects.blade.php | 41 ++ .../views/projects/_entry-fields.blade.php | 50 +++ src/resources/views/projects/create.blade.php | 44 ++ src/resources/views/projects/edit.blade.php | 41 ++ .../views/projects/entries.blade.php | 93 ++++ .../views/projects/entry-edit.blade.php | 20 + src/resources/views/projects/index.blade.php | 68 +++ src/resources/views/teams/edit.blade.php | 28 ++ src/resources/views/teams/index.blade.php | 65 +++ src/routes/api.php | 39 ++ src/routes/web.php | 72 +++ 82 files changed, 4546 insertions(+), 4 deletions(-) create mode 100644 docker/app/php-perf.ini create mode 100644 src/app/Enums/KpiEvaluation.php create mode 100644 src/app/Enums/KpiValueType.php create mode 100644 src/app/Enums/RagStatus.php create mode 100644 src/app/Http/Controllers/Api/KpiApiController.php create mode 100644 src/app/Http/Controllers/Api/KpiEntryApiController.php create mode 100644 src/app/Http/Controllers/Api/MeApiController.php create mode 100644 src/app/Http/Controllers/Api/ProjectApiController.php create mode 100644 src/app/Http/Controllers/Api/ProjectEntryApiController.php create mode 100644 src/app/Http/Controllers/ApiKeyController.php create mode 100644 src/app/Http/Controllers/BowlerChartController.php create mode 100644 src/app/Http/Controllers/CategoryController.php create mode 100644 src/app/Http/Controllers/Concerns/SyncsChartExclusions.php create mode 100644 src/app/Http/Controllers/KpiController.php create mode 100644 src/app/Http/Controllers/KpiEntryController.php create mode 100644 src/app/Http/Controllers/ProjectController.php create mode 100644 src/app/Http/Controllers/ProjectEntryController.php create mode 100644 src/app/Http/Controllers/TeamController.php create mode 100644 src/app/Models/BowlerChart.php create mode 100644 src/app/Models/Category.php create mode 100644 src/app/Models/Kpi.php create mode 100644 src/app/Models/KpiEntry.php create mode 100644 src/app/Models/Project.php create mode 100644 src/app/Models/ProjectEntry.php create mode 100644 src/app/Models/Team.php create mode 100644 src/app/Policies/BowlerChartPolicy.php create mode 100644 src/app/Policies/KpiPolicy.php create mode 100644 src/app/Policies/ProjectPolicy.php create mode 100644 src/app/Support/BowlerGrid.php create mode 100644 src/app/Support/KpiChart.php create mode 100644 src/database/migrations/0001_01_01_000005_create_kpis_tables.php create mode 100644 src/database/migrations/0001_01_01_000006_create_projects_tables.php create mode 100644 src/database/migrations/0001_01_01_000007_create_personal_access_tokens_table.php create mode 100644 src/database/migrations/0001_01_01_000008_add_plaintext_token_to_personal_access_tokens.php create mode 100644 src/database/migrations/0001_01_01_000009_create_bowler_charts_tables.php create mode 100644 src/database/migrations/0001_01_01_000010_create_teams_and_categories_tables.php create mode 100644 src/public/api-docs/openapi.json create mode 100644 src/resources/views/api-docs.blade.php create mode 100644 src/resources/views/api-keys/index.blade.php create mode 100644 src/resources/views/categories/edit.blade.php create mode 100644 src/resources/views/categories/index.blade.php create mode 100644 src/resources/views/charts/_grid.blade.php create mode 100644 src/resources/views/charts/_kpi-chart.blade.php create mode 100644 src/resources/views/charts/create.blade.php create mode 100644 src/resources/views/charts/edit.blade.php create mode 100644 src/resources/views/charts/index.blade.php create mode 100644 src/resources/views/charts/personal.blade.php create mode 100644 src/resources/views/charts/show.blade.php create mode 100644 src/resources/views/kpis/_entry-value-field.blade.php create mode 100644 src/resources/views/kpis/_form.blade.php create mode 100644 src/resources/views/kpis/create.blade.php create mode 100644 src/resources/views/kpis/edit.blade.php create mode 100644 src/resources/views/kpis/entries.blade.php create mode 100644 src/resources/views/kpis/entry-edit.blade.php create mode 100644 src/resources/views/kpis/index.blade.php create mode 100644 src/resources/views/partials/chart-sharing.blade.php create mode 100644 src/resources/views/partials/confirm-script.blade.php create mode 100644 src/resources/views/partials/status-badge.blade.php create mode 100644 src/resources/views/partials/taxonomy-cta.blade.php create mode 100644 src/resources/views/partials/team-category-selects.blade.php create mode 100644 src/resources/views/projects/_entry-fields.blade.php create mode 100644 src/resources/views/projects/create.blade.php create mode 100644 src/resources/views/projects/edit.blade.php create mode 100644 src/resources/views/projects/entries.blade.php create mode 100644 src/resources/views/projects/entry-edit.blade.php create mode 100644 src/resources/views/projects/index.blade.php create mode 100644 src/resources/views/teams/edit.blade.php create mode 100644 src/resources/views/teams/index.blade.php create mode 100644 src/routes/api.php diff --git a/.gitignore b/.gitignore index 2b0176d..813344f 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ db-data/ src/vendor/ src/public/css/app.css +src/public/vendor/swagger-ui/ src/composer.lock src/node_modules/ src/storage/app/* diff --git a/README.md b/README.md index 828cc9e..a0dde65 100644 --- a/README.md +++ b/README.md @@ -46,10 +46,62 @@ Nothing else — no local PHP, Composer, or Node needed. The app container insta |---|---| | `/` | Hello World landing page | | `/login` | Login (rejects disabled accounts, throttled to 10 attempts/min) | +| `/charts` | Bowler charts of your groups (group owners and admins manage them) | +| `/my-bowler` | Your personal bowler chart — all of your own KPIs and projects | +| `/kpis` | My KPIs: threshold-based metrics with auto-computed Green/Yellow/Red status | +| `/projects` | My projects: manually-statused G/Y/R with notes, headwinds, tailwinds, highlights, blockers | +| `/teams`, `/categories` | Personal lists used to group chart rows (Team → Category) | +| `/api-keys` | Personal API keys (multiple per user) for the automation API | +| `/api/docs` | Swagger UI for the REST API | | `/admin/users` | User management (admins only): create users, reset passwords, enable/disable accounts | +| `/admin/groups` | Group management (admins only): create groups, assign members with member/owner roles | Admins cannot disable their own account, so you can't lock yourself out. +### KPIs and entries + +A KPI is defined once (measurement type: number / percentage / duration, plus an evaluation +mode) and then receives dated entries; each entry's status is computed from the KPI's thresholds: + +- **Higher is better** — green when value ≥ green threshold, yellow when ≥ yellow threshold, else red +- **Lower is better** — the reverse +- **Target band** — green within ± green tolerance of target, yellow within ± yellow tolerance, else red +- **Milestone** — met (green) or not met (red) + +Projects work the same way but with manually chosen statuses and narrative fields. + +### Bowler charts + +Charts belong to groups; group **owners** (and site admins) can create any number of named +charts per group. Members' KPIs and projects **appear automatically** on all of their groups' +charts — including charts created later and groups joined later — unless the item's owner +deselects specific charts on the KPI/project form (stored as per-chart exclusions). + +Every KPI and project belongs to one of its owner's **Teams** (e.g. "Website Development") +and **Categories** (e.g. "Performance") — required on create/edit; the chart groups rows +Team → Category, merging same-named teams from different owners case-insensitively. +Items whose team/category was deleted appear under "No Team" / "Uncategorized". + +The grid shows the trailing 12 months; each cell is the **most recent entry in that month** +(color + value for KPIs, color for projects, tooltip with date and notes). Rows expand in place: +**click a KPI row** for an inline SVG trend chart with its threshold zones shaded (line + status-colored +points for numeric KPIs, a met/missed timeline for milestones); **click a project's month cell** for that +month's notes/headwinds/tailwinds/highlights/blockers. Clicking anywhere else collapses the row. +Group members can click through to any charted item's entries read-only; only the owner (or an +admin) can edit. Everyone also has a personal chart at `/my-bowler` covering all of their own items. + +### REST API + +Create a key on `/api-keys`, then call `/api/v1/...` with `Authorization: Bearer `. +Full interactive documentation at `/api/docs` (OpenAPI spec: `/api-docs/openapi.json`). + +```sh +curl -H "Authorization: Bearer $KEY" http://localhost:8080/api/v1/kpis +curl -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \ + -d '{"entry_date":"2026-07-09","value":96.2}' \ + http://localhost:8080/api/v1/kpis/1/entries +``` + ## Project layout ``` @@ -88,6 +140,23 @@ docker-compose exec app php artisan tinker # interactive REPL docker-compose down # stop (data persists in db-data/) ``` +## Performance notes + +The stack is tuned for running from Windows bind mounts (the slowest part of this setup): + +- **OPcache + realpath cache** (`docker/app/php-perf.ini`): compiled PHP stays in memory and + source files are re-checked at most every 60 s — after editing PHP code, wait up to a + minute or restart the app container. +- **Laravel caches**: the entrypoint runs `php artisan optimize` (config/route/view caches) + on every container start. While actively developing routes or config, run + `docker compose exec app php artisan optimize:clear` to work uncached; restart to re-cache. + Keep routes controller-based — closures break route caching. +- **Percona flags** (docker-compose.yml): redo log flushed once per second instead of per + commit (dev-appropriate durability trade-off), larger buffer pool, no reverse-DNS. + +If you ever want a substantially faster database and can accept the data living inside +Docker's managed storage instead of `./db-data`, switch the db volume to a named volume. + ## Roadmap - Bowler chart data model and CRUD diff --git a/docker-compose.yml b/docker-compose.yml index 9ae649a..eac4008 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -48,6 +48,13 @@ services: image: percona:latest container_name: bowler-db restart: unless-stopped + # Tuned for a dev DB on a Windows bind mount: flush the redo log once per + # second instead of per commit (worst case ~1s of writes lost on a crash), + # skip reverse-DNS lookups, and give InnoDB a larger buffer pool. + command: + - --innodb-flush-log-at-trx-commit=2 + - --innodb-buffer-pool-size=256M + - --skip-name-resolve volumes: - ./db-data:/var/lib/mysql environment: diff --git a/docker/app/Dockerfile b/docker/app/Dockerfile index 61c3453..1649fe7 100644 --- a/docker/app/Dockerfile +++ b/docker/app/Dockerfile @@ -21,11 +21,20 @@ RUN case "$TARGETARCH" in \ COPY --from=composer:latest /usr/bin/composer /usr/local/bin/composer ENV COMPOSER_ALLOW_SUPERUSER=1 +# PHP performance settings (opcache / realpath cache) +COPY php-perf.ini /usr/local/etc/php/conf.d/zz-perf.ini + # Serve Laravel's public/ directory ENV APACHE_DOCUMENT_ROOT=/var/www/html/public RUN sed -ri -e 's!/var/www/html!${APACHE_DOCUMENT_ROOT}!g' \ /etc/apache2/sites-available/*.conf /etc/apache2/apache2.conf /etc/apache2/conf-available/*.conf +# Swagger UI static assets (copied into public/vendor/swagger-ui by the entrypoint) +RUN mkdir -p /opt/swagger-ui \ + && curl -fsSL -o /opt/swagger-ui/swagger-ui.css "https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui.css" \ + && curl -fsSL -o /opt/swagger-ui/swagger-ui-bundle.js "https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui-bundle.js" \ + && curl -fsSL -o /opt/swagger-ui/swagger-ui-standalone-preset.js "https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui-standalone-preset.js" + COPY entrypoint.sh /usr/local/bin/entrypoint.sh # Strip Windows line endings in case the file was checked out with CRLF RUN sed -i 's/\r$//' /usr/local/bin/entrypoint.sh && chmod +x /usr/local/bin/entrypoint.sh diff --git a/docker/app/entrypoint.sh b/docker/app/entrypoint.sh index ba74383..12b4402 100644 --- a/docker/app/entrypoint.sh +++ b/docker/app/entrypoint.sh @@ -9,6 +9,13 @@ if [ ! -f vendor/autoload.php ]; then composer install --no-interaction --prefer-dist --no-progress fi +# Provide Swagger UI assets to the bind-mounted public directory +if [ -d /opt/swagger-ui ] && [ ! -f public/vendor/swagger-ui/swagger-ui-bundle.js ]; then + echo "Installing Swagger UI assets..." + mkdir -p public/vendor/swagger-ui + cp /opt/swagger-ui/* public/vendor/swagger-ui/ +fi + # Compile Tailwind CSS if [ -f resources/css/app.css ]; then echo "Building Tailwind CSS..." @@ -37,6 +44,10 @@ echo "Database is up." php artisan migrate --force php artisan db:seed --force +# Cache config/routes/views (re-built on every container start, so restarts +# always pick up changes; run `php artisan optimize:clear` while iterating). +php artisan optimize + # Make runtime directories writable by the web server. # chown can be a no-op or fail on Windows bind mounts - never fatal. chown -R www-data:www-data storage bootstrap/cache 2>/dev/null || true diff --git a/docker/app/php-perf.ini b/docker/app/php-perf.ini new file mode 100644 index 0000000..849b13c --- /dev/null +++ b/docker/app/php-perf.ini @@ -0,0 +1,16 @@ +; Performance tuning for serving Laravel from a Windows bind mount, +; where every file stat/read crosses the slow 9p/gRPC-FUSE boundary. + +; --- OPcache: keep compiled PHP in memory, rarely re-stat sources --- +opcache.enable=1 +opcache.memory_consumption=192 +opcache.interned_strings_buffer=32 +opcache.max_accelerated_files=20000 +; Re-check file mtimes at most every 60s instead of every 2s. +; After editing PHP code, changes appear within a minute (or restart the container). +opcache.validate_timestamps=1 +opcache.revalidate_freq=60 + +; --- Realpath cache: avoid repeated path resolution over the bind mount --- +realpath_cache_size=4096K +realpath_cache_ttl=600 diff --git a/src/app/Enums/KpiEvaluation.php b/src/app/Enums/KpiEvaluation.php new file mode 100644 index 0000000..2872e49 --- /dev/null +++ b/src/app/Enums/KpiEvaluation.php @@ -0,0 +1,21 @@ + 'Higher is better', + self::LowerIsBetter => 'Lower is better', + self::TargetBand => 'Target band (stay near target)', + self::Milestone => 'Milestone (met / not met)', + }; + } +} diff --git a/src/app/Enums/KpiValueType.php b/src/app/Enums/KpiValueType.php new file mode 100644 index 0000000..92e4af0 --- /dev/null +++ b/src/app/Enums/KpiValueType.php @@ -0,0 +1,32 @@ + 'Number', + self::Percent => 'Percentage', + self::Duration => 'Duration / time', + }; + } + + /** + * Format a raw value for display, e.g. "96.2%" or "12 days". + */ + public function format(float $value, ?string $unitLabel = null): string + { + $formatted = rtrim(rtrim(number_format($value, 4, '.', ','), '0'), '.'); + + return match ($this) { + self::Percent => $formatted . '%', + default => $unitLabel ? "{$formatted} {$unitLabel}" : $formatted, + }; + } +} diff --git a/src/app/Enums/RagStatus.php b/src/app/Enums/RagStatus.php new file mode 100644 index 0000000..5cb187b --- /dev/null +++ b/src/app/Enums/RagStatus.php @@ -0,0 +1,27 @@ +value); + } + + /** + * Tailwind classes for the status badge, matching the existing badge style. + */ + public function badgeClasses(): string + { + return match ($this) { + self::Green => 'bg-green-100 text-green-800', + self::Yellow => 'bg-yellow-100 text-yellow-800', + self::Red => 'bg-red-100 text-red-800', + }; + } +} diff --git a/src/app/Http/Controllers/Api/KpiApiController.php b/src/app/Http/Controllers/Api/KpiApiController.php new file mode 100644 index 0000000..632f4f2 --- /dev/null +++ b/src/app/Http/Controllers/Api/KpiApiController.php @@ -0,0 +1,60 @@ +user()->kpis() + ->withCount('entries') + ->with('team:id,name', 'category:id,name') + ->orderBy('name') + ->get(); + + return response()->json(['data' => $kpis]); + } + + public function store(Request $request): JsonResponse + { + $data = $request->validate(Kpi::validationRules($request->user())); + + $kpi = $request->user()->kpis()->create($data)->load('team:id,name', 'category:id,name'); + + return response()->json(['data' => $kpi], 201); + } + + public function show(Kpi $kpi): JsonResponse + { + $this->authorize('view', $kpi); + + $kpi->loadCount('entries')->load('team:id,name', 'category:id,name'); + + return response()->json(['data' => $kpi]); + } + + public function update(Request $request, Kpi $kpi): JsonResponse + { + $this->authorize('update', $kpi); + + $data = $request->validate(Kpi::validationRules($kpi->user)); + + $kpi->update($data); + + return response()->json(['data' => $kpi->fresh()->load('team:id,name', 'category:id,name')]); + } + + public function destroy(Kpi $kpi): JsonResponse + { + $this->authorize('delete', $kpi); + + $kpi->delete(); + + return response()->json(null, 204); + } +} diff --git a/src/app/Http/Controllers/Api/KpiEntryApiController.php b/src/app/Http/Controllers/Api/KpiEntryApiController.php new file mode 100644 index 0000000..bc1a27f --- /dev/null +++ b/src/app/Http/Controllers/Api/KpiEntryApiController.php @@ -0,0 +1,73 @@ +authorize('view', $kpi); + + $entries = $kpi->entries() + ->orderByDesc('entry_date') + ->orderByDesc('id') + ->get() + ->map(fn (KpiEntry $entry) => $this->serialize($entry, $kpi)); + + return response()->json(['data' => $entries]); + } + + public function store(Request $request, Kpi $kpi): JsonResponse + { + $this->authorize('update', $kpi); + + $data = $request->validate(KpiEntry::validationRules()); + + $entry = $kpi->entries()->create($data); + + return response()->json(['data' => $this->serialize($entry, $kpi)], 201); + } + + public function update(Request $request, KpiEntry $entry): JsonResponse + { + $this->authorize('update', $entry->kpi); + + $data = $request->validate(KpiEntry::validationRules()); + + $entry->update($data); + + return response()->json(['data' => $this->serialize($entry->fresh(), $entry->kpi)]); + } + + public function destroy(KpiEntry $entry): JsonResponse + { + $this->authorize('update', $entry->kpi); + + $entry->delete(); + + return response()->json(null, 204); + } + + /** + * @return array + */ + private function serialize(KpiEntry $entry, Kpi $kpi): array + { + return [ + 'id' => $entry->id, + 'kpi_id' => $entry->kpi_id, + 'entry_date' => $entry->entry_date->format('Y-m-d'), + 'value' => $entry->value, + 'status' => $kpi->evaluate($entry->value)->value, + 'notes' => $entry->notes, + 'created_at' => $entry->created_at?->toIso8601String(), + 'updated_at' => $entry->updated_at?->toIso8601String(), + ]; + } +} diff --git a/src/app/Http/Controllers/Api/MeApiController.php b/src/app/Http/Controllers/Api/MeApiController.php new file mode 100644 index 0000000..95a34db --- /dev/null +++ b/src/app/Http/Controllers/Api/MeApiController.php @@ -0,0 +1,19 @@ +json([ + 'id' => $request->user()->id, + 'name' => $request->user()->name, + 'email' => $request->user()->email, + ]); + } +} diff --git a/src/app/Http/Controllers/Api/ProjectApiController.php b/src/app/Http/Controllers/Api/ProjectApiController.php new file mode 100644 index 0000000..0e3f82f --- /dev/null +++ b/src/app/Http/Controllers/Api/ProjectApiController.php @@ -0,0 +1,60 @@ +user()->projects() + ->withCount('entries') + ->with('team:id,name', 'category:id,name') + ->orderBy('name') + ->get(); + + return response()->json(['data' => $projects]); + } + + public function store(Request $request): JsonResponse + { + $data = $request->validate(Project::validationRules($request->user())); + + $project = $request->user()->projects()->create($data)->load('team:id,name', 'category:id,name'); + + return response()->json(['data' => $project], 201); + } + + public function show(Project $project): JsonResponse + { + $this->authorize('view', $project); + + $project->loadCount('entries')->load('team:id,name', 'category:id,name'); + + return response()->json(['data' => $project]); + } + + public function update(Request $request, Project $project): JsonResponse + { + $this->authorize('update', $project); + + $data = $request->validate(Project::validationRules($project->user)); + + $project->update($data); + + return response()->json(['data' => $project->fresh()->load('team:id,name', 'category:id,name')]); + } + + public function destroy(Project $project): JsonResponse + { + $this->authorize('delete', $project); + + $project->delete(); + + return response()->json(null, 204); + } +} diff --git a/src/app/Http/Controllers/Api/ProjectEntryApiController.php b/src/app/Http/Controllers/Api/ProjectEntryApiController.php new file mode 100644 index 0000000..a98e637 --- /dev/null +++ b/src/app/Http/Controllers/Api/ProjectEntryApiController.php @@ -0,0 +1,55 @@ +authorize('view', $project); + + $entries = $project->entries() + ->orderByDesc('entry_date') + ->orderByDesc('id') + ->get(); + + return response()->json(['data' => $entries]); + } + + public function store(Request $request, Project $project): JsonResponse + { + $this->authorize('update', $project); + + $data = $request->validate(ProjectEntry::validationRules()); + + $entry = $project->entries()->create($data); + + return response()->json(['data' => $entry], 201); + } + + public function update(Request $request, ProjectEntry $entry): JsonResponse + { + $this->authorize('update', $entry->project); + + $data = $request->validate(ProjectEntry::validationRules()); + + $entry->update($data); + + return response()->json(['data' => $entry->fresh()]); + } + + public function destroy(ProjectEntry $entry): JsonResponse + { + $this->authorize('update', $entry->project); + + $entry->delete(); + + return response()->json(null, 204); + } +} diff --git a/src/app/Http/Controllers/ApiKeyController.php b/src/app/Http/Controllers/ApiKeyController.php new file mode 100644 index 0000000..09719d7 --- /dev/null +++ b/src/app/Http/Controllers/ApiKeyController.php @@ -0,0 +1,62 @@ +user()->tokens()->orderByDesc('created_at')->get(); + + return view('api-keys.index', ['tokens' => $tokens]); + } + + public function store(Request $request): RedirectResponse + { + $data = $request->validate([ + 'name' => ['required', 'string', 'max:255'], + ]); + + $token = $request->user()->createToken($data['name']); + + // Keep an encrypted copy so the owner can view the key again later + $token->accessToken->forceFill([ + 'plaintext_token' => Crypt::encryptString($token->plainTextToken), + ])->save(); + + return redirect() + ->route('api-keys.index') + ->with('status', "API key \"{$data['name']}\" created.") + ->with('plainTextToken', $token->plainTextToken); + } + + public function show(Request $request, int $tokenId): RedirectResponse + { + $token = $request->user()->tokens()->findOrFail($tokenId); + + if (! $token->plaintext_token) { + return redirect() + ->route('api-keys.index') + ->withErrors(['view' => "The key \"{$token->name}\" was created before viewing was supported. Revoke it and create a new one."]); + } + + return redirect() + ->route('api-keys.index') + ->with('status', "API key \"{$token->name}\":") + ->with('plainTextToken', Crypt::decryptString($token->plaintext_token)); + } + + public function destroy(Request $request, int $tokenId): RedirectResponse + { + $request->user()->tokens()->where('id', $tokenId)->delete(); + + return redirect() + ->route('api-keys.index') + ->with('status', 'API key revoked.'); + } +} diff --git a/src/app/Http/Controllers/BowlerChartController.php b/src/app/Http/Controllers/BowlerChartController.php new file mode 100644 index 0000000..a53b869 --- /dev/null +++ b/src/app/Http/Controllers/BowlerChartController.php @@ -0,0 +1,123 @@ +user(); + + $charts = $user->is_admin + ? BowlerChart::with('group')->get() + : BowlerChart::with('group') + ->whereIn('group_id', $user->groups()->select('groups.id')) + ->get(); + + return view('charts.index', [ + 'chartsByGroup' => $charts->sortBy('name')->groupBy(fn ($c) => $c->group->name)->sortKeys(), + ]); + } + + public function create(Request $request): View + { + return view('charts.create', [ + 'groups' => $this->manageableGroups($request), + ]); + } + + public function store(Request $request): RedirectResponse + { + $data = $request->validate([ + 'group_id' => ['required', 'integer', 'exists:groups,id'], + 'name' => ['required', 'string', 'max:255'], + 'description' => ['nullable', 'string', 'max:500'], + ]); + + if (! $request->user()->is_admin && ! $request->user()->ownsGroup((int) $data['group_id'])) { + abort(403, 'You must be an owner of the group to create charts in it.'); + } + + $chart = BowlerChart::create($data); + + return redirect() + ->route('charts.show', $chart) + ->with('status', "Bowler chart \"{$chart->name}\" created."); + } + + public function show(BowlerChart $chart): View + { + $this->authorize('view', $chart); + + return view('charts.show', [ + 'chart' => $chart, + 'months' => BowlerGrid::months(), + 'groupedRows' => BowlerGrid::groupedRows($chart->visibleKpis(), $chart->visibleProjects()), + ]); + } + + public function edit(BowlerChart $chart): View + { + $this->authorize('update', $chart); + + return view('charts.edit', ['chart' => $chart]); + } + + public function update(Request $request, BowlerChart $chart): RedirectResponse + { + $this->authorize('update', $chart); + + $data = $request->validate([ + 'name' => ['required', 'string', 'max:255'], + 'description' => ['nullable', 'string', 'max:500'], + ]); + + $chart->update($data); + + return redirect() + ->route('charts.show', $chart) + ->with('status', "Bowler chart \"{$chart->name}\" updated."); + } + + public function destroy(BowlerChart $chart): RedirectResponse + { + $this->authorize('delete', $chart); + + $name = $chart->name; + $chart->delete(); + + return redirect() + ->route('charts.index') + ->with('status', "Bowler chart \"{$name}\" deleted."); + } + + /** + * The user's personal bowler chart: all of their own items, always available. + */ + public function personal(Request $request): View + { + return view('charts.personal', [ + 'months' => BowlerGrid::months(), + 'groupedRows' => BowlerGrid::groupedRows( + $request->user()->kpis()->get(), + $request->user()->projects()->get(), + ), + ]); + } + + private function manageableGroups(Request $request) + { + $user = $request->user(); + + return $user->is_admin + ? Group::orderBy('name')->get() + : $user->groups()->wherePivot('role', 'owner')->get(); + } +} diff --git a/src/app/Http/Controllers/CategoryController.php b/src/app/Http/Controllers/CategoryController.php new file mode 100644 index 0000000..030dca2 --- /dev/null +++ b/src/app/Http/Controllers/CategoryController.php @@ -0,0 +1,62 @@ +user()->categories()->withCount(['kpis', 'projects'])->get(); + + return view('categories.index', ['categories' => $categories]); + } + + public function store(Request $request): RedirectResponse + { + $data = $request->validate([ + 'name' => ['required', 'string', 'max:255', + Rule::unique('categories', 'name')->where('user_id', $request->user()->id)], + ]); + + $request->user()->categories()->create($data); + + return redirect()->route('categories.index')->with('status', "Category \"{$data['name']}\" created."); + } + + public function edit(Request $request, Category $category): View + { + abort_unless($category->user_id === $request->user()->id, 404); + + return view('categories.edit', ['category' => $category]); + } + + public function update(Request $request, Category $category): RedirectResponse + { + abort_unless($category->user_id === $request->user()->id, 404); + + $data = $request->validate([ + 'name' => ['required', 'string', 'max:255', + Rule::unique('categories', 'name')->where('user_id', $request->user()->id)->ignore($category->id)], + ]); + + $category->update($data); + + return redirect()->route('categories.index')->with('status', "Category renamed to \"{$category->name}\"."); + } + + public function destroy(Request $request, Category $category): RedirectResponse + { + abort_unless($category->user_id === $request->user()->id, 404); + + $name = $category->name; + $category->delete(); + + return redirect()->route('categories.index')->with('status', "Category \"{$name}\" deleted. Its items are now unassigned."); + } +} diff --git a/src/app/Http/Controllers/Concerns/SyncsChartExclusions.php b/src/app/Http/Controllers/Concerns/SyncsChartExclusions.php new file mode 100644 index 0000000..b558d71 --- /dev/null +++ b/src/app/Http/Controllers/Concerns/SyncsChartExclusions.php @@ -0,0 +1,30 @@ +whereIn('group_id', $item->user->groups()->select('groups.id')) + ->pluck('id'); + + if ($ownerChartIds->isEmpty()) { + return; + } + + $checked = collect($request->input('charts', []))->map(fn ($v) => (int) $v); + + $item->excludedCharts()->sync($ownerChartIds->diff($checked)->values()->all()); + } +} diff --git a/src/app/Http/Controllers/Controller.php b/src/app/Http/Controllers/Controller.php index 8677cd5..e7f7c94 100644 --- a/src/app/Http/Controllers/Controller.php +++ b/src/app/Http/Controllers/Controller.php @@ -2,7 +2,9 @@ namespace App\Http\Controllers; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; + abstract class Controller { - // + use AuthorizesRequests; } diff --git a/src/app/Http/Controllers/KpiController.php b/src/app/Http/Controllers/KpiController.php new file mode 100644 index 0000000..4bb109e --- /dev/null +++ b/src/app/Http/Controllers/KpiController.php @@ -0,0 +1,86 @@ +user()->kpis() + ->withCount('entries') + ->with('latestEntry', 'team', 'category') + ->orderBy('name') + ->get(); + + return view('kpis.index', ['kpis' => $kpis]); + } + + public function create(): View + { + return view('kpis.create'); + } + + public function store(Request $request): RedirectResponse + { + $data = $request->validate(Kpi::validationRules($request->user()) + $this->chartRules()); + + $kpi = $request->user()->kpis()->create($data); + $this->syncChartExclusions($request, $kpi); + + return redirect() + ->route('kpis.entries.index', $kpi) + ->with('status', "KPI \"{$kpi->name}\" created. Add your first entry below."); + } + + public function edit(Kpi $kpi): View + { + $this->authorize('update', $kpi); + + return view('kpis.edit', ['kpi' => $kpi]); + } + + public function update(Request $request, Kpi $kpi): RedirectResponse + { + $this->authorize('update', $kpi); + + $data = $request->validate(Kpi::validationRules($kpi->user) + $this->chartRules()); + + $kpi->update($data); + $this->syncChartExclusions($request, $kpi); + + return redirect() + ->route('kpis.index') + ->with('status', "KPI \"{$kpi->name}\" updated."); + } + + /** + * @return array> + */ + private function chartRules(): array + { + return [ + 'charts' => ['nullable', 'array'], + 'charts.*' => ['integer'], + ]; + } + + public function destroy(Kpi $kpi): RedirectResponse + { + $this->authorize('delete', $kpi); + + $name = $kpi->name; + $kpi->delete(); + + return redirect() + ->route('kpis.index') + ->with('status', "KPI \"{$name}\" and all its entries deleted."); + } +} diff --git a/src/app/Http/Controllers/KpiEntryController.php b/src/app/Http/Controllers/KpiEntryController.php new file mode 100644 index 0000000..1984e2a --- /dev/null +++ b/src/app/Http/Controllers/KpiEntryController.php @@ -0,0 +1,71 @@ +authorize('view', $kpi); + + $entries = $kpi->entries() + ->orderByDesc('entry_date') + ->orderByDesc('created_at') + ->orderByDesc('id') + ->get() + ->each->setRelation('kpi', $kpi); // status accessor needs the parent; avoids one query per row + + return view('kpis.entries', ['kpi' => $kpi, 'entries' => $entries]); + } + + public function store(Request $request, Kpi $kpi): RedirectResponse + { + $this->authorize('update', $kpi); + + $data = $request->validate(KpiEntry::validationRules()); + + $kpi->entries()->create($data); + + return redirect() + ->route('kpis.entries.index', $kpi) + ->with('status', 'Entry added.'); + } + + public function edit(KpiEntry $entry): View + { + $this->authorize('update', $entry->kpi); + + return view('kpis.entry-edit', ['entry' => $entry, 'kpi' => $entry->kpi]); + } + + public function update(Request $request, KpiEntry $entry): RedirectResponse + { + $this->authorize('update', $entry->kpi); + + $data = $request->validate(KpiEntry::validationRules()); + + $entry->update($data); + + return redirect() + ->route('kpis.entries.index', $entry->kpi) + ->with('status', 'Entry updated.'); + } + + public function destroy(KpiEntry $entry): RedirectResponse + { + $this->authorize('update', $entry->kpi); + + $kpi = $entry->kpi; + $entry->delete(); + + return redirect() + ->route('kpis.entries.index', $kpi) + ->with('status', 'Entry deleted.'); + } +} diff --git a/src/app/Http/Controllers/ProjectController.php b/src/app/Http/Controllers/ProjectController.php new file mode 100644 index 0000000..542b6e0 --- /dev/null +++ b/src/app/Http/Controllers/ProjectController.php @@ -0,0 +1,86 @@ +user()->projects() + ->withCount('entries') + ->with('latestEntry', 'team', 'category') + ->orderBy('name') + ->get(); + + return view('projects.index', ['projects' => $projects]); + } + + public function create(): View + { + return view('projects.create'); + } + + public function store(Request $request): RedirectResponse + { + $data = $request->validate(Project::validationRules($request->user()) + $this->chartRules()); + + $project = $request->user()->projects()->create($data); + $this->syncChartExclusions($request, $project); + + return redirect() + ->route('projects.entries.index', $project) + ->with('status', "Project \"{$project->name}\" created. Add your first status entry below."); + } + + public function edit(Project $project): View + { + $this->authorize('update', $project); + + return view('projects.edit', ['project' => $project]); + } + + public function update(Request $request, Project $project): RedirectResponse + { + $this->authorize('update', $project); + + $data = $request->validate(Project::validationRules($project->user) + $this->chartRules()); + + $project->update($data); + $this->syncChartExclusions($request, $project); + + return redirect() + ->route('projects.index') + ->with('status', "Project \"{$project->name}\" updated."); + } + + /** + * @return array> + */ + private function chartRules(): array + { + return [ + 'charts' => ['nullable', 'array'], + 'charts.*' => ['integer'], + ]; + } + + public function destroy(Project $project): RedirectResponse + { + $this->authorize('delete', $project); + + $name = $project->name; + $project->delete(); + + return redirect() + ->route('projects.index') + ->with('status', "Project \"{$name}\" and all its entries deleted."); + } +} diff --git a/src/app/Http/Controllers/ProjectEntryController.php b/src/app/Http/Controllers/ProjectEntryController.php new file mode 100644 index 0000000..79036b0 --- /dev/null +++ b/src/app/Http/Controllers/ProjectEntryController.php @@ -0,0 +1,70 @@ +authorize('view', $project); + + $entries = $project->entries() + ->orderByDesc('entry_date') + ->orderByDesc('created_at') + ->orderByDesc('id') + ->get(); + + return view('projects.entries', ['project' => $project, 'entries' => $entries]); + } + + public function store(Request $request, Project $project): RedirectResponse + { + $this->authorize('update', $project); + + $data = $request->validate(ProjectEntry::validationRules()); + + $project->entries()->create($data); + + return redirect() + ->route('projects.entries.index', $project) + ->with('status', 'Entry added.'); + } + + public function edit(ProjectEntry $entry): View + { + $this->authorize('update', $entry->project); + + return view('projects.entry-edit', ['entry' => $entry, 'project' => $entry->project]); + } + + public function update(Request $request, ProjectEntry $entry): RedirectResponse + { + $this->authorize('update', $entry->project); + + $data = $request->validate(ProjectEntry::validationRules()); + + $entry->update($data); + + return redirect() + ->route('projects.entries.index', $entry->project) + ->with('status', 'Entry updated.'); + } + + public function destroy(ProjectEntry $entry): RedirectResponse + { + $this->authorize('update', $entry->project); + + $project = $entry->project; + $entry->delete(); + + return redirect() + ->route('projects.entries.index', $project) + ->with('status', 'Entry deleted.'); + } +} diff --git a/src/app/Http/Controllers/TeamController.php b/src/app/Http/Controllers/TeamController.php new file mode 100644 index 0000000..afcad5c --- /dev/null +++ b/src/app/Http/Controllers/TeamController.php @@ -0,0 +1,62 @@ +user()->teams()->withCount(['kpis', 'projects'])->get(); + + return view('teams.index', ['teams' => $teams]); + } + + public function store(Request $request): RedirectResponse + { + $data = $request->validate([ + 'name' => ['required', 'string', 'max:255', + Rule::unique('teams', 'name')->where('user_id', $request->user()->id)], + ]); + + $request->user()->teams()->create($data); + + return redirect()->route('teams.index')->with('status', "Team \"{$data['name']}\" created."); + } + + public function edit(Request $request, Team $team): View + { + abort_unless($team->user_id === $request->user()->id, 404); + + return view('teams.edit', ['team' => $team]); + } + + public function update(Request $request, Team $team): RedirectResponse + { + abort_unless($team->user_id === $request->user()->id, 404); + + $data = $request->validate([ + 'name' => ['required', 'string', 'max:255', + Rule::unique('teams', 'name')->where('user_id', $request->user()->id)->ignore($team->id)], + ]); + + $team->update($data); + + return redirect()->route('teams.index')->with('status', "Team renamed to \"{$team->name}\"."); + } + + public function destroy(Request $request, Team $team): RedirectResponse + { + abort_unless($team->user_id === $request->user()->id, 404); + + $name = $team->name; + $team->delete(); + + return redirect()->route('teams.index')->with('status', "Team \"{$name}\" deleted. Its items are now unassigned."); + } +} diff --git a/src/app/Models/BowlerChart.php b/src/app/Models/BowlerChart.php new file mode 100644 index 0000000..7938f05 --- /dev/null +++ b/src/app/Models/BowlerChart.php @@ -0,0 +1,55 @@ +belongsTo(Group::class); + } + + public function excludedKpis(): BelongsToMany + { + return $this->belongsToMany(Kpi::class, 'bowler_chart_kpi_exclusions'); + } + + public function excludedProjects(): BelongsToMany + { + return $this->belongsToMany(Project::class, 'bowler_chart_project_exclusions'); + } + + /** + * KPIs shown on this chart: owned by a member of the chart's group + * and not explicitly excluded. + */ + public function visibleKpis(): Collection + { + return Kpi::query() + ->whereIn('user_id', $this->group->users()->select('users.id')) + ->whereNotIn('id', $this->excludedKpis()->select('kpis.id')) + ->with('user') + ->orderBy('name') + ->get(); + } + + /** + * Projects shown on this chart, same rules as visibleKpis(). + */ + public function visibleProjects(): Collection + { + return Project::query() + ->whereIn('user_id', $this->group->users()->select('users.id')) + ->whereNotIn('id', $this->excludedProjects()->select('projects.id')) + ->with('user') + ->orderBy('name') + ->get(); + } +} diff --git a/src/app/Models/Category.php b/src/app/Models/Category.php new file mode 100644 index 0000000..14f5c9f --- /dev/null +++ b/src/app/Models/Category.php @@ -0,0 +1,27 @@ +belongsTo(User::class); + } + + public function kpis(): HasMany + { + return $this->hasMany(Kpi::class); + } + + public function projects(): HasMany + { + return $this->hasMany(Project::class); + } +} diff --git a/src/app/Models/Group.php b/src/app/Models/Group.php index 6838c24..93e2e59 100644 --- a/src/app/Models/Group.php +++ b/src/app/Models/Group.php @@ -4,6 +4,7 @@ namespace App\Models; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsToMany; +use Illuminate\Database\Eloquent\Relations\HasMany; class Group extends Model { @@ -16,4 +17,9 @@ class Group extends Model ->withTimestamps() ->orderBy('name'); } + + public function bowlerCharts(): HasMany + { + return $this->hasMany(BowlerChart::class)->orderBy('name'); + } } diff --git a/src/app/Models/Kpi.php b/src/app/Models/Kpi.php new file mode 100644 index 0000000..7e0019e --- /dev/null +++ b/src/app/Models/Kpi.php @@ -0,0 +1,142 @@ + KpiValueType::class, + 'evaluation' => KpiEvaluation::class, + 'green_threshold' => 'float', + 'yellow_threshold' => 'float', + 'target' => 'float', + 'green_tolerance' => 'float', + 'yellow_tolerance' => 'float', + ]; + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function team(): BelongsTo + { + return $this->belongsTo(Team::class); + } + + public function category(): BelongsTo + { + return $this->belongsTo(Category::class); + } + + public function entries(): HasMany + { + return $this->hasMany(KpiEntry::class); + } + + public function latestEntry() + { + return $this->hasOne(KpiEntry::class)->latestOfMany('entry_date'); + } + + /** + * Charts this KPI has been deselected from (exclusion model: + * it appears on every group chart unless listed here). + */ + public function excludedCharts() + { + return $this->belongsToMany(BowlerChart::class, 'bowler_chart_kpi_exclusions'); + } + + /** + * Validation rules shared by the web and API controllers. + * $owner is the item's owner: team/category must belong to them. + * + * @return array> + */ + public static function validationRules(User $owner): array + { + $numeric = ['nullable', 'numeric', 'between:-9999999999,9999999999']; + + return [ + 'name' => ['required', 'string', 'max:255'], + 'team_id' => ['required', 'integer', \Illuminate\Validation\Rule::exists('teams', 'id')->where('user_id', $owner->id)], + 'category_id' => ['required', 'integer', \Illuminate\Validation\Rule::exists('categories', 'id')->where('user_id', $owner->id)], + 'description' => ['nullable', 'string', 'max:2000'], + 'value_type' => ['required', 'in:number,percent,duration'], + 'evaluation' => ['required', 'in:higher_is_better,lower_is_better,target_band,milestone'], + 'unit_label' => ['nullable', 'string', 'max:50'], + 'green_threshold' => [...$numeric, 'required_if:evaluation,higher_is_better,lower_is_better'], + 'yellow_threshold' => [...$numeric, 'required_if:evaluation,higher_is_better,lower_is_better'], + 'target' => [...$numeric, 'required_if:evaluation,target_band'], + 'green_tolerance' => [...$numeric, 'min:0', 'required_if:evaluation,target_band'], + 'yellow_tolerance' => [...$numeric, 'min:0', 'required_if:evaluation,target_band'], + ]; + } + + /** + * Evaluate a measured value against this KPI's thresholds. + */ + public function evaluate(float $value): RagStatus + { + return match ($this->evaluation) { + KpiEvaluation::HigherIsBetter => match (true) { + $value >= $this->green_threshold => RagStatus::Green, + $value >= $this->yellow_threshold => RagStatus::Yellow, + default => RagStatus::Red, + }, + KpiEvaluation::LowerIsBetter => match (true) { + $value <= $this->green_threshold => RagStatus::Green, + $value <= $this->yellow_threshold => RagStatus::Yellow, + default => RagStatus::Red, + }, + KpiEvaluation::TargetBand => match (true) { + abs($value - $this->target) <= $this->green_tolerance => RagStatus::Green, + abs($value - $this->target) <= $this->yellow_tolerance => RagStatus::Yellow, + default => RagStatus::Red, + }, + KpiEvaluation::Milestone => $value >= 1 ? RagStatus::Green : RagStatus::Red, + }; + } + + /** + * Human-readable summary of the thresholds, e.g. "G ≥ 95, Y ≥ 90". + */ + public function thresholdSummary(): string + { + $fmt = fn (?float $v): string => $v === null ? '?' : rtrim(rtrim(number_format($v, 4, '.', ''), '0'), '.'); + + return match ($this->evaluation) { + KpiEvaluation::HigherIsBetter => "G ≥ {$fmt($this->green_threshold)}, Y ≥ {$fmt($this->yellow_threshold)}", + KpiEvaluation::LowerIsBetter => "G ≤ {$fmt($this->green_threshold)}, Y ≤ {$fmt($this->yellow_threshold)}", + KpiEvaluation::TargetBand => "Target {$fmt($this->target)} ± {$fmt($this->green_tolerance)} (G) / ± {$fmt($this->yellow_tolerance)} (Y)", + KpiEvaluation::Milestone => 'Met = Green, not met = Red', + }; + } +} diff --git a/src/app/Models/KpiEntry.php b/src/app/Models/KpiEntry.php new file mode 100644 index 0000000..67527f7 --- /dev/null +++ b/src/app/Models/KpiEntry.php @@ -0,0 +1,50 @@ + 'date', + 'value' => 'float', + ]; + } + + public function kpi(): BelongsTo + { + return $this->belongsTo(Kpi::class); + } + + /** + * Validation rules shared by the web and API controllers. + * + * @return array> + */ + public static function validationRules(): array + { + return [ + 'entry_date' => ['required', 'date'], + 'value' => ['required', 'numeric', 'between:-9999999999,9999999999'], + 'notes' => ['nullable', 'string', 'max:2000'], + ]; + } + + protected function status(): Attribute + { + return Attribute::get(fn (): RagStatus => $this->kpi->evaluate($this->value)); + } +} diff --git a/src/app/Models/Project.php b/src/app/Models/Project.php new file mode 100644 index 0000000..baafd17 --- /dev/null +++ b/src/app/Models/Project.php @@ -0,0 +1,68 @@ +belongsTo(User::class); + } + + public function team(): BelongsTo + { + return $this->belongsTo(Team::class); + } + + public function category(): BelongsTo + { + return $this->belongsTo(Category::class); + } + + public function entries(): HasMany + { + return $this->hasMany(ProjectEntry::class); + } + + public function latestEntry() + { + return $this->hasOne(ProjectEntry::class)->latestOfMany('entry_date'); + } + + /** + * Charts this project has been deselected from (exclusion model: + * it appears on every group chart unless listed here). + */ + public function excludedCharts() + { + return $this->belongsToMany(BowlerChart::class, 'bowler_chart_project_exclusions'); + } + + /** + * Validation rules shared by the web and API controllers. + * $owner is the item's owner: team/category must belong to them. + * + * @return array> + */ + public static function validationRules(User $owner): array + { + return [ + 'name' => ['required', 'string', 'max:255'], + 'team_id' => ['required', 'integer', \Illuminate\Validation\Rule::exists('teams', 'id')->where('user_id', $owner->id)], + 'category_id' => ['required', 'integer', \Illuminate\Validation\Rule::exists('categories', 'id')->where('user_id', $owner->id)], + 'description' => ['nullable', 'string', 'max:2000'], + ]; + } +} diff --git a/src/app/Models/ProjectEntry.php b/src/app/Models/ProjectEntry.php new file mode 100644 index 0000000..e53e834 --- /dev/null +++ b/src/app/Models/ProjectEntry.php @@ -0,0 +1,52 @@ + 'date', + 'status' => RagStatus::class, + ]; + } + + public function project(): BelongsTo + { + return $this->belongsTo(Project::class); + } + + /** + * Validation rules shared by the web and API controllers. + * + * @return array> + */ + public static function validationRules(): array + { + return [ + 'entry_date' => ['required', 'date'], + 'status' => ['required', 'in:green,yellow,red'], + 'notes' => ['nullable', 'string', 'max:5000'], + 'headwinds' => ['nullable', 'string', 'max:5000'], + 'tailwinds' => ['nullable', 'string', 'max:5000'], + 'highlights' => ['nullable', 'string', 'max:5000'], + 'blockers' => ['nullable', 'string', 'max:5000'], + ]; + } +} diff --git a/src/app/Models/Team.php b/src/app/Models/Team.php new file mode 100644 index 0000000..3e5a3b9 --- /dev/null +++ b/src/app/Models/Team.php @@ -0,0 +1,27 @@ +belongsTo(User::class); + } + + public function kpis(): HasMany + { + return $this->hasMany(Kpi::class); + } + + public function projects(): HasMany + { + return $this->hasMany(Project::class); + } +} diff --git a/src/app/Models/User.php b/src/app/Models/User.php index 74abd27..062254a 100644 --- a/src/app/Models/User.php +++ b/src/app/Models/User.php @@ -3,12 +3,14 @@ namespace App\Models; use Illuminate\Database\Eloquent\Relations\BelongsToMany; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; +use Laravel\Sanctum\HasApiTokens; class User extends Authenticatable { - use Notifiable; + use HasApiTokens, Notifiable; /** * @var list @@ -37,6 +39,47 @@ class User extends Authenticatable ->orderBy('name'); } + /** + * Whether this user is an owner of the given group. + */ + public function ownsGroup(int $groupId): bool + { + return $this->groups() + ->where('groups.id', $groupId) + ->wherePivot('role', 'owner') + ->exists(); + } + + /** + * Whether this user shares at least one group with another user. + */ + public function sharesGroupWith(User $other): bool + { + return $this->groups() + ->whereIn('groups.id', $other->groups()->select('groups.id')) + ->exists(); + } + + public function kpis(): HasMany + { + return $this->hasMany(Kpi::class); + } + + public function projects(): HasMany + { + return $this->hasMany(Project::class); + } + + public function teams(): HasMany + { + return $this->hasMany(Team::class)->orderBy('name'); + } + + public function categories(): HasMany + { + return $this->hasMany(Category::class)->orderBy('name'); + } + /** * @return array */ diff --git a/src/app/Policies/BowlerChartPolicy.php b/src/app/Policies/BowlerChartPolicy.php new file mode 100644 index 0000000..5369fab --- /dev/null +++ b/src/app/Policies/BowlerChartPolicy.php @@ -0,0 +1,31 @@ +is_admin + || $user->groups()->where('groups.id', $chart->group_id)->exists(); + } + + /** + * Only group owners and admins manage charts. + */ + public function update(User $user, BowlerChart $chart): bool + { + return $user->is_admin || $user->ownsGroup($chart->group_id); + } + + public function delete(User $user, BowlerChart $chart): bool + { + return $this->update($user, $chart); + } +} diff --git a/src/app/Policies/KpiPolicy.php b/src/app/Policies/KpiPolicy.php new file mode 100644 index 0000000..0009966 --- /dev/null +++ b/src/app/Policies/KpiPolicy.php @@ -0,0 +1,30 @@ +user_id === $user->id + || $user->is_admin + || $user->sharesGroupWith($kpi->user); + } + + public function update(User $user, Kpi $kpi): bool + { + return $kpi->user_id === $user->id || $user->is_admin; + } + + public function delete(User $user, Kpi $kpi): bool + { + return $kpi->user_id === $user->id || $user->is_admin; + } +} diff --git a/src/app/Policies/ProjectPolicy.php b/src/app/Policies/ProjectPolicy.php new file mode 100644 index 0000000..91f9e7f --- /dev/null +++ b/src/app/Policies/ProjectPolicy.php @@ -0,0 +1,30 @@ +user_id === $user->id + || $user->is_admin + || $user->sharesGroupWith($project->user); + } + + public function update(User $user, Project $project): bool + { + return $project->user_id === $user->id || $user->is_admin; + } + + public function delete(User $user, Project $project): bool + { + return $project->user_id === $user->id || $user->is_admin; + } +} diff --git a/src/app/Support/BowlerGrid.php b/src/app/Support/BowlerGrid.php new file mode 100644 index 0000000..487c3af --- /dev/null +++ b/src/app/Support/BowlerGrid.php @@ -0,0 +1,153 @@ + Category (by name, case-insensitively, so same-named teams + * from different owners merge on shared charts); within each month the + * most recent entry wins (entry_date desc, id desc). Items without a + * team/category fall into "No Team" / "Uncategorized", sorted last. + */ +class BowlerGrid +{ + /** Sorts after any UTF-8 name so fallback buckets land last. */ + private const LAST = "\xFF\xFF"; + + /** + * The trailing 12 months ending with the current month. + * + * @return array + */ + public static function months(): array + { + $months = []; + $cursor = now()->startOfMonth()->subMonths(11); + + for ($i = 0; $i < 12; $i++) { + $months[] = [ + 'key' => $cursor->format('Y-m'), + 'label' => $cursor->format('M y'), + ]; + $cursor = $cursor->addMonth(); + } + + return $months; + } + + /** + * @param Collection $kpis + * @param Collection $projects + * @return array>> + */ + public static function groupedRows(Collection $kpis, Collection $projects): array + { + $start = now()->startOfMonth()->subMonths(11)->format('Y-m-d'); + $entriesWindow = fn ($q) => $q->where('entry_date', '>=', $start)->orderBy('entry_date')->orderBy('id'); + + $kpis->load(['team', 'category', 'entries' => $entriesWindow]); + $projects->load(['team', 'category', 'entries' => $entriesWindow]); + + $months = self::months(); + + $rows = $kpis->map(function (Kpi $kpi) use ($months) { + $cells = self::kpiCells($kpi); + + return [ + 'type' => 'kpi', + 'item' => $kpi, + 'cells' => $cells, + 'chart' => KpiChart::build($kpi, $cells, $months), + ]; + }) + ->concat($projects->map(fn (Project $p) => ['type' => 'project', 'item' => $p, 'cells' => self::projectCells($p)])); + + // Group by team then category, merging names case-insensitively. + $grouped = []; + foreach ($rows as $row) { + $teamName = $row['item']->team?->name; + $catName = $row['item']->category?->name; + $teamKey = $teamName === null ? self::LAST : mb_strtolower(trim($teamName)); + $catKey = $catName === null ? self::LAST : mb_strtolower(trim($catName)); + + $grouped[$teamKey]['display'] ??= $teamName ?? 'No Team'; + $grouped[$teamKey]['cats'][$catKey]['display'] ??= $catName ?? 'Uncategorized'; + $grouped[$teamKey]['cats'][$catKey]['rows'][] = $row; + } + + ksort($grouped, SORT_STRING); + + $result = []; + foreach ($grouped as $team) { + ksort($team['cats'], SORT_STRING); + $categories = []; + foreach ($team['cats'] as $cat) { + // KPIs before projects, then alphabetical. + usort($cat['rows'], fn ($a, $b) => + [$a['type'] === 'project', mb_strtolower($a['item']->name)] + <=> [$b['type'] === 'project', mb_strtolower($b['item']->name)]); + $categories[$cat['display']] = $cat['rows']; + } + $result[$team['display']] = $categories; + } + + return $result; + } + + /** + * @return array + */ + private static function kpiCells(Kpi $kpi): array + { + $cells = []; + + // Entries are ordered ascending, so the last write per month wins. + foreach ($kpi->entries as $entry) { + $display = $kpi->evaluation === KpiEvaluation::Milestone + ? ($entry->value >= 1 ? 'Met' : 'Miss') + : $kpi->value_type->format($entry->value, $kpi->unit_label); + + $cells[$entry->entry_date->format('Y-m')] = [ + 'status' => $kpi->evaluate($entry->value), + 'display' => $display, + 'tooltip' => trim($entry->entry_date->format('Y-m-d') . ($entry->notes ? " — {$entry->notes}" : '')), + ]; + } + + return $cells; + } + + /** + * @return array + */ + private static function projectCells(Project $project): array + { + $cells = []; + + foreach ($project->entries as $entry) { + $cells[$entry->entry_date->format('Y-m')] = [ + 'status' => $entry->status, + 'display' => strtoupper(substr($entry->status->value, 0, 1)), + 'tooltip' => trim($entry->entry_date->format('Y-m-d') . ($entry->notes ? " — {$entry->notes}" : '')), + // Payload for the click-to-expand detail row on charts + 'details' => [ + 'date' => $entry->entry_date->format('Y-m-d'), + 'statusLabel' => $entry->status->label(), + 'statusClasses' => $entry->status->badgeClasses(), + 'notes' => $entry->notes, + 'headwinds' => $entry->headwinds, + 'tailwinds' => $entry->tailwinds, + 'highlights' => $entry->highlights, + 'blockers' => $entry->blockers, + ], + ]; + } + + return $cells; + } +} diff --git a/src/app/Support/KpiChart.php b/src/app/Support/KpiChart.php new file mode 100644 index 0000000..b48fe57 --- /dev/null +++ b/src/app/Support/KpiChart.php @@ -0,0 +1,315 @@ + $cells + * @param array $months + * @return array + */ + public static function build(Kpi $kpi, array $cells, array $months): array + { + if ($kpi->evaluation === KpiEvaluation::Milestone) { + return self::milestone($kpi, $cells, $months); + } + + return self::line($kpi, $cells, $months); + } + + /** + * @return array + */ + private static function line(Kpi $kpi, array $cells, array $months): array + { + // Values come from the loaded entries window (latest per month, ascending order). + $byMonth = []; + foreach ($kpi->entries as $entry) { + $byMonth[$entry->entry_date->format('Y-m')] = $entry; + } + + $numbers = array_map(fn ($e) => $e->value, $byMonth); + $thresholds = match ($kpi->evaluation) { + KpiEvaluation::TargetBand => [ + $kpi->target - $kpi->yellow_tolerance, + $kpi->target + $kpi->yellow_tolerance, + $kpi->target, + ], + default => [$kpi->green_threshold, $kpi->yellow_threshold], + }; + + $domain = array_merge(array_values($numbers), array_filter($thresholds, fn ($t) => $t !== null)); + if ($domain === []) { + $domain = [0, 1]; + } + + $min = min($domain); + $max = max($domain); + $span = $max - $min ?: max(abs($max), 1) * 0.2; + $min -= $span * 0.10; + $max += $span * 0.10; + + $plotW = self::W - self::PAD_LEFT - self::PAD_RIGHT; + $plotH = self::H - self::PAD_TOP - self::PAD_BOTTOM; + $slot = $plotW / count($months); + $y = fn (float $v): float => round(self::PAD_TOP + $plotH * (1 - ($v - $min) / ($max - $min)), 1); + $clampY = fn (float $v): float => max(self::PAD_TOP, min(self::PAD_TOP + $plotH, $y($v))); + + // Threshold zone bands (10% washes) + labeled reference lines. + [$bands, $refLines] = self::zones($kpi, $min, $max, $clampY); + + // Gridlines at nice tick values. + $gridlines = []; + foreach (self::ticks($min, $max) as $tick) { + $gridlines[] = ['y' => $y($tick), 'label' => self::fmtTick($tick, $kpi)]; + } + + // Points + line segments (gaps split the path). + $points = []; + $segments = []; + $current = []; + foreach ($months as $i => $month) { + $entry = $byMonth[$month['key']] ?? null; + if (! $entry) { + if (count($current) > 1) { + $segments[] = $current; + } + $current = []; + continue; + } + $px = round(self::PAD_LEFT + $slot * ($i + 0.5), 1); + $py = $y($entry->value); + $status = $kpi->evaluate($entry->value); + $points[] = [ + 'x' => $px, + 'y' => $py, + 'color' => self::statusColor($status->value), + 'title' => $entry->entry_date->format('M Y') . ': ' + . $kpi->value_type->format($entry->value, $kpi->unit_label) + . ' — ' . $status->label(), + ]; + $current[] = [$px, $py]; + } + if (count($current) > 1) { + $segments[] = $current; + } + + $paths = array_map( + fn ($seg) => 'M' . implode(' L', array_map(fn ($p) => "{$p[0]},{$p[1]}", $seg)), + $segments + ); + + // Selective labeling: the value at the line's end only. + $endLabel = null; + if ($points !== []) { + $last = end($points); + $endLabel = [ + 'x' => $last['x'] + 9, + 'y' => $last['y'] + 4, + 'text' => $kpi->value_type->format($byMonth[array_key_last($byMonth)]->value, $kpi->unit_label), + ]; + } + + return [ + 'type' => 'line', + 'width' => self::W, + 'height' => self::H, + 'plot' => ['x' => self::PAD_LEFT, 'y' => self::PAD_TOP, 'w' => $plotW, 'h' => $plotH], + 'bands' => $bands, + 'refLines' => $refLines, + 'gridlines' => $gridlines, + 'paths' => $paths, + 'lineColor' => self::LINE, + 'points' => $points, + 'endLabel' => $endLabel, + 'monthLabels' => self::monthLabels($months, $slot), + 'aria' => "{$kpi->name}: monthly values for the last 12 months against thresholds ({$kpi->thresholdSummary()})", + ]; + } + + /** + * Zone bands + reference lines for the three numeric evaluation modes. + * + * @return array{0: array, 1: array} + */ + private static function zones(Kpi $kpi, float $min, float $max, callable $clampY): array + { + $bands = []; + $refLines = []; + $band = function (float $from, float $to, string $color) use (&$bands, $clampY) { + $y1 = $clampY($to); + $y2 = $clampY($from); + if ($y2 - $y1 > 0.5) { + $bands[] = ['y' => $y1, 'h' => round($y2 - $y1, 1), 'color' => $color]; + } + }; + + $fmt = fn (?float $v) => $v === null ? '' : rtrim(rtrim(number_format($v, 4, '.', ''), '0'), '.'); + + switch ($kpi->evaluation) { + case KpiEvaluation::HigherIsBetter: + $band($kpi->green_threshold, $max, self::GOOD); + $band($kpi->yellow_threshold, $kpi->green_threshold, self::WARN); + $band($min, $kpi->yellow_threshold, self::CRIT); + $refLines[] = ['y' => $clampY($kpi->green_threshold), 'label' => 'G ≥ ' . $fmt($kpi->green_threshold)]; + $refLines[] = ['y' => $clampY($kpi->yellow_threshold), 'label' => 'Y ≥ ' . $fmt($kpi->yellow_threshold)]; + break; + + case KpiEvaluation::LowerIsBetter: + $band($min, $kpi->green_threshold, self::GOOD); + $band($kpi->green_threshold, $kpi->yellow_threshold, self::WARN); + $band($kpi->yellow_threshold, $max, self::CRIT); + $refLines[] = ['y' => $clampY($kpi->green_threshold), 'label' => 'G ≤ ' . $fmt($kpi->green_threshold)]; + $refLines[] = ['y' => $clampY($kpi->yellow_threshold), 'label' => 'Y ≤ ' . $fmt($kpi->yellow_threshold)]; + break; + + case KpiEvaluation::TargetBand: + $t = $kpi->target; + $g = $kpi->green_tolerance; + $yTol = $kpi->yellow_tolerance; + $band($t - $g, $t + $g, self::GOOD); + $band($t + $g, $t + $yTol, self::WARN); + $band($t - $yTol, $t - $g, self::WARN); + $band($t + $yTol, $max, self::CRIT); + $band($min, $t - $yTol, self::CRIT); + $refLines[] = ['y' => $clampY($t), 'label' => 'Target ' . $fmt($t), 'emphasis' => true]; + break; + } + + return [$bands, $refLines]; + } + + /** + * @return array + */ + private static function milestone(Kpi $kpi, array $cells, array $months): array + { + $byMonth = []; + foreach ($kpi->entries as $entry) { + $byMonth[$entry->entry_date->format('Y-m')] = $entry; + } + + $height = 110; + $plotW = self::W - self::PAD_LEFT - self::PAD_RIGHT; + $slot = $plotW / count($months); + $baseline = 48.0; + + $markers = []; + foreach ($months as $i => $month) { + $entry = $byMonth[$month['key']] ?? null; + if (! $entry) { + continue; + } + $met = $entry->value >= 1; + $markers[] = [ + 'x' => round(self::PAD_LEFT + $slot * ($i + 0.5), 1), + 'y' => $baseline, + 'color' => $met ? self::GOOD : self::CRIT, + 'glyph' => $met ? '✓' : '✕', + 'title' => $entry->entry_date->format('M Y') . ': ' . ($met ? 'Met' : 'Not met'), + ]; + } + + return [ + 'type' => 'milestone', + 'width' => self::W, + 'height' => $height, + 'baseline' => $baseline, + 'plot' => ['x' => self::PAD_LEFT, 'w' => $plotW], + 'markers' => $markers, + 'monthLabels' => self::monthLabels($months, $slot, $height - 12), + 'aria' => "{$kpi->name}: monthly milestone results (met or missed) for the last 12 months", + ]; + } + + /** + * @return array + */ + private static function monthLabels(array $months, float $slot, ?float $y = null): array + { + $labels = []; + foreach ($months as $i => $month) { + $labels[] = [ + 'x' => round(self::PAD_LEFT + $slot * ($i + 0.5), 1), + 'y' => $y ?? (self::H - 10), + 'label' => $month['label'], + ]; + } + + return $labels; + } + + /** + * Clean tick values (1/2/2.5/5 × 10^n steps) within [$min, $max]. + * + * @return array + */ + private static function ticks(float $min, float $max): array + { + $range = $max - $min; + $rough = $range / 4; + $mag = pow(10, floor(log10($rough))); + $step = $mag * 10; + foreach ([1, 2, 2.5, 5, 10] as $m) { + if ($rough <= $mag * $m) { + $step = $mag * $m; + break; + } + } + + $ticks = []; + for ($t = ceil($min / $step) * $step; $t <= $max + 1e-9; $t += $step) { + $ticks[] = round($t, 6); + } + + return $ticks; + } + + private static function fmtTick(float $tick, Kpi $kpi): string + { + $text = rtrim(rtrim(number_format($tick, 2, '.', ','), '0'), '.'); + + return $kpi->value_type === \App\Enums\KpiValueType::Percent ? "{$text}%" : $text; + } + + private static function statusColor(string $status): string + { + return match ($status) { + 'green' => self::GOOD, + 'yellow' => self::WARN, + default => self::CRIT, + }; + } +} diff --git a/src/bootstrap/app.php b/src/bootstrap/app.php index 9a3a873..fe445d7 100644 --- a/src/bootstrap/app.php +++ b/src/bootstrap/app.php @@ -7,6 +7,7 @@ use Illuminate\Foundation\Configuration\Middleware; return Application::configure(basePath: dirname(__DIR__)) ->withRouting( web: __DIR__.'/../routes/web.php', + api: __DIR__.'/../routes/api.php', commands: __DIR__.'/../routes/console.php', health: '/up', ) diff --git a/src/composer.json b/src/composer.json index 31871a4..bf5bb0a 100644 --- a/src/composer.json +++ b/src/composer.json @@ -5,7 +5,8 @@ "license": "proprietary", "require": { "php": "^8.4", - "laravel/framework": "^12.0" + "laravel/framework": "^12.0", + "laravel/sanctum": "^4.0" }, "autoload": { "psr-4": { @@ -14,6 +15,12 @@ "Database\\Seeders\\": "database/seeders/" } }, + "scripts": { + "post-autoload-dump": [ + "Illuminate\\Foundation\\ComposerScripts::postAutoloadDump", + "@php artisan package:discover --ansi" + ] + }, "config": { "optimize-autoloader": true, "preferred-install": "dist", diff --git a/src/database/migrations/0001_01_01_000005_create_kpis_tables.php b/src/database/migrations/0001_01_01_000005_create_kpis_tables.php new file mode 100644 index 0000000..8dcef51 --- /dev/null +++ b/src/database/migrations/0001_01_01_000005_create_kpis_tables.php @@ -0,0 +1,43 @@ +id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->string('name'); + $table->text('description')->nullable(); + $table->string('value_type', 20)->default('number'); + $table->string('evaluation', 30)->default('higher_is_better'); + $table->string('unit_label', 50)->nullable(); + $table->decimal('green_threshold', 14, 4)->nullable(); + $table->decimal('yellow_threshold', 14, 4)->nullable(); + $table->decimal('target', 14, 4)->nullable(); + $table->decimal('green_tolerance', 14, 4)->nullable(); + $table->decimal('yellow_tolerance', 14, 4)->nullable(); + $table->timestamps(); + }); + + Schema::create('kpi_entries', function (Blueprint $table) { + $table->id(); + $table->foreignId('kpi_id')->constrained()->cascadeOnDelete(); + $table->date('entry_date'); + $table->decimal('value', 14, 4); + $table->text('notes')->nullable(); + $table->timestamps(); + $table->index(['kpi_id', 'entry_date']); + }); + } + + public function down(): void + { + Schema::dropIfExists('kpi_entries'); + Schema::dropIfExists('kpis'); + } +}; diff --git a/src/database/migrations/0001_01_01_000006_create_projects_tables.php b/src/database/migrations/0001_01_01_000006_create_projects_tables.php new file mode 100644 index 0000000..e0b3a3c --- /dev/null +++ b/src/database/migrations/0001_01_01_000006_create_projects_tables.php @@ -0,0 +1,39 @@ +id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->string('name'); + $table->text('description')->nullable(); + $table->timestamps(); + }); + + Schema::create('project_entries', function (Blueprint $table) { + $table->id(); + $table->foreignId('project_id')->constrained()->cascadeOnDelete(); + $table->date('entry_date'); + $table->string('status', 10); + $table->text('notes')->nullable(); + $table->text('headwinds')->nullable(); + $table->text('tailwinds')->nullable(); + $table->text('highlights')->nullable(); + $table->text('blockers')->nullable(); + $table->timestamps(); + $table->index(['project_id', 'entry_date']); + }); + } + + public function down(): void + { + Schema::dropIfExists('project_entries'); + Schema::dropIfExists('projects'); + } +}; diff --git a/src/database/migrations/0001_01_01_000007_create_personal_access_tokens_table.php b/src/database/migrations/0001_01_01_000007_create_personal_access_tokens_table.php new file mode 100644 index 0000000..f082290 --- /dev/null +++ b/src/database/migrations/0001_01_01_000007_create_personal_access_tokens_table.php @@ -0,0 +1,27 @@ +id(); + $table->morphs('tokenable'); + $table->text('name'); + $table->string('token', 64)->unique(); + $table->text('abilities')->nullable(); + $table->timestamp('last_used_at')->nullable(); + $table->timestamp('expires_at')->nullable()->index(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('personal_access_tokens'); + } +}; diff --git a/src/database/migrations/0001_01_01_000008_add_plaintext_token_to_personal_access_tokens.php b/src/database/migrations/0001_01_01_000008_add_plaintext_token_to_personal_access_tokens.php new file mode 100644 index 0000000..aba8aa6 --- /dev/null +++ b/src/database/migrations/0001_01_01_000008_add_plaintext_token_to_personal_access_tokens.php @@ -0,0 +1,23 @@ +text('plaintext_token')->nullable()->after('token'); + }); + } + + public function down(): void + { + Schema::table('personal_access_tokens', function (Blueprint $table) { + $table->dropColumn('plaintext_token'); + }); + } +}; diff --git a/src/database/migrations/0001_01_01_000009_create_bowler_charts_tables.php b/src/database/migrations/0001_01_01_000009_create_bowler_charts_tables.php new file mode 100644 index 0000000..6310753 --- /dev/null +++ b/src/database/migrations/0001_01_01_000009_create_bowler_charts_tables.php @@ -0,0 +1,40 @@ +id(); + $table->foreignId('group_id')->constrained()->cascadeOnDelete(); + $table->string('name'); + $table->string('description')->nullable(); + $table->timestamps(); + }); + + // An item appears on a chart when its owner is in the chart's group + // and no exclusion row exists (auto-share with opt-out). + Schema::create('bowler_chart_kpi_exclusions', function (Blueprint $table) { + $table->foreignId('bowler_chart_id')->constrained()->cascadeOnDelete(); + $table->foreignId('kpi_id')->constrained()->cascadeOnDelete(); + $table->primary(['bowler_chart_id', 'kpi_id']); + }); + + Schema::create('bowler_chart_project_exclusions', function (Blueprint $table) { + $table->foreignId('bowler_chart_id')->constrained()->cascadeOnDelete(); + $table->foreignId('project_id')->constrained()->cascadeOnDelete(); + $table->primary(['bowler_chart_id', 'project_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('bowler_chart_project_exclusions'); + Schema::dropIfExists('bowler_chart_kpi_exclusions'); + Schema::dropIfExists('bowler_charts'); + } +}; diff --git a/src/database/migrations/0001_01_01_000010_create_teams_and_categories_tables.php b/src/database/migrations/0001_01_01_000010_create_teams_and_categories_tables.php new file mode 100644 index 0000000..eb2a61c --- /dev/null +++ b/src/database/migrations/0001_01_01_000010_create_teams_and_categories_tables.php @@ -0,0 +1,53 @@ +id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->string('name'); + $table->timestamps(); + $table->unique(['user_id', 'name']); + }); + + Schema::create('categories', function (Blueprint $table) { + $table->id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->string('name'); + $table->timestamps(); + $table->unique(['user_id', 'name']); + }); + + // Nullable so pre-existing items are grandfathered; deleting a + // team/category un-assigns items rather than deleting them. + Schema::table('kpis', function (Blueprint $table) { + $table->foreignId('team_id')->nullable()->after('user_id')->constrained()->nullOnDelete(); + $table->foreignId('category_id')->nullable()->after('team_id')->constrained()->nullOnDelete(); + }); + + Schema::table('projects', function (Blueprint $table) { + $table->foreignId('team_id')->nullable()->after('user_id')->constrained()->nullOnDelete(); + $table->foreignId('category_id')->nullable()->after('team_id')->constrained()->nullOnDelete(); + }); + } + + public function down(): void + { + Schema::table('kpis', function (Blueprint $table) { + $table->dropConstrainedForeignId('team_id'); + $table->dropConstrainedForeignId('category_id'); + }); + Schema::table('projects', function (Blueprint $table) { + $table->dropConstrainedForeignId('team_id'); + $table->dropConstrainedForeignId('category_id'); + }); + Schema::dropIfExists('categories'); + Schema::dropIfExists('teams'); + } +}; diff --git a/src/public/api-docs/openapi.json b/src/public/api-docs/openapi.json new file mode 100644 index 0000000..696f0a3 --- /dev/null +++ b/src/public/api-docs/openapi.json @@ -0,0 +1,418 @@ +{ + "openapi": "3.0.3", + "info": { + "title": "Bowler API", + "version": "1.0.0", + "description": "REST API for automating KPI and Project entries on Bowler charts.\n\nCreate an API key on the **API Keys** page, then send it as a Bearer token:\n`Authorization: Bearer `" + }, + "servers": [ + { "url": "/api/v1" } + ], + "security": [ + { "bearerAuth": [] } + ], + "components": { + "securitySchemes": { + "bearerAuth": { + "type": "http", + "scheme": "bearer", + "description": "A personal API key created on the API Keys page." + } + }, + "schemas": { + "TeamRef": { + "type": "object", + "nullable": true, + "description": "Owning user's team/category (managed in the web UI)", + "properties": { + "id": { "type": "integer" }, + "name": { "type": "string" } + } + }, + "Kpi": { + "type": "object", + "properties": { + "id": { "type": "integer" }, + "user_id": { "type": "integer" }, + "team_id": { "type": "integer", "nullable": true }, + "category_id": { "type": "integer", "nullable": true }, + "team": { "$ref": "#/components/schemas/TeamRef" }, + "category": { "$ref": "#/components/schemas/TeamRef" }, + "name": { "type": "string" }, + "description": { "type": "string", "nullable": true }, + "value_type": { "type": "string", "enum": ["number", "percent", "duration"] }, + "evaluation": { "type": "string", "enum": ["higher_is_better", "lower_is_better", "target_band", "milestone"] }, + "unit_label": { "type": "string", "nullable": true }, + "green_threshold": { "type": "number", "nullable": true }, + "yellow_threshold": { "type": "number", "nullable": true }, + "target": { "type": "number", "nullable": true }, + "green_tolerance": { "type": "number", "nullable": true }, + "yellow_tolerance": { "type": "number", "nullable": true }, + "entries_count": { "type": "integer" }, + "created_at": { "type": "string", "format": "date-time" }, + "updated_at": { "type": "string", "format": "date-time" } + } + }, + "KpiInput": { + "type": "object", + "required": ["name", "value_type", "evaluation", "team_id", "category_id"], + "properties": { + "name": { "type": "string", "maxLength": 255 }, + "team_id": { "type": "integer", "description": "Must be one of the owner's teams (create them in the web UI)" }, + "category_id": { "type": "integer", "description": "Must be one of the owner's categories" }, + "description": { "type": "string", "nullable": true }, + "value_type": { "type": "string", "enum": ["number", "percent", "duration"] }, + "evaluation": { "type": "string", "enum": ["higher_is_better", "lower_is_better", "target_band", "milestone"] }, + "unit_label": { "type": "string", "nullable": true, "example": "days" }, + "green_threshold": { "type": "number", "nullable": true, "description": "Required for higher_is_better / lower_is_better" }, + "yellow_threshold": { "type": "number", "nullable": true, "description": "Required for higher_is_better / lower_is_better" }, + "target": { "type": "number", "nullable": true, "description": "Required for target_band" }, + "green_tolerance": { "type": "number", "nullable": true, "description": "Required for target_band" }, + "yellow_tolerance": { "type": "number", "nullable": true, "description": "Required for target_band" } + } + }, + "KpiEntry": { + "type": "object", + "properties": { + "id": { "type": "integer" }, + "kpi_id": { "type": "integer" }, + "entry_date": { "type": "string", "format": "date" }, + "value": { "type": "number" }, + "status": { "type": "string", "enum": ["green", "yellow", "red"], "description": "Computed from the KPI's thresholds" }, + "notes": { "type": "string", "nullable": true }, + "created_at": { "type": "string", "format": "date-time" }, + "updated_at": { "type": "string", "format": "date-time" } + } + }, + "KpiEntryInput": { + "type": "object", + "required": ["entry_date", "value"], + "properties": { + "entry_date": { "type": "string", "format": "date", "example": "2026-07-09" }, + "value": { "type": "number", "example": 96.2, "description": "For milestone KPIs, send 1 (met) or 0 (not met)" }, + "notes": { "type": "string", "nullable": true } + } + }, + "Project": { + "type": "object", + "properties": { + "id": { "type": "integer" }, + "user_id": { "type": "integer" }, + "team_id": { "type": "integer", "nullable": true }, + "category_id": { "type": "integer", "nullable": true }, + "team": { "$ref": "#/components/schemas/TeamRef" }, + "category": { "$ref": "#/components/schemas/TeamRef" }, + "name": { "type": "string" }, + "description": { "type": "string", "nullable": true }, + "entries_count": { "type": "integer" }, + "created_at": { "type": "string", "format": "date-time" }, + "updated_at": { "type": "string", "format": "date-time" } + } + }, + "ProjectInput": { + "type": "object", + "required": ["name", "team_id", "category_id"], + "properties": { + "name": { "type": "string", "maxLength": 255 }, + "team_id": { "type": "integer", "description": "Must be one of the owner's teams (create them in the web UI)" }, + "category_id": { "type": "integer", "description": "Must be one of the owner's categories" }, + "description": { "type": "string", "nullable": true } + } + }, + "ProjectEntry": { + "type": "object", + "properties": { + "id": { "type": "integer" }, + "project_id": { "type": "integer" }, + "entry_date": { "type": "string", "format": "date" }, + "status": { "type": "string", "enum": ["green", "yellow", "red"] }, + "notes": { "type": "string", "nullable": true }, + "headwinds": { "type": "string", "nullable": true }, + "tailwinds": { "type": "string", "nullable": true }, + "highlights": { "type": "string", "nullable": true }, + "blockers": { "type": "string", "nullable": true }, + "created_at": { "type": "string", "format": "date-time" }, + "updated_at": { "type": "string", "format": "date-time" } + } + }, + "ProjectEntryInput": { + "type": "object", + "required": ["entry_date", "status"], + "properties": { + "entry_date": { "type": "string", "format": "date", "example": "2026-07-09" }, + "status": { "type": "string", "enum": ["green", "yellow", "red"] }, + "notes": { "type": "string", "nullable": true }, + "headwinds": { "type": "string", "nullable": true }, + "tailwinds": { "type": "string", "nullable": true }, + "highlights": { "type": "string", "nullable": true }, + "blockers": { "type": "string", "nullable": true } + } + }, + "ValidationError": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "errors": { "type": "object", "additionalProperties": { "type": "array", "items": { "type": "string" } } } + } + } + }, + "responses": { + "Unauthenticated": { "description": "Missing or invalid API key" }, + "Forbidden": { "description": "The API key's owner does not have access to this resource" }, + "NotFound": { "description": "Resource not found" }, + "ValidationFailed": { + "description": "Validation failed", + "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ValidationError" } } } + } + } + }, + "paths": { + "/me": { + "get": { + "tags": ["Auth"], + "summary": "Identify the authenticated user (API key sanity check)", + "responses": { + "200": { "description": "The user the API key belongs to" }, + "401": { "$ref": "#/components/responses/Unauthenticated" } + } + } + }, + "/kpis": { + "get": { + "tags": ["KPIs"], + "summary": "List my KPIs", + "responses": { + "200": { + "description": "KPIs owned by the authenticated user", + "content": { "application/json": { "schema": { "type": "object", "properties": { "data": { "type": "array", "items": { "$ref": "#/components/schemas/Kpi" } } } } } } + }, + "401": { "$ref": "#/components/responses/Unauthenticated" } + } + }, + "post": { + "tags": ["KPIs"], + "summary": "Create a KPI", + "requestBody": { + "required": true, + "content": { "application/json": { "schema": { "$ref": "#/components/schemas/KpiInput" } } } + }, + "responses": { + "201": { "description": "Created", "content": { "application/json": { "schema": { "type": "object", "properties": { "data": { "$ref": "#/components/schemas/Kpi" } } } } } }, + "401": { "$ref": "#/components/responses/Unauthenticated" }, + "422": { "$ref": "#/components/responses/ValidationFailed" } + } + } + }, + "/kpis/{kpi}": { + "parameters": [{ "name": "kpi", "in": "path", "required": true, "schema": { "type": "integer" } }], + "get": { + "tags": ["KPIs"], + "summary": "Get a KPI", + "responses": { + "200": { "description": "The KPI", "content": { "application/json": { "schema": { "type": "object", "properties": { "data": { "$ref": "#/components/schemas/Kpi" } } } } } }, + "401": { "$ref": "#/components/responses/Unauthenticated" }, + "403": { "$ref": "#/components/responses/Forbidden" }, + "404": { "$ref": "#/components/responses/NotFound" } + } + }, + "patch": { + "tags": ["KPIs"], + "summary": "Update a KPI", + "requestBody": { + "required": true, + "content": { "application/json": { "schema": { "$ref": "#/components/schemas/KpiInput" } } } + }, + "responses": { + "200": { "description": "Updated", "content": { "application/json": { "schema": { "type": "object", "properties": { "data": { "$ref": "#/components/schemas/Kpi" } } } } } }, + "401": { "$ref": "#/components/responses/Unauthenticated" }, + "403": { "$ref": "#/components/responses/Forbidden" }, + "404": { "$ref": "#/components/responses/NotFound" }, + "422": { "$ref": "#/components/responses/ValidationFailed" } + } + }, + "delete": { + "tags": ["KPIs"], + "summary": "Delete a KPI and all its entries", + "responses": { + "204": { "description": "Deleted" }, + "401": { "$ref": "#/components/responses/Unauthenticated" }, + "403": { "$ref": "#/components/responses/Forbidden" }, + "404": { "$ref": "#/components/responses/NotFound" } + } + } + }, + "/kpis/{kpi}/entries": { + "parameters": [{ "name": "kpi", "in": "path", "required": true, "schema": { "type": "integer" } }], + "get": { + "tags": ["KPI Entries"], + "summary": "List a KPI's entries (reverse chronological)", + "responses": { + "200": { "description": "Entries with computed status", "content": { "application/json": { "schema": { "type": "object", "properties": { "data": { "type": "array", "items": { "$ref": "#/components/schemas/KpiEntry" } } } } } } }, + "401": { "$ref": "#/components/responses/Unauthenticated" }, + "403": { "$ref": "#/components/responses/Forbidden" }, + "404": { "$ref": "#/components/responses/NotFound" } + } + }, + "post": { + "tags": ["KPI Entries"], + "summary": "Add an entry to a KPI", + "requestBody": { + "required": true, + "content": { "application/json": { "schema": { "$ref": "#/components/schemas/KpiEntryInput" } } } + }, + "responses": { + "201": { "description": "Created, with computed status", "content": { "application/json": { "schema": { "type": "object", "properties": { "data": { "$ref": "#/components/schemas/KpiEntry" } } } } } }, + "401": { "$ref": "#/components/responses/Unauthenticated" }, + "403": { "$ref": "#/components/responses/Forbidden" }, + "404": { "$ref": "#/components/responses/NotFound" }, + "422": { "$ref": "#/components/responses/ValidationFailed" } + } + } + }, + "/kpi-entries/{entry}": { + "parameters": [{ "name": "entry", "in": "path", "required": true, "schema": { "type": "integer" } }], + "patch": { + "tags": ["KPI Entries"], + "summary": "Update a KPI entry", + "requestBody": { + "required": true, + "content": { "application/json": { "schema": { "$ref": "#/components/schemas/KpiEntryInput" } } } + }, + "responses": { + "200": { "description": "Updated", "content": { "application/json": { "schema": { "type": "object", "properties": { "data": { "$ref": "#/components/schemas/KpiEntry" } } } } } }, + "401": { "$ref": "#/components/responses/Unauthenticated" }, + "403": { "$ref": "#/components/responses/Forbidden" }, + "404": { "$ref": "#/components/responses/NotFound" }, + "422": { "$ref": "#/components/responses/ValidationFailed" } + } + }, + "delete": { + "tags": ["KPI Entries"], + "summary": "Delete a KPI entry", + "responses": { + "204": { "description": "Deleted" }, + "401": { "$ref": "#/components/responses/Unauthenticated" }, + "403": { "$ref": "#/components/responses/Forbidden" }, + "404": { "$ref": "#/components/responses/NotFound" } + } + } + }, + "/projects": { + "get": { + "tags": ["Projects"], + "summary": "List my projects", + "responses": { + "200": { "description": "Projects owned by the authenticated user", "content": { "application/json": { "schema": { "type": "object", "properties": { "data": { "type": "array", "items": { "$ref": "#/components/schemas/Project" } } } } } } }, + "401": { "$ref": "#/components/responses/Unauthenticated" } + } + }, + "post": { + "tags": ["Projects"], + "summary": "Create a project", + "requestBody": { + "required": true, + "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ProjectInput" } } } + }, + "responses": { + "201": { "description": "Created", "content": { "application/json": { "schema": { "type": "object", "properties": { "data": { "$ref": "#/components/schemas/Project" } } } } } }, + "401": { "$ref": "#/components/responses/Unauthenticated" }, + "422": { "$ref": "#/components/responses/ValidationFailed" } + } + } + }, + "/projects/{project}": { + "parameters": [{ "name": "project", "in": "path", "required": true, "schema": { "type": "integer" } }], + "get": { + "tags": ["Projects"], + "summary": "Get a project", + "responses": { + "200": { "description": "The project", "content": { "application/json": { "schema": { "type": "object", "properties": { "data": { "$ref": "#/components/schemas/Project" } } } } } }, + "401": { "$ref": "#/components/responses/Unauthenticated" }, + "403": { "$ref": "#/components/responses/Forbidden" }, + "404": { "$ref": "#/components/responses/NotFound" } + } + }, + "patch": { + "tags": ["Projects"], + "summary": "Update a project", + "requestBody": { + "required": true, + "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ProjectInput" } } } + }, + "responses": { + "200": { "description": "Updated", "content": { "application/json": { "schema": { "type": "object", "properties": { "data": { "$ref": "#/components/schemas/Project" } } } } } }, + "401": { "$ref": "#/components/responses/Unauthenticated" }, + "403": { "$ref": "#/components/responses/Forbidden" }, + "404": { "$ref": "#/components/responses/NotFound" }, + "422": { "$ref": "#/components/responses/ValidationFailed" } + } + }, + "delete": { + "tags": ["Projects"], + "summary": "Delete a project and all its entries", + "responses": { + "204": { "description": "Deleted" }, + "401": { "$ref": "#/components/responses/Unauthenticated" }, + "403": { "$ref": "#/components/responses/Forbidden" }, + "404": { "$ref": "#/components/responses/NotFound" } + } + } + }, + "/projects/{project}/entries": { + "parameters": [{ "name": "project", "in": "path", "required": true, "schema": { "type": "integer" } }], + "get": { + "tags": ["Project Entries"], + "summary": "List a project's entries (reverse chronological)", + "responses": { + "200": { "description": "Entries", "content": { "application/json": { "schema": { "type": "object", "properties": { "data": { "type": "array", "items": { "$ref": "#/components/schemas/ProjectEntry" } } } } } } }, + "401": { "$ref": "#/components/responses/Unauthenticated" }, + "403": { "$ref": "#/components/responses/Forbidden" }, + "404": { "$ref": "#/components/responses/NotFound" } + } + }, + "post": { + "tags": ["Project Entries"], + "summary": "Add a status entry to a project", + "requestBody": { + "required": true, + "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ProjectEntryInput" } } } + }, + "responses": { + "201": { "description": "Created", "content": { "application/json": { "schema": { "type": "object", "properties": { "data": { "$ref": "#/components/schemas/ProjectEntry" } } } } } }, + "401": { "$ref": "#/components/responses/Unauthenticated" }, + "403": { "$ref": "#/components/responses/Forbidden" }, + "404": { "$ref": "#/components/responses/NotFound" }, + "422": { "$ref": "#/components/responses/ValidationFailed" } + } + } + }, + "/project-entries/{entry}": { + "parameters": [{ "name": "entry", "in": "path", "required": true, "schema": { "type": "integer" } }], + "patch": { + "tags": ["Project Entries"], + "summary": "Update a project entry", + "requestBody": { + "required": true, + "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ProjectEntryInput" } } } + }, + "responses": { + "200": { "description": "Updated", "content": { "application/json": { "schema": { "type": "object", "properties": { "data": { "$ref": "#/components/schemas/ProjectEntry" } } } } } }, + "401": { "$ref": "#/components/responses/Unauthenticated" }, + "403": { "$ref": "#/components/responses/Forbidden" }, + "404": { "$ref": "#/components/responses/NotFound" }, + "422": { "$ref": "#/components/responses/ValidationFailed" } + } + }, + "delete": { + "tags": ["Project Entries"], + "summary": "Delete a project entry", + "responses": { + "204": { "description": "Deleted" }, + "401": { "$ref": "#/components/responses/Unauthenticated" }, + "403": { "$ref": "#/components/responses/Forbidden" }, + "404": { "$ref": "#/components/responses/NotFound" } + } + } + } + } +} diff --git a/src/public/css/app.css b/src/public/css/app.css index 7a88c45..7915710 100644 --- a/src/public/css/app.css +++ b/src/public/css/app.css @@ -1,2 +1,2 @@ /*! tailwindcss v4.3.2 | MIT License | https://tailwindcss.com */ -@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-divide-x-reverse:0;--tw-border-style:solid;--tw-divide-y-reverse:0;--tw-gradient-position:initial;--tw-gradient-from:#0000;--tw-gradient-via:#0000;--tw-gradient-to:#0000;--tw-gradient-stops:initial;--tw-gradient-via-stops:initial;--tw-gradient-from-position:0%;--tw-gradient-via-position:50%;--tw-gradient-to-position:100%;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-duration:initial;--tw-ease:initial}}}@layer theme{:root,:host{--font-sans:ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--color-red-50:oklch(97.1% .013 17.38);--color-red-100:oklch(93.6% .032 17.717);--color-red-500:oklch(63.7% .237 25.331);--color-red-600:oklch(57.7% .245 27.325);--color-red-700:oklch(50.5% .213 27.518);--color-red-800:oklch(44.4% .177 26.899);--color-amber-200:oklch(92.4% .12 95.746);--color-amber-300:oklch(87.9% .169 91.605);--color-amber-500:oklch(76.9% .188 70.08);--color-amber-600:oklch(66.6% .179 58.318);--color-amber-800:oklch(47.3% .137 46.201);--color-amber-900:oklch(41.4% .112 45.904);--color-amber-950:oklch(27.9% .077 45.635);--color-green-50:oklch(98.2% .018 155.826);--color-green-100:oklch(96.2% .044 156.743);--color-green-300:oklch(87.1% .15 154.449);--color-green-400:oklch(79.2% .209 151.711);--color-green-800:oklch(44.8% .119 151.328);--color-emerald-200:oklch(90.5% .093 164.15);--color-emerald-400:oklch(76.5% .177 163.223);--color-emerald-500:oklch(69.6% .17 162.48);--color-emerald-600:oklch(59.6% .145 163.225);--color-emerald-900:oklch(37.8% .077 168.94);--color-blue-50:oklch(97% .014 254.604);--color-blue-100:oklch(93.2% .032 255.585);--color-blue-300:oklch(80.9% .105 251.813);--color-blue-500:oklch(62.3% .214 259.815);--color-blue-600:oklch(54.6% .245 262.881);--color-blue-700:oklch(48.8% .243 264.376);--color-blue-800:oklch(42.4% .199 265.638);--color-blue-900:oklch(37.9% .146 265.522);--color-blue-950:oklch(28.2% .091 267.935);--color-rose-100:oklch(94.1% .03 12.58);--color-rose-200:oklch(89.2% .058 10.001);--color-rose-500:oklch(64.5% .246 16.439);--color-rose-600:oklch(58.6% .253 17.585);--color-rose-900:oklch(41% .159 10.272);--color-rose-950:oklch(27.1% .105 12.094);--color-gray-50:oklch(98.5% .002 247.839);--color-gray-100:oklch(96.7% .003 264.542);--color-gray-200:oklch(92.8% .006 264.531);--color-gray-300:oklch(87.2% .01 258.338);--color-gray-400:oklch(70.7% .022 261.325);--color-gray-500:oklch(55.1% .027 264.364);--color-gray-600:oklch(44.6% .03 256.802);--color-gray-700:oklch(37.3% .034 259.733);--color-gray-800:oklch(27.8% .033 256.848);--color-gray-900:oklch(21% .034 264.665);--color-neutral-50:oklch(98.5% 0 0);--color-neutral-100:oklch(97% 0 0);--color-neutral-200:oklch(92.2% 0 0);--color-neutral-300:oklch(87% 0 0);--color-neutral-400:oklch(70.8% 0 0);--color-neutral-500:oklch(55.6% 0 0);--color-neutral-600:oklch(43.9% 0 0);--color-neutral-700:oklch(37.1% 0 0);--color-neutral-800:oklch(26.9% 0 0);--color-neutral-900:oklch(20.5% 0 0);--color-neutral-950:oklch(14.5% 0 0);--color-black:#000;--color-white:#fff;--spacing:.25rem;--container-md:28rem;--container-xl:36rem;--container-5xl:64rem;--container-6xl:72rem;--container-7xl:80rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25 / 1.875);--text-4xl:2.25rem;--text-4xl--line-height:calc(2.5 / 2.25);--font-weight-light:300;--font-weight-normal:400;--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--tracking-tight:-.025em;--tracking-wide:.025em;--tracking-wider:.05em;--leading-relaxed:1.625;--radius-md:.375rem;--radius-lg:.5rem;--radius-xl:.75rem;--ease-in-out:cubic-bezier(.4, 0, .2, 1);--animate-ping:ping 1s cubic-bezier(0, 0, .2, 1) infinite;--blur-md:12px;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.collapse{visibility:collapse}.invisible{visibility:hidden}.visible{visibility:visible}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.static{position:static}.sticky{position:sticky}.top-0{top:0}.top-\[-1px\]{top:-1px}.right-0{right:0}.bottom-0{bottom:0}.left-0{left:0}.isolate{isolation:isolate}.-z-10{z-index:calc(10 * -1)}.z-50{z-index:50}.container{width:100%}@media (min-width:40rem){.container{max-width:40rem}}@media (min-width:48rem){.container{max-width:48rem}}@media (min-width:64rem){.container{max-width:64rem}}@media (min-width:80rem){.container{max-width:80rem}}@media (min-width:96rem){.container{max-width:96rem}}.mx-2{margin-inline:calc(var(--spacing) * 2)}.mx-auto{margin-inline:auto}.my-1{margin-block:var(--spacing)}.-mt-3{margin-top:calc(var(--spacing) * -3)}.-mt-5{margin-top:calc(var(--spacing) * -5)}.-mt-px{margin-top:-1px}.mt-1{margin-top:var(--spacing)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mt-6{margin-top:calc(var(--spacing) * 6)}.mt-8{margin-top:calc(var(--spacing) * 8)}.mr-1{margin-right:var(--spacing)}.mr-2{margin-right:calc(var(--spacing) * 2)}.mr-6{margin-right:calc(var(--spacing) * 6)}.mr-auto{margin-right:auto}.mb-1{margin-bottom:var(--spacing)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.mb-4{margin-bottom:calc(var(--spacing) * 4)}.mb-6{margin-bottom:calc(var(--spacing) * 6)}.mb-8{margin-bottom:calc(var(--spacing) * 8)}.-ml-px{margin-left:-1px}.ml-1{margin-left:var(--spacing)}.ml-2{margin-left:calc(var(--spacing) * 2)}.ml-4{margin-left:calc(var(--spacing) * 4)}.ml-6{margin-left:calc(var(--spacing) * 6)}.ml-12{margin-left:calc(var(--spacing) * 12)}.ml-auto{margin-left:auto}.block{display:block}.contents{display:contents}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.inline-block{display:inline-block}.inline-flex{display:inline-flex}.table{display:table}.size-2{width:calc(var(--spacing) * 2);height:calc(var(--spacing) * 2)}.size-3{width:calc(var(--spacing) * 3);height:calc(var(--spacing) * 3)}.h-0{height:0}.h-2\.5{height:calc(var(--spacing) * 2.5)}.h-3{height:calc(var(--spacing) * 3)}.h-5{height:calc(var(--spacing) * 5)}.h-6{height:calc(var(--spacing) * 6)}.h-7{height:calc(var(--spacing) * 7)}.h-8{height:calc(var(--spacing) * 8)}.h-10{height:calc(var(--spacing) * 10)}.h-11{height:calc(var(--spacing) * 11)}.h-14{height:calc(var(--spacing) * 14)}.h-16{height:calc(var(--spacing) * 16)}.h-\[18px\]{height:18px}.h-\[56px\]{height:56px}.h-full{height:100%}.min-h-dvh{min-height:100dvh}.min-h-screen{min-height:100vh}.w-2\.5{width:calc(var(--spacing) * 2.5)}.w-3{width:calc(var(--spacing) * 3)}.w-5{width:calc(var(--spacing) * 5)}.w-6{width:calc(var(--spacing) * 6)}.w-8{width:calc(var(--spacing) * 8)}.w-\[18px\]{width:18px}.w-auto{width:auto}.w-fit{width:fit-content}.w-full{width:100%}.max-w-5xl{max-width:var(--container-5xl)}.max-w-6xl{max-width:var(--container-6xl)}.max-w-7xl{max-width:var(--container-7xl)}.max-w-150{max-width:calc(var(--spacing) * 150)}.max-w-full{max-width:100%}.max-w-md{max-width:var(--container-md)}.max-w-xl{max-width:var(--container-xl)}.min-w-0{min-width:0}.min-w-5{min-width:calc(var(--spacing) * 5)}.min-w-6{min-width:calc(var(--spacing) * 6)}.min-w-full{min-width:100%}.flex-1{flex:1}.flex-shrink{flex-shrink:1}.flex-shrink-0{flex-shrink:0}.shrink{flex-shrink:1}.shrink-0{flex-shrink:0}.grow{flex-grow:1}.transform{transform:var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,)}.transform\!{transform:var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,)!important}.animate-ping{animation:var(--animate-ping)}.cursor-default{cursor:default}.cursor-not-allowed{cursor:not-allowed}.cursor-not-allowed\!{cursor:not-allowed!important}.cursor-pointer{cursor:pointer}.resize{resize:both}.list-decimal{list-style-type:decimal}.list-disc{list-style-type:disc}.list-none{list-style-type:none}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.flex-col{flex-direction:column}.items-baseline{align-items:baseline}.items-center{align-items:center}.items-start{align-items:flex-start}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.justify-items-center{justify-items:center}.gap-0\.5{gap:calc(var(--spacing) * .5)}.gap-1{gap:var(--spacing)}.gap-1\.5{gap:calc(var(--spacing) * 1.5)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-2\.5{gap:calc(var(--spacing) * 2.5)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}.gap-5{gap:calc(var(--spacing) * 5)}.gap-6{gap:calc(var(--spacing) * 6)}.gap-8{gap:calc(var(--spacing) * 8)}.gap-12{gap:calc(var(--spacing) * 12)}:where(.divide-x>:not(:last-child)){--tw-divide-x-reverse:0;border-inline-style:var(--tw-border-style);border-inline-start-width:calc(1px * var(--tw-divide-x-reverse));border-inline-end-width:calc(1px * calc(1 - var(--tw-divide-x-reverse)))}:where(.divide-y>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px * var(--tw-divide-y-reverse));border-bottom-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)))}:where(.divide-gray-200>:not(:last-child)){border-color:var(--color-gray-200)}:where(.divide-neutral-200>:not(:last-child)){border-color:var(--color-neutral-200)}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-x-hidden{overflow-x:hidden}.rounded{border-radius:.25rem}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius-lg)}.rounded-md{border-radius:var(--radius-md)}.rounded-xl{border-radius:var(--radius-xl)}.rounded-t-lg{border-top-left-radius:var(--radius-lg);border-top-right-radius:var(--radius-lg)}.rounded-l-md{border-top-left-radius:var(--radius-md);border-bottom-left-radius:var(--radius-md)}.rounded-r-md{border-top-right-radius:var(--radius-md);border-bottom-right-radius:var(--radius-md)}.rounded-b-lg{border-bottom-right-radius:var(--radius-lg);border-bottom-left-radius:var(--radius-lg)}.border{border-style:var(--tw-border-style);border-width:1px}.border-x{border-inline-style:var(--tw-border-style);border-inline-width:1px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-r{border-right-style:var(--tw-border-style);border-right-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-b-2{border-bottom-style:var(--tw-border-style);border-bottom-width:2px}.border-dashed{--tw-border-style:dashed;border-style:dashed}.border-dotted{--tw-border-style:dotted;border-style:dotted}.border-gray-200{border-color:var(--color-gray-200)}.border-gray-300{border-color:var(--color-gray-300)}.border-gray-400{border-color:var(--color-gray-400)}.border-neutral-100{border-color:var(--color-neutral-100)}.border-neutral-200{border-color:var(--color-neutral-200)}.border-neutral-300{border-color:var(--color-neutral-300)}.border-red-600{border-color:var(--color-red-600)}.bg-amber-200{background-color:var(--color-amber-200)}.bg-amber-600{background-color:var(--color-amber-600)}.bg-black\/8{background-color:#00000014}@supports (color:color-mix(in lab, red, red)){.bg-black\/8{background-color:color-mix(in oklab, var(--color-black) 8%, transparent)}}.bg-black\/10{background-color:#0000001a}@supports (color:color-mix(in lab, red, red)){.bg-black\/10{background-color:color-mix(in oklab, var(--color-black) 10%, transparent)}}.bg-blue-50{background-color:var(--color-blue-50)}.bg-blue-100{background-color:var(--color-blue-100)}.bg-blue-300{background-color:var(--color-blue-300)}.bg-blue-600{background-color:var(--color-blue-600)}.bg-blue-700{background-color:var(--color-blue-700)}.bg-emerald-200{background-color:var(--color-emerald-200)}.bg-emerald-600{background-color:var(--color-emerald-600)}.bg-gray-50{background-color:var(--color-gray-50)}.bg-gray-100{background-color:var(--color-gray-100)}.bg-gray-200{background-color:var(--color-gray-200)}.bg-green-50{background-color:var(--color-green-50)}.bg-green-100{background-color:var(--color-green-100)}.bg-green-300{background-color:var(--color-green-300)}.bg-green-400{background-color:var(--color-green-400)}.bg-neutral-50{background-color:var(--color-neutral-50)}.bg-neutral-600{background-color:var(--color-neutral-600)}.bg-red-50{background-color:var(--color-red-50)}.bg-red-100{background-color:var(--color-red-100)}.bg-rose-200{background-color:var(--color-rose-200)}.bg-rose-200\!{background-color:var(--color-rose-200)!important}.bg-rose-500{background-color:var(--color-rose-500)}.bg-rose-600{background-color:var(--color-rose-600)}.bg-transparent\!{background-color:#0000!important}.bg-white{background-color:var(--color-white)}.bg-white\/5{background-color:#ffffff0d}@supports (color:color-mix(in lab, red, red)){.bg-white\/5{background-color:color-mix(in oklab, var(--color-white) 5%, transparent)}}.bg-white\/\[2\%\]{background-color:#ffffff05}@supports (color:color-mix(in lab, red, red)){.bg-white\/\[2\%\]{background-color:color-mix(in oklab, var(--color-white) 2%, transparent)}}.from-gray-700\/50{--tw-gradient-from:#36415380}@supports (color:color-mix(in lab, red, red)){.from-gray-700\/50{--tw-gradient-from:color-mix(in oklab, var(--color-gray-700) 50%, transparent)}}.from-gray-700\/50{--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.via-transparent{--tw-gradient-via:transparent;--tw-gradient-via-stops:var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-via) var(--tw-gradient-via-position), var(--tw-gradient-to) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-via-stops)}.p-1{padding:var(--spacing)}.p-2{padding:calc(var(--spacing) * 2)}.p-2\.5{padding:calc(var(--spacing) * 2.5)}.p-4{padding:calc(var(--spacing) * 4)}.p-5{padding:calc(var(--spacing) * 5)}.p-6{padding:calc(var(--spacing) * 6)}.p-8{padding:calc(var(--spacing) * 8)}.p-10{padding:calc(var(--spacing) * 10)}.px-1{padding-inline:var(--spacing)}.px-1\.5{padding-inline:calc(var(--spacing) * 1.5)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-2\.5{padding-inline:calc(var(--spacing) * 2.5)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-6{padding-inline:calc(var(--spacing) * 6)}.px-\[6px\]{padding-inline:6px}.py-0{padding-block:0}.py-0\.5{padding-block:calc(var(--spacing) * .5)}.py-1{padding-block:var(--spacing)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-4{padding-block:calc(var(--spacing) * 4)}.py-6{padding-block:calc(var(--spacing) * 6)}.py-10{padding-block:calc(var(--spacing) * 10)}.pt-6{padding-top:calc(var(--spacing) * 6)}.pt-8{padding-top:calc(var(--spacing) * 8)}.pt-14{padding-top:calc(var(--spacing) * 14)}.pr-2\.5{padding-right:calc(var(--spacing) * 2.5)}.pb-0{padding-bottom:0}.pb-12{padding-bottom:calc(var(--spacing) * 12)}.pl-4{padding-left:calc(var(--spacing) * 4)}.text-center{text-align:center}.text-left{text-align:left}.text-right{text-align:right}.font-mono{font-family:var(--font-mono)}.font-sans{font-family:var(--font-sans)}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-3xl{font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height))}.text-4xl{font-size:var(--text-4xl);line-height:var(--tw-leading,var(--text-4xl--line-height))}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-xs\/none{font-size:var(--text-xs);line-height:1}.text-\[13px\]{font-size:13px}.leading-3{--tw-leading:calc(var(--spacing) * 3);line-height:calc(var(--spacing) * 3)}.leading-5{--tw-leading:calc(var(--spacing) * 5);line-height:calc(var(--spacing) * 5)}.leading-7{--tw-leading:calc(var(--spacing) * 7);line-height:calc(var(--spacing) * 7)}.leading-relaxed{--tw-leading:var(--leading-relaxed);line-height:var(--leading-relaxed)}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-light{--tw-font-weight:var(--font-weight-light);font-weight:var(--font-weight-light)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-normal{--tw-font-weight:var(--font-weight-normal);font-weight:var(--font-weight-normal)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-tight{--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight)}.tracking-wide{--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide)}.tracking-wider{--tw-tracking:var(--tracking-wider);letter-spacing:var(--tracking-wider)}.break-all{word-break:break-all}.whitespace-nowrap{white-space:nowrap}.text-amber-900{color:var(--color-amber-900)}.text-black{color:var(--color-black)}.text-blue-500{color:var(--color-blue-500)}.text-blue-600{color:var(--color-blue-600)}.text-blue-700{color:var(--color-blue-700)}.text-blue-900{color:var(--color-blue-900)}.text-emerald-500{color:var(--color-emerald-500)}.text-emerald-900{color:var(--color-emerald-900)}.text-gray-200{color:var(--color-gray-200)}.text-gray-300{color:var(--color-gray-300)}.text-gray-400{color:var(--color-gray-400)}.text-gray-500{color:var(--color-gray-500)}.text-gray-600{color:var(--color-gray-600)}.text-gray-700{color:var(--color-gray-700)}.text-gray-800{color:var(--color-gray-800)}.text-gray-900{color:var(--color-gray-900)}.text-green-800{color:var(--color-green-800)}.text-neutral-100{color:var(--color-neutral-100)}.text-neutral-400{color:var(--color-neutral-400)}.text-neutral-500{color:var(--color-neutral-500)}.text-neutral-500\!{color:var(--color-neutral-500)!important}.text-neutral-600{color:var(--color-neutral-600)}.text-neutral-800{color:var(--color-neutral-800)}.text-neutral-900{color:var(--color-neutral-900)}.text-neutral-950{color:var(--color-neutral-950)}.text-red-700{color:var(--color-red-700)}.text-red-800{color:var(--color-red-800)}.text-rose-900{color:var(--color-rose-900)}.text-white{color:var(--color-white)}.lowercase{text-transform:lowercase}.uppercase{text-transform:uppercase}.italic{font-style:italic}.ordinal{--tw-ordinal:ordinal;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.line-through{text-decoration-line:line-through}.overline{text-decoration-line:overline}.underline{text-decoration-line:underline}.decoration-neutral-400{-webkit-text-decoration-color:var(--color-neutral-400);-webkit-text-decoration-color:var(--color-neutral-400);text-decoration-color:var(--color-neutral-400)}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.scheme-light-dark{color-scheme:light dark}.opacity-75{opacity:.75}.opacity-90{opacity:.9}.shadow{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-2xl{--tw-shadow:0 25px 50px -12px var(--tw-shadow-color,#00000040);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-xs{--tw-shadow:0 1px 2px 0 var(--tw-shadow-color,#0000000d);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-gray-500\/20{--tw-shadow-color:#6a728233}@supports (color:color-mix(in lab, red, red)){.shadow-gray-500\/20{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-gray-500) 20%, transparent) var(--tw-shadow-alpha), transparent)}}.ring-gray-300{--tw-ring-color:var(--color-gray-300)}.outline{outline-style:var(--tw-outline-style);outline-width:1px}.blur{--tw-blur:blur(8px);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.invert{--tw-invert:invert(100%);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.sepia{--tw-sepia:sepia(100%);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.filter\!{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)!important}.backdrop-filter{-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.duration-150{--tw-duration:.15s;transition-duration:.15s}.duration-200{--tw-duration:.2s;transition-duration:.2s}.ease-in-out{--tw-ease:var(--ease-in-out);transition-timing-function:var(--ease-in-out)}@media (hover:hover){.group-hover\:text-blue-500:is(:where(.group):hover *){color:var(--color-blue-500)}}.group-\[\.status-down\]\:bg-red-600:is(:where(.group).status-down *){background-color:var(--color-red-600)}.selection\:bg-red-500 ::selection{background-color:var(--color-red-500)}.selection\:bg-red-500::selection{background-color:var(--color-red-500)}.selection\:text-white ::selection{color:var(--color-white)}.selection\:text-white::selection{color:var(--color-white)}.odd\:bg-white\/2:nth-child(odd){background-color:#ffffff05}@supports (color:color-mix(in lab, red, red)){.odd\:bg-white\/2:nth-child(odd){background-color:color-mix(in oklab, var(--color-white) 2%, transparent)}}.even\:bg-white:nth-child(2n){background-color:var(--color-white)}@media (hover:hover){.hover\:bg-blue-100:hover{background-color:var(--color-blue-100)}.hover\:bg-blue-700:hover{background-color:var(--color-blue-700)}.hover\:bg-gray-50:hover{background-color:var(--color-gray-50)}.hover\:bg-gray-100:hover{background-color:var(--color-gray-100)}.hover\:bg-neutral-100:hover{background-color:var(--color-neutral-100)}.hover\:bg-neutral-200:hover{background-color:var(--color-neutral-200)}.hover\:bg-red-50:hover{background-color:var(--color-red-50)}.hover\:bg-white\/50:hover{background-color:#ffffff80}@supports (color:color-mix(in lab, red, red)){.hover\:bg-white\/50:hover{background-color:color-mix(in oklab, var(--color-white) 50%, transparent)}}.hover\:text-gray-400:hover{color:var(--color-gray-400)}.hover\:text-gray-700:hover{color:var(--color-gray-700)}.hover\:underline:hover{text-decoration-line:underline}}.focus\:border-blue-300:focus{border-color:var(--color-blue-300)}.focus\:border-blue-500:focus{border-color:var(--color-blue-500)}.focus\:ring:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus\:ring-2:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus\:ring-blue-500\/40:focus{--tw-ring-color:#3080ff66}@supports (color:color-mix(in lab, red, red)){.focus\:ring-blue-500\/40:focus{--tw-ring-color:color-mix(in oklab, var(--color-blue-500) 40%, transparent)}}.focus\:outline:focus{outline-style:var(--tw-outline-style);outline-width:1px}.focus\:outline-2:focus{outline-style:var(--tw-outline-style);outline-width:2px}.focus\:outline-red-500:focus{outline-color:var(--color-red-500)}.focus\:outline-none:focus{--tw-outline-style:none;outline-style:none}.active\:bg-gray-100:active{background-color:var(--color-gray-100)}.active\:text-gray-500:active{color:var(--color-gray-500)}.active\:text-gray-700:active{color:var(--color-gray-700)}.active\:text-gray-800:active{color:var(--color-gray-800)}@media (min-width:40rem){.sm\:mb-16{margin-bottom:calc(var(--spacing) * 16)}.sm\:flex{display:flex}.sm\:hidden{display:none}.sm\:w-3\/4{width:75%}.sm\:flex-1{flex:1}.sm\:items-center{align-items:center}.sm\:justify-between{justify-content:space-between}.sm\:justify-start{justify-content:flex-start}.sm\:gap-2{gap:calc(var(--spacing) * 2)}.sm\:p-14{padding:calc(var(--spacing) * 14)}.sm\:px-6{padding-inline:calc(var(--spacing) * 6)}.sm\:py-0{padding-block:0}.sm\:pt-0{padding-top:0}.sm\:pt-16{padding-top:calc(var(--spacing) * 16)}.sm\:pb-0{padding-bottom:0}}@media (min-width:64rem){.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.lg\:px-8{padding-inline:calc(var(--spacing) * 8)}}@media (min-width:80rem){.xl\:w-1\/2{width:50%}}.rtl\:flex-row-reverse:where(:dir(rtl),[dir=rtl],[dir=rtl] *){flex-direction:row-reverse}@media (prefers-color-scheme:dark){:where(.dark\:divide-white\/5>:not(:last-child)){border-color:#ffffff0d}@supports (color:color-mix(in lab, red, red)){:where(.dark\:divide-white\/5>:not(:last-child)){border-color:color-mix(in oklab, var(--color-white) 5%, transparent)}}:where(.dark\:divide-white\/10>:not(:last-child)){border-color:#ffffff1a}@supports (color:color-mix(in lab, red, red)){:where(.dark\:divide-white\/10>:not(:last-child)){border-color:color-mix(in oklab, var(--color-white) 10%, transparent)}}.dark\:border{border-style:var(--tw-border-style);border-width:1px}.dark\:border-none{--tw-border-style:none;border-style:none}.dark\:border-amber-500{border-color:var(--color-amber-500)}.dark\:border-amber-800{border-color:var(--color-amber-800)}.dark\:border-blue-600{border-color:var(--color-blue-600)}.dark\:border-blue-800{border-color:var(--color-blue-800)}.dark\:border-emerald-500{border-color:var(--color-emerald-500)}.dark\:border-emerald-600{border-color:var(--color-emerald-600)}.dark\:border-gray-600{border-color:var(--color-gray-600)}.dark\:border-neutral-500{border-color:var(--color-neutral-500)}.dark\:border-neutral-700{border-color:var(--color-neutral-700)}.dark\:border-neutral-800{border-color:var(--color-neutral-800)}.dark\:border-rose-500{border-color:var(--color-rose-500)}.dark\:border-rose-900{border-color:var(--color-rose-900)}.dark\:border-white\/5{border-color:#ffffff0d}@supports (color:color-mix(in lab, red, red)){.dark\:border-white\/5{border-color:color-mix(in oklab, var(--color-white) 5%, transparent)}}.dark\:border-white\/8{border-color:#ffffff14}@supports (color:color-mix(in lab, red, red)){.dark\:border-white\/8{border-color:color-mix(in oklab, var(--color-white) 8%, transparent)}}.dark\:border-white\/10{border-color:#ffffff1a}@supports (color:color-mix(in lab, red, red)){.dark\:border-white\/10{border-color:color-mix(in oklab, var(--color-white) 10%, transparent)}}.dark\:border-white\/20{border-color:#fff3}@supports (color:color-mix(in lab, red, red)){.dark\:border-white\/20{border-color:color-mix(in oklab, var(--color-white) 20%, transparent)}}.dark\:border-white\/\[9\%\]{border-color:#ffffff17}@supports (color:color-mix(in lab, red, red)){.dark\:border-white\/\[9\%\]{border-color:color-mix(in oklab, var(--color-white) 9%, transparent)}}.dark\:bg-\[\#1a1a1a\]{background-color:#1a1a1a}.dark\:bg-amber-600{background-color:var(--color-amber-600)}.dark\:bg-amber-950{background-color:var(--color-amber-950)}.dark\:bg-blue-700{background-color:var(--color-blue-700)}.dark\:bg-blue-950{background-color:var(--color-blue-950)}.dark\:bg-emerald-600{background-color:var(--color-emerald-600)}.dark\:bg-emerald-900\/70{background-color:#004e3bb3}@supports (color:color-mix(in lab, red, red)){.dark\:bg-emerald-900\/70{background-color:color-mix(in oklab, var(--color-emerald-900) 70%, transparent)}}.dark\:bg-gray-700{background-color:var(--color-gray-700)}.dark\:bg-gray-800{background-color:var(--color-gray-800)}.dark\:bg-gray-900{background-color:var(--color-gray-900)}.dark\:bg-neutral-400{background-color:var(--color-neutral-400)}.dark\:bg-neutral-600{background-color:var(--color-neutral-600)}.dark\:bg-neutral-700{background-color:var(--color-neutral-700)}.dark\:bg-neutral-800{background-color:var(--color-neutral-800)}.dark\:bg-neutral-900{background-color:var(--color-neutral-900)}.dark\:bg-rose-600{background-color:var(--color-rose-600)}.dark\:bg-rose-900\!{background-color:var(--color-rose-900)!important}.dark\:bg-rose-950{background-color:var(--color-rose-950)}.dark\:bg-transparent{background-color:#0000}.dark\:bg-white{background-color:var(--color-white)}.dark\:bg-white\/1{background-color:#ffffff03}@supports (color:color-mix(in lab, red, red)){.dark\:bg-white\/1{background-color:color-mix(in oklab, var(--color-white) 1%, transparent)}}.dark\:bg-white\/3{background-color:#ffffff08}@supports (color:color-mix(in lab, red, red)){.dark\:bg-white\/3{background-color:color-mix(in oklab, var(--color-white) 3%, transparent)}}.dark\:bg-white\/5{background-color:#ffffff0d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-white\/5{background-color:color-mix(in oklab, var(--color-white) 5%, transparent)}}.dark\:bg-white\/10{background-color:#ffffff1a}@supports (color:color-mix(in lab, red, red)){.dark\:bg-white\/10{background-color:color-mix(in oklab, var(--color-white) 10%, transparent)}}.dark\:bg-white\/\[2\%\]{background-color:#ffffff05}@supports (color:color-mix(in lab, red, red)){.dark\:bg-white\/\[2\%\]{background-color:color-mix(in oklab, var(--color-white) 2%, transparent)}}.dark\:bg-white\/\[3\%\]{background-color:#ffffff08}@supports (color:color-mix(in lab, red, red)){.dark\:bg-white\/\[3\%\]{background-color:color-mix(in oklab, var(--color-white) 3%, transparent)}}.dark\:text-amber-300{color:var(--color-amber-300)}.dark\:text-blue-300{color:var(--color-blue-300)}.dark\:text-emerald-400{color:var(--color-emerald-400)}.dark\:text-emerald-500{color:var(--color-emerald-500)}.dark\:text-gray-200{color:var(--color-gray-200)}.dark\:text-gray-300{color:var(--color-gray-300)}.dark\:text-gray-400{color:var(--color-gray-400)}.dark\:text-gray-600{color:var(--color-gray-600)}.dark\:text-neutral-100{color:var(--color-neutral-100)}.dark\:text-neutral-200{color:var(--color-neutral-200)}.dark\:text-neutral-300{color:var(--color-neutral-300)}.dark\:text-neutral-400{color:var(--color-neutral-400)}.dark\:text-neutral-500{color:var(--color-neutral-500)}.dark\:text-neutral-600{color:var(--color-neutral-600)}.dark\:text-neutral-600\!{color:var(--color-neutral-600)!important}.dark\:text-neutral-900{color:var(--color-neutral-900)}.dark\:text-rose-100{color:var(--color-rose-100)}.dark\:text-white{color:var(--color-white)}.dark\:text-white\!{color:var(--color-white)!important}}@media (hover:hover){@media (prefers-color-scheme:dark){.group-hover\:dark\:text-emerald-500:is(:where(.group):hover *){color:var(--color-emerald-500)}}}@media (prefers-color-scheme:dark){.odd\:dark\:bg-white\/4:nth-child(odd){background-color:#ffffff0a}@supports (color:color-mix(in lab, red, red)){.odd\:dark\:bg-white\/4:nth-child(odd){background-color:color-mix(in oklab, var(--color-white) 4%, transparent)}}.even\:dark\:bg-white\/2:nth-child(2n){background-color:#ffffff05}@supports (color:color-mix(in lab, red, red)){.even\:dark\:bg-white\/2:nth-child(2n){background-color:color-mix(in oklab, var(--color-white) 2%, transparent)}}@media (hover:hover){.dark\:hover\:bg-gray-900:hover{background-color:var(--color-gray-900)}.dark\:hover\:bg-white\/2:hover{background-color:#ffffff05}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-white\/2:hover{background-color:color-mix(in oklab, var(--color-white) 2%, transparent)}}.dark\:hover\:bg-white\/5:hover{background-color:#ffffff0d}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-white\/5:hover{background-color:color-mix(in oklab, var(--color-white) 5%, transparent)}}.dark\:hover\:bg-white\/10:hover{background-color:#ffffff1a}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-white\/10:hover{background-color:color-mix(in oklab, var(--color-white) 10%, transparent)}}}}@media (hover:hover){@media (prefers-color-scheme:dark){.hover\:dark\:bg-white\/5:hover{background-color:#ffffff0d}@supports (color:color-mix(in lab, red, red)){.hover\:dark\:bg-white\/5:hover{background-color:color-mix(in oklab, var(--color-white) 5%, transparent)}}}}@media (prefers-color-scheme:dark){@media (hover:hover){.dark\:hover\:text-gray-200:hover{color:var(--color-gray-200)}.dark\:hover\:text-gray-300:hover{color:var(--color-gray-300)}}}@media (hover:hover){@media (prefers-color-scheme:dark){.hover\:dark\:text-white:hover{color:var(--color-white)}}}@media (prefers-color-scheme:dark){.dark\:focus\:border-blue-700:focus{border-color:var(--color-blue-700)}.dark\:focus\:border-blue-800:focus{border-color:var(--color-blue-800)}.dark\:active\:bg-gray-700:active{background-color:var(--color-gray-700)}.dark\:active\:text-gray-300:active{color:var(--color-gray-300)}}.\[\&_svg\]\:size-2\.5 svg{width:calc(var(--spacing) * 2.5);height:calc(var(--spacing) * 2.5)}.\[\&_svg\]\:\!text-white svg{color:var(--color-white)!important}@media (hover:hover){.hover\:\[\&_svg\]\:stroke-emerald-500:hover svg{stroke:var(--color-emerald-500)}}@media (prefers-color-scheme:dark){.dark\:\[\&_svg\]\:\!text-white svg{color:var(--color-white)!important}}}@property --tw-rotate-x{syntax:"*";inherits:false}@property --tw-rotate-y{syntax:"*";inherits:false}@property --tw-rotate-z{syntax:"*";inherits:false}@property --tw-skew-x{syntax:"*";inherits:false}@property --tw-skew-y{syntax:"*";inherits:false}@property --tw-divide-x-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-divide-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-gradient-position{syntax:"*";inherits:false}@property --tw-gradient-from{syntax:"";inherits:false;initial-value:#0000}@property --tw-gradient-via{syntax:"";inherits:false;initial-value:#0000}@property --tw-gradient-to{syntax:"";inherits:false;initial-value:#0000}@property --tw-gradient-stops{syntax:"*";inherits:false}@property --tw-gradient-via-stops{syntax:"*";inherits:false}@property --tw-gradient-from-position{syntax:"";inherits:false;initial-value:0%}@property --tw-gradient-via-position{syntax:"";inherits:false;initial-value:50%}@property --tw-gradient-to-position{syntax:"";inherits:false;initial-value:100%}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-ordinal{syntax:"*";inherits:false}@property --tw-slashed-zero{syntax:"*";inherits:false}@property --tw-numeric-figure{syntax:"*";inherits:false}@property --tw-numeric-spacing{syntax:"*";inherits:false}@property --tw-numeric-fraction{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-outline-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@property --tw-backdrop-blur{syntax:"*";inherits:false}@property --tw-backdrop-brightness{syntax:"*";inherits:false}@property --tw-backdrop-contrast{syntax:"*";inherits:false}@property --tw-backdrop-grayscale{syntax:"*";inherits:false}@property --tw-backdrop-hue-rotate{syntax:"*";inherits:false}@property --tw-backdrop-invert{syntax:"*";inherits:false}@property --tw-backdrop-opacity{syntax:"*";inherits:false}@property --tw-backdrop-saturate{syntax:"*";inherits:false}@property --tw-backdrop-sepia{syntax:"*";inherits:false}@property --tw-duration{syntax:"*";inherits:false}@property --tw-ease{syntax:"*";inherits:false}@keyframes ping{75%,to{opacity:0;transform:scale(2)}} \ No newline at end of file +@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-space-y-reverse:0;--tw-divide-x-reverse:0;--tw-border-style:solid;--tw-divide-y-reverse:0;--tw-gradient-position:initial;--tw-gradient-from:#0000;--tw-gradient-via:#0000;--tw-gradient-to:#0000;--tw-gradient-stops:initial;--tw-gradient-via-stops:initial;--tw-gradient-from-position:0%;--tw-gradient-via-position:50%;--tw-gradient-to-position:100%;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-duration:initial;--tw-ease:initial}}}@layer theme{:root,:host{--font-sans:ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--color-red-50:oklch(97.1% .013 17.38);--color-red-100:oklch(93.6% .032 17.717);--color-red-500:oklch(63.7% .237 25.331);--color-red-600:oklch(57.7% .245 27.325);--color-red-700:oklch(50.5% .213 27.518);--color-red-800:oklch(44.4% .177 26.899);--color-amber-200:oklch(92.4% .12 95.746);--color-amber-300:oklch(87.9% .169 91.605);--color-amber-500:oklch(76.9% .188 70.08);--color-amber-600:oklch(66.6% .179 58.318);--color-amber-800:oklch(47.3% .137 46.201);--color-amber-900:oklch(41.4% .112 45.904);--color-amber-950:oklch(27.9% .077 45.635);--color-yellow-100:oklch(97.3% .071 103.193);--color-yellow-800:oklch(47.6% .114 61.907);--color-green-50:oklch(98.2% .018 155.826);--color-green-100:oklch(96.2% .044 156.743);--color-green-300:oklch(87.1% .15 154.449);--color-green-400:oklch(79.2% .209 151.711);--color-green-600:oklch(62.7% .194 149.214);--color-green-700:oklch(52.7% .154 150.069);--color-green-800:oklch(44.8% .119 151.328);--color-emerald-200:oklch(90.5% .093 164.15);--color-emerald-400:oklch(76.5% .177 163.223);--color-emerald-500:oklch(69.6% .17 162.48);--color-emerald-600:oklch(59.6% .145 163.225);--color-emerald-900:oklch(37.8% .077 168.94);--color-blue-50:oklch(97% .014 254.604);--color-blue-100:oklch(93.2% .032 255.585);--color-blue-200:oklch(88.2% .059 254.128);--color-blue-300:oklch(80.9% .105 251.813);--color-blue-400:oklch(70.7% .165 254.624);--color-blue-500:oklch(62.3% .214 259.815);--color-blue-600:oklch(54.6% .245 262.881);--color-blue-700:oklch(48.8% .243 264.376);--color-blue-800:oklch(42.4% .199 265.638);--color-blue-900:oklch(37.9% .146 265.522);--color-blue-950:oklch(28.2% .091 267.935);--color-rose-100:oklch(94.1% .03 12.58);--color-rose-200:oklch(89.2% .058 10.001);--color-rose-500:oklch(64.5% .246 16.439);--color-rose-600:oklch(58.6% .253 17.585);--color-rose-900:oklch(41% .159 10.272);--color-rose-950:oklch(27.1% .105 12.094);--color-gray-50:oklch(98.5% .002 247.839);--color-gray-100:oklch(96.7% .003 264.542);--color-gray-200:oklch(92.8% .006 264.531);--color-gray-300:oklch(87.2% .01 258.338);--color-gray-400:oklch(70.7% .022 261.325);--color-gray-500:oklch(55.1% .027 264.364);--color-gray-600:oklch(44.6% .03 256.802);--color-gray-700:oklch(37.3% .034 259.733);--color-gray-800:oklch(27.8% .033 256.848);--color-gray-900:oklch(21% .034 264.665);--color-neutral-50:oklch(98.5% 0 0);--color-neutral-100:oklch(97% 0 0);--color-neutral-200:oklch(92.2% 0 0);--color-neutral-300:oklch(87% 0 0);--color-neutral-400:oklch(70.8% 0 0);--color-neutral-500:oklch(55.6% 0 0);--color-neutral-600:oklch(43.9% 0 0);--color-neutral-700:oklch(37.1% 0 0);--color-neutral-800:oklch(26.9% 0 0);--color-neutral-900:oklch(20.5% 0 0);--color-neutral-950:oklch(14.5% 0 0);--color-black:#000;--color-white:#fff;--spacing:.25rem;--container-md:28rem;--container-xl:36rem;--container-3xl:48rem;--container-5xl:64rem;--container-6xl:72rem;--container-7xl:80rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25 / 1.875);--text-4xl:2.25rem;--text-4xl--line-height:calc(2.5 / 2.25);--font-weight-light:300;--font-weight-normal:400;--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--tracking-tight:-.025em;--tracking-wide:.025em;--tracking-wider:.05em;--leading-relaxed:1.625;--radius-md:.375rem;--radius-lg:.5rem;--radius-xl:.75rem;--ease-in-out:cubic-bezier(.4, 0, .2, 1);--animate-ping:ping 1s cubic-bezier(0, 0, .2, 1) infinite;--blur-md:12px;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.collapse{visibility:collapse}.invisible{visibility:hidden}.visible{visibility:visible}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.static{position:static}.sticky{position:sticky}.sticky\!{position:sticky!important}.top-0{top:0}.top-\[-1px\]{top:-1px}.right-0{right:0}.bottom-0{bottom:0}.left-0{left:0}.isolate{isolation:isolate}.-z-10{z-index:calc(10 * -1)}.z-10{z-index:10}.z-50{z-index:50}.float-right{float:right}.container{width:100%}@media (min-width:40rem){.container{max-width:40rem}}@media (min-width:48rem){.container{max-width:48rem}}@media (min-width:64rem){.container{max-width:64rem}}@media (min-width:80rem){.container{max-width:80rem}}@media (min-width:96rem){.container{max-width:96rem}}.mx-2{margin-inline:calc(var(--spacing) * 2)}.mx-auto{margin-inline:auto}.my-1{margin-block:var(--spacing)}.-mt-2{margin-top:calc(var(--spacing) * -2)}.-mt-3{margin-top:calc(var(--spacing) * -3)}.-mt-5{margin-top:calc(var(--spacing) * -5)}.-mt-px{margin-top:-1px}.mt-0\.5{margin-top:calc(var(--spacing) * .5)}.mt-1{margin-top:var(--spacing)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mt-6{margin-top:calc(var(--spacing) * 6)}.mt-8{margin-top:calc(var(--spacing) * 8)}.mr-1{margin-right:var(--spacing)}.mr-2{margin-right:calc(var(--spacing) * 2)}.mr-4{margin-right:calc(var(--spacing) * 4)}.mr-6{margin-right:calc(var(--spacing) * 6)}.mr-auto{margin-right:auto}.mb-1{margin-bottom:var(--spacing)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.mb-4{margin-bottom:calc(var(--spacing) * 4)}.mb-6{margin-bottom:calc(var(--spacing) * 6)}.mb-8{margin-bottom:calc(var(--spacing) * 8)}.-ml-px{margin-left:-1px}.ml-1{margin-left:var(--spacing)}.ml-2{margin-left:calc(var(--spacing) * 2)}.ml-4{margin-left:calc(var(--spacing) * 4)}.ml-6{margin-left:calc(var(--spacing) * 6)}.ml-12{margin-left:calc(var(--spacing) * 12)}.ml-auto{margin-left:auto}.block{display:block}.contents{display:contents}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.inline-block{display:inline-block}.inline-flex{display:inline-flex}.table{display:table}.size-2{width:calc(var(--spacing) * 2);height:calc(var(--spacing) * 2)}.size-3{width:calc(var(--spacing) * 3);height:calc(var(--spacing) * 3)}.h-0{height:0}.h-2\.5{height:calc(var(--spacing) * 2.5)}.h-3{height:calc(var(--spacing) * 3)}.h-5{height:calc(var(--spacing) * 5)}.h-6{height:calc(var(--spacing) * 6)}.h-7{height:calc(var(--spacing) * 7)}.h-8{height:calc(var(--spacing) * 8)}.h-10{height:calc(var(--spacing) * 10)}.h-11{height:calc(var(--spacing) * 11)}.h-14{height:calc(var(--spacing) * 14)}.h-16{height:calc(var(--spacing) * 16)}.h-\[18px\]{height:18px}.h-\[56px\]{height:56px}.h-auto{height:auto}.h-full{height:100%}.min-h-dvh{min-height:100dvh}.min-h-screen{min-height:100vh}.w-2\.5{width:calc(var(--spacing) * 2.5)}.w-3{width:calc(var(--spacing) * 3)}.w-5{width:calc(var(--spacing) * 5)}.w-6{width:calc(var(--spacing) * 6)}.w-8{width:calc(var(--spacing) * 8)}.w-\[18px\]{width:18px}.w-auto{width:auto}.w-fit{width:fit-content}.w-full{width:100%}.max-w-3xl{max-width:var(--container-3xl)}.max-w-5xl{max-width:var(--container-5xl)}.max-w-6xl{max-width:var(--container-6xl)}.max-w-7xl{max-width:var(--container-7xl)}.max-w-150{max-width:calc(var(--spacing) * 150)}.max-w-full{max-width:100%}.max-w-md{max-width:var(--container-md)}.max-w-xl{max-width:var(--container-xl)}.min-w-0{min-width:0}.min-w-5{min-width:calc(var(--spacing) * 5)}.min-w-6{min-width:calc(var(--spacing) * 6)}.min-w-28{min-width:calc(var(--spacing) * 28)}.min-w-48{min-width:calc(var(--spacing) * 48)}.min-w-full{min-width:100%}.flex-1{flex:1}.flex-shrink{flex-shrink:1}.flex-shrink-0{flex-shrink:0}.shrink{flex-shrink:1}.shrink-0{flex-shrink:0}.grow{flex-grow:1}.border-collapse{border-collapse:collapse}.transform{transform:var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,)}.transform\!{transform:var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,)!important}.animate-ping{animation:var(--animate-ping)}.cursor-default{cursor:default}.cursor-not-allowed{cursor:not-allowed}.cursor-not-allowed\!{cursor:not-allowed!important}.cursor-pointer{cursor:pointer}.resize{resize:both}.list-decimal{list-style-type:decimal}.list-disc{list-style-type:disc}.list-none{list-style-type:none}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.flex-col{flex-direction:column}.items-baseline{align-items:baseline}.items-center{align-items:center}.items-end{align-items:flex-end}.items-start{align-items:flex-start}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.justify-items-center{justify-items:center}.gap-0\.5{gap:calc(var(--spacing) * .5)}.gap-1{gap:var(--spacing)}.gap-1\.5{gap:calc(var(--spacing) * 1.5)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-2\.5{gap:calc(var(--spacing) * 2.5)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}.gap-5{gap:calc(var(--spacing) * 5)}.gap-6{gap:calc(var(--spacing) * 6)}.gap-8{gap:calc(var(--spacing) * 8)}.gap-12{gap:calc(var(--spacing) * 12)}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}.gap-x-6{column-gap:calc(var(--spacing) * 6)}.gap-x-8{column-gap:calc(var(--spacing) * 8)}.gap-y-2{row-gap:calc(var(--spacing) * 2)}.gap-y-3{row-gap:calc(var(--spacing) * 3)}:where(.divide-x>:not(:last-child)){--tw-divide-x-reverse:0;border-inline-style:var(--tw-border-style);border-inline-start-width:calc(1px * var(--tw-divide-x-reverse));border-inline-end-width:calc(1px * calc(1 - var(--tw-divide-x-reverse)))}:where(.divide-y>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px * var(--tw-divide-y-reverse));border-bottom-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)))}:where(.divide-gray-200>:not(:last-child)){border-color:var(--color-gray-200)}:where(.divide-neutral-200>:not(:last-child)){border-color:var(--color-neutral-200)}.self-end{align-self:flex-end}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-x-hidden{overflow-x:hidden}.rounded{border-radius:.25rem}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius-lg)}.rounded-md{border-radius:var(--radius-md)}.rounded-xl{border-radius:var(--radius-xl)}.rounded-t-lg{border-top-left-radius:var(--radius-lg);border-top-right-radius:var(--radius-lg)}.rounded-l-md{border-top-left-radius:var(--radius-md);border-bottom-left-radius:var(--radius-md)}.rounded-r-md{border-top-right-radius:var(--radius-md);border-bottom-right-radius:var(--radius-md)}.rounded-b-lg{border-bottom-right-radius:var(--radius-lg);border-bottom-left-radius:var(--radius-lg)}.border{border-style:var(--tw-border-style);border-width:1px}.border-x{border-inline-style:var(--tw-border-style);border-inline-width:1px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-r{border-right-style:var(--tw-border-style);border-right-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-b-2{border-bottom-style:var(--tw-border-style);border-bottom-width:2px}.border-dashed{--tw-border-style:dashed;border-style:dashed}.border-dotted{--tw-border-style:dotted;border-style:dotted}.border-blue-200{border-color:var(--color-blue-200)}.border-blue-600{border-color:var(--color-blue-600)}.border-gray-200{border-color:var(--color-gray-200)}.border-gray-300{border-color:var(--color-gray-300)}.border-gray-400{border-color:var(--color-gray-400)}.border-green-300{border-color:var(--color-green-300)}.border-green-600{border-color:var(--color-green-600)}.border-neutral-100{border-color:var(--color-neutral-100)}.border-neutral-200{border-color:var(--color-neutral-200)}.border-neutral-300{border-color:var(--color-neutral-300)}.border-red-600{border-color:var(--color-red-600)}.bg-amber-200{background-color:var(--color-amber-200)}.bg-amber-600{background-color:var(--color-amber-600)}.bg-black\/8{background-color:#00000014}@supports (color:color-mix(in lab, red, red)){.bg-black\/8{background-color:color-mix(in oklab, var(--color-black) 8%, transparent)}}.bg-black\/10{background-color:#0000001a}@supports (color:color-mix(in lab, red, red)){.bg-black\/10{background-color:color-mix(in oklab, var(--color-black) 10%, transparent)}}.bg-blue-50{background-color:var(--color-blue-50)}.bg-blue-50\/50{background-color:#eff6ff80}@supports (color:color-mix(in lab, red, red)){.bg-blue-50\/50{background-color:color-mix(in oklab, var(--color-blue-50) 50%, transparent)}}.bg-blue-50\/60{background-color:#eff6ff99}@supports (color:color-mix(in lab, red, red)){.bg-blue-50\/60{background-color:color-mix(in oklab, var(--color-blue-50) 60%, transparent)}}.bg-blue-100{background-color:var(--color-blue-100)}.bg-blue-300{background-color:var(--color-blue-300)}.bg-blue-600{background-color:var(--color-blue-600)}.bg-blue-700{background-color:var(--color-blue-700)}.bg-emerald-200{background-color:var(--color-emerald-200)}.bg-emerald-600{background-color:var(--color-emerald-600)}.bg-gray-50{background-color:var(--color-gray-50)}.bg-gray-100{background-color:var(--color-gray-100)}.bg-gray-200{background-color:var(--color-gray-200)}.bg-gray-800{background-color:var(--color-gray-800)}.bg-green-50{background-color:var(--color-green-50)}.bg-green-100{background-color:var(--color-green-100)}.bg-green-300{background-color:var(--color-green-300)}.bg-green-400{background-color:var(--color-green-400)}.bg-neutral-50{background-color:var(--color-neutral-50)}.bg-neutral-600{background-color:var(--color-neutral-600)}.bg-red-50{background-color:var(--color-red-50)}.bg-red-100{background-color:var(--color-red-100)}.bg-rose-200{background-color:var(--color-rose-200)}.bg-rose-200\!{background-color:var(--color-rose-200)!important}.bg-rose-500{background-color:var(--color-rose-500)}.bg-rose-600{background-color:var(--color-rose-600)}.bg-transparent\!{background-color:#0000!important}.bg-white{background-color:var(--color-white)}.bg-white\/5{background-color:#ffffff0d}@supports (color:color-mix(in lab, red, red)){.bg-white\/5{background-color:color-mix(in oklab, var(--color-white) 5%, transparent)}}.bg-white\/\[2\%\]{background-color:#ffffff05}@supports (color:color-mix(in lab, red, red)){.bg-white\/\[2\%\]{background-color:color-mix(in oklab, var(--color-white) 2%, transparent)}}.bg-yellow-100{background-color:var(--color-yellow-100)}.from-gray-700\/50{--tw-gradient-from:#36415380}@supports (color:color-mix(in lab, red, red)){.from-gray-700\/50{--tw-gradient-from:color-mix(in oklab, var(--color-gray-700) 50%, transparent)}}.from-gray-700\/50{--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.via-transparent{--tw-gradient-via:transparent;--tw-gradient-via-stops:var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-via) var(--tw-gradient-via-position), var(--tw-gradient-to) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-via-stops)}.p-1{padding:var(--spacing)}.p-2{padding:calc(var(--spacing) * 2)}.p-2\.5{padding:calc(var(--spacing) * 2.5)}.p-3{padding:calc(var(--spacing) * 3)}.p-4{padding:calc(var(--spacing) * 4)}.p-5{padding:calc(var(--spacing) * 5)}.p-6{padding:calc(var(--spacing) * 6)}.p-8{padding:calc(var(--spacing) * 8)}.p-10{padding:calc(var(--spacing) * 10)}.px-1{padding-inline:var(--spacing)}.px-1\.5{padding-inline:calc(var(--spacing) * 1.5)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-2\.5{padding-inline:calc(var(--spacing) * 2.5)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-5{padding-inline:calc(var(--spacing) * 5)}.px-6{padding-inline:calc(var(--spacing) * 6)}.px-\[6px\]{padding-inline:6px}.py-0{padding-block:0}.py-0\.5{padding-block:calc(var(--spacing) * .5)}.py-1{padding-block:var(--spacing)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-4{padding-block:calc(var(--spacing) * 4)}.py-6{padding-block:calc(var(--spacing) * 6)}.py-10{padding-block:calc(var(--spacing) * 10)}.pt-6{padding-top:calc(var(--spacing) * 6)}.pt-8{padding-top:calc(var(--spacing) * 8)}.pt-14{padding-top:calc(var(--spacing) * 14)}.pr-2\.5{padding-right:calc(var(--spacing) * 2.5)}.pb-0{padding-bottom:0}.pb-12{padding-bottom:calc(var(--spacing) * 12)}.pl-4{padding-left:calc(var(--spacing) * 4)}.text-center{text-align:center}.text-left{text-align:left}.text-right{text-align:right}.font-mono{font-family:var(--font-mono)}.font-sans{font-family:var(--font-sans)}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-3xl{font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height))}.text-4xl{font-size:var(--text-4xl);line-height:var(--tw-leading,var(--text-4xl--line-height))}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-xs\/none{font-size:var(--text-xs);line-height:1}.text-\[13px\]{font-size:13px}.leading-3{--tw-leading:calc(var(--spacing) * 3);line-height:calc(var(--spacing) * 3)}.leading-5{--tw-leading:calc(var(--spacing) * 5);line-height:calc(var(--spacing) * 5)}.leading-7{--tw-leading:calc(var(--spacing) * 7);line-height:calc(var(--spacing) * 7)}.leading-relaxed{--tw-leading:var(--leading-relaxed);line-height:var(--leading-relaxed)}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-light{--tw-font-weight:var(--font-weight-light);font-weight:var(--font-weight-light)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-normal{--tw-font-weight:var(--font-weight-normal);font-weight:var(--font-weight-normal)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-tight{--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight)}.tracking-wide{--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide)}.tracking-wider{--tw-tracking:var(--tracking-wider);letter-spacing:var(--tracking-wider)}.break-all{word-break:break-all}.whitespace-nowrap{white-space:nowrap}.whitespace-pre-line{white-space:pre-line}.text-amber-900{color:var(--color-amber-900)}.text-black{color:var(--color-black)}.text-blue-500{color:var(--color-blue-500)}.text-blue-600{color:var(--color-blue-600)}.text-blue-700{color:var(--color-blue-700)}.text-blue-700\/80{color:#1447e6cc}@supports (color:color-mix(in lab, red, red)){.text-blue-700\/80{color:color-mix(in oklab, var(--color-blue-700) 80%, transparent)}}.text-blue-800{color:var(--color-blue-800)}.text-blue-800\/80{color:#193cb8cc}@supports (color:color-mix(in lab, red, red)){.text-blue-800\/80{color:color-mix(in oklab, var(--color-blue-800) 80%, transparent)}}.text-blue-900{color:var(--color-blue-900)}.text-emerald-500{color:var(--color-emerald-500)}.text-emerald-900{color:var(--color-emerald-900)}.text-gray-200{color:var(--color-gray-200)}.text-gray-300{color:var(--color-gray-300)}.text-gray-400{color:var(--color-gray-400)}.text-gray-500{color:var(--color-gray-500)}.text-gray-600{color:var(--color-gray-600)}.text-gray-700{color:var(--color-gray-700)}.text-gray-800{color:var(--color-gray-800)}.text-gray-900{color:var(--color-gray-900)}.text-green-700{color:var(--color-green-700)}.text-green-800{color:var(--color-green-800)}.text-neutral-100{color:var(--color-neutral-100)}.text-neutral-400{color:var(--color-neutral-400)}.text-neutral-500{color:var(--color-neutral-500)}.text-neutral-500\!{color:var(--color-neutral-500)!important}.text-neutral-600{color:var(--color-neutral-600)}.text-neutral-800{color:var(--color-neutral-800)}.text-neutral-900{color:var(--color-neutral-900)}.text-neutral-950{color:var(--color-neutral-950)}.text-red-700{color:var(--color-red-700)}.text-red-800{color:var(--color-red-800)}.text-rose-900{color:var(--color-rose-900)}.text-white{color:var(--color-white)}.text-yellow-800{color:var(--color-yellow-800)}.lowercase{text-transform:lowercase}.uppercase{text-transform:uppercase}.italic{font-style:italic}.ordinal{--tw-ordinal:ordinal;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.line-through{text-decoration-line:line-through}.overline{text-decoration-line:overline}.underline{text-decoration-line:underline}.decoration-neutral-400{-webkit-text-decoration-color:var(--color-neutral-400);-webkit-text-decoration-color:var(--color-neutral-400);text-decoration-color:var(--color-neutral-400)}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.scheme-light-dark{color-scheme:light dark}.opacity-75{opacity:.75}.opacity-90{opacity:.9}.shadow{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-2xl{--tw-shadow:0 25px 50px -12px var(--tw-shadow-color,#00000040);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-xs{--tw-shadow:0 1px 2px 0 var(--tw-shadow-color,#0000000d);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring-2{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-gray-500\/20{--tw-shadow-color:#6a728233}@supports (color:color-mix(in lab, red, red)){.shadow-gray-500\/20{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-gray-500) 20%, transparent) var(--tw-shadow-alpha), transparent)}}.ring-blue-500{--tw-ring-color:var(--color-blue-500)}.ring-gray-300{--tw-ring-color:var(--color-gray-300)}.outline{outline-style:var(--tw-outline-style);outline-width:1px}.blur{--tw-blur:blur(8px);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.invert{--tw-invert:invert(100%);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.sepia{--tw-sepia:sepia(100%);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.filter\!{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)!important}.backdrop-filter{-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.duration-150{--tw-duration:.15s;transition-duration:.15s}.duration-200{--tw-duration:.2s;transition-duration:.2s}.ease-in-out{--tw-ease:var(--ease-in-out);transition-timing-function:var(--ease-in-out)}@media (hover:hover){.group-hover\:text-blue-500:is(:where(.group):hover *){color:var(--color-blue-500)}}.group-\[\.status-down\]\:bg-red-600:is(:where(.group).status-down *){background-color:var(--color-red-600)}.selection\:bg-red-500 ::selection{background-color:var(--color-red-500)}.selection\:bg-red-500::selection{background-color:var(--color-red-500)}.selection\:text-white ::selection{color:var(--color-white)}.selection\:text-white::selection{color:var(--color-white)}.odd\:bg-white\/2:nth-child(odd){background-color:#ffffff05}@supports (color:color-mix(in lab, red, red)){.odd\:bg-white\/2:nth-child(odd){background-color:color-mix(in oklab, var(--color-white) 2%, transparent)}}.even\:bg-white:nth-child(2n){background-color:var(--color-white)}@media (hover:hover){.hover\:bg-blue-100:hover{background-color:var(--color-blue-100)}.hover\:bg-blue-700:hover{background-color:var(--color-blue-700)}.hover\:bg-gray-50:hover{background-color:var(--color-gray-50)}.hover\:bg-gray-100:hover{background-color:var(--color-gray-100)}.hover\:bg-green-100:hover{background-color:var(--color-green-100)}.hover\:bg-neutral-100:hover{background-color:var(--color-neutral-100)}.hover\:bg-neutral-200:hover{background-color:var(--color-neutral-200)}.hover\:bg-red-50:hover{background-color:var(--color-red-50)}.hover\:bg-white\/50:hover{background-color:#ffffff80}@supports (color:color-mix(in lab, red, red)){.hover\:bg-white\/50:hover{background-color:color-mix(in oklab, var(--color-white) 50%, transparent)}}.hover\:text-gray-400:hover{color:var(--color-gray-400)}.hover\:text-gray-700:hover{color:var(--color-gray-700)}.hover\:underline:hover{text-decoration-line:underline}.hover\:ring-2:hover{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.hover\:ring-blue-400:hover{--tw-ring-color:var(--color-blue-400)}}.focus\:border-blue-300:focus{border-color:var(--color-blue-300)}.focus\:border-blue-500:focus{border-color:var(--color-blue-500)}.focus\:ring:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus\:ring-2:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus\:ring-blue-500\/40:focus{--tw-ring-color:#3080ff66}@supports (color:color-mix(in lab, red, red)){.focus\:ring-blue-500\/40:focus{--tw-ring-color:color-mix(in oklab, var(--color-blue-500) 40%, transparent)}}.focus\:outline:focus{outline-style:var(--tw-outline-style);outline-width:1px}.focus\:outline-2:focus{outline-style:var(--tw-outline-style);outline-width:2px}.focus\:outline-red-500:focus{outline-color:var(--color-red-500)}.focus\:outline-none:focus{--tw-outline-style:none;outline-style:none}.active\:bg-gray-100:active{background-color:var(--color-gray-100)}.active\:text-gray-500:active{color:var(--color-gray-500)}.active\:text-gray-700:active{color:var(--color-gray-700)}.active\:text-gray-800:active{color:var(--color-gray-800)}@media (min-width:40rem){.sm\:mb-16{margin-bottom:calc(var(--spacing) * 16)}.sm\:flex{display:flex}.sm\:hidden{display:none}.sm\:w-3\/4{width:75%}.sm\:flex-1{flex:1}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:grid-cols-\[10rem_14rem_1fr_auto\]{grid-template-columns:10rem 14rem 1fr auto}.sm\:items-center{align-items:center}.sm\:justify-between{justify-content:space-between}.sm\:justify-start{justify-content:flex-start}.sm\:gap-2{gap:calc(var(--spacing) * 2)}.sm\:p-14{padding:calc(var(--spacing) * 14)}.sm\:px-6{padding-inline:calc(var(--spacing) * 6)}.sm\:py-0{padding-block:0}.sm\:pt-0{padding-top:0}.sm\:pt-16{padding-top:calc(var(--spacing) * 16)}.sm\:pb-0{padding-bottom:0}}@media (min-width:64rem){.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:px-8{padding-inline:calc(var(--spacing) * 8)}}@media (min-width:80rem){.xl\:w-1\/2{width:50%}}.rtl\:flex-row-reverse:where(:dir(rtl),[dir=rtl],[dir=rtl] *){flex-direction:row-reverse}@media (prefers-color-scheme:dark){:where(.dark\:divide-white\/5>:not(:last-child)){border-color:#ffffff0d}@supports (color:color-mix(in lab, red, red)){:where(.dark\:divide-white\/5>:not(:last-child)){border-color:color-mix(in oklab, var(--color-white) 5%, transparent)}}:where(.dark\:divide-white\/10>:not(:last-child)){border-color:#ffffff1a}@supports (color:color-mix(in lab, red, red)){:where(.dark\:divide-white\/10>:not(:last-child)){border-color:color-mix(in oklab, var(--color-white) 10%, transparent)}}.dark\:border{border-style:var(--tw-border-style);border-width:1px}.dark\:border-none{--tw-border-style:none;border-style:none}.dark\:border-amber-500{border-color:var(--color-amber-500)}.dark\:border-amber-800{border-color:var(--color-amber-800)}.dark\:border-blue-600{border-color:var(--color-blue-600)}.dark\:border-blue-800{border-color:var(--color-blue-800)}.dark\:border-emerald-500{border-color:var(--color-emerald-500)}.dark\:border-emerald-600{border-color:var(--color-emerald-600)}.dark\:border-gray-600{border-color:var(--color-gray-600)}.dark\:border-neutral-500{border-color:var(--color-neutral-500)}.dark\:border-neutral-700{border-color:var(--color-neutral-700)}.dark\:border-neutral-800{border-color:var(--color-neutral-800)}.dark\:border-rose-500{border-color:var(--color-rose-500)}.dark\:border-rose-900{border-color:var(--color-rose-900)}.dark\:border-white\/5{border-color:#ffffff0d}@supports (color:color-mix(in lab, red, red)){.dark\:border-white\/5{border-color:color-mix(in oklab, var(--color-white) 5%, transparent)}}.dark\:border-white\/8{border-color:#ffffff14}@supports (color:color-mix(in lab, red, red)){.dark\:border-white\/8{border-color:color-mix(in oklab, var(--color-white) 8%, transparent)}}.dark\:border-white\/10{border-color:#ffffff1a}@supports (color:color-mix(in lab, red, red)){.dark\:border-white\/10{border-color:color-mix(in oklab, var(--color-white) 10%, transparent)}}.dark\:border-white\/20{border-color:#fff3}@supports (color:color-mix(in lab, red, red)){.dark\:border-white\/20{border-color:color-mix(in oklab, var(--color-white) 20%, transparent)}}.dark\:border-white\/\[9\%\]{border-color:#ffffff17}@supports (color:color-mix(in lab, red, red)){.dark\:border-white\/\[9\%\]{border-color:color-mix(in oklab, var(--color-white) 9%, transparent)}}.dark\:bg-\[\#1a1a1a\]{background-color:#1a1a1a}.dark\:bg-amber-600{background-color:var(--color-amber-600)}.dark\:bg-amber-950{background-color:var(--color-amber-950)}.dark\:bg-blue-700{background-color:var(--color-blue-700)}.dark\:bg-blue-950{background-color:var(--color-blue-950)}.dark\:bg-emerald-600{background-color:var(--color-emerald-600)}.dark\:bg-emerald-900\/70{background-color:#004e3bb3}@supports (color:color-mix(in lab, red, red)){.dark\:bg-emerald-900\/70{background-color:color-mix(in oklab, var(--color-emerald-900) 70%, transparent)}}.dark\:bg-gray-700{background-color:var(--color-gray-700)}.dark\:bg-gray-800{background-color:var(--color-gray-800)}.dark\:bg-gray-900{background-color:var(--color-gray-900)}.dark\:bg-neutral-400{background-color:var(--color-neutral-400)}.dark\:bg-neutral-600{background-color:var(--color-neutral-600)}.dark\:bg-neutral-700{background-color:var(--color-neutral-700)}.dark\:bg-neutral-800{background-color:var(--color-neutral-800)}.dark\:bg-neutral-900{background-color:var(--color-neutral-900)}.dark\:bg-rose-600{background-color:var(--color-rose-600)}.dark\:bg-rose-900\!{background-color:var(--color-rose-900)!important}.dark\:bg-rose-950{background-color:var(--color-rose-950)}.dark\:bg-transparent{background-color:#0000}.dark\:bg-white{background-color:var(--color-white)}.dark\:bg-white\/1{background-color:#ffffff03}@supports (color:color-mix(in lab, red, red)){.dark\:bg-white\/1{background-color:color-mix(in oklab, var(--color-white) 1%, transparent)}}.dark\:bg-white\/3{background-color:#ffffff08}@supports (color:color-mix(in lab, red, red)){.dark\:bg-white\/3{background-color:color-mix(in oklab, var(--color-white) 3%, transparent)}}.dark\:bg-white\/5{background-color:#ffffff0d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-white\/5{background-color:color-mix(in oklab, var(--color-white) 5%, transparent)}}.dark\:bg-white\/10{background-color:#ffffff1a}@supports (color:color-mix(in lab, red, red)){.dark\:bg-white\/10{background-color:color-mix(in oklab, var(--color-white) 10%, transparent)}}.dark\:bg-white\/\[2\%\]{background-color:#ffffff05}@supports (color:color-mix(in lab, red, red)){.dark\:bg-white\/\[2\%\]{background-color:color-mix(in oklab, var(--color-white) 2%, transparent)}}.dark\:bg-white\/\[3\%\]{background-color:#ffffff08}@supports (color:color-mix(in lab, red, red)){.dark\:bg-white\/\[3\%\]{background-color:color-mix(in oklab, var(--color-white) 3%, transparent)}}.dark\:text-amber-300{color:var(--color-amber-300)}.dark\:text-blue-300{color:var(--color-blue-300)}.dark\:text-emerald-400{color:var(--color-emerald-400)}.dark\:text-emerald-500{color:var(--color-emerald-500)}.dark\:text-gray-200{color:var(--color-gray-200)}.dark\:text-gray-300{color:var(--color-gray-300)}.dark\:text-gray-400{color:var(--color-gray-400)}.dark\:text-gray-600{color:var(--color-gray-600)}.dark\:text-neutral-100{color:var(--color-neutral-100)}.dark\:text-neutral-200{color:var(--color-neutral-200)}.dark\:text-neutral-300{color:var(--color-neutral-300)}.dark\:text-neutral-400{color:var(--color-neutral-400)}.dark\:text-neutral-500{color:var(--color-neutral-500)}.dark\:text-neutral-600{color:var(--color-neutral-600)}.dark\:text-neutral-600\!{color:var(--color-neutral-600)!important}.dark\:text-neutral-900{color:var(--color-neutral-900)}.dark\:text-rose-100{color:var(--color-rose-100)}.dark\:text-white{color:var(--color-white)}.dark\:text-white\!{color:var(--color-white)!important}}@media (hover:hover){@media (prefers-color-scheme:dark){.group-hover\:dark\:text-emerald-500:is(:where(.group):hover *){color:var(--color-emerald-500)}}}@media (prefers-color-scheme:dark){.odd\:dark\:bg-white\/4:nth-child(odd){background-color:#ffffff0a}@supports (color:color-mix(in lab, red, red)){.odd\:dark\:bg-white\/4:nth-child(odd){background-color:color-mix(in oklab, var(--color-white) 4%, transparent)}}.even\:dark\:bg-white\/2:nth-child(2n){background-color:#ffffff05}@supports (color:color-mix(in lab, red, red)){.even\:dark\:bg-white\/2:nth-child(2n){background-color:color-mix(in oklab, var(--color-white) 2%, transparent)}}@media (hover:hover){.dark\:hover\:bg-gray-900:hover{background-color:var(--color-gray-900)}.dark\:hover\:bg-white\/2:hover{background-color:#ffffff05}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-white\/2:hover{background-color:color-mix(in oklab, var(--color-white) 2%, transparent)}}.dark\:hover\:bg-white\/5:hover{background-color:#ffffff0d}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-white\/5:hover{background-color:color-mix(in oklab, var(--color-white) 5%, transparent)}}.dark\:hover\:bg-white\/10:hover{background-color:#ffffff1a}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-white\/10:hover{background-color:color-mix(in oklab, var(--color-white) 10%, transparent)}}}}@media (hover:hover){@media (prefers-color-scheme:dark){.hover\:dark\:bg-white\/5:hover{background-color:#ffffff0d}@supports (color:color-mix(in lab, red, red)){.hover\:dark\:bg-white\/5:hover{background-color:color-mix(in oklab, var(--color-white) 5%, transparent)}}}}@media (prefers-color-scheme:dark){@media (hover:hover){.dark\:hover\:text-gray-200:hover{color:var(--color-gray-200)}.dark\:hover\:text-gray-300:hover{color:var(--color-gray-300)}}}@media (hover:hover){@media (prefers-color-scheme:dark){.hover\:dark\:text-white:hover{color:var(--color-white)}}}@media (prefers-color-scheme:dark){.dark\:focus\:border-blue-700:focus{border-color:var(--color-blue-700)}.dark\:focus\:border-blue-800:focus{border-color:var(--color-blue-800)}.dark\:active\:bg-gray-700:active{background-color:var(--color-gray-700)}.dark\:active\:text-gray-300:active{color:var(--color-gray-300)}}.\[\&_svg\]\:size-2\.5 svg{width:calc(var(--spacing) * 2.5);height:calc(var(--spacing) * 2.5)}.\[\&_svg\]\:\!text-white svg{color:var(--color-white)!important}@media (hover:hover){.hover\:\[\&_svg\]\:stroke-emerald-500:hover svg{stroke:var(--color-emerald-500)}}@media (prefers-color-scheme:dark){.dark\:\[\&_svg\]\:\!text-white svg{color:var(--color-white)!important}}}@property --tw-rotate-x{syntax:"*";inherits:false}@property --tw-rotate-y{syntax:"*";inherits:false}@property --tw-rotate-z{syntax:"*";inherits:false}@property --tw-skew-x{syntax:"*";inherits:false}@property --tw-skew-y{syntax:"*";inherits:false}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-divide-x-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-divide-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-gradient-position{syntax:"*";inherits:false}@property --tw-gradient-from{syntax:"";inherits:false;initial-value:#0000}@property --tw-gradient-via{syntax:"";inherits:false;initial-value:#0000}@property --tw-gradient-to{syntax:"";inherits:false;initial-value:#0000}@property --tw-gradient-stops{syntax:"*";inherits:false}@property --tw-gradient-via-stops{syntax:"*";inherits:false}@property --tw-gradient-from-position{syntax:"";inherits:false;initial-value:0%}@property --tw-gradient-via-position{syntax:"";inherits:false;initial-value:50%}@property --tw-gradient-to-position{syntax:"";inherits:false;initial-value:100%}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-ordinal{syntax:"*";inherits:false}@property --tw-slashed-zero{syntax:"*";inherits:false}@property --tw-numeric-figure{syntax:"*";inherits:false}@property --tw-numeric-spacing{syntax:"*";inherits:false}@property --tw-numeric-fraction{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-outline-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@property --tw-backdrop-blur{syntax:"*";inherits:false}@property --tw-backdrop-brightness{syntax:"*";inherits:false}@property --tw-backdrop-contrast{syntax:"*";inherits:false}@property --tw-backdrop-grayscale{syntax:"*";inherits:false}@property --tw-backdrop-hue-rotate{syntax:"*";inherits:false}@property --tw-backdrop-invert{syntax:"*";inherits:false}@property --tw-backdrop-opacity{syntax:"*";inherits:false}@property --tw-backdrop-saturate{syntax:"*";inherits:false}@property --tw-backdrop-sepia{syntax:"*";inherits:false}@property --tw-duration{syntax:"*";inherits:false}@property --tw-ease{syntax:"*";inherits:false}@keyframes ping{75%,to{opacity:0;transform:scale(2)}} \ No newline at end of file diff --git a/src/resources/views/api-docs.blade.php b/src/resources/views/api-docs.blade.php new file mode 100644 index 0000000..3da802e --- /dev/null +++ b/src/resources/views/api-docs.blade.php @@ -0,0 +1,30 @@ + + + + + + API Documentation — {{ config('app.name', 'Bowler') }} + + + + +
+ + + + + diff --git a/src/resources/views/api-keys/index.blade.php b/src/resources/views/api-keys/index.blade.php new file mode 100644 index 0000000..b6e3edd --- /dev/null +++ b/src/resources/views/api-keys/index.blade.php @@ -0,0 +1,93 @@ +@extends('layouts.app') + +@section('title', 'API Keys — ' . config('app.name')) + +@section('content') +
+

API Keys

+ API documentation → +
+ + @if ($errors->has('view')) +
{{ $errors->first('view') }}
+ @endif + + @if (session('plainTextToken')) +
+

Copy your API key — treat it like a password:

+
+ {{ session('plainTextToken') }} + +
+
+ + @endif + +
+

Create a new key

+
+ @csrf +
+ + +
+ +
+ @error('name') +

{{ $message }}

+ @enderror +

+ Use keys as a Bearer token: Authorization: Bearer <key> +

+
+ +
+ + + + + + + + + + + @forelse ($tokens as $token) + + + + + + + @empty + + + + @endforelse + +
NameCreatedLast usedActions
{{ $token->name }}{{ $token->created_at->format('Y-m-d H:i') }}{{ $token->last_used_at?->format('Y-m-d H:i') ?? 'Never' }} +
+ @csrf + +
+
+ @csrf + @method('DELETE') + +
+
No API keys yet.
+
+ + @include('partials.confirm-script') +@endsection diff --git a/src/resources/views/categories/edit.blade.php b/src/resources/views/categories/edit.blade.php new file mode 100644 index 0000000..b0eb574 --- /dev/null +++ b/src/resources/views/categories/edit.blade.php @@ -0,0 +1,28 @@ +@extends('layouts.app') + +@section('title', 'Rename Category — ' . config('app.name')) + +@section('content') +
+

Rename category

+ +
+ @csrf + @method('PUT') + +
+ + + @error('name') +

{{ $message }}

+ @enderror +
+ +
+ + Cancel +
+
+
+@endsection diff --git a/src/resources/views/categories/index.blade.php b/src/resources/views/categories/index.blade.php new file mode 100644 index 0000000..7deb746 --- /dev/null +++ b/src/resources/views/categories/index.blade.php @@ -0,0 +1,65 @@ +@extends('layouts.app') + +@section('title', 'Categories — ' . config('app.name')) + +@section('content') +
+

My Categories

+ Manage teams → +
+

Categories sub-group items within each team on bowler charts — e.g. "Performance", "General", or "Security".

+ +
+
+ @csrf +
+ + +
+ +
+ @error('name') +

{{ $message }}

+ @enderror +
+ +
+ + + + + + + + + + + @forelse ($categories as $category) + + + + + + + @empty + + + + @endforelse + +
NameKPIsProjectsActions
{{ $category->name }}{{ $category->kpis_count }}{{ $category->projects_count }} + Rename +
+ @csrf + @method('DELETE') + +
+
No categories yet — add your first above.
+
+ + @include('partials.confirm-script') +@endsection diff --git a/src/resources/views/charts/_grid.blade.php b/src/resources/views/charts/_grid.blade.php new file mode 100644 index 0000000..0bd6976 --- /dev/null +++ b/src/resources/views/charts/_grid.blade.php @@ -0,0 +1,182 @@ +{{-- Expects: $months, $groupedRows (Team => Category => rows), $showOwner (bool) --}} +@php($totalCols = count($months) + 1 + ($showOwner ? 1 : 0)) + +@if (empty($groupedRows)) +
+ Nothing to show yet — KPIs and projects will appear here automatically. +
+@else +
+ + + + + @if ($showOwner) + + @endif + @foreach ($months as $month) + + @endforeach + + + + @foreach ($groupedRows as $teamName => $categories) + + + + @foreach ($categories as $categoryName => $rows) + + + + @foreach ($rows as $row) + @php($item = $row['item']) + + + @if ($showOwner) + + @endif + @foreach ($months as $month) + @php($cell = $row['cells'][$month['key']] ?? null) + + @endforeach + + @if ($row['type'] === 'project') + + + + @else + + + + @endif + @endforeach + @endforeach + @endforeach + +
ItemOwner{{ $month['label'] }}
{{ $teamName }}
{{ $categoryName }}
+ @if ($row['type'] === 'kpi') + {{ $item->name }} + {{ $item->thresholdSummary() }} + @else + {{ $item->name }} + Project + @endif + {{ $item->user->name }} + @if ($cell && $row['type'] === 'project') + + @elseif ($cell) + + {{ $cell['display'] }} + + @else + + @endif +
+
+

+ Each cell shows the most recent entry in that month. The current month is highlighted. + Click a KPI row to expand its trend chart; click a project's cell to expand that month's details. +

+ + +@endif diff --git a/src/resources/views/charts/_kpi-chart.blade.php b/src/resources/views/charts/_kpi-chart.blade.php new file mode 100644 index 0000000..e81537d --- /dev/null +++ b/src/resources/views/charts/_kpi-chart.blade.php @@ -0,0 +1,80 @@ +{{-- Expects: $kpi (App\Models\Kpi), $chart (array from App\Support\KpiChart::build) --}} +
+

{{ $kpi->name }} — last 12 months

+

{{ $kpi->thresholdSummary() }}@if ($kpi->unit_label) · unit: {{ $kpi->unit_label }}@endif

+ + @if (($chart['type'] === 'line' && empty($chart['points'])) || ($chart['type'] === 'milestone' && empty($chart['markers']))) +

No entries in the last 12 months.

+ @elseif ($chart['type'] === 'line') + + {{-- Threshold zone bands (10% status washes) --}} + @foreach ($chart['bands'] as $band) + + @endforeach + + {{-- Gridlines + y ticks --}} + @foreach ($chart['gridlines'] as $grid) + + {{ $grid['label'] }} + @endforeach + + {{-- Threshold / target reference lines --}} + @foreach ($chart['refLines'] as $ref) + + {{ $ref['label'] }} + @endforeach + + {{-- Value line (gaps split the path) --}} + @foreach ($chart['paths'] as $path) + + @endforeach + + {{-- Points, status-colored with a 2px surface ring --}} + @foreach ($chart['points'] as $point) + + {{ $point['title'] }} + + @endforeach + + {{-- End value label (selective direct label) --}} + @if ($chart['endLabel']) + {{ $chart['endLabel']['text'] }} + @endif + + {{-- Month labels --}} + @foreach ($chart['monthLabels'] as $label) + {{ $label['label'] }} + @endforeach + + @else + {{-- Milestone timeline: met / missed markers per month --}} + + + @foreach ($chart['markers'] as $marker) + + {{ $marker['title'] }} + + {{ $marker['glyph'] }} + + @endforeach + @foreach ($chart['monthLabels'] as $label) + {{ $label['label'] }} + @endforeach + + @endif +
diff --git a/src/resources/views/charts/create.blade.php b/src/resources/views/charts/create.blade.php new file mode 100644 index 0000000..4bc85dd --- /dev/null +++ b/src/resources/views/charts/create.blade.php @@ -0,0 +1,57 @@ +@extends('layouts.app') + +@section('title', 'New Bowler Chart — ' . config('app.name')) + +@section('content') +
+

Create bowler chart

+ + @if ($groups->isEmpty()) +

+ You are not an owner of any group, so you can't create group charts. + Ask a group owner (or an administrator) to make you an owner, or use + your personal bowler. +

+ @else +
+ @csrf + +
+ + + @error('group_id') +

{{ $message }}

+ @enderror +
+ +
+ + + @error('name') +

{{ $message }}

+ @enderror +
+ +
+ + + @error('description') +

{{ $message }}

+ @enderror +
+ +
+ + Cancel +
+
+ @endif +
+@endsection diff --git a/src/resources/views/charts/edit.blade.php b/src/resources/views/charts/edit.blade.php new file mode 100644 index 0000000..126c21a --- /dev/null +++ b/src/resources/views/charts/edit.blade.php @@ -0,0 +1,38 @@ +@extends('layouts.app') + +@section('title', 'Edit Bowler Chart — ' . config('app.name')) + +@section('content') +
+

Edit bowler chart

+

Group: {{ $chart->group->name }}

+ +
+ @csrf + @method('PUT') + +
+ + + @error('name') +

{{ $message }}

+ @enderror +
+ +
+ + + @error('description') +

{{ $message }}

+ @enderror +
+ +
+ + Cancel +
+
+
+@endsection diff --git a/src/resources/views/charts/index.blade.php b/src/resources/views/charts/index.blade.php new file mode 100644 index 0000000..7a024d2 --- /dev/null +++ b/src/resources/views/charts/index.blade.php @@ -0,0 +1,58 @@ +@extends('layouts.app') + +@section('title', 'Bowler Charts — ' . config('app.name')) + +@section('content') +
+

Bowler Charts

+ New chart +
+ + {{-- Personal chart --}} +
+
+ My Bowler +

Your personal chart with all of your own KPIs and projects.

+
+ Open +
+ + @if ($chartsByGroup->isEmpty()) +
+ No group charts yet. Group owners can create charts for their groups. +
+ @else + @foreach ($chartsByGroup as $groupName => $charts) +

{{ $groupName }}

+
+ @foreach ($charts as $chart) +
+
+ {{ $chart->name }} + @if ($chart->description) +

{{ $chart->description }}

+ @endif +
+
+ Open + @can('update', $chart) + Edit +
+ @csrf + @method('DELETE') + +
+ @endcan +
+
+ @endforeach +
+ @endforeach + @endif + + @include('partials.confirm-script') +@endsection diff --git a/src/resources/views/charts/personal.blade.php b/src/resources/views/charts/personal.blade.php new file mode 100644 index 0000000..2417114 --- /dev/null +++ b/src/resources/views/charts/personal.blade.php @@ -0,0 +1,18 @@ +@extends('layouts.app') + +@section('title', 'My Bowler — ' . config('app.name')) + +@section('content') +
+

My Bowler

+ +
+

All of your own KPIs and projects, regardless of chart sharing.

+ + @include('charts._grid', ['showOwner' => false]) +@endsection diff --git a/src/resources/views/charts/show.blade.php b/src/resources/views/charts/show.blade.php new file mode 100644 index 0000000..8f6b38f --- /dev/null +++ b/src/resources/views/charts/show.blade.php @@ -0,0 +1,28 @@ +@extends('layouts.app') + +@section('title', $chart->name . ' — ' . config('app.name')) + +@section('content') +
+ Charts + / + {{ $chart->group->name }} + / + {{ $chart->name }} +
+
+

{{ $chart->name }}

+ @can('update', $chart) + Edit chart + @endcan +
+

+ {{ $chart->group->name }} group + @if ($chart->description) + · {{ $chart->description }} + @endif +

+ + @include('charts._grid', ['showOwner' => true]) +@endsection diff --git a/src/resources/views/kpis/_entry-value-field.blade.php b/src/resources/views/kpis/_entry-value-field.blade.php new file mode 100644 index 0000000..80ee8b8 --- /dev/null +++ b/src/resources/views/kpis/_entry-value-field.blade.php @@ -0,0 +1,37 @@ +{{-- Expects: $kpi, optional $entry --}} +@php($entry = $entry ?? null) + +@if ($kpi->evaluation === \App\Enums\KpiEvaluation::Milestone) +
+ Milestone +
+ + +
+ @error('value') +

{{ $message }}

+ @enderror +
+@else +
+ + + @error('value') +

{{ $message }}

+ @enderror +
+@endif diff --git a/src/resources/views/kpis/_form.blade.php b/src/resources/views/kpis/_form.blade.php new file mode 100644 index 0000000..aaecf7a --- /dev/null +++ b/src/resources/views/kpis/_form.blade.php @@ -0,0 +1,141 @@ +{{-- Expects: $kpi (nullable App\Models\Kpi for edit mode) --}} +@php($kpi = $kpi ?? null) + +
+ + + @error('name') +

{{ $message }}

+ @enderror +
+ +
+ + + @error('description') +

{{ $message }}

+ @enderror +
+ +@include('partials.team-category-selects', ['item' => $kpi]) + +
+
+ + +
+
+ + + @error('unit_label') +

{{ $message }}

+ @enderror +
+
+ +
+ + + @error('evaluation') +

{{ $message }}

+ @enderror +
+ +{{-- Higher/lower-is-better thresholds --}} +
+
+ + +

Green when value ≥ this

+ + @error('green_threshold') +

{{ $message }}

+ @enderror +
+
+ + +

Beyond yellow is red

+ @error('yellow_threshold') +

{{ $message }}

+ @enderror +
+
+ +{{-- Target-band fields --}} +
+
+ + + @error('target') +

{{ $message }}

+ @enderror +
+
+ + + @error('green_tolerance') +

{{ $message }}

+ @enderror +
+
+ + + @error('yellow_tolerance') +

{{ $message }}

+ @enderror +
+
+ + + +@include('partials.chart-sharing', ['item' => $kpi]) + + diff --git a/src/resources/views/kpis/create.blade.php b/src/resources/views/kpis/create.blade.php new file mode 100644 index 0000000..8a238ae --- /dev/null +++ b/src/resources/views/kpis/create.blade.php @@ -0,0 +1,22 @@ +@extends('layouts.app') + +@section('title', 'New KPI — ' . config('app.name')) + +@section('content') +
+

Create KPI

+ + @if (auth()->user()->teams->isEmpty() || auth()->user()->categories->isEmpty()) + @include('partials.taxonomy-cta') + @else +
+ @csrf + @include('kpis._form') +
+ + Cancel +
+
+ @endif +
+@endsection diff --git a/src/resources/views/kpis/edit.blade.php b/src/resources/views/kpis/edit.blade.php new file mode 100644 index 0000000..2d953cd --- /dev/null +++ b/src/resources/views/kpis/edit.blade.php @@ -0,0 +1,19 @@ +@extends('layouts.app') + +@section('title', 'Edit KPI — ' . config('app.name')) + +@section('content') +
+

Edit KPI

+ +
+ @csrf + @method('PUT') + @include('kpis._form') +
+ + Cancel +
+
+
+@endsection diff --git a/src/resources/views/kpis/entries.blade.php b/src/resources/views/kpis/entries.blade.php new file mode 100644 index 0000000..c1185a1 --- /dev/null +++ b/src/resources/views/kpis/entries.blade.php @@ -0,0 +1,111 @@ +@extends('layouts.app') + +@section('title', $kpi->name . ' — ' . config('app.name')) + +@section('content') +
+ KPIs + / + {{ $kpi->name }} +
+
+

{{ $kpi->name }}

+ @can('update', $kpi) + Edit KPI + @endcan +
+

+ @if ($kpi->team || $kpi->category) + {{ $kpi->team?->name ?? 'No Team' }} / {{ $kpi->category?->name ?? 'Uncategorized' }} · + @endif + {{ $kpi->value_type->label() }} · {{ $kpi->evaluation->label() }} · {{ $kpi->thresholdSummary() }} + @if ($kpi->description) + {{ $kpi->description }} + @endif +

+ + {{-- Add entry --}} + @can('update', $kpi) +
+

Add entry

+
+ @csrf +
+ + + @error('entry_date') +

{{ $message }}

+ @enderror +
+ @include('kpis._entry-value-field', ['kpi' => $kpi]) +
+ + + @error('notes') +

{{ $message }}

+ @enderror +
+
+ +
+
+
+ @endcan + + {{-- Entries, reverse chronological --}} + @if ($entries->isEmpty()) +
+ No entries yet. Add the first one above. +
+ @else +
+ + + + + + + + + + + + @foreach ($entries as $entry) + + + + + + + + @endforeach + +
DateValueStatusNotesActions
{{ $entry->entry_date->format('Y-m-d') }} + @if ($kpi->evaluation === \App\Enums\KpiEvaluation::Milestone) + {{ $entry->value >= 1 ? 'Met' : 'Not met' }} + @else + {{ $kpi->value_type->format($entry->value, $kpi->unit_label) }} + @endif + @include('partials.status-badge', ['status' => $entry->status]){{ $entry->notes }} + @can('update', $kpi) + Edit +
+ @csrf + @method('DELETE') + +
+ @else + + @endcan +
+
+ @endif + + @include('partials.confirm-script') +@endsection diff --git a/src/resources/views/kpis/entry-edit.blade.php b/src/resources/views/kpis/entry-edit.blade.php new file mode 100644 index 0000000..95d903d --- /dev/null +++ b/src/resources/views/kpis/entry-edit.blade.php @@ -0,0 +1,42 @@ +@extends('layouts.app') + +@section('title', 'Edit Entry — ' . config('app.name')) + +@section('content') +
+

Edit entry

+

{{ $kpi->name }}

+ +
+ @csrf + @method('PUT') + +
+ + + @error('entry_date') +

{{ $message }}

+ @enderror +
+ +
+ @include('kpis._entry-value-field', ['kpi' => $kpi, 'entry' => $entry]) +
+ +
+ + + @error('notes') +

{{ $message }}

+ @enderror +
+ +
+ + Cancel +
+
+
+@endsection diff --git a/src/resources/views/kpis/index.blade.php b/src/resources/views/kpis/index.blade.php new file mode 100644 index 0000000..cab4700 --- /dev/null +++ b/src/resources/views/kpis/index.blade.php @@ -0,0 +1,70 @@ +@extends('layouts.app') + +@section('title', 'KPIs — ' . config('app.name')) + +@section('content') +
+

My KPIs

+ New KPI +
+ + @if ($kpis->isEmpty()) +
+ No KPIs yet. Create your first KPI. +
+ @else +
+ + + + + + + + + + + + + + @foreach ($kpis as $kpi) + + + + + + + + + + @endforeach + +
NameTeam / CategoryTypeThresholdsLatestEntriesActions
+ {{ $kpi->name }} + + {{ $kpi->team?->name ?? '—' }} / {{ $kpi->category?->name ?? '—' }} + {{ $kpi->value_type->label() }} · {{ $kpi->evaluation->label() }}{{ $kpi->thresholdSummary() }} + @if ($kpi->latestEntry) + @include('partials.status-badge', ['status' => $kpi->latestEntry->status]) + {{ $kpi->value_type->format($kpi->latestEntry->value, $kpi->unit_label) }} + @else + + @endif + {{ $kpi->entries_count }} + Entries + Edit +
+ @csrf + @method('DELETE') + +
+
+
+ @endif + + @include('partials.confirm-script') +@endsection diff --git a/src/resources/views/layouts/app.blade.php b/src/resources/views/layouts/app.blade.php index ef6eb3f..141d623 100644 --- a/src/resources/views/layouts/app.blade.php +++ b/src/resources/views/layouts/app.blade.php @@ -12,6 +12,12 @@ {{ config('app.name', 'Bowler') }}