Protect project timesheets on delete and improve View Project team UI.

Soft-delete projects with restore support, prevent timesheet cascade loss, and redesign the team members and contributions panels with matched-height scrollable cards.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Syazwan 2026-07-07 11:25:11 +08:00
parent 2502601c08
commit 755fee72ae
12 changed files with 401 additions and 38 deletions

View File

@ -15,6 +15,11 @@ use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Schema;
use Filament\Tables;
use Filament\Tables\Table;
use Filament\Actions\DeleteAction;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\ForceDeleteAction;
use Filament\Actions\RestoreAction;
use Filament\Actions\RestoreBulkAction;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
@ -246,17 +251,28 @@ class ProjectResource extends Resource
Tables\Filters\SelectFilter::make('project_type_id')
->label('Project type')
->relationship('projectType', 'name'),
Tables\Filters\TrashedFilter::make()
->visible(fn () => auth()->user()?->isAdmin() ?? false),
])
->actions([
\Filament\Actions\ViewAction::make(),
\Filament\Actions\EditAction::make()
->visible(fn (Project $record) => static::canEdit($record)),
\Filament\Actions\DeleteAction::make()
->visible(fn () => auth()->user()?->isAdmin() ?? false),
static::configureProjectDeleteAction(DeleteAction::make())
->visible(fn (Project $record): bool => ! $record->trashed() && (auth()->user()?->isAdmin() ?? false)),
RestoreAction::make()
->visible(fn (Project $record): bool => $record->trashed() && (auth()->user()?->isAdmin() ?? false)),
ForceDeleteAction::make()
->visible(fn (Project $record): bool => $record->trashed()
&& (auth()->user()?->isAdmin() ?? false)
&& $record->canBeForceDeleted())
->modalDescription('This permanently removes the project. It can only be done when no timesheets exist.'),
])
->bulkActions([
\Filament\Actions\BulkActionGroup::make([
\Filament\Actions\DeleteBulkAction::make()
static::configureProjectDeleteAction(DeleteBulkAction::make())
->visible(fn () => auth()->user()?->isAdmin() ?? false),
RestoreBulkAction::make()
->visible(fn () => auth()->user()?->isAdmin() ?? false),
]),
]));
@ -288,6 +304,18 @@ class ProjectResource extends Resource
return auth()->user()?->isAdmin() ?? false;
}
public static function canRestore(Model $record): bool
{
return auth()->user()?->isAdmin() ?? false;
}
public static function canForceDelete(Model $record): bool
{
return (auth()->user()?->isAdmin() ?? false)
&& $record instanceof Project
&& $record->canBeForceDeleted();
}
public static function getRelations(): array
{
return [];
@ -314,4 +342,20 @@ class ProjectResource extends Resource
'program_manager',
], true);
}
public static function configureProjectDeleteAction(DeleteAction | DeleteBulkAction $action): DeleteAction | DeleteBulkAction
{
$action
->label('Move to trash')
->modalHeading('Move project to trash')
->successNotificationTitle('Project moved to trash');
if ($action instanceof DeleteAction) {
$action->modalDescription(fn (Project $record) => $record->trashDeletionMessage());
} else {
$action->modalDescription('Selected projects will be hidden from active lists. Timesheet records are kept and projects can be restored from the trash filter.');
}
return $action;
}
}

View File

@ -16,7 +16,7 @@ class EditProject extends EditRecord
protected function getHeaderActions(): array
{
return [
Actions\DeleteAction::make()
ProjectResource::configureProjectDeleteAction(Actions\DeleteAction::make())
->visible(fn () => auth()->user()?->isAdmin() ?? false),
];
}

View File

