Added Charts, Graphs, Types, Categories, etc.

This commit is contained in:
Brian Fertig 2026-07-09 12:46:19 -06:00
parent 0bde948081
commit a76cc39494
82 changed files with 4546 additions and 4 deletions

1
.gitignore vendored
View File

@ -2,6 +2,7 @@
db-data/ db-data/
src/vendor/ src/vendor/
src/public/css/app.css src/public/css/app.css
src/public/vendor/swagger-ui/
src/composer.lock src/composer.lock
src/node_modules/ src/node_modules/
src/storage/app/* src/storage/app/*

View File

@ -46,10 +46,62 @@ Nothing else — no local PHP, Composer, or Node needed. The app container insta
|---|---| |---|---|
| `/` | Hello World landing page | | `/` | Hello World landing page |
| `/login` | Login (rejects disabled accounts, throttled to 10 attempts/min) | | `/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/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. 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 <key>`.
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 ## Project layout
``` ```
@ -88,6 +140,23 @@ docker-compose exec app php artisan tinker # interactive REPL
docker-compose down # stop (data persists in db-data/) 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 ## Roadmap
- Bowler chart data model and CRUD - Bowler chart data model and CRUD

View File

@ -48,6 +48,13 @@ services:
image: percona:latest image: percona:latest
container_name: bowler-db container_name: bowler-db
restart: unless-stopped 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: volumes:
- ./db-data:/var/lib/mysql - ./db-data:/var/lib/mysql
environment: environment:

View File

@ -21,11 +21,20 @@ RUN case "$TARGETARCH" in \
COPY --from=composer:latest /usr/bin/composer /usr/local/bin/composer COPY --from=composer:latest /usr/bin/composer /usr/local/bin/composer
ENV COMPOSER_ALLOW_SUPERUSER=1 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 # Serve Laravel's public/ directory
ENV APACHE_DOCUMENT_ROOT=/var/www/html/public ENV APACHE_DOCUMENT_ROOT=/var/www/html/public
RUN sed -ri -e 's!/var/www/html!${APACHE_DOCUMENT_ROOT}!g' \ 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 /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 COPY entrypoint.sh /usr/local/bin/entrypoint.sh
# Strip Windows line endings in case the file was checked out with CRLF # 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 RUN sed -i 's/\r$//' /usr/local/bin/entrypoint.sh && chmod +x /usr/local/bin/entrypoint.sh

View File

@ -9,6 +9,13 @@ if [ ! -f vendor/autoload.php ]; then
composer install --no-interaction --prefer-dist --no-progress composer install --no-interaction --prefer-dist --no-progress
fi 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 # Compile Tailwind CSS
if [ -f resources/css/app.css ]; then if [ -f resources/css/app.css ]; then
echo "Building Tailwind CSS..." echo "Building Tailwind CSS..."
@ -37,6 +44,10 @@ echo "Database is up."
php artisan migrate --force php artisan migrate --force
php artisan db:seed --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. # Make runtime directories writable by the web server.
# chown can be a no-op or fail on Windows bind mounts - never fatal. # 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 chown -R www-data:www-data storage bootstrap/cache 2>/dev/null || true

16
docker/app/php-perf.ini Normal file
View File

@ -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

View File

@ -0,0 +1,21 @@
<?php
namespace App\Enums;
enum KpiEvaluation: string
{
case HigherIsBetter = 'higher_is_better';
case LowerIsBetter = 'lower_is_better';
case TargetBand = 'target_band';
case Milestone = 'milestone';
public function label(): string
{
return match ($this) {
self::HigherIsBetter => 'Higher is better',
self::LowerIsBetter => 'Lower is better',
self::TargetBand => 'Target band (stay near target)',
self::Milestone => 'Milestone (met / not met)',
};
}
}

View File

@ -0,0 +1,32 @@
<?php
namespace App\Enums;
enum KpiValueType: string
{
case Number = 'number';
case Percent = 'percent';
case Duration = 'duration';
public function label(): string
{
return match ($this) {
self::Number => '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,
};
}
}

View File

@ -0,0 +1,27 @@
<?php
namespace App\Enums;
enum RagStatus: string
{
case Green = 'green';
case Yellow = 'yellow';
case Red = 'red';
public function label(): string
{
return ucfirst($this->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',
};
}
}

View File

@ -0,0 +1,60 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Kpi;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class KpiApiController extends Controller
{
public function index(Request $request): JsonResponse
{
$kpis = $request->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);
}
}

View File

@ -0,0 +1,73 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Kpi;
use App\Models\KpiEntry;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class KpiEntryApiController extends Controller
{
public function index(Kpi $kpi): JsonResponse
{
$this->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<string, mixed>
*/
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(),
];
}
}

View File

@ -0,0 +1,19 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class MeApiController extends Controller
{
public function __invoke(Request $request): JsonResponse
{
return response()->json([
'id' => $request->user()->id,
'name' => $request->user()->name,
'email' => $request->user()->email,
]);
}
}

View File

@ -0,0 +1,60 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Project;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class ProjectApiController extends Controller
{
public function index(Request $request): JsonResponse
{
$projects = $request->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);
}
}

View File

@ -0,0 +1,55 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Project;
use App\Models\ProjectEntry;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class ProjectEntryApiController extends Controller
{
public function index(Project $project): JsonResponse
{
$this->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);
}
}

View File

@ -0,0 +1,62 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Crypt;
use Illuminate\View\View;
class ApiKeyController extends Controller
{
public function index(Request $request): View
{
$tokens = $request->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.');
}
}

View File

@ -0,0 +1,123 @@
<?php
namespace App\Http\Controllers;
use App\Models\BowlerChart;
use App\Models\Group;
use App\Support\BowlerGrid;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class BowlerChartController extends Controller
{
public function index(Request $request): View
{
$user = $request->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();
}
}

View File

@ -0,0 +1,62 @@
<?php
namespace App\Http\Controllers;
use App\Models\Category;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
use Illuminate\View\View;
class CategoryController extends Controller
{
public function index(Request $request): View
{
$categories = $request->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.");
}
}

View File

@ -0,0 +1,30 @@
<?php
namespace App\Http\Controllers\Concerns;
use App\Models\BowlerChart;
use App\Models\Kpi;
use App\Models\Project;
use Illuminate\Http\Request;
trait SyncsChartExclusions
{
/**
* Convert the "share to charts" checkboxes into exclusion rows:
* every chart in the owner's groups that was NOT checked is excluded.
*/
private function syncChartExclusions(Request $request, Kpi|Project $item): void
{
$ownerChartIds = BowlerChart::query()
->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());
}
}

View File

@ -2,7 +2,9 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
abstract class Controller abstract class Controller
{ {
// use AuthorizesRequests;
} }

View File

@ -0,0 +1,86 @@
<?php
namespace App\Http\Controllers;
use App\Http\Controllers\Concerns\SyncsChartExclusions;
use App\Models\Kpi;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class KpiController extends Controller
{
use SyncsChartExclusions;
public function index(Request $request): View
{
$kpis = $request->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<string, array<int, string>>
*/
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.");
}
}

View File

@ -0,0 +1,71 @@
<?php
namespace App\Http\Controllers;
use App\Models\Kpi;
use App\Models\KpiEntry;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class KpiEntryController extends Controller
{
public function index(Kpi $kpi): View
{
$this->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.');
}
}

View File

@ -0,0 +1,86 @@
<?php
namespace App\Http\Controllers;
use App\Http\Controllers\Concerns\SyncsChartExclusions;
use App\Models\Project;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class ProjectController extends Controller
{
use SyncsChartExclusions;
public function index(Request $request): View
{
$projects = $request->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<string, array<int, string>>
*/
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.");
}
}

View File

@ -0,0 +1,70 @@
<?php
namespace App\Http\Controllers;
use App\Models\Project;
use App\Models\ProjectEntry;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class ProjectEntryController extends Controller
{
public function index(Project $project): View
{
$this->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.');
}
}

View File

@ -0,0 +1,62 @@
<?php
namespace App\Http\Controllers;
use App\Models\Team;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
use Illuminate\View\View;
class TeamController extends Controller
{
public function index(Request $request): View
{
$teams = $request->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.");
}
}

View File

@ -0,0 +1,55 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
class BowlerChart extends Model
{
protected $fillable = ['group_id', 'name', 'description'];
public function group(): BelongsTo
{
return $this->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();
}
}

View File

@ -0,0 +1,27 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Category extends Model
{
protected $fillable = ['user_id', 'name'];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function kpis(): HasMany
{
return $this->hasMany(Kpi::class);
}
public function projects(): HasMany
{
return $this->hasMany(Project::class);
}
}

View File

@ -4,6 +4,7 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Group extends Model class Group extends Model
{ {
@ -16,4 +17,9 @@ class Group extends Model
->withTimestamps() ->withTimestamps()
->orderBy('name'); ->orderBy('name');
} }
public function bowlerCharts(): HasMany
{
return $this->hasMany(BowlerChart::class)->orderBy('name');
}
} }

142
src/app/Models/Kpi.php Normal file
View File

@ -0,0 +1,142 @@
<?php
namespace App\Models;
use App\Enums\KpiEvaluation;
use App\Enums\KpiValueType;
use App\Enums\RagStatus;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Kpi extends Model
{
protected $fillable = [
'user_id',
'team_id',
'category_id',
'name',
'description',
'value_type',
'evaluation',
'unit_label',
'green_threshold',
'yellow_threshold',
'target',
'green_tolerance',
'yellow_tolerance',
];
protected function casts(): array
{
return [
'value_type' => 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<string, array<int, mixed>>
*/
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',
};
}
}

View File

@ -0,0 +1,50 @@
<?php
namespace App\Models;
use App\Enums\RagStatus;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class KpiEntry extends Model
{
protected $fillable = [
'kpi_id',
'entry_date',
'value',
'notes',
];
protected function casts(): array
{
return [
'entry_date' => 'date',
'value' => 'float',
];
}
public function kpi(): BelongsTo
{
return $this->belongsTo(Kpi::class);
}
/**
* Validation rules shared by the web and API controllers.
*
* @return array<string, array<int, mixed>>
*/
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));
}
}

View File

@ -0,0 +1,68 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Project extends Model
{
protected $fillable = [
'user_id',
'team_id',
'category_id',
'name',
'description',
];
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(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<string, array<int, mixed>>
*/
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'],
];
}
}

View File

@ -0,0 +1,52 @@
<?php
namespace App\Models;
use App\Enums\RagStatus;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ProjectEntry extends Model
{
protected $fillable = [
'project_id',
'entry_date',
'status',
'notes',
'headwinds',
'tailwinds',
'highlights',
'blockers',
];
protected function casts(): array
{
return [
'entry_date' => 'date',
'status' => RagStatus::class,
];
}
public function project(): BelongsTo
{
return $this->belongsTo(Project::class);
}
/**
* Validation rules shared by the web and API controllers.
*
* @return array<string, array<int, mixed>>
*/
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'],
];
}
}

27
src/app/Models/Team.php Normal file
View File

@ -0,0 +1,27 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Team extends Model
{
protected $fillable = ['user_id', 'name'];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function kpis(): HasMany
{
return $this->hasMany(Kpi::class);
}
public function projects(): HasMany
{
return $this->hasMany(Project::class);
}
}

View File

