mirror of
https://github.com/skysyaz/timesheet.git
synced 2026-07-14 08:40:50 +00:00
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:
parent
2502601c08
commit
755fee72ae
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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),
|
||||
];
|
||||
}
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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>
|
||||
*/
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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();
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -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();
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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
|
||||
|
||||
102
tests/Feature/ProjectDeletionProtectionTest.php
Normal file
102
tests/Feature/ProjectDeletionProtectionTest.php
Normal 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]);
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user