Menu refinement

This commit is contained in:
Brian Fertig 2026-07-09 14:22:18 -06:00
parent a76cc39494
commit 25d749cb29
29 changed files with 504 additions and 67 deletions

View File

@ -58,6 +58,13 @@ Nothing else — no local PHP, Composer, or Node needed. The app container insta
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.
### User types
Every user is either **standard** (default) or **admin** (`users.type`). Admins manage users
and groups, see every bowler chart, and can edit any user's content; standard users see only
their own content plus what's shared with their groups. New abilities can be gated on
`$user->isAdmin()` (or new `UserType` enum cases) as the app grows.
### KPIs and entries ### KPIs and entries
A KPI is defined once (measurement type: number / percentage / duration, plus an evaluation A KPI is defined once (measurement type: number / percentage / duration, plus an evaluation
@ -80,7 +87,9 @@ deselects specific charts on the KPI/project form (stored as per-chart exclusion
Every KPI and project belongs to one of its owner's **Teams** (e.g. "Website Development") 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 and **Categories** (e.g. "Performance") — required on create/edit; the chart groups rows
Team → Category, merging same-named teams from different owners case-insensitively. Team → Category, merging same-named teams from different owners case-insensitively.
Items whose team/category was deleted appear under "No Team" / "Uncategorized". A team or category **cannot be deleted while KPIs/projects still use it** — reassign the
items first. (Items grandfathered from before teams existed appear under "No Team" /
"Uncategorized".)
The grid shows the trailing 12 months; each cell is the **most recent entry in that month** 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: (color + value for KPIs, color for projects, tooltip with date and notes). Rows expand in place:

View File

@ -0,0 +1,17 @@
<?php
namespace App\Enums;
enum UserType: string
{
case Standard = 'standard';
case Admin = 'admin';
public function label(): string
{
return match ($this) {
self::Standard => 'Standard',
self::Admin => 'Administrator',
};
}
}

View File

@ -14,6 +14,7 @@ class MeApiController extends Controller
'id' => $request->user()->id, 'id' => $request->user()->id,
'name' => $request->user()->name, 'name' => $request->user()->name,
'email' => $request->user()->email, 'email' => $request->user()->email,
'type' => $request->user()->type->value,
]); ]);
} }
} }

View File

@ -15,7 +15,7 @@ class BowlerChartController extends Controller
{ {
$user = $request->user(); $user = $request->user();
$charts = $user->is_admin $charts = $user->isAdmin()
? BowlerChart::with('group')->get() ? BowlerChart::with('group')->get()
: BowlerChart::with('group') : BowlerChart::with('group')
->whereIn('group_id', $user->groups()->select('groups.id')) ->whereIn('group_id', $user->groups()->select('groups.id'))
@ -41,7 +41,7 @@ class BowlerChartController extends Controller
'description' => ['nullable', 'string', 'max:500'], 'description' => ['nullable', 'string', 'max:500'],
]); ]);
if (! $request->user()->is_admin && ! $request->user()->ownsGroup((int) $data['group_id'])) { if (! $request->user()->isAdmin() && ! $request->user()->ownsGroup((int) $data['group_id'])) {
abort(403, 'You must be an owner of the group to create charts in it.'); abort(403, 'You must be an owner of the group to create charts in it.');
} }
@ -116,7 +116,7 @@ class BowlerChartController extends Controller
{ {
$user = $request->user(); $user = $request->user();
return $user->is_admin return $user->isAdmin()
? Group::orderBy('name')->get() ? Group::orderBy('name')->get()
: $user->groups()->wherePivot('role', 'owner')->get(); : $user->groups()->wherePivot('role', 'owner')->get();
} }

View File

@ -54,9 +54,16 @@ class CategoryController extends Controller
{ {
abort_unless($category->user_id === $request->user()->id, 404); abort_unless($category->user_id === $request->user()->id, 404);
$inUse = $category->kpis()->count() + $category->projects()->count();
if ($inUse > 0) {
return redirect()
->route('categories.index')
->withErrors(['delete' => "Cannot delete \"{$category->name}\"{$inUse} KPI(s)/project(s) still use it. Reassign them to another category first."]);
}
$name = $category->name; $name = $category->name;
$category->delete(); $category->delete();
return redirect()->route('categories.index')->with('status', "Category \"{$name}\" deleted. Its items are now unassigned."); return redirect()->route('categories.index')->with('status', "Category \"{$name}\" deleted.");
} }
} }

View File

@ -54,9 +54,16 @@ class TeamController extends Controller
{ {
abort_unless($team->user_id === $request->user()->id, 404); abort_unless($team->user_id === $request->user()->id, 404);
$inUse = $team->kpis()->count() + $team->projects()->count();
if ($inUse > 0) {
return redirect()
->route('teams.index')
->withErrors(['delete' => "Cannot delete \"{$team->name}\"{$inUse} KPI(s)/project(s) still use it. Reassign them to another team first."]);
}
$name = $team->name; $name = $team->name;
$team->delete(); $team->delete();
return redirect()->route('teams.index')->with('status', "Team \"{$name}\" deleted. Its items are now unassigned."); return redirect()->route('teams.index')->with('status', "Team \"{$name}\" deleted.");
} }
} }

