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.
### 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
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")
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".
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**
(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,
'name' => $request->user()->name,
'email' => $request->user()->email,
'type' => $request->user()->type->value,
]);
}
}

View File

@ -15,7 +15,7 @@ class BowlerChartController extends Controller
{
$user = $request->user();
$charts = $user->is_admin
$charts = $user->isAdmin()
? BowlerChart::with('group')->get()
: BowlerChart::with('group')
->whereIn('group_id', $user->groups()->select('groups.id'))
@ -41,7 +41,7 @@ class BowlerChartController extends Controller
'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.');
}
@ -116,7 +116,7 @@ class BowlerChartController extends Controller
{
$user = $request->user();
return $user->is_admin
return $user->isAdmin()
? Group::orderBy('name')->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);
$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;
$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);
$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;
$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'],
'email' => ['required', 'email', 'max:255', 'unique:users,email'],
'password' => ['required', 'string', Password::min(8)],
'type' => ['required', 'in:standard,admin'],
]);
User::create([
'name' => $data['name'],
'email' => $data['email'],
'password' => $data['password'],
'is_admin' => $request->boolean('is_admin'),
'type' => $data['type'],
'is_active' => true,
]);
@ -43,6 +44,33 @@ class UserAdminController extends Controller
->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
{
return view('admin.users.password', ['user' => $user]);

View File

@ -10,7 +10,7 @@ class EnsureUserIsAdmin
{
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.');
}

View File

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

View File

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

View File

@ -14,17 +14,17 @@ class KpiPolicy
public function view(User $user, Kpi $kpi): bool
{
return $kpi->user_id === $user->id
|| $user->is_admin
|| $user->isAdmin()
|| $user->sharesGroupWith($kpi->user);
}
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
{
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
{
return $project->user_id === $user->id
|| $user->is_admin
|| $user->isAdmin()
|| $user->sharesGroupWith($project->user);
}
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
{
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'),
'email' => $email,
'password' => $password,
'is_admin' => true,
'type' => \App\Enums\UserType::Admin,
'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 */
@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 class="mb-6">
<label class="inline-flex items-center gap-2">
<input type="checkbox" name="is_admin" value="1" {{ old('is_admin') ? 'checked' : '' }} class="rounded border-gray-300">
Administrator (can manage users)
</label>
<label for="type" class="mb-1 block font-semibold">User type</label>
<select id="type" name="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\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 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
</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">
@forelse ($user->groups as $group)
<a href="{{ route('admin.groups.members', $group) }}"
@ -47,6 +47,8 @@
</td>
<td class="px-4 py-3">{{ $user->created_at?->format('Y-m-d') }}</td>
<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) }}"
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())

View File

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

View File

@ -68,12 +68,16 @@
</tr>
@if ($row['type'] === 'project')
<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>
@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 colspan="{{ $totalCols }}" class="bg-blue-50/60 p-0">
<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>
</tr>
@endif
@ -90,8 +94,32 @@
<script>
(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() {
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'); });
}
@ -145,10 +173,10 @@
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');
var kpiOpen = kpiDetail && kpiDetail.classList.contains('is-open');
collapseAll();
if (kpiDetail && !kpiOpen) {
kpiDetail.classList.remove('hidden');
expandRow(kpiDetail);
}
return;
}
@ -158,15 +186,15 @@
if (cell) {
var detailRow = cell.closest('tr').nextElementSibling;
var isOpenForThisCell = detailRow
&& !detailRow.classList.contains('hidden')
&& detailRow.classList.contains('is-open')
&& detailRow.dataset.month === cell.dataset.month;
collapseAll();
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.classList.remove('hidden');
expandRow(detailRow);
cell.classList.add('ring-2', 'ring-blue-500');
}
return;

View File

