feat: Implement Study Group management with CRUD actions, model, migrations, and Filament resource for creating, editing, and deleting study groups.

This commit is contained in:
Yoga Pangestu 2026-03-11 10:09:09 +07:00
parent 8c681ca5f2
commit e2bb51561c
10 changed files with 675 additions and 0 deletions

View File

@ -0,0 +1,40 @@
<?php
namespace App\Filament\Pages\Learning\StudyGroup\Actions;
use App\Filament\Actions\Cheerful\CreateAction;
use App\Models\StudyGroup;
use Filament\Support\Enums\Width;
class CreateStudyGroupAction extends CreateAction
{
public static function getDefaultName(): string
{
return 'createStudyGroup';
}
protected function setUp(): void
{
parent::setUp();
$this->model(StudyGroup::class)
->label('Tambah')
->modalHeading('Tambah Kelompok Belajar')
->modalSubmitActionLabel('Simpan')
->modalCancelActionLabel('Batal')
->extraModalFooterActions(fn (CreateAction $action): array => [
$action->makeModalSubmitAction('createAnother', arguments: ['another' => true])
->label('Simpan dan Tambah Lagi'),
])
->modalWidth(Width::Large)
->schema(fn ($livewire) => $livewire->studyGroupFormSchema())
->after(function (StudyGroup $record, array $data) {
if (isset($data['course_id'])) {
$record->courses()->sync($data['course_id']);
}
if (isset($data['students'])) {
$record->students()->sync($data['students']);
}
});
}
}

View File

@ -0,0 +1,25 @@
<?php
namespace App\Filament\Pages\Learning\StudyGroup\Actions;
use App\Filament\Actions\Cheerful\DeleteAction;
use App\Models\StudyGroup;
class DeleteStudyGroupAction extends DeleteAction
{
public static function getDefaultName(): string
{
return 'deleteStudyGroup';
}
protected function setUp(): void
{
parent::setUp();
$this->record(fn (array $arguments): StudyGroup => StudyGroup::findOrFail($arguments['record']))
->modalHeading(fn (StudyGroup $record) => "Hapus {$record->name}")
->modalDescription('Apakah Anda yakin ingin menghapus kelompok ini? Tindakan ini tidak dapat dibatalkan.')
->modalSubmitActionLabel('Hapus')
->modalCancelActionLabel('Batal');
}
}

View File

@ -0,0 +1,43 @@
<?php
namespace App\Filament\Pages\Learning\StudyGroup\Actions;
use App\Filament\Actions\Cheerful\EditAction;
use App\Models\StudyGroup;
use Filament\Support\Enums\Width;
class EditStudyGroupAction extends EditAction
{
public static function getDefaultName(): ?string
{
return 'editStudyGroup';
}
protected function setUp(): void
{
parent::setUp();
$this->record(fn (array $arguments): StudyGroup => StudyGroup::findOrFail($arguments['record']))
->modalHeading(fn (StudyGroup $record) => "Ubah {$record->name}")
->modalSubmitActionLabel('Simpan')
->modalCancelActionLabel('Batal')
->modalWidth(Width::Large)
->schema(fn ($livewire) => $livewire->studyGroupFormSchema())
->fillForm(function (StudyGroup $record): array {
return [
'name' => $record->name,
'leader_id' => $record->leader_id,
'course_id' => $record->courses->pluck('id')->toArray(),
'students' => $record->students->pluck('id')->toArray(),
];
})
->after(function (StudyGroup $record, array $data) {
if (isset($data['course_id'])) {
$record->courses()->sync($data['course_id']);
}
if (isset($data['students'])) {
$record->students()->sync($data['students']);
}
});
}
}

View File