@ -3,12 +3,14 @@
namespace App\Models; namespace App\Models;
use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable; use Illuminate\Notifications\Notifiable;
use Laravel\Sanctum\HasApiTokens;
class User extends Authenticatable class User extends Authenticatable
{ {
use Notifiable; use HasApiTokens, Notifiable;
/** /**
* @var list<string> * @var list<string>
@ -37,6 +39,47 @@ class User extends Authenticatable
->orderBy('name'); ->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<string, string> * @return array<string, string>
*/ */

View File

@ -0,0 +1,31 @@
<?php
namespace App\Policies;
use App\Models\BowlerChart;
use App\Models\User;
class BowlerChartPolicy
{
/**
* Any member of the chart's group (or an admin) can view it.
*/
public function view(User $user, BowlerChart $chart): bool
{
return $user->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);
}
}

View File

@ -0,0 +1,30 @@
<?php
namespace App\Policies;
use App\Models\Kpi;
use App\Models\User;
class KpiPolicy
{
/**
* Owner and admins have full view; group-mates of the owner get
* (read-only) view so they can click through from shared charts.
*/
public function view(User $user, Kpi $kpi): bool
{
return $kpi->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;
}
}

View File

@ -0,0 +1,30 @@
<?php
namespace App\Policies;
use App\Models\Project;
use App\Models\User;
class ProjectPolicy
{
/**
* Owner and admins have full view; group-mates of the owner get
* (read-only) view so they can click through from shared charts.
*/
public function view(User $user, Project $project): bool
{
return $project->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;
}
}

View File

@ -0,0 +1,153 @@
<?php
namespace App\Support;
use App\Enums\KpiEvaluation;
use App\Models\Kpi;
use App\Models\Project;
use Illuminate\Support\Collection;
/**
* Builds the rolling 12-month bowler grid. Rows are grouped
* Team -> 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<int, array{key: string, label: string}>
*/
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<int, Kpi> $kpis
* @param Collection<int, Project> $projects
* @return array<string, array<string, array<int, array{type: string, item: Kpi|Project, cells: 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<string, array{status: \App\Enums\RagStatus, display: string, tooltip: string}>
*/
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<string, array{status: \App\Enums\RagStatus, display: string, tooltip: string}>
*/
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;
}
}

View File

@ -0,0 +1,315 @@
<?php
namespace App\Support;
use App\Enums\KpiEvaluation;
use App\Models\Kpi;
/**
* View-model builder for the click-to-expand KPI chart on bowler charts.
* Produces SVG-ready geometry: threshold zone bands, gridlines, the value
* line (split on missing months), status-colored points, and axis labels.
*
* Colors follow the dataviz status palette (state, not series identity):
* good/warning/critical washes for the zones, points colored by each
* month's RAG status, and a neutral series blue for the value line itself.
*/
class KpiChart
{
// Status palette (reserved for state) + neutral series line color.
private const GOOD = '#0ca30c';
private const WARN = '#fab219';
private const CRIT = '#d03b3b';
private const LINE = '#2a78d6';
// Chart chrome (ink tokens / hairlines).
public const INK_PRIMARY = '#0b0b0b';
public const INK_SECONDARY = '#52514e';
public const INK_MUTED = '#898781';
public const GRIDLINE = '#e1e0d9';
private const W = 720;
private const H = 220;
private const PAD_LEFT = 52;
private const PAD_RIGHT = 60;
private const PAD_TOP = 14;
private const PAD_BOTTOM = 30;
/**
* @param array<string, array{status: \App\Enums\RagStatus, display: string, tooltip: string}> $cells
* @param array<int, array{key: string, label: string}> $months
* @return array<string, mixed>
*/
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<string, mixed>
*/
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<int, array{y: float, h: float, color: string}>, 1: array<int, array{y: float, label: string, emphasis?: bool}>}
*/
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<string, mixed>
*/
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<int, array{x: float, label: string}>
*/
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<int, float>
*/
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,
};
}
}

View File

@ -7,6 +7,7 @@ use Illuminate\Foundation\Configuration\Middleware;
return Application::configure(basePath: dirname(__DIR__)) return Application::configure(basePath: dirname(__DIR__))
->withRouting( ->withRouting(
web: __DIR__.'/../routes/web.php', web: __DIR__.'/../routes/web.php',
api: __DIR__.'/../routes/api.php',
commands: __DIR__.'/../routes/console.php', commands: __DIR__.'/../routes/console.php',
health: '/up', health: '/up',
) )

View File

@ -5,7 +5,8 @@
"license": "proprietary", "license": "proprietary",
"require": { "require": {
"php": "^8.4", "php": "^8.4",
"laravel/framework": "^12.0" "laravel/framework": "^12.0",
"laravel/sanctum": "^4.0"
}, },
"autoload": { "autoload": {
"psr-4": { "psr-4": {
@ -14,6 +15,12 @@
"Database\\Seeders\\": "database/seeders/" "Database\\Seeders\\": "database/seeders/"
} }
}, },
"scripts": {
"post-autoload-dump": [
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
"@php artisan package:discover --ansi"
]
},
"config": { "config": {
"optimize-autoloader": true, "optimize-autoloader": true,
"preferred-install": "dist", "preferred-install": "dist",

View File

@ -0,0 +1,43 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('kpis', function (Blueprint $table) {
$table->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');
}
};

View File

@ -0,0 +1,39 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('projects', function (Blueprint $table) {
$table->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');
}
};

View File

@ -0,0 +1,27 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('personal_access_tokens', function (Blueprint $table) {
$table->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');
}
};

View File

@ -0,0 +1,23 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('personal_access_tokens', function (Blueprint $table) {
// Encrypted copy of the plaintext token so users can view keys later
$table->text('plaintext_token')->nullable()->after('token');
});
}
public function down(): void
{
Schema::table('personal_access_tokens', function (Blueprint $table) {
$table->dropColumn('plaintext_token');
});
}
};

View File

@ -0,0 +1,40 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('bowler_charts', function (Blueprint $table) {
$table->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');
}
};

View File

@ -0,0 +1,53 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('teams', function (Blueprint $table) {
$table->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');
}
};

View File

