Added Charts, Graphs, Types, Categories, etc.
This commit is contained in:
parent
0bde948081
commit
a76cc39494
|
|
@ -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/*
|
||||||
|
|
|
||||||
69
README.md
69
README.md
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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)',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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',
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -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
|
|
@ -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>
|
||||||
|
|
@ -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 <key></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 "{{ $token->name }}"? 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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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 "{{ $category->name }}"? 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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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 "{{ $chart->name }}"? 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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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 "{{ $kpi->name }}" 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
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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 "{{ $project->name }}" 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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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 "{{ $team->name }}"? 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
|
||||||
|
|
@ -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']);
|
||||||
|
});
|
||||||
|
|
@ -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');
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue