Menu refinement
This commit is contained in:
parent
a76cc39494
commit
25d749cb29
11
README.md
11
README.md
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
|
|
|||
|
|
@ -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.');
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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 "{{ $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>
|
||||
@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 "{{ $category->name }}"?">
|
||||
@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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 "{{ $kpi->name }}" 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 "{{ $kpi->name }}"?"
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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 "{{ $project->name }}" 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 "{{ $project->name }}"?"
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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 "{{ $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>
|
||||
@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 "{{ $team->name }}"?">
|
||||
@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
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
Loading…
Reference in New Issue