View File

@ -28,13 +28,14 @@ class UserAdminController extends Controller
'name' => ['required', 'string', 'max:255'], 'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'email', 'max:255', 'unique:users,email'], 'email' => ['required', 'email', 'max:255', 'unique:users,email'],
'password' => ['required', 'string', Password::min(8)], 'password' => ['required', 'string', Password::min(8)],
'type' => ['required', 'in:standard,admin'],
]); ]);
User::create([ User::create([
'name' => $data['name'], 'name' => $data['name'],
'email' => $data['email'], 'email' => $data['email'],
'password' => $data['password'], 'password' => $data['password'],
'is_admin' => $request->boolean('is_admin'), 'type' => $data['type'],
'is_active' => true, 'is_active' => true,
]); ]);
@ -43,6 +44,33 @@ class UserAdminController extends Controller
->with('status', "User \"{$data['name']}\" created."); ->with('status', "User \"{$data['name']}\" created.");
} }
public function edit(User $user): View
{
return view('admin.users.edit', ['user' => $user]);
}
public function update(Request $request, User $user): RedirectResponse
{
$data = $request->validate([
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'email', 'max:255', 'unique:users,email,' . $user->id],
'type' => ['required', 'in:standard,admin'],
]);
// Lockout guard: an admin cannot demote their own account.
if ($user->id === $request->user()->id && $data['type'] !== 'admin') {
return back()
->withInput()
->withErrors(['type' => 'You cannot change your own account to a non-admin type.']);
}
$user->update($data);
return redirect()
->route('admin.users.index')
->with('status', "User \"{$user->name}\" updated.");
}
public function editPassword(User $user): View public function editPassword(User $user): View
{ {
return view('admin.users.password', ['user' => $user]); return view('admin.users.password', ['user' => $user]);

View File

@ -10,7 +10,7 @@ class EnsureUserIsAdmin
{ {
public function handle(Request $request, Closure $next): Response public function handle(Request $request, Closure $next): Response
{ {
if (! $request->user() || ! $request->user()->is_admin) { if (! $request->user() || ! $request->user()->isAdmin()) {
abort(403, 'Administrator access required.'); abort(403, 'Administrator access required.');
} }

View File

@ -19,7 +19,7 @@ class User extends Authenticatable
'name', 'name',
'email', 'email',
'password', 'password',
'is_admin', 'type',
'is_active', 'is_active',
]; ];
@ -39,6 +39,11 @@ class User extends Authenticatable
->orderBy('name'); ->orderBy('name');
} }
public function isAdmin(): bool
{
return $this->type === \App\Enums\UserType::Admin;
}
/** /**
* Whether this user is an owner of the given group. * Whether this user is an owner of the given group.
*/ */
@ -88,7 +93,7 @@ class User extends Authenticatable
return [ return [
'email_verified_at' => 'datetime', 'email_verified_at' => 'datetime',
'password' => 'hashed', 'password' => 'hashed',
'is_admin' => 'boolean', 'type' => \App\Enums\UserType::class,
'is_active' => 'boolean', 'is_active' => 'boolean',
]; ];
} }

View File

@ -12,7 +12,7 @@ class BowlerChartPolicy
*/ */
public function view(User $user, BowlerChart $chart): bool public function view(User $user, BowlerChart $chart): bool
{ {
return $user->is_admin return $user->isAdmin()
|| $user->groups()->where('groups.id', $chart->group_id)->exists(); || $user->groups()->where('groups.id', $chart->group_id)->exists();
} }
@ -21,7 +21,7 @@ class BowlerChartPolicy
*/ */
public function update(User $user, BowlerChart $chart): bool public function update(User $user, BowlerChart $chart): bool
{ {
return $user->is_admin || $user->ownsGroup($chart->group_id); return $user->isAdmin() || $user->ownsGroup($chart->group_id);
} }
public function delete(User $user, BowlerChart $chart): bool public function delete(User $user, BowlerChart $chart): bool

View File

@ -14,17 +14,17 @@ class KpiPolicy
public function view(User $user, Kpi $kpi): bool public function view(User $user, Kpi $kpi): bool
{ {
return $kpi->user_id === $user->id return $kpi->user_id === $user->id
|| $user->is_admin || $user->isAdmin()
|| $user->sharesGroupWith($kpi->user); || $user->sharesGroupWith($kpi->user);
} }
public function update(User $user, Kpi $kpi): bool public function update(User $user, Kpi $kpi): bool
{ {
return $kpi->user_id === $user->id || $user->is_admin; return $kpi->user_id === $user->id || $user->isAdmin();
} }
public function delete(User $user, Kpi $kpi): bool public function delete(User $user, Kpi $kpi): bool
{ {
return $kpi->user_id === $user->id || $user->is_admin; return $kpi->user_id === $user->id || $user->isAdmin();
} }
} }