@ -0,0 +1,214 @@
<?php
namespace App\Filament\Pages\Learning\StudyGroup;
use App\Filament\Pages\Learning\StudyGroup\Actions\CreateStudyGroupAction;
use App\Filament\Pages\Learning\StudyGroup\Actions\DeleteStudyGroupAction;
use App\Filament\Pages\Learning\StudyGroup\Actions\EditStudyGroupAction;
use App\Models\Course;
use App\Models\Student;
use App\Models\StudyGroup;
use BackedEnum;
use Filament\Actions\Action;
use Filament\Actions\Concerns\InteractsWithActions;
use Filament\Actions\Contracts\HasActions;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms;
use Filament\Pages\Page;
use Filament\Schemas\Components\Grid;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
use UnitEnum;
class Index extends Page implements HasActions, HasForms
{
use InteractsWithActions, InteractsWithForms;
protected static ?string $model = StudyGroup::class;
protected static string|UnitEnum|null $navigationGroup = 'Pembelajaran';
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedUsers;
protected static ?string $title = 'Kelompok Belajar';
protected static ?int $navigationSort = 10;
protected static ?string $slug = 'learning/study-groups';
protected string $view = 'filament.pages.information.list-study-groups';
public ?int $course_id = null;
public function mount(): void
{
$this->form->fill([
'course_id' => $this->course_id,
]);
}
public function form(Schema $schema): Schema
{
return $schema
->schema([
Select::make('course_id')
->label('Mata Kuliah')
->options(function () {
return Course::all()
->groupBy('semester')
->mapWithKeys(function ($courses, $semester) {
return [
"Semester $semester" => $courses->pluck('name', 'id')->toArray(),
];
})
->toArray();
})
->searchable()
->required()
->optionsLimit(100),
])
->columns(1);
}
protected function getHeaderActions(): array
{
return [
CreateStudyGroupAction::make(),
];
}
protected function editStudyGroupAction(): Action
{
return EditStudyGroupAction::make();
}
protected function deleteStudyGroupAction(): Action
{
return DeleteStudyGroupAction::make();
}
public function getStudyGroups(): Collection
{
$query = StudyGroup::with(['leader', 'students', 'courses']);
if ($this->course_id) {
$query->whereHas('courses', fn ($q) => $q->where('courses.id', $this->course_id));
}
return $query->latest()->get();
}
protected function getViewData(): array
{
return [
'groups' => $this->getStudyGroups(),
'studentId' => auth()->user()?->student?->id,
];
}
public function isMyGroup(StudyGroup $record): bool
{
$studentId = $this->studentId;
if (! $studentId) {
return false;
}
return $record->leader_id === $studentId || $record->students->contains($studentId);
}
public function studyGroupFormSchema(): array
{
return [
Grid::make(2)
->schema([
Select::make('course_id')
->label('Mata Kuliah')
->options(function () {
return Course::all()
->groupBy('semester')
->mapWithKeys(function ($courses, $semester) {
return [
"Semester $semester" => $courses->pluck('name', 'id')->toArray(),
];
})
->toArray();
})
->multiple()
->searchable()
->required()
->optionsLimit(100)
->columnSpanFull(),
TextInput::make('name')
->label('Nama Kelompok')
->placeholder('Kelompok A / Kelompok 1')
->required()
->maxLength(100)
->autocomplete(false),
Select::make('leader_id')
->label('Ketua Kelompok')
->options(function (Get $get, ?StudyGroup $record) {
$query = Student::query();
$courseIds = array_values(array_filter((array) ($get('courses') ?? [])));
if (! empty($courseIds)) {
$allBusyIds = StudyGroup::getBusyStudentIdsForCourses(
courseIds: $courseIds,
excludeGroupId: $record?->id,
);
$selectedMembers = array_values(array_filter((array) ($get('students') ?? [])));
$query->whereNotIn('id', array_unique(array_merge($allBusyIds, $selectedMembers)));
}
return $query->get()->pluck('full_name', 'id')->toArray();
})
->searchable()
->required()
->native(false)
->live(),
Select::make('students')
->label('Anggota Mahasiswa')
->relationship(
name: 'students',
titleAttribute: 'full_name',
modifyQueryUsing: function (Builder $query, Get $get, ?StudyGroup $record) {
$courseIds = array_values(array_filter((array) ($get('courses') ?? [])));
if (! empty($courseIds)) {
$allBusyIds = StudyGroup::getBusyStudentIdsForCourses(
courseIds: $courseIds,
excludeGroupId: $record?->id,
);
$leaderId = $get('leader_id');
if ($leaderId) {
$allBusyIds[] = (int) $leaderId;
}
$query->whereNotIn(
$query->getModel()->getTable().'.id',
array_unique($allBusyIds)
);
}
return $query->orderBy('student_number', 'asc');
}
)
->multiple()
->searchable()
->preload()
->native(false)
->live()
->columnSpanFull()
->required(),
]),
];
}
}

70
app/Models/StudyGroup.php Normal file
View File