@ -5,9 +5,13 @@ namespace App\Filament\Resources\ProjectResource\Pages;
use App\Filament\Resources\ProjectResource;
use Filament\Actions;
use Filament\Infolists\Components\TextEntry;
use Filament\Infolists\Components\ViewEntry;
use Filament\Resources\Pages\ViewRecord;
use Filament\Schemas\Components\Flex;
use Filament\Schemas\Components\Group;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
use Filament\Support\Enums\TextSize;
class ViewProject extends ViewRecord
{
@ -33,6 +37,7 @@ class ViewProject extends ViewRecord
return $schema
->schema([
Section::make('Project details')
->icon('heroicon-o-folder-open')
->schema([
TextEntry::make('code')
->badge()
@ -54,6 +59,7 @@ class ViewProject extends ViewRecord
])
->columns(3),
Section::make('Timeline & schedule')
->icon('heroicon-o-calendar-days')
->schema([
TextEntry::make('start_date')
->label('Start date')
@ -97,43 +103,56 @@ class ViewProject extends ViewRecord
])
->columns(3),
Section::make('Approvers')
->icon('heroicon-o-shield-check')
->schema([
TextEntry::make('projectManager.name')
->label('Project Manager'),
->label('Project Manager')
->placeholder('Not assigned'),
TextEntry::make('programManager.name')
->label('Program Manager'),
->label('Program Manager')
->placeholder('Not assigned'),
])
->columns(2),
Section::make('Team members')
->schema([
TextEntry::make('members_list')
->label('Assigned members')
->state(fn ($record) => $record->members
->sortBy('name')
->map(fn ($member) => "{$member->name}{$member->pivot->assigned_role}")
->join("\n") ?: '—')
->markdown()
->columnSpanFull(),
]),
Section::make('Member contributions')
->schema([
TextEntry::make('member_contributions')
->label('Hours by member')
->state(function ($record): string {
$rows = $record->memberContributions();
if ($rows === []) {
return '—';
}
return collect($rows)
->map(fn (array $row): string => "{$row['name']} ({$row['role']}): {$row['hours']}h")
->join("\n");
})
->markdown()
->columnSpanFull(),
]),
Flex::make([
Group::make([
Section::make('Team members')
->icon('heroicon-o-users')
->afterHeader([
TextEntry::make('team_members_count')
->hiddenLabel()
->state(fn ($record): string => $record->members()->count().' assigned')
->color('gray')
->size(TextSize::Small),
])
->schema([
ViewEntry::make('team_members')
->hiddenLabel()
->view('filament.infolists.project-team-members')
->columnSpanFull(),
]),
])->grow(),
Group::make([
Section::make('Member contributions')
->icon('heroicon-o-chart-bar')
->afterHeader([
TextEntry::make('member_contributions_count')
->hiddenLabel()
->state(fn ($record): string => count($record->memberContributions()).' contributors')
->color('gray')
->size(TextSize::Small),
])
->schema([
ViewEntry::make('member_contributions')
->hiddenLabel()
->view('filament.infolists.project-member-contributions')
->columnSpanFull(),
]),
])->grow(),
])
->from('md')
->columnSpanFull(),
Section::make('Record')
->icon('heroicon-o-clock')
->schema([
TextEntry::make('creator.name')
->label('Created by')

View File

@ -8,10 +8,12 @@ use App\Models\ProjectType;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class Project extends Model
{
use LogsAuditableChanges;
use SoftDeletes;
protected static function booted(): void
{
@ -140,6 +142,29 @@ class Project extends Model
return $this->timesheets()->distinct('user_id')->count('user_id');
}
public function timesheetCount(): int
{
return $this->timesheets()->count();
}
public function trashDeletionMessage(): string
{
$count = $this->timesheetCount();
$base = 'The project will be hidden from active lists but can be restored by an admin.';
if ($count > 0) {
return "{$base} {$count} timesheet record(s) will be kept and are not deleted.";
}
return "{$base} This project has no timesheet records yet.";
}
public function canBeForceDeleted(): bool
{
return $this->timesheetCount() === 0;
}
/**
* @return list<string>
*/

View File

@ -39,7 +39,7 @@ class Timesheet extends Model
public function project()
{
return $this->belongsTo(Project::class);
return $this->belongsTo(Project::class)->withTrashed();
}
public function approvalLogs()

View File

@ -0,0 +1,22 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('projects', function (Blueprint $table) {
$table->softDeletes();
});
}
public function down(): void
{
Schema::table('projects', function (Blueprint $table) {
$table->dropSoftDeletes();
});
}
};

View File

@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('timesheets', function (Blueprint $table) {
$table->dropForeign(['project_id']);
$table->foreign('project_id')
->references('id')
->on('projects')
->restrictOnDelete();
});
}
public function down(): void
{
Schema::table('timesheets', function (Blueprint $table) {
$table->dropForeign(['project_id']);
$table->foreign('project_id')
->references('id')
->on('projects')
->cascadeOnDelete();
});
}
};

View File

@ -0,0 +1,27 @@
<style>
.project-member-scroll {
scrollbar-width: thin;
scrollbar-color: rgb(209 213 219 / 0.7) transparent;
}
.dark .project-member-scroll {
scrollbar-color: rgb(75 85 99 / 0.7) transparent;
}
.project-member-scroll::-webkit-scrollbar {
width: 6px;
}
.project-member-scroll::-webkit-scrollbar-track {
background: transparent;
}
.project-member-scroll::-webkit-scrollbar-thumb {
background-color: rgb(209 213 219 / 0.75);
border-radius: 9999px;
}
.dark .project-member-scroll::-webkit-scrollbar-thumb {
background-color: rgb(75 85 99 / 0.75);
}
</style>

View File

@ -0,0 +1,50 @@
@php
$project = $entry->getRecord();
$rows = $project->memberContributions();
$maxHours = collect($rows)->max('hours') ?: 0;
@endphp
@include('filament.infolists.partials.project-member-scroll-styles')
<div class="h-60">
@if ($rows === [])
<div class="flex h-full flex-col items-center justify-center gap-2 text-center">
<span class="flex h-10 w-10 items-center justify-center rounded-full bg-gray-100 text-gray-400 dark:bg-gray-800 dark:text-gray-500">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="h-5 w-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 0 1 3 19.875v-6.75ZM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V8.625ZM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V4.125Z" />
</svg>
</span>
<p class="text-sm text-gray-500 dark:text-gray-400">No hours logged yet</p>
</div>
@else
<div class="project-member-scroll h-full overflow-y-auto pr-1.5">
<div class="flex flex-col gap-3">
@foreach ($rows as $row)
@php
$hours = (float) $row['hours'];
$pct = $maxHours > 0 ? ($hours / $maxHours) * 100 : 0;
$roleLabel = filled($row['role']) && $row['role'] !== '—'
? '('.mb_strtolower($row['role']).')'
: '';
@endphp
<div>
<div class="flex items-baseline justify-between gap-3">
<p class="min-w-0 truncate text-sm text-gray-900 dark:text-white">
{{ $row['name'] }}
@if ($roleLabel !== '')
<span class="text-gray-500 dark:text-gray-400">{{ $roleLabel }}</span>
@endif
</p>
<span class="shrink-0 text-sm font-bold tabular-nums text-gray-900 dark:text-white">
{{ number_format($hours, 1) }}h
</span>
</div>
<div class="mt-1.5 h-1 overflow-hidden rounded-full bg-gray-200/80 dark:bg-gray-800">
<div class="h-full rounded-full bg-primary-500 dark:bg-primary-400" style="width: {{ $pct }}%"></div>
</div>
</div>
@endforeach
</div>
</div>
@endif
</div>

View File

@ -0,0 +1,41 @@
@php
$project = $entry->getRecord();
$members = $project->members->sortBy('name');
@endphp
@include('filament.infolists.partials.project-member-scroll-styles')
<div class="h-60">
@if ($members->isEmpty())
<div class="flex h-full flex-col items-center justify-center gap-2 text-center">
<span class="flex h-10 w-10 items-center justify-center rounded-full bg-gray-100 text-gray-400 dark:bg-gray-800 dark:text-gray-500">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="h-5 w-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19.128a9.38 9.38 0 0 0 2.625.372 9.337 9.337 0 0 0 4.121-.952 4.125 4.125 0 0 0-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 0 1 8.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0 1 11.964-3.07M12 6.375a3.375 3.375 0 1 1-6.75 0 3.375 3.375 0 0 1 6.75 0Zm8.25 2.25a2.625 2.625 0 1 1-5.25 0 2.625 2.625 0 0 1 5.25 0Z" />
</svg>
</span>
<p class="text-sm text-gray-500 dark:text-gray-400">No members assigned yet</p>
</div>
@else
<div class="project-member-scroll h-full overflow-y-auto pr-1.5">
<div class="flex flex-col gap-2">
@foreach ($members as $member)
@php
$initial = mb_strtoupper(mb_substr($member->name ?? '?', 0, 1));
$role = $member->pivot->assigned_role ?? '—';
@endphp
<div class="flex items-center gap-3 rounded-lg border border-gray-200/80 px-3 py-2.5 dark:border-gray-700/80">
<span class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-primary-600/20 text-sm font-semibold text-primary-600 dark:bg-primary-500/20 dark:text-primary-400">
{{ $initial }}
</span>
<p class="min-w-0 flex-1 truncate text-sm font-medium text-gray-900 dark:text-white">
{{ $member->name }}
</p>
<span class="shrink-0 text-sm text-gray-500 dark:text-gray-400">
{{ $role }}
</span>
</div>
@endforeach
</div>
</div>
@endif
</div>

View File

@ -18,7 +18,10 @@ echo "Building assets..."
(cd "$LOCAL_PATH" && npm run build)
echo "Syncing files to staging ($REMOTE_PATH)..."
SSHPASS="$SSHPASS" sshpass -e rsync -az --delete \
# Run rsync from inside $LOCAL_PATH using "./" as the source so Windows
# absolute paths (which contain a drive-letter colon) aren't mistaken for
# remote host specs, and the source stays a single token if the path has spaces.
(cd "$LOCAL_PATH" && SSHPASS="$SSHPASS" sshpass -e rsync -az --delete \
--exclude '.git' \
--exclude 'node_modules' \
--exclude 'vendor' \
@ -27,7 +30,7 @@ SSHPASS="$SSHPASS" sshpass -e rsync -az --delete \
--exclude '.phpunit.result.cache' \
--exclude 'tests' \
-e "ssh -o StrictHostKeyChecking=no" \
"$LOCAL_PATH/" "$HOST:$REMOTE_PATH/"
./ "$HOST:$REMOTE_PATH/")
echo "Running remote staging deploy steps..."
SSHPASS="$SSHPASS" sshpass -e ssh -o StrictHostKeyChecking=no "$HOST" "bash -s" <<REMOTE

View File

@ -0,0 +1,102 @@
<?php
namespace Tests\Feature;
use App\Models\Project;
use App\Models\Timesheet;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class ProjectDeletionProtectionTest extends TestCase
{
use RefreshDatabase;
public function test_soft_deleting_project_preserves_timesheets(): void
{
$admin = User::factory()->create(['role' => 'admin']);
$employee = User::factory()->create();
$project = Project::create([
'code' => 'KEEP-01',
'name' => 'Keep Timesheets',
'status' => 'active',
]);
$project->members()->attach($employee->id, ['assigned_role' => 'Developer']);
$timesheet = Timesheet::create([
'user_id' => $employee->id,
'project_id' => $project->id,
'week_start' => '2026-06-22',
'hours' => [8, 8, 0, 0, 0, 0, 0],
'overtime_hours' => [0, 0, 0, 0, 0, 0, 0],
'status' => 'approved',
]);
$this->actingAs($admin);
$project->delete();
$this->assertSoftDeleted('projects', ['id' => $project->id]);
$this->assertDatabaseHas('timesheets', ['id' => $timesheet->id, 'project_id' => $project->id]);
$this->assertSame('Keep Timesheets', $timesheet->fresh()->project?->name);
}
public function test_trashed_project_can_be_restored(): void
{
$project = Project::create([
'code' => 'REST-01',
'name' => 'Restore Me',
'status' => 'active',
]);
$project->delete();
$this->assertSoftDeleted('projects', ['id' => $project->id]);
$project->restore();
$this->assertDatabaseHas('projects', [
'id' => $project->id,
'deleted_at' => null,
]);
}
public function test_force_delete_is_blocked_when_timesheets_exist(): void
{
$employee = User::factory()->create();
$project = Project::create([
'code' => 'BLOCK-01',
'name' => 'Blocked Delete',
'status' => 'active',
]);
Timesheet::create([
'user_id' => $employee->id,
'project_id' => $project->id,
'week_start' => '2026-06-22',
'hours' => [4, 0, 0, 0, 0, 0, 0],
'overtime_hours' => [0, 0, 0, 0, 0, 0, 0],
'status' => 'draft',
]);
$project->delete();
$this->expectException(\Illuminate\Database\QueryException::class);
$project->forceDelete();
}
public function test_force_delete_succeeds_when_no_timesheets_exist(): void
{
$project = Project::create([
'code' => 'EMPTY-01',
'name' => 'Empty Project',
'status' => 'active',
]);
$project->delete();
$project->forceDelete();
$this->assertDatabaseMissing('projects', ['id' => $project->id]);
}
}