View File

@ -14,17 +14,17 @@ class ProjectPolicy
public function view(User $user, Project $project): bool public function view(User $user, Project $project): bool
{ {
return $project->user_id === $user->id return $project->user_id === $user->id
|| $user->is_admin || $user->isAdmin()
|| $user->sharesGroupWith($project->user); || $user->sharesGroupWith($project->user);
} }
public function update(User $user, Project $project): bool public function update(User $user, Project $project): bool
{ {
return $project->user_id === $user->id || $user->is_admin; return $project->user_id === $user->id || $user->isAdmin();
} }
public function delete(User $user, Project $project): bool public function delete(User $user, Project $project): bool
{ {
return $project->user_id === $user->id || $user->is_admin; return $project->user_id === $user->id || $user->isAdmin();
} }
} }

View File

@ -0,0 +1,39 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Replace the is_admin boolean with a user type ('standard' | 'admin')
* so future user types can be added without another schema change.
*/
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->string('type', 20)->default('standard')->after('password');
});
DB::table('users')->where('is_admin', true)->update(['type' => 'admin']);
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('is_admin');
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->boolean('is_admin')->default(false)->after('password');
});
DB::table('users')->where('type', 'admin')->update(['is_admin' => true]);
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('type');
});
}
};

View File

@ -30,7 +30,7 @@ class DatabaseSeeder extends Seeder
'name' => env('ADMIN_NAME', 'Administrator'), 'name' => env('ADMIN_NAME', 'Administrator'),
'email' => $email, 'email' => $email,
'password' => $password, 'password' => $password,
'is_admin' => true, 'type' => \App\Enums\UserType::Admin,
'is_active' => true, 'is_active' => true,
]); ]);

File diff suppressed because one or more lines are too long

View File

@ -2,3 +2,75 @@
/* Scan Blade templates for utility classes */ /* Scan Blade templates for utility classes */
@source "../views"; @source "../views";
/* --- Destructive-action confirm modal ---------------------------------- */
#confirm-delete-modal::backdrop {
background: rgba(17, 24, 39, 0.5);
}
/* --- Bowler chart expand/collapse ------------------------------------- */
/* The detail <tr> itself can't animate height; the inner grid wrapper
animates 0fr -> 1fr (no magic max-heights). Entrances are gentle,
exits are quicker, and the KPI line "draws" itself as the hero moment. */
.detail-outer {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 180ms cubic-bezier(0.4, 0, 0.2, 1);
}
.detail-inner {
overflow: hidden;
min-height: 0;
opacity: 0;
transform: translateY(-4px);
transition: opacity 120ms ease-in, transform 120ms ease-in;
}
.js-detail-row.is-open .detail-outer {
grid-template-rows: 1fr;
transition-duration: 250ms;
}
.js-detail-row.is-open .detail-inner {
opacity: 1;
transform: translateY(0);
transition: opacity 200ms ease-out 80ms, transform 200ms ease-out 80ms;
}
/* KPI chart: the value line draws in; points/markers pop with a stagger. */
@keyframes kpi-draw {
from { stroke-dashoffset: 1; }
to { stroke-dashoffset: 0; }
}
@keyframes kpi-pop {
from { opacity: 0; transform: scale(0.4); }
to { opacity: 1; transform: scale(1); }
}
.is-open .kpi-line {
stroke-dasharray: 1;
animation: kpi-draw 600ms cubic-bezier(0.25, 0.1, 0.25, 1) 150ms backwards;
}
.is-open .kpi-pop {
transform-box: fill-box;
transform-origin: center;
animation: kpi-pop 220ms cubic-bezier(0.34, 1.4, 0.64, 1) backwards;
animation-delay: calc(250ms + var(--i, 0) * 40ms);
}
@media (prefers-reduced-motion: reduce) {
.detail-outer,
.detail-inner,
.js-detail-row.is-open .detail-outer,
.js-detail-row.is-open .detail-inner {
transition: none;
}
.is-open .kpi-line,
.is-open .kpi-pop {
animation: none;
}
}

View File