@ -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 <your-key>`"
},
"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" }
}
}
}
}
}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,30 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>API Documentation {{ config('app.name', 'Bowler') }}</title>
<link rel="stylesheet" href="{{ asset('vendor/swagger-ui/swagger-ui.css') }}">
<style>
body { margin: 0; }
.topbar { display: none; }
</style>
</head>
<body>
<div id="swagger-ui"></div>
<script src="{{ asset('vendor/swagger-ui/swagger-ui-bundle.js') }}"></script>
<script src="{{ asset('vendor/swagger-ui/swagger-ui-standalone-preset.js') }}"></script>
<script>
window.onload = function () {
window.ui = SwaggerUIBundle({
url: "{{ asset('api-docs/openapi.json') }}",
dom_id: '#swagger-ui',
presets: [SwaggerUIBundle.presets.apis, SwaggerUIStandalonePreset],
layout: 'BaseLayout',
deepLinking: true,
persistAuthorization: true
});
};
</script>
</body>
</html>

View File

@ -0,0 +1,93 @@
@extends('layouts.app')
@section('title', 'API Keys — ' . config('app.name'))
@section('content')
<div class="mb-4 flex items-center justify-between">
<h1 class="text-2xl font-bold">API Keys</h1>
<a href="{{ route('api.docs') }}" class="text-blue-600 hover:underline">API documentation </a>
</div>
@if ($errors->has('view'))
<div class="mb-4 rounded-md bg-red-50 px-4 py-3 text-red-800">{{ $errors->first('view') }}</div>
@endif
@if (session('plainTextToken'))
<div class="mb-6 rounded-lg border border-green-300 bg-green-50 p-4">
<p class="mb-2 font-semibold text-green-800">Copy your API key treat it like a password:</p>
<div class="flex items-center gap-2">
<code id="new-token" class="block flex-1 overflow-x-auto rounded bg-white px-3 py-2 font-mono text-sm">{{ session('plainTextToken') }}</code>
<button type="button" id="copy-token"
class="cursor-pointer rounded-md border border-green-600 px-3 py-2 text-sm text-green-800 hover:bg-green-100">Copy</button>
</div>
</div>
<script>
document.getElementById('copy-token').addEventListener('click', function () {
navigator.clipboard.writeText(document.getElementById('new-token').textContent.trim()).then(() => {
this.textContent = 'Copied!';
});
});
</script>
@endif
<div class="mb-6 rounded-lg border border-gray-200 bg-white p-4">
<h2 class="mb-3 text-sm font-semibold uppercase tracking-wide text-gray-500">Create a new key</h2>
<form method="POST" action="{{ route('api-keys.store') }}" class="flex items-end gap-3">
@csrf
<div class="flex-1">
<label for="name" class="mb-1 block font-semibold">Key name</label>
<input id="name" type="text" name="name" value="{{ old('name') }}" required placeholder="e.g. CI pipeline, Power BI, my-script"
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/40">
</div>
<button type="submit" class="cursor-pointer rounded-md bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700">Create key</button>
</form>
@error('name')
<p class="mt-1 text-sm text-red-700">{{ $message }}</p>
@enderror
<p class="mt-2 text-sm text-gray-500">
Use keys as a Bearer token: <code class="rounded bg-gray-100 px-1 py-0.5 font-mono text-xs">Authorization: Bearer &lt;key&gt;</code>
</p>
</div>
<div class="overflow-x-auto rounded-lg border border-gray-200 bg-white">
<table class="w-full text-left">
<thead>
<tr class="bg-gray-50 text-xs font-semibold uppercase tracking-wide text-gray-500">
<th class="px-4 py-3">Name</th>
<th class="px-4 py-3">Created</th>
<th class="px-4 py-3">Last used</th>
<th class="px-4 py-3">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
@forelse ($tokens as $token)
<tr>
<td class="px-4 py-3 font-medium">{{ $token->name }}</td>
<td class="px-4 py-3">{{ $token->created_at->format('Y-m-d H:i') }}</td>
<td class="px-4 py-3">{{ $token->last_used_at?->format('Y-m-d H:i') ?? 'Never' }}</td>
<td class="whitespace-nowrap px-4 py-3">
<form method="POST" action="{{ route('api-keys.show', $token->id) }}" class="inline">
@csrf
<button type="submit"
class="mr-1 cursor-pointer rounded-md border border-gray-300 bg-white px-2.5 py-1 text-sm text-gray-900 hover:bg-gray-50">View</button>
</form>
<form method="POST" action="{{ route('api-keys.destroy', $token->id) }}" class="js-confirm inline"
data-confirm="Revoke API key &quot;{{ $token->name }}&quot;? Anything using it will stop working.">
@csrf
@method('DELETE')
<button type="submit"
class="cursor-pointer rounded-md border border-red-600 px-2.5 py-1 text-sm text-red-700 hover:bg-red-50">Revoke</button>
</form>
</td>
</tr>
@empty
<tr>
<td colspan="4" class="px-4 py-6 text-center text-gray-500">No API keys yet.</td>
</tr>
@endforelse
</tbody>
</table>
</div>
@include('partials.confirm-script')
@endsection

View File

@ -0,0 +1,28 @@
@extends('layouts.app')
@section('title', 'Rename Category — ' . config('app.name'))
@section('content')
<div class="mx-auto max-w-md rounded-lg border border-gray-200 bg-white p-8">
<h1 class="mb-6 text-2xl font-bold">Rename category</h1>
<form method="POST" action="{{ route('categories.update', $category) }}">
@csrf
@method('PUT')
<div class="mb-6">
<label for="name" class="mb-1 block font-semibold">Name</label>
<input id="name" type="text" name="name" value="{{ old('name', $category->name) }}" required autofocus
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/40">
@error('name')
<p class="mt-1 text-sm text-red-700">{{ $message }}</p>
@enderror
</div>
<div class="flex gap-2">
<button type="submit" class="cursor-pointer rounded-md bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700">Save</button>
<a href="{{ route('categories.index') }}" class="rounded-md border border-gray-300 bg-white px-4 py-2 text-gray-900 hover:bg-gray-50">Cancel</a>
</div>
</form>
</div>
@endsection

View File

@ -0,0 +1,65 @@
@extends('layouts.app')
@section('title', 'Categories — ' . config('app.name'))
@section('content')
<div class="mb-4 flex items-center justify-between">
<h1 class="text-2xl font-bold">My Categories</h1>
<a href="{{ route('teams.index') }}" class="text-blue-600 hover:underline">Manage teams </a>
</div>
<p class="mb-4 text-sm text-gray-500">Categories sub-group items within each team on bowler charts e.g. "Performance", "General", or "Security".</p>
<div class="mb-6 rounded-lg border border-gray-200 bg-white p-4">
<form method="POST" action="{{ route('categories.store') }}" class="flex items-end gap-3">
@csrf
<div class="flex-1">
<label for="name" class="mb-1 block font-semibold">New category</label>
<input id="name" type="text" name="name" value="{{ old('name') }}" required placeholder="e.g. Performance"
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/40">
</div>
<button type="submit" class="cursor-pointer rounded-md bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700">Add category</button>
</form>
@error('name')
<p class="mt-1 text-sm text-red-700">{{ $message }}</p>
@enderror
</div>
<div class="overflow-x-auto rounded-lg border border-gray-200 bg-white">
<table class="w-full text-left">
<thead>
<tr class="bg-gray-50 text-xs font-semibold uppercase tracking-wide text-gray-500">
<th class="px-4 py-3">Name</th>
<th class="px-4 py-3">KPIs</th>
<th class="px-4 py-3">Projects</th>
<th class="px-4 py-3">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
@forelse ($categories as $category)
<tr>
<td class="px-4 py-3 font-medium">{{ $category->name }}</td>
<td class="px-4 py-3">{{ $category->kpis_count }}</td>
<td class="px-4 py-3">{{ $category->projects_count }}</td>
<td class="whitespace-nowrap px-4 py-3">
<a href="{{ route('categories.edit', $category) }}"
class="mr-1 inline-block rounded-md border border-gray-300 bg-white px-2.5 py-1 text-sm text-gray-900 hover:bg-gray-50">Rename</a>
<form method="POST" action="{{ route('categories.destroy', $category) }}" class="js-confirm inline"
data-confirm="Delete category &quot;{{ $category->name }}&quot;? Its {{ $category->kpis_count + $category->projects_count }} item(s) will become unassigned (not deleted).">
@csrf
@method('DELETE')
<button type="submit"
class="cursor-pointer rounded-md border border-red-600 px-2.5 py-1 text-sm text-red-700 hover:bg-red-50">Delete</button>
</form>
</td>
</tr>
@empty
<tr>
<td colspan="4" class="px-4 py-6 text-center text-gray-500">No categories yet add your first above.</td>
</tr>
@endforelse
</tbody>
</table>
</div>
@include('partials.confirm-script')
@endsection

View File

@ -0,0 +1,182 @@
{{-- Expects: $months, $groupedRows (Team => Category => rows), $showOwner (bool) --}}
@php($totalCols = count($months) + 1 + ($showOwner ? 1 : 0))
@if (empty($groupedRows))
<div class="rounded-lg border border-gray-200 bg-white px-6 py-10 text-center text-gray-500">
Nothing to show yet KPIs and projects will appear here automatically.
</div>
@else
<div class="overflow-x-auto rounded-lg border border-gray-200 bg-white">
<table class="w-full border-collapse text-left text-sm">
<thead>
<tr class="bg-gray-50 text-xs font-semibold uppercase tracking-wide text-gray-500">
<th class="sticky left-0 z-10 min-w-48 bg-gray-50 px-4 py-3">Item</th>
@if ($showOwner)
<th class="min-w-28 px-2 py-3">Owner</th>
@endif
@foreach ($months as $month)
<th class="px-2 py-3 text-center {{ $loop->last ? 'bg-blue-50' : '' }}">{{ $month['label'] }}</th>
@endforeach
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
@foreach ($groupedRows as $teamName => $categories)
<tr>
<td colspan="{{ $totalCols }}"
class="bg-gray-800 px-4 py-2 text-xs font-bold uppercase tracking-wider text-white">{{ $teamName }}</td>
</tr>
@foreach ($categories as $categoryName => $rows)
<tr>
<td colspan="{{ $totalCols }}"
class="bg-gray-100 px-4 py-1.5 text-xs font-semibold uppercase tracking-wide text-gray-500">{{ $categoryName }}</td>
</tr>
@foreach ($rows as $row)
@php($item = $row['item'])
<tr class="{{ $row['type'] === 'kpi' ? 'js-kpi-row cursor-pointer hover:bg-gray-50' : '' }}">
<td class="sticky left-0 z-10 bg-white px-4 py-2 font-medium">
@if ($row['type'] === 'kpi')
<a href="{{ route('kpis.entries.index', $item) }}" class="hover:underline">{{ $item->name }}</a>
<span class="block text-xs font-normal text-gray-400">{{ $item->thresholdSummary() }}</span>
@else
<a href="{{ route('projects.entries.index', $item) }}" class="hover:underline">{{ $item->name }}</a>
<span class="block text-xs font-normal text-gray-400">Project</span>
@endif
</td>
@if ($showOwner)
<td class="px-2 py-2 text-xs text-gray-500">{{ $item->user->name }}</td>
@endif
@foreach ($months as $month)
@php($cell = $row['cells'][$month['key']] ?? null)
<td class="px-1 py-1 text-center {{ $loop->last ? 'bg-blue-50/50' : '' }}">
@if ($cell && $row['type'] === 'project')
<button type="button" title="{{ $cell['tooltip'] }}"
data-month="{{ $month['key'] }}"
data-details="{{ json_encode($cell['details']) }}"
class="js-project-cell inline-block w-full cursor-pointer rounded px-1 py-1.5 text-xs font-semibold hover:ring-2 hover:ring-blue-400 {{ $cell['status']->badgeClasses() }}">
{{ $cell['display'] }}
</button>
@elseif ($cell)
<span title="{{ $cell['tooltip'] }}"
class="inline-block w-full rounded px-1 py-1.5 text-xs font-semibold {{ $cell['status']->badgeClasses() }}">
{{ $cell['display'] }}
</span>
@else
<span class="text-gray-300"></span>
@endif
</td>
@endforeach
</tr>
@if ($row['type'] === 'project')
<tr class="js-detail-row hidden">
<td colspan="{{ $totalCols }}" class="bg-blue-50/60 px-6 py-4"></td>
</tr>
@else
<tr class="js-detail-row hidden">
<td colspan="{{ $totalCols }}" class="bg-blue-50/60 px-6 py-4">
@include('charts._kpi-chart', ['kpi' => $item, 'chart' => $row['chart']])
</td>
</tr>
@endif
@endforeach
@endforeach
@endforeach
</tbody>
</table>
</div>
<p class="mt-2 text-xs text-gray-400">
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.
</p>
<script>
(function () {
function collapseAll() {
document.querySelectorAll('.js-detail-row').forEach(function (r) { r.classList.add('hidden'); });
document.querySelectorAll('.js-project-cell.ring-2').forEach(function (c) { c.classList.remove('ring-2', 'ring-blue-500'); });
}
function section(label, text) {
var wrap = document.createElement('div');
var dt = document.createElement('p');
dt.className = 'text-xs font-semibold uppercase tracking-wide text-gray-500';
dt.textContent = label;
var dd = document.createElement('p');
dd.className = 'mt-0.5 whitespace-pre-line text-sm text-gray-700';
dd.textContent = text;
wrap.appendChild(dt);
wrap.appendChild(dd);
return wrap;
}
function populate(td, d) {
td.innerHTML = '';
var head = document.createElement('div');
head.className = 'mb-2 flex items-center gap-3';
var date = document.createElement('span');
date.className = 'font-semibold';
date.textContent = d.date;
var badge = document.createElement('span');
badge.className = 'inline-block rounded-full px-2.5 py-0.5 text-xs font-semibold ' + d.statusClasses;
badge.textContent = d.statusLabel;
head.appendChild(date);
head.appendChild(badge);
td.appendChild(head);
var sections = [['Notes', d.notes], ['Headwinds', d.headwinds], ['Tailwinds', d.tailwinds], ['Highlights', d.highlights], ['Blockers', d.blockers]]
.filter(function (s) { return s[1]; });
if (sections.length === 0) {
var empty = document.createElement('p');
empty.className = 'text-sm text-gray-500';
empty.textContent = 'No details were recorded for this entry.';
td.appendChild(empty);
return;
}
var grid = document.createElement('div');
grid.className = 'grid gap-x-8 gap-y-3 sm:grid-cols-2';
sections.forEach(function (s) { grid.appendChild(section(s[0], s[1])); });
td.appendChild(grid);
}
document.addEventListener('click', function (e) {
// KPI rows expand to a threshold chart (but let the entries link navigate).
var kpiRow = e.target.closest('.js-kpi-row');
if (kpiRow && !e.target.closest('a')) {
var kpiDetail = kpiRow.nextElementSibling;
var kpiOpen = kpiDetail && !kpiDetail.classList.contains('hidden');
collapseAll();
if (kpiDetail && !kpiOpen) {
kpiDetail.classList.remove('hidden');
}
return;
}
var cell = e.target.closest('.js-project-cell');
if (cell) {
var detailRow = cell.closest('tr').nextElementSibling;
var isOpenForThisCell = detailRow
&& !detailRow.classList.contains('hidden')
&& detailRow.dataset.month === cell.dataset.month;
collapseAll();
if (detailRow && !isOpenForThisCell) {
populate(detailRow.firstElementChild, JSON.parse(cell.dataset.details));
detailRow.dataset.month = cell.dataset.month;
detailRow.classList.remove('hidden');
cell.classList.add('ring-2', 'ring-blue-500');
}
return;
}
// Clicks inside an open detail row keep it open; anywhere else collapses.
if (!e.target.closest('.js-detail-row')) {
collapseAll();
}
});
})();
</script>
@endif

View File

@ -0,0 +1,80 @@
{{-- Expects: $kpi (App\Models\Kpi), $chart (array from App\Support\KpiChart::build) --}}
<div class="rounded-md bg-white p-4">
<p class="mb-1 text-sm font-semibold text-gray-900">{{ $kpi->name }} last 12 months</p>
<p class="mb-3 text-xs text-gray-500">{{ $kpi->thresholdSummary() }}@if ($kpi->unit_label) · unit: {{ $kpi->unit_label }}@endif</p>
@if (($chart['type'] === 'line' && empty($chart['points'])) || ($chart['type'] === 'milestone' && empty($chart['markers'])))
<p class="py-4 text-sm text-gray-500">No entries in the last 12 months.</p>
@elseif ($chart['type'] === 'line')
<svg viewBox="0 0 {{ $chart['width'] }} {{ $chart['height'] }}" role="img" aria-label="{{ $chart['aria'] }}"
class="h-auto w-full max-w-3xl" font-family="system-ui, -apple-system, 'Segoe UI', sans-serif">
{{-- Threshold zone bands (10% status washes) --}}
@foreach ($chart['bands'] as $band)
<rect x="{{ $chart['plot']['x'] }}" y="{{ $band['y'] }}" width="{{ $chart['plot']['w'] }}" height="{{ $band['h'] }}"
fill="{{ $band['color'] }}" fill-opacity="0.10" />
@endforeach
{{-- Gridlines + y ticks --}}
@foreach ($chart['gridlines'] as $grid)
<line x1="{{ $chart['plot']['x'] }}" y1="{{ $grid['y'] }}" x2="{{ $chart['plot']['x'] + $chart['plot']['w'] }}" y2="{{ $grid['y'] }}"
stroke="{{ \App\Support\KpiChart::GRIDLINE }}" stroke-width="1" />
<text x="{{ $chart['plot']['x'] - 6 }}" y="{{ $grid['y'] + 3 }}" text-anchor="end"
font-size="10" fill="{{ \App\Support\KpiChart::INK_MUTED }}">{{ $grid['label'] }}</text>
@endforeach
{{-- Threshold / target reference lines --}}
@foreach ($chart['refLines'] as $ref)
<line x1="{{ $chart['plot']['x'] }}" y1="{{ $ref['y'] }}" x2="{{ $chart['plot']['x'] + $chart['plot']['w'] }}" y2="{{ $ref['y'] }}"
stroke="{{ \App\Support\KpiChart::INK_MUTED }}" stroke-width="1" stroke-dasharray="4 3" />
<text x="{{ $chart['plot']['x'] + $chart['plot']['w'] + 4 }}" y="{{ $ref['y'] + 3 }}"
font-size="9" fill="{{ \App\Support\KpiChart::INK_SECONDARY }}">{{ $ref['label'] }}</text>
@endforeach
{{-- Value line (gaps split the path) --}}
@foreach ($chart['paths'] as $path)
<path d="{{ $path }}" fill="none" stroke="{{ $chart['lineColor'] }}" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round" />
@endforeach
{{-- Points, status-colored with a 2px surface ring --}}
@foreach ($chart['points'] as $point)
<circle cx="{{ $point['x'] }}" cy="{{ $point['y'] }}" r="4.5"
fill="{{ $point['color'] }}" stroke="#ffffff" stroke-width="2">
<title>{{ $point['title'] }}</title>
</circle>
@endforeach
{{-- End value label (selective direct label) --}}
@if ($chart['endLabel'])
<text x="{{ $chart['endLabel']['x'] }}" y="{{ $chart['endLabel']['y'] }}"
font-size="11" font-weight="600" fill="{{ \App\Support\KpiChart::INK_PRIMARY }}">{{ $chart['endLabel']['text'] }}</text>
@endif
{{-- Month labels --}}
@foreach ($chart['monthLabels'] as $label)
<text x="{{ $label['x'] }}" y="{{ $label['y'] }}" text-anchor="middle"
font-size="9" fill="{{ \App\Support\KpiChart::INK_MUTED }}">{{ $label['label'] }}</text>
@endforeach
</svg>
@else
{{-- Milestone timeline: met / missed markers per month --}}
<svg viewBox="0 0 {{ $chart['width'] }} {{ $chart['height'] }}" role="img" aria-label="{{ $chart['aria'] }}"
class="h-auto w-full max-w-3xl" font-family="system-ui, -apple-system, 'Segoe UI', sans-serif">
<line x1="{{ $chart['plot']['x'] }}" y1="{{ $chart['baseline'] }}" x2="{{ $chart['plot']['x'] + $chart['plot']['w'] }}" y2="{{ $chart['baseline'] }}"
stroke="{{ \App\Support\KpiChart::GRIDLINE }}" stroke-width="1" />
@foreach ($chart['markers'] as $marker)
<g>
<title>{{ $marker['title'] }}</title>
<circle cx="{{ $marker['x'] }}" cy="{{ $marker['y'] }}" r="9"
fill="{{ $marker['color'] }}" stroke="#ffffff" stroke-width="2" />
<text x="{{ $marker['x'] }}" y="{{ $marker['y'] + 3.5 }}" text-anchor="middle"
font-size="10" font-weight="700" fill="#ffffff">{{ $marker['glyph'] }}</text>
</g>
@endforeach
@foreach ($chart['monthLabels'] as $label)
<text x="{{ $label['x'] }}" y="{{ $label['y'] }}" text-anchor="middle"
font-size="9" fill="{{ \App\Support\KpiChart::INK_MUTED }}">{{ $label['label'] }}</text>
@endforeach
</svg>
@endif
</div>

View File

@ -0,0 +1,57 @@
@extends('layouts.app')
@section('title', 'New Bowler Chart — ' . config('app.name'))
@section('content')
<div class="mx-auto max-w-md rounded-lg border border-gray-200 bg-white p-8">
<h1 class="mb-6 text-2xl font-bold">Create bowler chart</h1>
@if ($groups->isEmpty())
<p class="text-gray-600">
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
<a href="{{ route('charts.personal') }}" class="text-blue-600 hover:underline">your personal bowler</a>.
</p>
@else
<form method="POST" action="{{ route('charts.store') }}">
@csrf
<div class="mb-4">
<label for="group_id" class="mb-1 block font-semibold">Group</label>
<select id="group_id" name="group_id" required
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/40">
@foreach ($groups as $group)
<option value="{{ $group->id }}" {{ (int) old('group_id') === $group->id ? 'selected' : '' }}>{{ $group->name }}</option>
@endforeach
</select>
@error('group_id')
<p class="mt-1 text-sm text-red-700">{{ $message }}</p>
@enderror
</div>
<div class="mb-4">
<label for="name" class="mb-1 block font-semibold">Name</label>
<input id="name" type="text" name="name" value="{{ old('name') }}" required
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/40">
@error('name')
<p class="mt-1 text-sm text-red-700">{{ $message }}</p>
@enderror
</div>
<div class="mb-6">
<label for="description" class="mb-1 block font-semibold">Description <span class="font-normal text-gray-500">(optional)</span></label>
<textarea id="description" name="description" rows="2"
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/40">{{ old('description') }}</textarea>
@error('description')
<p class="mt-1 text-sm text-red-700">{{ $message }}</p>
@enderror
</div>
<div class="flex gap-2">
<button type="submit" class="cursor-pointer rounded-md bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700">Create chart</button>
<a href="{{ route('charts.index') }}" class="rounded-md border border-gray-300 bg-white px-4 py-2 text-gray-900 hover:bg-gray-50">Cancel</a>
</div>
</form>
@endif
</div>
@endsection

View File

@ -0,0 +1,38 @@
@extends('layouts.app')
@section('title', 'Edit Bowler Chart — ' . config('app.name'))
@section('content')
<div class="mx-auto max-w-md rounded-lg border border-gray-200 bg-white p-8">
<h1 class="mb-2 text-2xl font-bold">Edit bowler chart</h1>
<p class="mb-6 text-gray-600">Group: {{ $chart->group->name }}</p>
<form method="POST" action="{{ route('charts.update', $chart) }}">
@csrf
@method('PUT')
<div class="mb-4">
<label for="name" class="mb-1 block font-semibold">Name</label>
<input id="name" type="text" name="name" value="{{ old('name', $chart->name) }}" required
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/40">
@error('name')
<p class="mt-1 text-sm text-red-700">{{ $message }}</p>
@enderror
</div>
<div class="mb-6">
<label for="description" class="mb-1 block font-semibold">Description <span class="font-normal text-gray-500">(optional)</span></label>
<textarea id="description" name="description" rows="2"
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/40">{{ old('description', $chart->description) }}</textarea>
@error('description')
<p class="mt-1 text-sm text-red-700">{{ $message }}</p>
@enderror
</div>
<div class="flex gap-2">
<button type="submit" class="cursor-pointer rounded-md bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700">Save changes</button>
<a href="{{ route('charts.show', $chart) }}" class="rounded-md border border-gray-300 bg-white px-4 py-2 text-gray-900 hover:bg-gray-50">Cancel</a>
</div>
</form>
</div>
@endsection

View File

@ -0,0 +1,58 @@
@extends('layouts.app')
@section('title', 'Bowler Charts — ' . config('app.name'))
@section('content')
<div class="mb-4 flex items-center justify-between">
<h1 class="text-2xl font-bold">Bowler Charts</h1>
<a href="{{ route('charts.create') }}" class="rounded-md bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700">New chart</a>
</div>
{{-- Personal chart --}}
<div class="mb-6 flex items-center justify-between rounded-lg border border-blue-200 bg-blue-50 px-5 py-4">
<div>
<a href="{{ route('charts.personal') }}" class="font-semibold text-blue-800 hover:underline">My Bowler</a>
<p class="text-sm text-blue-700/80">Your personal chart with all of your own KPIs and projects.</p>
</div>
<a href="{{ route('charts.personal') }}" class="rounded-md border border-blue-600 px-3 py-1.5 text-sm text-blue-700 hover:bg-blue-100">Open</a>
</div>
@if ($chartsByGroup->isEmpty())
<div class="rounded-lg border border-gray-200 bg-white px-6 py-10 text-center text-gray-500">
No group charts yet. Group owners can create charts for their groups.
</div>
@else
@foreach ($chartsByGroup as $groupName => $charts)
<h2 class="mb-2 mt-6 text-sm font-semibold uppercase tracking-wide text-gray-500">{{ $groupName }}</h2>
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
@foreach ($charts as $chart)
<div class="flex flex-col justify-between rounded-lg border border-gray-200 bg-white p-5">
<div>
<a href="{{ route('charts.show', $chart) }}" class="font-semibold hover:underline">{{ $chart->name }}</a>
@if ($chart->description)
<p class="mt-1 text-sm text-gray-500">{{ $chart->description }}</p>
@endif
</div>
<div class="mt-4 flex gap-2">
<a href="{{ route('charts.show', $chart) }}"
class="rounded-md border border-gray-300 bg-white px-2.5 py-1 text-sm text-gray-900 hover:bg-gray-50">Open</a>
@can('update', $chart)
<a href="{{ route('charts.edit', $chart) }}"
class="rounded-md border border-gray-300 bg-white px-2.5 py-1 text-sm text-gray-900 hover:bg-gray-50">Edit</a>
<form method="POST" action="{{ route('charts.destroy', $chart) }}" class="js-confirm inline"
data-confirm="Delete bowler chart &quot;{{ $chart->name }}&quot;? Items and entries are not deleted.">
@csrf
@method('DELETE')
<button type="submit"
class="cursor-pointer rounded-md border border-red-600 px-2.5 py-1 text-sm text-red-700 hover:bg-red-50">Delete</button>
</form>
@endcan
</div>
</div>
@endforeach
</div>
@endforeach
@endif
@include('partials.confirm-script')
@endsection

View File

@ -0,0 +1,18 @@
@extends('layouts.app')
@section('title', 'My Bowler — ' . config('app.name'))
@section('content')
<div class="mb-1 flex items-center justify-between">
<h1 class="text-2xl font-bold">My Bowler</h1>
<div class="flex gap-2">
<a href="{{ route('kpis.index') }}"
class="rounded-md border border-gray-300 bg-white px-3 py-1.5 text-sm text-gray-900 hover:bg-gray-50">Manage KPIs</a>
<a href="{{ route('projects.index') }}"
class="rounded-md border border-gray-300 bg-white px-3 py-1.5 text-sm text-gray-900 hover:bg-gray-50">Manage projects</a>
</div>
</div>
<p class="mb-4 text-sm text-gray-500">All of your own KPIs and projects, regardless of chart sharing.</p>
@include('charts._grid', ['showOwner' => false])
@endsection

View File

@ -0,0 +1,28 @@
@extends('layouts.app')
@section('title', $chart->name . ' — ' . config('app.name'))
@section('content')
<div class="mb-1 flex items-center gap-2 text-sm text-gray-500">
<a href="{{ route('charts.index') }}" class="hover:underline">Charts</a>
<span>/</span>
<span>{{ $chart->group->name }}</span>
<span>/</span>
<span class="font-medium text-gray-900">{{ $chart->name }}</span>
</div>
<div class="mb-1 flex items-center justify-between">
<h1 class="text-2xl font-bold">{{ $chart->name }}</h1>
@can('update', $chart)
<a href="{{ route('charts.edit', $chart) }}"
class="rounded-md border border-gray-300 bg-white px-3 py-1.5 text-sm text-gray-900 hover:bg-gray-50">Edit chart</a>
@endcan
</div>
<p class="mb-4 text-sm text-gray-500">
{{ $chart->group->name }} group
@if ($chart->description)
· {{ $chart->description }}
@endif
</p>
@include('charts._grid', ['showOwner' => true])
@endsection

View File

@ -0,0 +1,37 @@
{{-- Expects: $kpi, optional $entry --}}
@php($entry = $entry ?? null)
@if ($kpi->evaluation === \App\Enums\KpiEvaluation::Milestone)
<div>
<span class="mb-1 block font-semibold">Milestone</span>
<div class="flex gap-4 rounded-md border border-gray-300 px-3 py-2">
<label class="inline-flex cursor-pointer items-center gap-1.5">
<input type="radio" name="value" value="1" {{ old('value', $entry?->value) >= 1 ? 'checked' : '' }} required>
<span class="text-green-700">Met</span>
</label>
<label class="inline-flex cursor-pointer items-center gap-1.5">
<input type="radio" name="value" value="0" {{ $entry !== null && old('value', $entry->value) < 1 ? 'checked' : '' }} required>
<span class="text-red-700">Not met</span>
</label>
</div>
@error('value')
<p class="mt-1 text-sm text-red-700">{{ $message }}</p>
@enderror
</div>
@else
<div>
<label for="value" class="mb-1 block font-semibold">
Value
@if ($kpi->value_type === \App\Enums\KpiValueType::Percent)
<span class="font-normal text-gray-500">(%)</span>
@elseif ($kpi->unit_label)
<span class="font-normal text-gray-500">({{ $kpi->unit_label }})</span>
@endif
</label>
<input id="value" type="number" step="any" name="value" value="{{ old('value', $entry?->value) }}" required
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/40">
@error('value')
<p class="mt-1 text-sm text-red-700">{{ $message }}</p>
@enderror
</div>
@endif

View File

@ -0,0 +1,141 @@
{{-- Expects: $kpi (nullable App\Models\Kpi for edit mode) --}}
@php($kpi = $kpi ?? null)
<div class="mb-4">
<label for="name" class="mb-1 block font-semibold">Name</label>
<input id="name" type="text" name="name" value="{{ old('name', $kpi?->name) }}" required autofocus
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/40">
@error('name')
<p class="mt-1 text-sm text-red-700">{{ $message }}</p>
@enderror
</div>
<div class="mb-4">
<label for="description" class="mb-1 block font-semibold">Description <span class="font-normal text-gray-500">(optional)</span></label>
<textarea id="description" name="description" rows="2"
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/40">{{ old('description', $kpi?->description) }}</textarea>
@error('description')
<p class="mt-1 text-sm text-red-700">{{ $message }}</p>
@enderror
</div>
@include('partials.team-category-selects', ['item' => $kpi])
<div class="mb-4 grid grid-cols-2 gap-4">
<div>
<label for="value_type" class="mb-1 block font-semibold">Measurement type</label>
<select id="value_type" name="value_type"
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/40">
@foreach (\App\Enums\KpiValueType::cases() as $type)
<option value="{{ $type->value }}" {{ old('value_type', $kpi?->value_type?->value ?? 'number') === $type->value ? 'selected' : '' }}>
{{ $type->label() }}
</option>
@endforeach
</select>
</div>
<div>
<label for="unit_label" class="mb-1 block font-semibold">Unit label <span class="font-normal text-gray-500">(optional)</span></label>
<input id="unit_label" type="text" name="unit_label" value="{{ old('unit_label', $kpi?->unit_label) }}" placeholder="e.g. days, units, $"
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/40">
@error('unit_label')
<p class="mt-1 text-sm text-red-700">{{ $message }}</p>
@enderror
</div>
</div>
<div class="mb-4">
<label for="evaluation" class="mb-1 block font-semibold">Evaluation</label>
<select id="evaluation" name="evaluation"
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/40">
@foreach (\App\Enums\KpiEvaluation::cases() as $mode)
<option value="{{ $mode->value }}" {{ old('evaluation', $kpi?->evaluation?->value ?? 'higher_is_better') === $mode->value ? 'selected' : '' }}>
{{ $mode->label() }}
</option>
@endforeach
</select>
@error('evaluation')
<p class="mt-1 text-sm text-red-700">{{ $message }}</p>
@enderror
</div>
{{-- Higher/lower-is-better thresholds --}}
<div id="threshold-fields" class="mb-4 grid grid-cols-2 gap-4">
<div>
<label for="green_threshold" class="mb-1 block font-semibold">Green threshold</label>
<input id="green_threshold" type="number" step="any" name="green_threshold" value="{{ old('green_threshold', $kpi?->green_threshold) }}"
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/40">
<p class="mt-1 text-sm text-gray-500" data-hint-higher>Green when value this</p>
<p class="mt-1 hidden text-sm text-gray-500" data-hint-lower>Green when value this</p>
@error('green_threshold')
<p class="mt-1 text-sm text-red-700">{{ $message }}</p>
@enderror
</div>
<div>
<label for="yellow_threshold" class="mb-1 block font-semibold">Yellow threshold</label>
<input id="yellow_threshold" type="number" step="any" name="yellow_threshold" value="{{ old('yellow_threshold', $kpi?->yellow_threshold) }}"
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/40">
<p class="mt-1 text-sm text-gray-500">Beyond yellow is red</p>
@error('yellow_threshold')
<p class="mt-1 text-sm text-red-700">{{ $message }}</p>
@enderror
</div>
</div>
{{-- Target-band fields --}}
<div id="band-fields" class="mb-4 grid grid-cols-3 gap-4">
<div>
<label for="target" class="mb-1 block font-semibold">Target</label>
<input id="target" type="number" step="any" name="target" value="{{ old('target', $kpi?->target) }}"
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/40">
@error('target')
<p class="mt-1 text-sm text-red-700">{{ $message }}</p>
@enderror
</div>
<div>
<label for="green_tolerance" class="mb-1 block font-semibold">Green ±</label>
<input id="green_tolerance" type="number" step="any" min="0" name="green_tolerance" value="{{ old('green_tolerance', $kpi?->green_tolerance) }}"
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/40">
@error('green_tolerance')
<p class="mt-1 text-sm text-red-700">{{ $message }}</p>
@enderror
</div>
<div>
<label for="yellow_tolerance" class="mb-1 block font-semibold">Yellow ±</label>
<input id="yellow_tolerance" type="number" step="any" min="0" name="yellow_tolerance" value="{{ old('yellow_tolerance', $kpi?->yellow_tolerance) }}"
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/40">
@error('yellow_tolerance')
<p class="mt-1 text-sm text-red-700">{{ $message }}</p>
@enderror
</div>
</div>
<p id="milestone-hint" class="mb-4 hidden rounded-md bg-gray-50 px-4 py-3 text-sm text-gray-600">
Milestone KPIs have no thresholds each entry simply records whether the milestone was <strong>met</strong> (green) or <strong>not met</strong> (red).
</p>
@include('partials.chart-sharing', ['item' => $kpi])
<script>
(function () {
var evaluation = document.getElementById('evaluation');
var thresholds = document.getElementById('threshold-fields');
var band = document.getElementById('band-fields');
var milestoneHint = document.getElementById('milestone-hint');
function refresh() {
var mode = evaluation.value;
thresholds.classList.toggle('hidden', mode !== 'higher_is_better' && mode !== 'lower_is_better');
band.classList.toggle('hidden', mode !== 'target_band');
milestoneHint.classList.toggle('hidden', mode !== 'milestone');
document.querySelectorAll('[data-hint-higher]').forEach(function (el) {
el.classList.toggle('hidden', mode !== 'higher_is_better');
});
document.querySelectorAll('[data-hint-lower]').forEach(function (el) {
el.classList.toggle('hidden', mode !== 'lower_is_better');
});
}
evaluation.addEventListener('change', refresh);
refresh();
})();
</script>

View File

@ -0,0 +1,22 @@
@extends('layouts.app')
@section('title', 'New KPI — ' . config('app.name'))
@section('content')
<div class="mx-auto max-w-xl rounded-lg border border-gray-200 bg-white p-8">
<h1 class="mb-6 text-2xl font-bold">Create KPI</h1>
@if (auth()->user()->teams->isEmpty() || auth()->user()->categories->isEmpty())
@include('partials.taxonomy-cta')
@else
<form method="POST" action="{{ route('kpis.store') }}">
@csrf
@include('kpis._form')
<div class="flex gap-2">
<button type="submit" class="cursor-pointer rounded-md bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700">Create KPI</button>
<a href="{{ route('kpis.index') }}" class="rounded-md border border-gray-300 bg-white px-4 py-2 text-gray-900 hover:bg-gray-50">Cancel</a>
</div>
</form>
@endif
</div>
@endsection

View File

@ -0,0 +1,19 @@
@extends('layouts.app')
@section('title', 'Edit KPI — ' . config('app.name'))
@section('content')
<div class="mx-auto max-w-xl rounded-lg border border-gray-200 bg-white p-8">
<h1 class="mb-6 text-2xl font-bold">Edit KPI</h1>
<form method="POST" action="{{ route('kpis.update', $kpi) }}">
@csrf
@method('PUT')
@include('kpis._form')
<div class="flex gap-2">
<button type="submit" class="cursor-pointer rounded-md bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700">Save changes</button>
<a href="{{ route('kpis.index') }}" class="rounded-md border border-gray-300 bg-white px-4 py-2 text-gray-900 hover:bg-gray-50">Cancel</a>
</div>
</form>
</div>
@endsection

View File

@ -0,0 +1,111 @@
@extends('layouts.app')
@section('title', $kpi->name . ' — ' . config('app.name'))
@section('content')
<div class="mb-1 flex items-center gap-2 text-sm text-gray-500">
<a href="{{ route('kpis.index') }}" class="hover:underline">KPIs</a>
<span>/</span>
<span class="font-medium text-gray-900">{{ $kpi->name }}</span>
</div>
<div class="mb-1 flex items-center justify-between">
<h1 class="text-2xl font-bold">{{ $kpi->name }}</h1>
@can('update', $kpi)
<a href="{{ route('kpis.edit', $kpi) }}"
class="rounded-md border border-gray-300 bg-white px-3 py-1.5 text-sm text-gray-900 hover:bg-gray-50">Edit KPI</a>
@endcan
</div>
<p class="mb-4 text-sm text-gray-500">
@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)
<span class="block">{{ $kpi->description }}</span>
@endif
</p>
{{-- Add entry --}}
@can('update', $kpi)
<div class="mb-6 rounded-lg border border-gray-200 bg-white p-4">
<h2 class="mb-3 text-sm font-semibold uppercase tracking-wide text-gray-500">Add entry</h2>
<form method="POST" action="{{ route('kpis.entries.store', $kpi) }}" class="grid gap-4 sm:grid-cols-[10rem_14rem_1fr_auto]">
@csrf
<div>
<label for="entry_date" class="mb-1 block font-semibold">Date</label>
<input id="entry_date" type="date" name="entry_date" value="{{ old('entry_date', now()->format('Y-m-d')) }}" required
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/40">
@error('entry_date')
<p class="mt-1 text-sm text-red-700">{{ $message }}</p>
@enderror
</div>
@include('kpis._entry-value-field', ['kpi' => $kpi])
<div>
<label for="notes" class="mb-1 block font-semibold">Notes <span class="font-normal text-gray-500">(optional)</span></label>
<input id="notes" type="text" name="notes" value="{{ old('notes') }}"
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/40">
@error('notes')
<p class="mt-1 text-sm text-red-700">{{ $message }}</p>
@enderror
</div>
<div class="self-end">
<button type="submit" class="cursor-pointer rounded-md bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700">Add</button>
</div>
</form>
</div>
@endcan
{{-- Entries, reverse chronological --}}
@if ($entries->isEmpty())
<div class="rounded-lg border border-gray-200 bg-white px-6 py-10 text-center text-gray-500">
No entries yet. Add the first one above.
</div>
@else
<div class="overflow-x-auto rounded-lg border border-gray-200 bg-white">
<table class="w-full text-left">
<thead>
<tr class="bg-gray-50 text-xs font-semibold uppercase tracking-wide text-gray-500">
<th class="px-4 py-3">Date</th>
<th class="px-4 py-3">Value</th>
<th class="px-4 py-3">Status</th>
<th class="px-4 py-3">Notes</th>
<th class="px-4 py-3">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
@foreach ($entries as $entry)
<tr>
<td class="whitespace-nowrap px-4 py-3">{{ $entry->entry_date->format('Y-m-d') }}</td>
<td class="px-4 py-3">
@if ($kpi->evaluation === \App\Enums\KpiEvaluation::Milestone)
{{ $entry->value >= 1 ? 'Met' : 'Not met' }}
@else
{{ $kpi->value_type->format($entry->value, $kpi->unit_label) }}
@endif
</td>
<td class="px-4 py-3">@include('partials.status-badge', ['status' => $entry->status])</td>
<td class="max-w-md px-4 py-3 text-sm text-gray-600">{{ $entry->notes }}</td>
<td class="whitespace-nowrap px-4 py-3">
@can('update', $kpi)
<a href="{{ route('kpi-entries.edit', $entry) }}"
class="mr-1 inline-block rounded-md border border-gray-300 bg-white px-2.5 py-1 text-sm text-gray-900 hover:bg-gray-50">Edit</a>
<form method="POST" action="{{ route('kpi-entries.destroy', $entry) }}" class="js-confirm inline"
data-confirm="Delete the {{ $entry->entry_date->format('Y-m-d') }} entry?">
@csrf
@method('DELETE')
<button type="submit"
class="cursor-pointer rounded-md border border-red-600 px-2.5 py-1 text-sm text-red-700 hover:bg-red-50">Delete</button>
</form>
@else
<span class="text-gray-300"></span>
@endcan
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@endif
@include('partials.confirm-script')
@endsection

View File

@ -0,0 +1,42 @@
@extends('layouts.app')
@section('title', 'Edit Entry — ' . config('app.name'))
@section('content')
<div class="mx-auto max-w-md rounded-lg border border-gray-200 bg-white p-8">
<h1 class="mb-2 text-2xl font-bold">Edit entry</h1>
<p class="mb-6 text-gray-600">{{ $kpi->name }}</p>
<form method="POST" action="{{ route('kpi-entries.update', $entry) }}">
@csrf
@method('PUT')
<div class="mb-4">
<label for="entry_date" class="mb-1 block font-semibold">Date</label>
<input id="entry_date" type="date" name="entry_date" value="{{ old('entry_date', $entry->entry_date->format('Y-m-d')) }}" required
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/40">
@error('entry_date')
<p class="mt-1 text-sm text-red-700">{{ $message }}</p>
@enderror
</div>
<div class="mb-4">
@include('kpis._entry-value-field', ['kpi' => $kpi, 'entry' => $entry])
</div>
<div class="mb-6">
<label for="notes" class="mb-1 block font-semibold">Notes <span class="font-normal text-gray-500">(optional)</span></label>
<textarea id="notes" name="notes" rows="3"
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/40">{{ old('notes', $entry->notes) }}</textarea>
@error('notes')
<p class="mt-1 text-sm text-red-700">{{ $message }}</p>
@enderror
</div>
<div class="flex gap-2">
<button type="submit" class="cursor-pointer rounded-md bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700">Save entry</button>
<a href="{{ route('kpis.entries.index', $kpi) }}" class="rounded-md border border-gray-300 bg-white px-4 py-2 text-gray-900 hover:bg-gray-50">Cancel</a>
</div>
</form>
</div>
@endsection

View File

@ -0,0 +1,70 @@
@extends('layouts.app')
@section('title', 'KPIs — ' . config('app.name'))
@section('content')
<div class="mb-4 flex items-center justify-between">
<h1 class="text-2xl font-bold">My KPIs</h1>
<a href="{{ route('kpis.create') }}" class="rounded-md bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700">New KPI</a>
</div>
@if ($kpis->isEmpty())
<div class="rounded-lg border border-gray-200 bg-white px-6 py-10 text-center text-gray-500">
No KPIs yet. <a href="{{ route('kpis.create') }}" class="text-blue-600 hover:underline">Create your first KPI.</a>
</div>
@else
<div class="overflow-x-auto rounded-lg border border-gray-200 bg-white">
<table class="w-full text-left">
<thead>
<tr class="bg-gray-50 text-xs font-semibold uppercase tracking-wide text-gray-500">
<th class="px-4 py-3">Name</th>
<th class="px-4 py-3">Team / Category</th>
<th class="px-4 py-3">Type</th>
<th class="px-4 py-3">Thresholds</th>
<th class="px-4 py-3">Latest</th>
<th class="px-4 py-3">Entries</th>
<th class="px-4 py-3">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
@foreach ($kpis as $kpi)
<tr>
<td class="px-4 py-3 font-medium">
<a href="{{ route('kpis.entries.index', $kpi) }}" class="hover:underline">{{ $kpi->name }}</a>
</td>
<td class="px-4 py-3 text-sm">
{{ $kpi->team?->name ?? '—' }} <span class="text-gray-400">/</span> {{ $kpi->category?->name ?? '—' }}
</td>
<td class="px-4 py-3">{{ $kpi->value_type->label() }} · {{ $kpi->evaluation->label() }}</td>
<td class="px-4 py-3 text-sm text-gray-600">{{ $kpi->thresholdSummary() }}</td>
<td class="px-4 py-3">
@if ($kpi->latestEntry)
@include('partials.status-badge', ['status' => $kpi->latestEntry->status])
<span class="ml-1 text-sm text-gray-500">{{ $kpi->value_type->format($kpi->latestEntry->value, $kpi->unit_label) }}</span>
@else
<span class="text-gray-400"></span>
@endif
</td>
<td class="px-4 py-3">{{ $kpi->entries_count }}</td>
<td class="whitespace-nowrap px-4 py-3">
<a href="{{ route('kpis.entries.index', $kpi) }}"
class="mr-1 inline-block rounded-md border border-gray-300 bg-white px-2.5 py-1 text-sm text-gray-900 hover:bg-gray-50">Entries</a>
<a href="{{ route('kpis.edit', $kpi) }}"
class="mr-1 inline-block rounded-md border border-gray-300 bg-white px-2.5 py-1 text-sm text-gray-900 hover:bg-gray-50">Edit</a>
<form method="POST" action="{{ route('kpis.destroy', $kpi) }}" class="js-confirm inline"
data-confirm="Delete KPI &quot;{{ $kpi->name }}&quot; and all {{ $kpi->entries_count }} entries? This cannot be undone.">
@csrf
@method('DELETE')
<button type="submit"
class="cursor-pointer rounded-md border border-red-600 px-2.5 py-1 text-sm text-red-700 hover:bg-red-50">Delete</button>
</form>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@endif
@include('partials.confirm-script')
@endsection

View File

@ -12,6 +12,12 @@
<a href="{{ route('home') }}" class="text-lg font-bold text-gray-900">{{ config('app.name', 'Bowler') }}</a> <a href="{{ route('home') }}" class="text-lg font-bold text-gray-900">{{ config('app.name', 'Bowler') }}</a>
<nav class="flex items-center gap-4"> <nav class="flex items-center gap-4">
@auth @auth
<a href="{{ route('charts.index') }}" class="text-blue-600 hover:underline">Charts</a>
<a href="{{ route('kpis.index') }}" class="text-blue-600 hover:underline">KPIs</a>
<a href="{{ route('projects.index') }}" class="text-blue-600 hover:underline">Projects</a>
<a href="{{ route('teams.index') }}" class="text-blue-600 hover:underline">Teams</a>
<a href="{{ route('categories.index') }}" class="text-blue-600 hover:underline">Categories</a>
<a href="{{ route('api-keys.index') }}" class="text-blue-600 hover:underline">API Keys</a>
@if (auth()->user()->is_admin) @if (auth()->user()->is_admin)
<a href="{{ route('admin.users.index') }}" class="text-blue-600 hover:underline">Users</a> <a href="{{ route('admin.users.index') }}" class="text-blue-600 hover:underline">Users</a>
<a href="{{ route('admin.groups.index') }}" class="text-blue-600 hover:underline">Groups</a> <a href="{{ route('admin.groups.index') }}" class="text-blue-600 hover:underline">Groups</a>

View File

@ -0,0 +1,33 @@
{{-- Share-to-charts checkboxes. Expects optional $item (Kpi|Project being edited). --}}
@php
$item = $item ?? null;
$shareOwner = $item?->user ?? auth()->user();
$groupsWithCharts = $shareOwner->groups()->with('bowlerCharts')->get()->filter(fn ($g) => $g->bowlerCharts->isNotEmpty());
$excludedIds = $item ? $item->excludedCharts()->pluck('bowler_charts.id')->all() : [];
$defaultChecked = $groupsWithCharts->flatMap->bowlerCharts->pluck('id')->diff($excludedIds)->all();
$checkedIds = collect(old('charts', $defaultChecked))->map(fn ($v) => (int) $v)->all();
@endphp
@if ($groupsWithCharts->isNotEmpty())
<div class="mb-4">
<span class="mb-1 block font-semibold">Share to bowler charts</span>
<div class="space-y-3 rounded-md border border-gray-300 p-3">
@foreach ($groupsWithCharts as $group)
<div>
<p class="mb-1 text-xs font-semibold uppercase tracking-wide text-gray-500">{{ $group->name }}</p>
@foreach ($group->bowlerCharts as $chart)
<label class="mr-4 inline-flex cursor-pointer items-center gap-1.5">
<input type="checkbox" name="charts[]" value="{{ $chart->id }}"
class="rounded border-gray-300" {{ in_array($chart->id, $checkedIds, true) ? 'checked' : '' }}>
{{ $chart->name }}
</label>
@endforeach
</div>
@endforeach
</div>
<p class="mt-1 text-sm text-gray-500">
Shared automatically with your groups' charts uncheck any chart to hide this item there.
Charts in groups you join later will include it automatically.
</p>
</div>
@endif

View File

@ -0,0 +1,9 @@
<script>
document.querySelectorAll('form.js-confirm').forEach(function (form) {
form.addEventListener('submit', function (event) {
if (!window.confirm(form.dataset.confirm || 'Are you sure?')) {
event.preventDefault();
}
});
});
</script>

View File

@ -0,0 +1,4 @@
{{-- Expects: $status (App\Enums\RagStatus) --}}
<span class="inline-block rounded-full px-2.5 py-0.5 text-xs font-semibold {{ $status->badgeClasses() }}">
{{ $status->label() }}
</span>

View File

@ -0,0 +1,16 @@
{{-- Shown instead of a create form when the user has no teams or categories yet. --}}
<div class="rounded-lg border border-blue-200 bg-blue-50 p-6 text-center">
<p class="mb-3 font-semibold text-blue-900">First, set up your teams and categories</p>
<p class="mb-4 text-sm text-blue-800/80">
Every KPI and project belongs to a team (e.g. "Website Development") and a category
(e.g. "Performance") so bowler charts can group them. Create at least one of each to continue.
</p>
<div class="flex justify-center gap-3">
@if (auth()->user()->teams->isEmpty())
<a href="{{ route('teams.index') }}" class="rounded-md bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700">Create a team</a>
@endif
@if (auth()->user()->categories->isEmpty())
<a href="{{ route('categories.index') }}" class="rounded-md bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700">Create a category</a>
@endif
</div>
</div>

View File

@ -0,0 +1,41 @@
{{-- Required Team + Category selects. Expects optional $item (Kpi|Project being edited). --}}
@php
$item = $item ?? null;
$tcOwner = $item?->user ?? auth()->user();
$ownerTeams = $tcOwner->teams;
$ownerCategories = $tcOwner->categories;
@endphp
<div class="mb-4 grid grid-cols-2 gap-4">
<div>
<label for="team_id" class="mb-1 block font-semibold">Team</label>
<select id="team_id" name="team_id" required
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/40">
<option value="">Select a team…</option>
@foreach ($ownerTeams as $team)
<option value="{{ $team->id }}" {{ (int) old('team_id', $item?->team_id) === $team->id ? 'selected' : '' }}>{{ $team->name }}</option>
@endforeach
</select>
@error('team_id')
<p class="mt-1 text-sm text-red-700">{{ $message }}</p>
@enderror
</div>
<div>
<label for="category_id" class="mb-1 block font-semibold">Category</label>
<select id="category_id" name="category_id" required
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/40">
<option value="">Select a category…</option>
@foreach ($ownerCategories as $category)
<option value="{{ $category->id }}" {{ (int) old('category_id', $item?->category_id) === $category->id ? 'selected' : '' }}>{{ $category->name }}</option>
@endforeach
</select>
@error('category_id')
<p class="mt-1 text-sm text-red-700">{{ $message }}</p>
@enderror
</div>
</div>
<p class="-mt-2 mb-4 text-sm text-gray-500">
Used to group items on bowler charts.
<a href="{{ route('teams.index') }}" class="text-blue-600 hover:underline">Manage teams</a> ·
<a href="{{ route('categories.index') }}" class="text-blue-600 hover:underline">Manage categories</a>
</p>

View File

@ -0,0 +1,50 @@
{{-- Expects: optional $entry --}}
@php($entry = $entry ?? null)
<div class="mb-4 grid grid-cols-2 gap-4">
<div>
<label for="entry_date" class="mb-1 block font-semibold">Date</label>
<input id="entry_date" type="date" name="entry_date"
value="{{ old('entry_date', $entry?->entry_date?->format('Y-m-d') ?? now()->format('Y-m-d')) }}" required
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/40">
@error('entry_date')
<p class="mt-1 text-sm text-red-700">{{ $message }}</p>
@enderror
</div>
<div>
<label for="status" class="mb-1 block font-semibold">Status</label>
<select id="status" name="status" required
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/40">
@foreach (\App\Enums\RagStatus::cases() as $status)
<option value="{{ $status->value }}" {{ old('status', $entry?->status?->value) === $status->value ? 'selected' : '' }}>
{{ $status->label() }}
</option>
@endforeach
</select>
@error('status')
<p class="mt-1 text-sm text-red-700">{{ $message }}</p>
@enderror
</div>
</div>
<div class="mb-4">
<label for="notes" class="mb-1 block font-semibold">Notes <span class="font-normal text-gray-500">(optional)</span></label>
<textarea id="notes" name="notes" rows="2" placeholder="Overall status description"
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/40">{{ old('notes', $entry?->notes) }}</textarea>
@error('notes')
<p class="mt-1 text-sm text-red-700">{{ $message }}</p>
@enderror
</div>
<div class="mb-4 grid gap-4 sm:grid-cols-2">
@foreach (['headwinds' => 'Headwinds', 'tailwinds' => 'Tailwinds', 'highlights' => 'Highlights', 'blockers' => 'Blockers'] as $field => $label)
<div>
<label for="{{ $field }}" class="mb-1 block font-semibold">{{ $label }} <span class="font-normal text-gray-500">(optional)</span></label>
<textarea id="{{ $field }}" name="{{ $field }}" rows="2"
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/40">{{ old($field, $entry?->{$field}) }}</textarea>
@error($field)
<p class="mt-1 text-sm text-red-700">{{ $message }}</p>
@enderror
</div>
@endforeach
</div>

View File

@ -0,0 +1,44 @@
@extends('layouts.app')
@section('title', 'New Project — ' . config('app.name'))
@section('content')
<div class="mx-auto max-w-md rounded-lg border border-gray-200 bg-white p-8">
<h1 class="mb-6 text-2xl font-bold">Create project</h1>
@if (auth()->user()->teams->isEmpty() || auth()->user()->categories->isEmpty())
@include('partials.taxonomy-cta')
@else
<form method="POST" action="{{ route('projects.store') }}">
@csrf
<div class="mb-4">
<label for="name" class="mb-1 block font-semibold">Name</label>
<input id="name" type="text" name="name" value="{{ old('name') }}" required autofocus
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/40">
@error('name')
<p class="mt-1 text-sm text-red-700">{{ $message }}</p>
@enderror
</div>
@include('partials.team-category-selects')
<div class="mb-6">
<label for="description" class="mb-1 block font-semibold">Description <span class="font-normal text-gray-500">(optional)</span></label>
<textarea id="description" name="description" rows="3"
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/40">{{ old('description') }}</textarea>
@error('description')
<p class="mt-1 text-sm text-red-700">{{ $message }}</p>
@enderror
</div>
@include('partials.chart-sharing')
<div class="flex gap-2">
<button type="submit" class="cursor-pointer rounded-md bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700">Create project</button>
<a href="{{ route('projects.index') }}" class="rounded-md border border-gray-300 bg-white px-4 py-2 text-gray-900 hover:bg-gray-50">Cancel</a>
</div>
</form>
@endif
</div>
@endsection

View File

@ -0,0 +1,41 @@
@extends('layouts.app')
@section('title', 'Edit Project — ' . config('app.name'))
@section('content')
<div class="mx-auto max-w-md rounded-lg border border-gray-200 bg-white p-8">
<h1 class="mb-6 text-2xl font-bold">Edit project</h1>
<form method="POST" action="{{ route('projects.update', $project) }}">
@csrf
@method('PUT')
<div class="mb-4">
<label for="name" class="mb-1 block font-semibold">Name</label>
<input id="name" type="text" name="name" value="{{ old('name', $project->name) }}" required autofocus
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/40">
@error('name')
<p class="mt-1 text-sm text-red-700">{{ $message }}</p>
@enderror
</div>
@include('partials.team-category-selects', ['item' => $project])
<div class="mb-6">
<label for="description" class="mb-1 block font-semibold">Description <span class="font-normal text-gray-500">(optional)</span></label>
<textarea id="description" name="description" rows="3"
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/40">{{ old('description', $project->description) }}</textarea>
@error('description')
<p class="mt-1 text-sm text-red-700">{{ $message }}</p>
@enderror
</div>
@include('partials.chart-sharing', ['item' => $project])
<div class="flex gap-2">
<button type="submit" class="cursor-pointer rounded-md bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700">Save changes</button>
<a href="{{ route('projects.index') }}" class="rounded-md border border-gray-300 bg-white px-4 py-2 text-gray-900 hover:bg-gray-50">Cancel</a>
</div>
</form>
</div>
@endsection

View File

@ -0,0 +1,93 @@
@extends('layouts.app')
@section('title', $project->name . ' — ' . config('app.name'))
@section('content')
<div class="mb-1 flex items-center gap-2 text-sm text-gray-500">
<a href="{{ route('projects.index') }}" class="hover:underline">Projects</a>
<span>/</span>
<span class="font-medium text-gray-900">{{ $project->name }}</span>
</div>
<div class="mb-1 flex items-center justify-between">
<h1 class="text-2xl font-bold">{{ $project->name }}</h1>
@can('update', $project)
<a href="{{ route('projects.edit', $project) }}"
class="rounded-md border border-gray-300 bg-white px-3 py-1.5 text-sm text-gray-900 hover:bg-gray-50">Edit project</a>
@endcan
</div>
<p class="mb-4 text-sm text-gray-500">
@if ($project->team || $project->category)
{{ $project->team?->name ?? 'No Team' }} / {{ $project->category?->name ?? 'Uncategorized' }}
@if ($project->description) · @endif
@endif
{{ $project->description }}
</p>
{{-- Add entry --}}
@can('update', $project)
<details class="mb-6 rounded-lg border border-gray-200 bg-white" {{ $entries->isEmpty() ? 'open' : '' }}>
<summary class="cursor-pointer px-4 py-3 text-sm font-semibold uppercase tracking-wide text-gray-500">Add status entry</summary>
<form method="POST" action="{{ route('projects.entries.store', $project) }}" class="border-t border-gray-200 p-4">
@csrf
@include('projects._entry-fields')
<button type="submit" class="cursor-pointer rounded-md bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700">Add entry</button>
</form>
</details>
@endcan
{{-- Entries, reverse chronological --}}
@if ($entries->isEmpty())
<div class="rounded-lg border border-gray-200 bg-white px-6 py-10 text-center text-gray-500">
No entries yet. Add the first status entry above.
</div>
@else
<div class="space-y-4">
@foreach ($entries as $entry)
<div class="rounded-lg border border-gray-200 bg-white p-4">
<div class="mb-2 flex items-center justify-between">
<div class="flex items-center gap-3">
<span class="font-semibold">{{ $entry->entry_date->format('Y-m-d') }}</span>
@include('partials.status-badge', ['status' => $entry->status])
</div>
<div class="whitespace-nowrap">
@can('update', $project)
<a href="{{ route('project-entries.edit', $entry) }}"
class="mr-1 inline-block rounded-md border border-gray-300 bg-white px-2.5 py-1 text-sm text-gray-900 hover:bg-gray-50">Edit</a>
<form method="POST" action="{{ route('project-entries.destroy', $entry) }}" class="js-confirm inline"
data-confirm="Delete the {{ $entry->entry_date->format('Y-m-d') }} entry?">
@csrf
@method('DELETE')
<button type="submit"
class="cursor-pointer rounded-md border border-red-600 px-2.5 py-1 text-sm text-red-700 hover:bg-red-50">Delete</button>
</form>
@endcan
</div>
</div>
@if ($entry->notes)
<p class="mb-2 text-gray-700">{{ $entry->notes }}</p>
@endif
@php($sections = array_filter([
'Headwinds' => $entry->headwinds,
'Tailwinds' => $entry->tailwinds,
'Highlights' => $entry->highlights,
'Blockers' => $entry->blockers,
]))
@if ($sections)
<dl class="grid gap-x-6 gap-y-2 text-sm sm:grid-cols-2">
@foreach ($sections as $label => $text)
<div>
<dt class="font-semibold text-gray-500">{{ $label }}</dt>
<dd class="text-gray-700">{{ $text }}</dd>
</div>
@endforeach
</dl>
@endif
</div>
@endforeach
</div>
@endif
@include('partials.confirm-script')
@endsection

View File

@ -0,0 +1,20 @@
@extends('layouts.app')
@section('title', 'Edit Entry — ' . config('app.name'))
@section('content')
<div class="mx-auto max-w-xl rounded-lg border border-gray-200 bg-white p-8">
<h1 class="mb-2 text-2xl font-bold">Edit status entry</h1>
<p class="mb-6 text-gray-600">{{ $project->name }}</p>
<form method="POST" action="{{ route('project-entries.update', $entry) }}">
@csrf
@method('PUT')
@include('projects._entry-fields', ['entry' => $entry])
<div class="flex gap-2">
<button type="submit" class="cursor-pointer rounded-md bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700">Save entry</button>
<a href="{{ route('projects.entries.index', $project) }}" class="rounded-md border border-gray-300 bg-white px-4 py-2 text-gray-900 hover:bg-gray-50">Cancel</a>
</div>
</form>
</div>
@endsection

View File

@ -0,0 +1,68 @@
@extends('layouts.app')
@section('title', 'Projects — ' . config('app.name'))
@section('content')
<div class="mb-4 flex items-center justify-between">
<h1 class="text-2xl font-bold">My Projects</h1>
<a href="{{ route('projects.create') }}" class="rounded-md bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700">New project</a>
</div>
@if ($projects->isEmpty())
<div class="rounded-lg border border-gray-200 bg-white px-6 py-10 text-center text-gray-500">
No projects yet. <a href="{{ route('projects.create') }}" class="text-blue-600 hover:underline">Create your first project.</a>
</div>
@else
<div class="overflow-x-auto rounded-lg border border-gray-200 bg-white">
<table class="w-full text-left">
<thead>
<tr class="bg-gray-50 text-xs font-semibold uppercase tracking-wide text-gray-500">
<th class="px-4 py-3">Name</th>
<th class="px-4 py-3">Team / Category</th>
<th class="px-4 py-3">Description</th>
<th class="px-4 py-3">Current status</th>
<th class="px-4 py-3">Entries</th>
<th class="px-4 py-3">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
@foreach ($projects as $project)
<tr>
<td class="px-4 py-3 font-medium">
<a href="{{ route('projects.entries.index', $project) }}" class="hover:underline">{{ $project->name }}</a>
</td>
<td class="px-4 py-3 text-sm">
{{ $project->team?->name ?? '—' }} <span class="text-gray-400">/</span> {{ $project->category?->name ?? '—' }}
</td>
<td class="max-w-md px-4 py-3 text-gray-600">{{ $project->description }}</td>
<td class="px-4 py-3">
@if ($project->latestEntry)
@include('partials.status-badge', ['status' => $project->latestEntry->status])
<span class="ml-1 text-sm text-gray-500">{{ $project->latestEntry->entry_date->format('Y-m-d') }}</span>
@else
<span class="text-gray-400"></span>
@endif
</td>
<td class="px-4 py-3">{{ $project->entries_count }}</td>
<td class="whitespace-nowrap px-4 py-3">
<a href="{{ route('projects.entries.index', $project) }}"
class="mr-1 inline-block rounded-md border border-gray-300 bg-white px-2.5 py-1 text-sm text-gray-900 hover:bg-gray-50">Entries</a>
<a href="{{ route('projects.edit', $project) }}"
class="mr-1 inline-block rounded-md border border-gray-300 bg-white px-2.5 py-1 text-sm text-gray-900 hover:bg-gray-50">Edit</a>
<form method="POST" action="{{ route('projects.destroy', $project) }}" class="js-confirm inline"
data-confirm="Delete project &quot;{{ $project->name }}&quot; and all {{ $project->entries_count }} entries? This cannot be undone.">
@csrf
@method('DELETE')
<button type="submit"
class="cursor-pointer rounded-md border border-red-600 px-2.5 py-1 text-sm text-red-700 hover:bg-red-50">Delete</button>
</form>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@endif
@include('partials.confirm-script')
@endsection

View File

@ -0,0 +1,28 @@
@extends('layouts.app')
@section('title', 'Rename Team — ' . config('app.name'))
@section('content')
<div class="mx-auto max-w-md rounded-lg border border-gray-200 bg-white p-8">
<h1 class="mb-6 text-2xl font-bold">Rename team</h1>
<form method="POST" action="{{ route('teams.update', $team) }}">
@csrf
@method('PUT')
<div class="mb-6">
<label for="name" class="mb-1 block font-semibold">Name</label>
<input id="name" type="text" name="name" value="{{ old('name', $team->name) }}" required autofocus
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/40">
@error('name')
<p class="mt-1 text-sm text-red-700">{{ $message }}</p>
@enderror
</div>
<div class="flex gap-2">
<button type="submit" class="cursor-pointer rounded-md bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700">Save</button>
<a href="{{ route('teams.index') }}" class="rounded-md border border-gray-300 bg-white px-4 py-2 text-gray-900 hover:bg-gray-50">Cancel</a>
</div>
</form>
</div>
@endsection

View File

@ -0,0 +1,65 @@
@extends('layouts.app')
@section('title', 'Teams — ' . config('app.name'))
@section('content')
<div class="mb-4 flex items-center justify-between">
<h1 class="text-2xl font-bold">My Teams</h1>
<a href="{{ route('categories.index') }}" class="text-blue-600 hover:underline">Manage categories </a>
</div>
<p class="mb-4 text-sm text-gray-500">Teams group your KPIs and projects on bowler charts e.g. "Website Development" or "Integrations".</p>
<div class="mb-6 rounded-lg border border-gray-200 bg-white p-4">
<form method="POST" action="{{ route('teams.store') }}" class="flex items-end gap-3">
@csrf
<div class="flex-1">
<label for="name" class="mb-1 block font-semibold">New team</label>
<input id="name" type="text" name="name" value="{{ old('name') }}" required placeholder="e.g. Website Development"
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/40">
</div>
<button type="submit" class="cursor-pointer rounded-md bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700">Add team</button>
</form>
@error('name')
<p class="mt-1 text-sm text-red-700">{{ $message }}</p>
@enderror
</div>
<div class="overflow-x-auto rounded-lg border border-gray-200 bg-white">
<table class="w-full text-left">
<thead>
<tr class="bg-gray-50 text-xs font-semibold uppercase tracking-wide text-gray-500">
<th class="px-4 py-3">Name</th>
<th class="px-4 py-3">KPIs</th>
<th class="px-4 py-3">Projects</th>
<th class="px-4 py-3">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
@forelse ($teams as $team)
<tr>
<td class="px-4 py-3 font-medium">{{ $team->name }}</td>
<td class="px-4 py-3">{{ $team->kpis_count }}</td>
<td class="px-4 py-3">{{ $team->projects_count }}</td>
<td class="whitespace-nowrap px-4 py-3">
<a href="{{ route('teams.edit', $team) }}"
class="mr-1 inline-block rounded-md border border-gray-300 bg-white px-2.5 py-1 text-sm text-gray-900 hover:bg-gray-50">Rename</a>
<form method="POST" action="{{ route('teams.destroy', $team) }}" class="js-confirm inline"
data-confirm="Delete team &quot;{{ $team->name }}&quot;? Its {{ $team->kpis_count + $team->projects_count }} item(s) will become unassigned (not deleted).">
@csrf
@method('DELETE')
<button type="submit"
class="cursor-pointer rounded-md border border-red-600 px-2.5 py-1 text-sm text-red-700 hover:bg-red-50">Delete</button>
</form>
</td>
</tr>
@empty
<tr>
<td colspan="4" class="px-4 py-6 text-center text-gray-500">No teams yet add your first above.</td>
</tr>
@endforelse
</tbody>
</table>
</div>
@include('partials.confirm-script')
@endsection

39
src/routes/api.php Normal file
View File

@ -0,0 +1,39 @@
<?php
use App\Http\Controllers\Api\KpiApiController;
use App\Http\Controllers\Api\KpiEntryApiController;
use App\Http\Controllers\Api\MeApiController;
use App\Http\Controllers\Api\ProjectApiController;
use App\Http\Controllers\Api\ProjectEntryApiController;
use Illuminate\Support\Facades\Route;
// NOTE: keep all routes controller-based — closures break `php artisan route:cache`.
Route::middleware('auth:sanctum')->prefix('v1')->group(function () {
Route::get('/me', MeApiController::class);
// KPIs
Route::get('/kpis', [KpiApiController::class, 'index']);
Route::post('/kpis', [KpiApiController::class, 'store']);
Route::get('/kpis/{kpi}', [KpiApiController::class, 'show']);
Route::patch('/kpis/{kpi}', [KpiApiController::class, 'update']);
Route::delete('/kpis/{kpi}', [KpiApiController::class, 'destroy']);
// KPI entries
Route::get('/kpis/{kpi}/entries', [KpiEntryApiController::class, 'index']);
Route::post('/kpis/{kpi}/entries', [KpiEntryApiController::class, 'store']);
Route::patch('/kpi-entries/{entry}', [KpiEntryApiController::class, 'update']);
Route::delete('/kpi-entries/{entry}', [KpiEntryApiController::class, 'destroy']);
// Projects
Route::get('/projects', [ProjectApiController::class, 'index']);
Route::post('/projects', [ProjectApiController::class, 'store']);
Route::get('/projects/{project}', [ProjectApiController::class, 'show']);
Route::patch('/projects/{project}', [ProjectApiController::class, 'update']);
Route::delete('/projects/{project}', [ProjectApiController::class, 'destroy']);
// Project entries
Route::get('/projects/{project}/entries', [ProjectEntryApiController::class, 'index']);
Route::post('/projects/{project}/entries', [ProjectEntryApiController::class, 'store']);
Route::patch('/project-entries/{entry}', [ProjectEntryApiController::class, 'update']);
Route::delete('/project-entries/{entry}', [ProjectEntryApiController::class, 'destroy']);
});

View File

@ -1,7 +1,15 @@
<?php <?php
use App\Http\Controllers\ApiKeyController;
use App\Http\Controllers\AuthController; use App\Http\Controllers\AuthController;
use App\Http\Controllers\BowlerChartController;
use App\Http\Controllers\CategoryController;
use App\Http\Controllers\GroupAdminController; use App\Http\Controllers\GroupAdminController;
use App\Http\Controllers\KpiController;
use App\Http\Controllers\KpiEntryController;
use App\Http\Controllers\ProjectController;
use App\Http\Controllers\ProjectEntryController;
use App\Http\Controllers\TeamController;
use App\Http\Controllers\UserAdminController; use App\Http\Controllers\UserAdminController;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
@ -18,6 +26,70 @@ Route::post('/logout', [AuthController::class, 'logout'])
->middleware('auth') ->middleware('auth')
->name('logout'); ->name('logout');
Route::middleware('auth')->group(function () {
// Bowler charts
Route::get('/my-bowler', [BowlerChartController::class, 'personal'])->name('charts.personal');
Route::get('/charts', [BowlerChartController::class, 'index'])->name('charts.index');
Route::get('/charts/create', [BowlerChartController::class, 'create'])->name('charts.create');
Route::post('/charts', [BowlerChartController::class, 'store'])->name('charts.store');
Route::get('/charts/{chart}', [BowlerChartController::class, 'show'])->name('charts.show');
Route::get('/charts/{chart}/edit', [BowlerChartController::class, 'edit'])->name('charts.edit');
Route::put('/charts/{chart}', [BowlerChartController::class, 'update'])->name('charts.update');
Route::delete('/charts/{chart}', [BowlerChartController::class, 'destroy'])->name('charts.destroy');
// KPIs
Route::get('/kpis', [KpiController::class, 'index'])->name('kpis.index');
Route::get('/kpis/create', [KpiController::class, 'create'])->name('kpis.create');
Route::post('/kpis', [KpiController::class, 'store'])->name('kpis.store');
Route::get('/kpis/{kpi}/edit', [KpiController::class, 'edit'])->name('kpis.edit');
Route::put('/kpis/{kpi}', [KpiController::class, 'update'])->name('kpis.update');
Route::delete('/kpis/{kpi}', [KpiController::class, 'destroy'])->name('kpis.destroy');
// KPI entries (shallow)
Route::get('/kpis/{kpi}/entries', [KpiEntryController::class, 'index'])->name('kpis.entries.index');
Route::post('/kpis/{kpi}/entries', [KpiEntryController::class, 'store'])->name('kpis.entries.store');
Route::get('/kpi-entries/{entry}/edit', [KpiEntryController::class, 'edit'])->name('kpi-entries.edit');
Route::put('/kpi-entries/{entry}', [KpiEntryController::class, 'update'])->name('kpi-entries.update');
Route::delete('/kpi-entries/{entry}', [KpiEntryController::class, 'destroy'])->name('kpi-entries.destroy');
// Projects
Route::get('/projects', [ProjectController::class, 'index'])->name('projects.index');
Route::get('/projects/create', [ProjectController::class, 'create'])->name('projects.create');
Route::post('/projects', [ProjectController::class, 'store'])->name('projects.store');
Route::get('/projects/{project}/edit', [ProjectController::class, 'edit'])->name('projects.edit');
Route::put('/projects/{project}', [ProjectController::class, 'update'])->name('projects.update');
Route::delete('/projects/{project}', [ProjectController::class, 'destroy'])->name('projects.destroy');
// Project entries (shallow)
Route::get('/projects/{project}/entries', [ProjectEntryController::class, 'index'])->name('projects.entries.index');
Route::post('/projects/{project}/entries', [ProjectEntryController::class, 'store'])->name('projects.entries.store');
Route::get('/project-entries/{entry}/edit', [ProjectEntryController::class, 'edit'])->name('project-entries.edit');
Route::put('/project-entries/{entry}', [ProjectEntryController::class, 'update'])->name('project-entries.update');
Route::delete('/project-entries/{entry}', [ProjectEntryController::class, 'destroy'])->name('project-entries.destroy');
// Teams & Categories (per-user lists)
Route::get('/teams', [TeamController::class, 'index'])->name('teams.index');
Route::post('/teams', [TeamController::class, 'store'])->name('teams.store');
Route::get('/teams/{team}/edit', [TeamController::class, 'edit'])->name('teams.edit');
Route::put('/teams/{team}', [TeamController::class, 'update'])->name('teams.update');
Route::delete('/teams/{team}', [TeamController::class, 'destroy'])->name('teams.destroy');
Route::get('/categories', [CategoryController::class, 'index'])->name('categories.index');
Route::post('/categories', [CategoryController::class, 'store'])->name('categories.store');
Route::get('/categories/{category}/edit', [CategoryController::class, 'edit'])->name('categories.edit');
Route::put('/categories/{category}', [CategoryController::class, 'update'])->name('categories.update');
Route::delete('/categories/{category}', [CategoryController::class, 'destroy'])->name('categories.destroy');
// API keys
Route::get('/api-keys', [ApiKeyController::class, 'index'])->name('api-keys.index');
Route::post('/api-keys', [ApiKeyController::class, 'store'])->name('api-keys.store');
Route::post('/api-keys/{tokenId}/view', [ApiKeyController::class, 'show'])->name('api-keys.show');
Route::delete('/api-keys/{tokenId}', [ApiKeyController::class, 'destroy'])->name('api-keys.destroy');
// Swagger / API documentation
Route::view('/api/docs', 'api-docs')->name('api.docs');
});
Route::middleware(['auth', 'admin'])->prefix('admin')->name('admin.')->group(function () { Route::middleware(['auth', 'admin'])->prefix('admin')->name('admin.')->group(function () {
Route::get('/users', [UserAdminController::class, 'index'])->name('users.index'); Route::get('/users', [UserAdminController::class, 'index'])->name('users.index');
Route::get('/users/create', [UserAdminController::class, 'create'])->name('users.create'); Route::get('/users/create', [UserAdminController::class, 'create'])->name('users.create');