@ -30,15 +30,16 @@
font-size="9" fill="{{ \App\Support\KpiChart::INK_SECONDARY }}">{{ $ref['label'] }}</text>
@endforeach
{{-- Value line (gaps split the path) --}}
{{-- Value line (gaps split the path; pathLength normalizes the draw-in animation) --}}
@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" />
@endforeach
{{-- Points, status-colored with a 2px surface ring --}}
@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">
<title>{{ $point['title'] }}</title>
</circle>
@ -63,7 +64,7 @@
<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>
<g class="kpi-pop" style="--i: {{ $loop->index }}">
<title>{{ $marker['title'] }}</title>
<circle cx="{{ $marker['x'] }}" cy="{{ $marker['y'] }}" r="9"
fill="{{ $marker['color'] }}" stroke="#ffffff" stroke-width="2" />

View File

@ -15,7 +15,7 @@
@auth
<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">
<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>

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>
<a href="{{ route('kpis.edit', $kpi) }}"
class="mr-1 inline-block rounded-md border border-gray-300 bg-white px-2.5 py-1 text-sm text-gray-900 hover:bg-gray-50">Edit</a>
<form method="POST" action="{{ route('kpis.destroy', $kpi) }}" class="js-confirm inline"
data-confirm="Delete KPI &quot;{{ $kpi->name }}&quot; and all {{ $kpi->entries_count }} entries? This cannot be undone.">
<form method="POST" action="{{ route('kpis.destroy', $kpi) }}" class="js-confirm-delete inline"
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
@method('DELETE')
<button type="submit"
@ -66,5 +67,5 @@
</div>
@endif
@include('partials.confirm-script')
@include('partials.delete-modal')
@endsection

View File

@ -13,15 +13,53 @@
<nav class="flex items-center gap-4">
@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)
<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>
<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">
Data
<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('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
<span class="text-gray-500">{{ auth()->user()->name }}</span>
<form method="POST" action="{{ route('logout') }}" class="inline">
@csrf
@ -45,5 +83,46 @@
@yield('content')
</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>
</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>
<a href="{{ route('projects.edit', $project) }}"
class="mr-1 inline-block rounded-md border border-gray-300 bg-white px-2.5 py-1 text-sm text-gray-900 hover:bg-gray-50">Edit</a>
<form method="POST" action="{{ route('projects.destroy', $project) }}" class="js-confirm inline"
data-confirm="Delete project &quot;{{ $project->name }}&quot; and all {{ $project->entries_count }} entries? This cannot be undone.">
<form method="POST" action="{{ route('projects.destroy', $project) }}" class="js-confirm-delete inline"
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
@method('DELETE')
<button type="submit"
@ -64,5 +65,5 @@
</div>
@endif
@include('partials.confirm-script')
@include('partials.delete-modal')
@endsection

View File

@ -9,6 +9,10 @@
</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>
@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">
<form method="POST" action="{{ route('teams.store') }}" class="flex items-end gap-3">
@csrf
@ -43,13 +47,19 @@
<td class="whitespace-nowrap px-4 py-3">
<a href="{{ route('teams.edit', $team) }}"
class="mr-1 inline-block rounded-md border border-gray-300 bg-white px-2.5 py-1 text-sm text-gray-900 hover:bg-gray-50">Rename</a>
<form method="POST" action="{{ route('teams.destroy', $team) }}" class="js-confirm inline"
data-confirm="Delete team &quot;{{ $team->name }}&quot;? Its {{ $team->kpis_count + $team->projects_count }} item(s) will become unassigned (not deleted).">
@csrf
@method('DELETE')
<button type="submit"
class="cursor-pointer rounded-md border border-red-600 px-2.5 py-1 text-sm text-red-700 hover:bg-red-50">Delete</button>
</form>
@if ($team->kpis_count + $team->projects_count > 0)
<button type="button" disabled
title="In use by {{ $team->kpis_count + $team->projects_count }} KPI(s)/project(s) — reassign them first"
class="cursor-not-allowed rounded-md border border-gray-200 px-2.5 py-1 text-sm text-gray-400">Delete</button>
@else
<form method="POST" action="{{ route('teams.destroy', $team) }}" class="js-confirm inline"
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>
</tr>
@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/create', [UserAdminController::class, 'create'])->name('users.create');
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::put('/users/{user}/password', [UserAdminController::class, 'updatePassword'])->name('users.password.update');
Route::post('/users/{user}/toggle-active', [UserAdminController::class, 'toggleActive'])->name('users.toggle');