@ -37,10 +37,17 @@
</div> </div>
<div class="mb-6"> <div class="mb-6">
<label class="inline-flex items-center gap-2"> <label for="type" class="mb-1 block font-semibold">User type</label>
<input type="checkbox" name="is_admin" value="1" {{ old('is_admin') ? 'checked' : '' }} class="rounded border-gray-300"> <select id="type" name="type"
Administrator (can manage users) 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">
</label> @foreach (\App\Enums\UserType::cases() as $type)
<option value="{{ $type->value }}" {{ old('type', 'standard') === $type->value ? 'selected' : '' }}>{{ $type->label() }}</option>
@endforeach
</select>
<p class="mt-1 text-sm text-gray-500">Administrators can manage users, groups, and all content.</p>
@error('type')
<p class="mt-1 text-sm text-red-700">{{ $message }}</p>
@enderror
</div> </div>
<div class="flex gap-2"> <div class="flex gap-2">

View File

@ -0,0 +1,58 @@
@extends('layouts.app')
@section('title', 'Edit User — ' . 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 user</h1>
<form method="POST" action="{{ route('admin.users.update', $user) }}">
@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', $user->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="email" class="mb-1 block font-semibold">Email</label>
<input id="email" type="email" name="email" value="{{ old('email', $user->email) }}" 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('email')
<p class="mt-1 text-sm text-red-700">{{ $message }}</p>
@enderror
</div>
<div class="mb-6">
<label for="type" class="mb-1 block font-semibold">User type</label>
<select id="type" name="type" {{ $user->id === auth()->id() ? 'disabled' : '' }}
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 disabled:cursor-not-allowed disabled:bg-gray-50 disabled:text-gray-500">
@foreach (\App\Enums\UserType::cases() as $type)
<option value="{{ $type->value }}" {{ old('type', $user->type->value) === $type->value ? 'selected' : '' }}>{{ $type->label() }}</option>
@endforeach
</select>
@if ($user->id === auth()->id())
{{-- Disabled selects don't submit; keep the current value in the request --}}
<input type="hidden" name="type" value="{{ $user->type->value }}">
<p class="mt-1 text-sm text-gray-500">You cannot change your own account's type.</p>
@else
<p class="mt-1 text-sm text-gray-500">Administrators can manage users, groups, and all content.</p>
@endif
@error('type')
<p class="mt-1 text-sm text-red-700">{{ $message }}</p>
@enderror
</div>
<div class="flex items-center 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('admin.users.index') }}" class="rounded-md border border-gray-300 bg-white px-4 py-2 text-gray-900 hover:bg-gray-50">Cancel</a>
<a href="{{ route('admin.users.password.edit', $user) }}" class="ml-auto text-sm text-blue-600 hover:underline">Reset password </a>
</div>
</form>
</div>
@endsection

View File

@ -31,7 +31,7 @@
@endif @endif
</td> </td>
<td class="px-4 py-3">{{ $user->email }}</td> <td class="px-4 py-3">{{ $user->email }}</td>
<td class="px-4 py-3">{{ $user->is_admin ? 'Administrator' : 'User' }}</td> <td class="px-4 py-3">{{ $user->type->label() }}</td>
<td class="px-4 py-3"> <td class="px-4 py-3">
@forelse ($user->groups as $group) @forelse ($user->groups as $group)
<a href="{{ route('admin.groups.members', $group) }}" <a href="{{ route('admin.groups.members', $group) }}"
@ -47,6 +47,8 @@
</td> </td>
<td class="px-4 py-3">{{ $user->created_at?->format('Y-m-d') }}</td> <td class="px-4 py-3">{{ $user->created_at?->format('Y-m-d') }}</td>
<td class="whitespace-nowrap px-4 py-3"> <td class="whitespace-nowrap px-4 py-3">
<a href="{{ route('admin.users.edit', $user) }}"
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>
<a href="{{ route('admin.users.password.edit', $user) }}" <a href="{{ route('admin.users.password.edit', $user) }}"
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">Reset password</a> 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">Reset password</a>
@if ($user->id !== auth()->id()) @if ($user->id !== auth()->id())

View File

@ -9,6 +9,10 @@
</div> </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> <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>
@if ($errors->has('delete'))
<div class="mb-4 rounded-md bg-red-50 px-4 py-3 text-red-800">{{ $errors->first('delete') }}</div>
@endif
<div class="mb-6 rounded-lg border border-gray-200 bg-white p-4"> <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"> <form method="POST" action="{{ route('categories.store') }}" class="flex items-end gap-3">
@csrf @csrf
@ -43,13 +47,19 @@
<td class="whitespace-nowrap px-4 py-3"> <td class="whitespace-nowrap px-4 py-3">
<a href="{{ route('categories.edit', $category) }}" <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> 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" @if ($category->kpis_count + $category->projects_count > 0)
data-confirm="Delete category &quot;{{ $category->name }}&quot;? Its {{ $category->kpis_count + $category->projects_count }} item(s) will become unassigned (not deleted)."> <button type="button" disabled
@csrf title="In use by {{ $category->kpis_count + $category->projects_count }} KPI(s)/project(s) — reassign them first"
@method('DELETE') class="cursor-not-allowed rounded-md border border-gray-200 px-2.5 py-1 text-sm text-gray-400">Delete</button>
<button type="submit" @else
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 method="POST" action="{{ route('categories.destroy', $category) }}" class="js-confirm inline"
</form> data-confirm="Delete category &quot;{{ $category->name }}&quot;?">
@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>
@endif
</td> </td>
</tr> </tr>
@empty @empty

View File

@ -68,12 +68,16 @@
</tr> </tr>
@if ($row['type'] === 'project') @if ($row['type'] === 'project')
<tr class="js-detail-row hidden"> <tr class="js-detail-row hidden">
<td colspan="{{ $totalCols }}" class="bg-blue-50/60 px-6 py-4"></td> <td colspan="{{ $totalCols }}" class="bg-blue-50/60 p-0">
<div class="detail-outer"><div class="detail-inner js-detail-content px-6 py-4"></div></div>
</td>
</tr> </tr>
@else @else
<tr class="js-detail-row hidden"> <tr class="js-detail-row hidden">
<td colspan="{{ $totalCols }}" class="bg-blue-50/60 px-6 py-4"> <td colspan="{{ $totalCols }}" class="bg-blue-50/60 p-0">
@include('charts._kpi-chart', ['kpi' => $item, 'chart' => $row['chart']]) <div class="detail-outer"><div class="detail-inner js-detail-content px-6 py-4">
@include('charts._kpi-chart', ['kpi' => $item, 'chart' => $row['chart']])
</div></div>
</td> </td>
</tr> </tr>
@endif @endif
@ -90,8 +94,32 @@
<script> <script>
(function () { (function () {
function expandRow(row) {
row.classList.remove('hidden');
void row.offsetHeight; // force reflow so the 0fr -> 1fr transition runs
row.classList.add('is-open');
}
function collapseRow(row) {
row.classList.remove('is-open');
var done = false;
var finish = function () {
if (done) return;
done = true;
// Skip if the row was re-opened before the collapse finished.
if (!row.classList.contains('is-open')) {
row.classList.add('hidden');
}
};
var outer = row.querySelector('.detail-outer');
if (outer) {
outer.addEventListener('transitionend', finish, { once: true });
}
setTimeout(finish, 350); // safety net if the transition event never fires
}
function collapseAll() { function collapseAll() {
document.querySelectorAll('.js-detail-row').forEach(function (r) { r.classList.add('hidden'); }); document.querySelectorAll('.js-detail-row.is-open').forEach(collapseRow);
document.querySelectorAll('.js-project-cell.ring-2').forEach(function (c) { c.classList.remove('ring-2', 'ring-blue-500'); }); document.querySelectorAll('.js-project-cell.ring-2').forEach(function (c) { c.classList.remove('ring-2', 'ring-blue-500'); });
} }
@ -145,10 +173,10 @@
var kpiRow = e.target.closest('.js-kpi-row'); var kpiRow = e.target.closest('.js-kpi-row');
if (kpiRow && !e.target.closest('a')) { if (kpiRow && !e.target.closest('a')) {
var kpiDetail = kpiRow.nextElementSibling; var kpiDetail = kpiRow.nextElementSibling;
var kpiOpen = kpiDetail && !kpiDetail.classList.contains('hidden'); var kpiOpen = kpiDetail && kpiDetail.classList.contains('is-open');
collapseAll(); collapseAll();
if (kpiDetail && !kpiOpen) { if (kpiDetail && !kpiOpen) {
kpiDetail.classList.remove('hidden'); expandRow(kpiDetail);
} }
return; return;
} }
@ -158,15 +186,15 @@
if (cell) { if (cell) {
var detailRow = cell.closest('tr').nextElementSibling; var detailRow = cell.closest('tr').nextElementSibling;
var isOpenForThisCell = detailRow var isOpenForThisCell = detailRow
&& !detailRow.classList.contains('hidden') && detailRow.classList.contains('is-open')
&& detailRow.dataset.month === cell.dataset.month; && detailRow.dataset.month === cell.dataset.month;
collapseAll(); collapseAll();
if (detailRow && !isOpenForThisCell) { if (detailRow && !isOpenForThisCell) {
populate(detailRow.firstElementChild, JSON.parse(cell.dataset.details)); populate(detailRow.querySelector('.js-detail-content'), JSON.parse(cell.dataset.details));
detailRow.dataset.month = cell.dataset.month; detailRow.dataset.month = cell.dataset.month;
detailRow.classList.remove('hidden'); expandRow(detailRow);
cell.classList.add('ring-2', 'ring-blue-500'); cell.classList.add('ring-2', 'ring-blue-500');
} }
return; return;

View File

@ -30,15 +30,16 @@
font-size="9" fill="{{ \App\Support\KpiChart::INK_SECONDARY }}">{{ $ref['label'] }}</text> font-size="9" fill="{{ \App\Support\KpiChart::INK_SECONDARY }}">{{ $ref['label'] }}</text>
@endforeach @endforeach
{{-- Value line (gaps split the path) --}} {{-- Value line (gaps split the path; pathLength normalizes the draw-in animation) --}}
@foreach ($chart['paths'] as $path) @foreach ($chart['paths'] as $path)
<path d="{{ $path }}" fill="none" stroke="{{ $chart['lineColor'] }}" stroke-width="2" <path d="{{ $path }}" pathLength="1" class="kpi-line"
fill="none" stroke="{{ $chart['lineColor'] }}" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round" /> stroke-linecap="round" stroke-linejoin="round" />
@endforeach @endforeach
{{-- Points, status-colored with a 2px surface ring --}} {{-- Points, status-colored with a 2px surface ring --}}
@foreach ($chart['points'] as $point) @foreach ($chart['points'] as $point)
<circle cx="{{ $point['x'] }}" cy="{{ $point['y'] }}" r="4.5" <circle cx="{{ $point['x'] }}" cy="{{ $point['y'] }}" r="4.5" class="kpi-pop" style="--i: {{ $loop->index }}"
fill="{{ $point['color'] }}" stroke="#ffffff" stroke-width="2"> fill="{{ $point['color'] }}" stroke="#ffffff" stroke-width="2">
<title>{{ $point['title'] }}</title> <title>{{ $point['title'] }}</title>
</circle> </circle>
@ -63,7 +64,7 @@
<line x1="{{ $chart['plot']['x'] }}" y1="{{ $chart['baseline'] }}" x2="{{ $chart['plot']['x'] + $chart['plot']['w'] }}" y2="{{ $chart['baseline'] }}" <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" /> stroke="{{ \App\Support\KpiChart::GRIDLINE }}" stroke-width="1" />
@foreach ($chart['markers'] as $marker) @foreach ($chart['markers'] as $marker)
<g> <g class="kpi-pop" style="--i: {{ $loop->index }}">
<title>{{ $marker['title'] }}</title> <title>{{ $marker['title'] }}</title>
<circle cx="{{ $marker['x'] }}" cy="{{ $marker['y'] }}" r="9" <circle cx="{{ $marker['x'] }}" cy="{{ $marker['y'] }}" r="9"
fill="{{ $marker['color'] }}" stroke="#ffffff" stroke-width="2" /> fill="{{ $marker['color'] }}" stroke="#ffffff" stroke-width="2" />

View File

@ -15,7 +15,7 @@
@auth @auth
<p class="mt-6">You are logged in as <strong>{{ auth()->user()->name }}</strong>.</p> <p class="mt-6">You are logged in as <strong>{{ auth()->user()->name }}</strong>.</p>
@if (auth()->user()->is_admin) @if (auth()->user()->isAdmin())
<p class="mt-4"> <p class="mt-4">
<a href="{{ route('admin.users.index') }}" class="inline-block rounded-md bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700">Manage users</a> <a href="{{ route('admin.users.index') }}" class="inline-block rounded-md bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700">Manage users</a>
</p> </p>

View File

@ -51,8 +51,9 @@
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> 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) }}" <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> 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" <form method="POST" action="{{ route('kpis.destroy', $kpi) }}" class="js-confirm-delete inline"
data-confirm="Delete KPI &quot;{{ $kpi->name }}&quot; and all {{ $kpi->entries_count }} entries? This cannot be undone."> data-title="Delete KPI &quot;{{ $kpi->name }}&quot;?"
data-message="This permanently deletes the KPI and all {{ $kpi->entries_count }} of its entries, and removes it from every bowler chart it appears on.">
@csrf @csrf
@method('DELETE') @method('DELETE')
<button type="submit" <button type="submit"
@ -66,5 +67,5 @@
</div> </div>
@endif @endif
@include('partials.confirm-script') @include('partials.delete-modal')
@endsection @endsection

View File

@ -13,15 +13,53 @@
<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('charts.index') }}" class="text-blue-600 hover:underline">Charts</a>
<a href="{{ route('kpis.index') }}" class="text-blue-600 hover:underline">KPIs</a> <div class="relative" data-dropdown>
<a href="{{ route('projects.index') }}" class="text-blue-600 hover:underline">Projects</a> <button type="button" data-dropdown-toggle aria-expanded="false" aria-haspopup="true"
<a href="{{ route('teams.index') }}" class="text-blue-600 hover:underline">Teams</a> class="flex cursor-pointer items-center gap-1 text-blue-600 hover:underline">
<a href="{{ route('categories.index') }}" class="text-blue-600 hover:underline">Categories</a> Data
<a href="{{ route('api-keys.index') }}" class="text-blue-600 hover:underline">API Keys</a> <svg data-dropdown-chevron class="h-3 w-3 transition-transform duration-150" viewBox="0 0 12 12" fill="none" aria-hidden="true">
@if (auth()->user()->is_admin) <path d="M2.5 4.5 6 8l3.5-3.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<a href="{{ route('admin.users.index') }}" class="text-blue-600 hover:underline">Users</a> </svg>
<a href="{{ route('admin.groups.index') }}" class="text-blue-600 hover:underline">Groups</a> </button>
<div data-dropdown-menu
class="absolute right-0 top-full z-20 mt-2 hidden w-44 rounded-md border border-gray-200 bg-white py-1 shadow-lg">
<a href="{{ route('kpis.index') }}" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-50">KPIs</a>
<a href="{{ route('projects.index') }}" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-50">Projects</a>
</div>
</div>
<div class="relative" data-dropdown>
<button type="button" data-dropdown-toggle aria-expanded="false" aria-haspopup="true"
class="flex cursor-pointer items-center gap-1 text-blue-600 hover:underline">
Account
<svg data-dropdown-chevron class="h-3 w-3 transition-transform duration-150" viewBox="0 0 12 12" fill="none" aria-hidden="true">
<path d="M2.5 4.5 6 8l3.5-3.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
<div data-dropdown-menu
class="absolute right-0 top-full z-20 mt-2 hidden w-44 rounded-md border border-gray-200 bg-white py-1 shadow-lg">
<a href="{{ route('teams.index') }}" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-50">Teams</a>
<a href="{{ route('categories.index') }}" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-50">Categories</a>
<a href="{{ route('api-keys.index') }}" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-50">API Keys</a>
</div>
</div>
@if (auth()->user()->isAdmin())
<div class="relative" data-dropdown>
<button type="button" data-dropdown-toggle aria-expanded="false" aria-haspopup="true"
class="flex cursor-pointer items-center gap-1 text-blue-600 hover:underline">
Admin
<svg data-dropdown-chevron class="h-3 w-3 transition-transform duration-150" viewBox="0 0 12 12" fill="none" aria-hidden="true">
<path d="M2.5 4.5 6 8l3.5-3.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
<div data-dropdown-menu
class="absolute right-0 top-full z-20 mt-2 hidden w-44 rounded-md border border-gray-200 bg-white py-1 shadow-lg">
<a href="{{ route('admin.users.index') }}" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-50">Users</a>
<a href="{{ route('admin.groups.index') }}" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-50">Groups</a>
</div>
</div>
@endif @endif
<span class="text-gray-500">{{ auth()->user()->name }}</span> <span class="text-gray-500">{{ auth()->user()->name }}</span>
<form method="POST" action="{{ route('logout') }}" class="inline"> <form method="POST" action="{{ route('logout') }}" class="inline">
@csrf @csrf
@ -45,5 +83,46 @@
@yield('content') @yield('content')
</main> </main>
<script>
// Reusable nav dropdowns: toggle on click, close on outside click or Escape.
(function () {
function close(dropdown) {
dropdown.querySelector('[data-dropdown-menu]').classList.add('hidden');
var toggle = dropdown.querySelector('[data-dropdown-toggle]');
toggle.setAttribute('aria-expanded', 'false');
var chevron = dropdown.querySelector('[data-dropdown-chevron]');
if (chevron) chevron.classList.remove('rotate-180');
}
function closeAll(except) {
document.querySelectorAll('[data-dropdown]').forEach(function (d) {
if (d !== except) close(d);
});
}
document.querySelectorAll('[data-dropdown]').forEach(function (dropdown) {
var toggle = dropdown.querySelector('[data-dropdown-toggle]');
var menu = dropdown.querySelector('[data-dropdown-menu]');
toggle.addEventListener('click', function () {
var opening = menu.classList.contains('hidden');
closeAll(dropdown);
menu.classList.toggle('hidden', !opening);
toggle.setAttribute('aria-expanded', opening ? 'true' : 'false');
var chevron = dropdown.querySelector('[data-dropdown-chevron]');
if (chevron) chevron.classList.toggle('rotate-180', opening);
});
});
document.addEventListener('click', function (e) {
if (!e.target.closest('[data-dropdown]')) closeAll();
});
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') closeAll();
});
})();
</script>
</body> </body>
</html> </html>

View File

@ -0,0 +1,53 @@
{{-- Strong confirmation modal for permanent deletions.
Attach class="js-confirm-delete" + data-title + data-message to a form. --}}
<dialog id="confirm-delete-modal" class="m-auto w-full max-w-md rounded-lg p-0 shadow-xl">
<div class="p-6">
<h2 class="js-modal-title mb-2 text-lg font-bold text-gray-900">Delete?</h2>
<p class="js-modal-message mb-4 text-gray-700"></p>
<div class="mb-5 rounded-md bg-red-50 px-4 py-3 text-sm font-semibold text-red-800">
This is a one-way action. Once deleted, the data cannot be recovered.
</div>
<div class="flex justify-end gap-2">
<button type="button" class="js-modal-cancel cursor-pointer rounded-md border border-gray-300 bg-white px-4 py-2 text-gray-900 hover:bg-gray-50">Cancel</button>
<button type="button" class="js-modal-confirm cursor-pointer rounded-md bg-red-600 px-4 py-2 font-medium text-white hover:bg-red-700">Permanently delete</button>
</div>
</div>
</dialog>
<script>
(function () {
var dialog = document.getElementById('confirm-delete-modal');
var pendingForm = null;
document.querySelectorAll('form.js-confirm-delete').forEach(function (form) {
form.addEventListener('submit', function (event) {
event.preventDefault();
pendingForm = form;
dialog.querySelector('.js-modal-title').textContent = form.dataset.title || 'Delete?';
dialog.querySelector('.js-modal-message').textContent = form.dataset.message || 'Are you sure?';
dialog.showModal();
});
});
dialog.querySelector('.js-modal-confirm').addEventListener('click', function () {
dialog.close();
if (pendingForm) {
pendingForm.submit(); // native submit() skips the submit listener, so no loop
pendingForm = null;
}
});
dialog.querySelector('.js-modal-cancel').addEventListener('click', function () {
pendingForm = null;
dialog.close();
});
// Clicking the backdrop cancels too (Esc already works natively).
dialog.addEventListener('click', function (event) {
if (event.target === dialog) {
pendingForm = null;
dialog.close();
}
});
})();
</script>

View File

@ -49,8 +49,9 @@
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> 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) }}" <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> 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" <form method="POST" action="{{ route('projects.destroy', $project) }}" class="js-confirm-delete inline"
data-confirm="Delete project &quot;{{ $project->name }}&quot; and all {{ $project->entries_count }} entries? This cannot be undone."> data-title="Delete project &quot;{{ $project->name }}&quot;?"
data-message="This permanently deletes the project and all {{ $project->entries_count }} of its status entries, and removes it from every bowler chart it appears on.">
@csrf @csrf
@method('DELETE') @method('DELETE')
<button type="submit" <button type="submit"
@ -64,5 +65,5 @@
</div> </div>
@endif @endif
@include('partials.confirm-script') @include('partials.delete-modal')
@endsection @endsection