@ -0,0 +1,70 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class StudyGroup extends Model
{
use HasFactory, SoftDeletes;
protected $fillable = [
'leader_id',
'name',
];
public function isLeader(Student $student): bool
{
return $this->leader_id === $student->id;
}
public function courses(): BelongsToMany
{
return $this->belongsToMany(Course::class, 'study_group_courses');
}
public function leader(): BelongsTo
{
return $this->belongsTo(Student::class, 'leader_id');
}
public function members(): HasMany
{
return $this->hasMany(StudyGroupMember::class);
}
public function students(): BelongsToMany
{
return $this->belongsToMany(Student::class, 'study_group_members');
}
public static function getBusyStudentIdsForCourses(array $courseIds, ?int $excludeGroupId = null): array
{
$busyMemberIds = StudyGroupMember::whereHas(
'studyGroup',
function ($q) use ($courseIds, $excludeGroupId) {
if ($excludeGroupId) {
$q->where('study_groups.id', '!=', $excludeGroupId);
}
$q->whereHas('courses', function ($cq) use ($courseIds) {
$cq->whereIn('courses.id', $courseIds);
});
}
)->pluck('student_id')->toArray();
$busyLeaderIds = static::query()
->when($excludeGroupId, fn ($q) => $q->where('id', '!=', $excludeGroupId))
->whereHas('courses', function ($cq) use ($courseIds) {
$cq->whereIn('courses.id', $courseIds);
})
->pluck('leader_id')
->toArray();
return array_unique(array_merge($busyMemberIds, $busyLeaderIds));
}
}

View File

@ -0,0 +1,27 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class StudyGroupMember extends Model
{
use HasFactory;
protected $fillable = [
'student_id',
'study_group_id',
];
public function student(): BelongsTo
{
return $this->belongsTo(Student::class);
}
public function studyGroup(): BelongsTo
{
return $this->belongsTo(StudyGroup::class);
}
}

View File

@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('study_groups', function (Blueprint $table) {
$table->id();
$table->foreignId('leader_id')->constrained('students')->cascadeOnDelete();
$table->string('name', 100);
$table->timestamp('created_at')->useCurrent();
$table->timestamp('updated_at')->nullable()->useCurrentOnUpdate();
$table->softDeletes();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('study_groups');
}
};

View File