View File

@ -9,6 +9,10 @@
</div> </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> <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>
@if ($errors->has('delete'))
<div class="mb-4 rounded-md bg-red-50 px-4 py-3 text-red-800">{{ $errors->first('delete') }}</div>
@endif
<div class="mb-6 rounded-lg border border-gray-200 bg-white p-4"> <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"> <form method="POST" action="{{ route('teams.store') }}" class="flex items-end gap-3">
@csrf @csrf
@ -43,13 +47,19 @@
<td class="whitespace-nowrap px-4 py-3"> <td class="whitespace-nowrap px-4 py-3">
<a href="{{ route('teams.edit', $team) }}" <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> 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" @if ($team->kpis_count + $team->projects_count > 0)
data-confirm="Delete team &quot;{{ $team->name }}&quot;? Its {{ $team->kpis_count + $team->projects_count }} item(s) will become unassigned (not deleted)."> <button type="button" disabled
@csrf title="In use by {{ $team->kpis_count + $team->projects_count }} KPI(s)/project(s) — reassign them first"
@method('DELETE') class="cursor-not-allowed rounded-md border border-gray-200 px-2.5 py-1 text-sm text-gray-400">Delete</button>
<button type="submit" @else
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 method="POST" action="{{ route('teams.destroy', $team) }}" class="js-confirm inline"
</form> data-confirm="Delete team &quot;{{ $team->name }}&quot;?">
@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>
@endif
</td> </td>
</tr> </tr>
@empty @empty

View File

@ -94,6 +94,8 @@ Route::middleware(['auth', 'admin'])->prefix('admin')->name('admin.')->group(fun
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');
Route::post('/users', [UserAdminController::class, 'store'])->name('users.store'); Route::post('/users', [UserAdminController::class, 'store'])->name('users.store');
Route::get('/users/{user}/edit', [UserAdminController::class, 'edit'])->name('users.edit');
Route::put('/users/{user}', [UserAdminController::class, 'update'])->name('users.update');
Route::get('/users/{user}/password', [UserAdminController::class, 'editPassword'])->name('users.password.edit'); Route::get('/users/{user}/password', [UserAdminController::class, 'editPassword'])->name('users.password.edit');
Route::put('/users/{user}/password', [UserAdminController::class, 'updatePassword'])->name('users.password.update'); Route::put('/users/{user}/password', [UserAdminController::class, 'updatePassword'])->name('users.password.update');
Route::post('/users/{user}/toggle-active', [UserAdminController::class, 'toggleActive'])->name('users.toggle'); Route::post('/users/{user}/toggle-active', [UserAdminController::class, 'toggleActive'])->name('users.toggle');