@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('study_group_members', function (Blueprint $table) {
$table->id();
$table->foreignId('study_group_id')->constrained()->cascadeOnDelete();
$table->foreignId('student_id')->constrained()->cascadeOnDelete();
$table->timestamp('created_at')->useCurrent();
$table->timestamp('updated_at')->nullable()->useCurrentOnUpdate();
$table->unique(['study_group_id', 'student_id']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('study_group_members');
}
};

View File

@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('study_group_courses', function (Blueprint $table) {
$table->id();
$table->foreignId('study_group_id')->constrained()->cascadeOnDelete();
$table->foreignId('course_id')->constrained()->cascadeOnDelete();
$table->unique(['study_group_id', 'course_id']);
$table->timestamp('created_at')->useCurrent();
$table->timestamp('updated_at')->nullable()->useCurrentOnUpdate();
$table->softDeletes();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('study_group_courses');
}
};

View File

@ -0,0 +1,162 @@
<x-filament-panels::page>
<x-filament::section>
<div class="flex items-center justify-between gap-4 mb-6">
<div class="w-full max-w-xl">
{{ $this->form }}
</div>
</div>
@if ($groups->isNotEmpty())
<div class="columns-1 md:columns-2 lg:columns-3 xl:columns-4 gap-6 space-y-6 pb-8">
@foreach ($groups as $record)
@php $isMyGroup = $this->isMyGroup($record); @endphp
<div class="break-inside-avoid mb-6">
<div @class([
'fi-card flex flex-col justify-between rounded-xl border transition duration-200 group relative',
'bg-white dark:bg-gray-900 border-gray-200 dark:border-gray-700 shadow-sm hover:shadow-md' => !$isMyGroup,
'bg-primary-50/30 dark:bg-primary-900/10 border-primary-500 shadow-md ring-1 ring-primary-500' => $isMyGroup,
])>
@if ($isMyGroup)
<div class="absolute -top-3 -right-3 z-10">
<span
class="inline-flex items-center gap-1 rounded-full bg-primary-600 px-3 py-1 text-[10px] font-bold text-white dark:text-gray-900 shadow-lg ring-2 ring-white dark:ring-gray-900 uppercase tracking-widest">
<x-heroicon-m-check-badge class="w-3.5 h-3.5" />
Kelompok Saya
</span>
</div>
@endif
<div class="p-6 space-y-4 flex-1">
<div class="flex flex-wrap items-center gap-1.5">
@forelse ($record->courses as $course)
<span @class([
'inline-flex items-center rounded-md px-2.5 py-1 text-[10px] font-bold ring-1 ring-inset uppercase tracking-tight',
'bg-primary-50 dark:bg-primary-500/10 text-primary-700 dark:text-primary-300 ring-primary-600/20' => !$isMyGroup,
'bg-white dark:bg-primary-800/40 text-primary-700 dark:text-primary-200 ring-primary-500/30' => $isMyGroup,
])>
{{ $course->name }}
</span>
@empty
<span
class="inline-flex items-center rounded-md bg-gray-50 dark:bg-gray-500/10 px-2 py-0.5 text-[10px] font-bold text-gray-500 dark:text-gray-400 ring-1 ring-inset ring-gray-600/20 uppercase tracking-tight italic">
Tanpa Mata Kuliah
</span>
@endforelse
</div>
<div class="space-y-1">
<h3 @class([
'text-lg font-bold leading-tight flex items-center gap-2 transition-colors',
'text-gray-950 dark:text-white group-hover:text-primary-600 dark:group-hover:text-primary-400' => !$isMyGroup,
'text-primary-900 dark:text-primary-100' => $isMyGroup,
])>
<x-heroicon-o-users @class([
'w-5 h-5 shrink-0',
'opacity-40' => !$isMyGroup,
'text-primary-500' => $isMyGroup,
]) />
{{ $record->name }}
</h3>
</div>
<div class="space-y-4">
<div class="flex items-center text-sm text-gray-600 dark:text-gray-400">
<div @class([
'w-8 h-8 rounded-full flex items-center justify-center mr-3 shrink-0 border',
'bg-primary-100 dark:bg-primary-900/40 border-primary-200 dark:border-primary-800' => !$isMyGroup,
'bg-white dark:bg-primary-800 border-primary-300 dark:border-primary-600' => $isMyGroup,
])>
@if ($record->leader)
<img src="{{ $record->leader->user->facehash_avatar_url }}"
alt="User avatar" class="w-full h-full rounded-full object-cover" />
@else
<x-heroicon-m-user @class([
'w-4 h-4',
'text-primary-600 dark:text-primary-300' => !$isMyGroup,
'text-primary-700 dark:text-primary-200' => $isMyGroup,
]) />
@endif
</div>
<div class="flex flex-col min-w-0">
<span
class="text-[10px] font-bold uppercase text-gray-400 tracking-widest leading-none mb-1">Ketua
Kelompok</span>
<span @class([
'truncate font-medium',
'text-gray-900 dark:text-gray-200' => !$isMyGroup,
'text-primary-900 dark:text-primary-50' => $isMyGroup,
])>
{{ $record->leader->full_name ?? 'Belum Ditentukan' }}
@if ($studentId && $record->leader_id === $studentId)
<span
class="text-[10px] text-primary-600 font-bold ml-1">(Anda)</span>
@endif
</span>
</div>
</div>
<div @class([
'pt-4 border-t',
'border-gray-100 dark:border-gray-800' => !$isMyGroup,
'border-primary-200 dark:border-primary-800' => $isMyGroup,
])>
<div class="flex items-center justify-between mb-3">
<span
class="text-[10px] font-bold text-gray-400 uppercase tracking-widest leading-none">Anggota
Kelompok</span>
<span
class="text-[10px] font-bold px-1.5 py-0.5 rounded bg-violet-50 dark:bg-violet-500/10 text-violet-600 dark:text-violet-400 ring-1 ring-inset ring-violet-600/20">
{{ $record->students->count() }} orang
</span>
</div>
<div class="flex flex-wrap gap-1.5">
@forelse ($record->students as $student)
<span @class([
'inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-[10px] font-medium ring-1 ring-inset',
'bg-gray-50 dark:bg-gray-800/50 text-gray-700 dark:text-gray-300 ring-gray-200 dark:ring-gray-700' =>
$student->id !== $studentId,
'bg-primary-600 text-white dark:text-gray-900 ring-primary-700 font-bold shadow-sm' =>
$student->id === $studentId,
])>
{{ $student->full_name }}
@if ($student->id === $studentId)
(Anda)
@endif
</span>
@empty
<span class="text-xs text-gray-400 italic">Belum ada anggota</span>
@endforelse
</div>
</div>
</div>
</div>
<div @class([
'flex items-center justify-end gap-2 p-4 pt-0 rounded-b-xl',
'bg-primary-100/30 dark:bg-primary-900/10 pt-4' => $isMyGroup,
])>
<x-filament::button size="xs" color="warning" tooltip="Ubah" variant="primary"
class="rounded-lg shadow-sm"
wire:click="mountAction('editStudyGroup', { record: {{ $record->id }} })">
<x-heroicon-m-pencil-square class="w-4 h-4" />
</x-filament::button>
<x-filament::button size="xs" color="danger" tooltip="Hapus"
class="rounded-lg shadow-sm"
wire:click="mountAction('deleteStudyGroup', { record: {{ $record->id }} })">
<x-heroicon-m-trash class="w-4 h-4" />
</x-filament::button>
</div>
</div>
</div>
@endforeach
</div>
@endif
@if ($groups->isEmpty())
<x-filament::empty-state icon="heroicon-o-users" heading="Tidak ada data yang ditemukan"
description="Setelah Anda membuat data pertama, maka akan muncul disini." iconColor="gray">
</x-filament::empty-state>
@endif
</x-filament::section>
</x-filament-panels::page>