# Admin CRUD for Jadwal Perkuliahan Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Add a new admin "Jadwal" tab in `/app/kelola-data` with full CRUD (list, add, edit, delete) for `jadwal_ruangan` rows, with SKS-based end-time auto-calculation and overlap conflict detection.

**Architecture:** Server-rendered Blade with Yajra DataTable + Alpine modal. The form's `jam_keluar` is computed from `jam_masuk + (mata_kuliah.sks × 50 min)` on both client (Alpine) and server (controller). Conflict detection is server-side only. A new sibling `jadwal-action-modal` is created instead of refactoring the existing `action-modal`, to minimize blast radius.

**Tech Stack:** Laravel 11, Blade, Yajra DataTables, Tailwind, Alpine.js 3.x, Carbon 2.x, PHPUnit 11, sqlite in-memory for tests.

## File Structure

| File | Type | Responsibility |
|---|---|---|
| `app/DataTables/JadwalDataTable.php` | **NEW** | Yajra DataTable for `jadwal_ruangan` |
| `app/Http/Controllers/DataController.php` | edit | Add `jadwal()`, `storeJadwal()`, `jadwalUpdate()`, `jadwalDelete()` |
| `routes/web.php` | edit | Add 4 routes under `admin` group (`admin.jadwal.*`) |
| `resources/views/components/form-data-create.blade.php` | edit | Add `<template x-if="title === 'jadwal'">` branch |
| `resources/views/components/datatable/jadwal-action-modal.blade.php` | **NEW** | Alpine modal: add/edit/delete (sibling of `action-modal`) |
| `resources/views/app/kelola-data/index.blade.php` | edit | Add "Jadwal" tab (first), include DataTable + modal, embed SKS map |
| `resources/js/alpine/components/jadwal-form.js` | **NEW** | Alpine component: SKS auto-fill + end-time auto-calc |
| `resources/js/app.js` | edit | Register `jadwalForm` |
| `tests/Feature/JadwalAdminCrudTest.php` | **NEW** | Feature tests for CRUD + conflict detection |

No new models, no migrations, no npm packages.

## Global Constraints

- Source: only `jadwal_ruangan` (the `tetap` schedule). Reservations are out of scope.
- Slot math: 1 SKS = 50 minutes. `jam_keluar = jam_masuk + (sks × 50 min)`.
- Hari values: Senin, Selasa, Rabu, Kamis, Jumat only.
- Server always recomputes `jam_keluar` from `mata_kuliah.sks + jam_masuk`. Client preview is a UX nicety, not the source of truth.
- Server always re-checks overlap; client cannot bypass.
- Free-text `dosen` field (matches existing column type).
- `mata_kuliah` is a `<select>` over all `MataKuliah`; SKS auto-filled.
- `kelas` (class group) is free-text.
- Tab order: Jadwal → Ruangan → Personal. Jadwal is the default.
- Existing `JadwalRuanganDataTable` (per-room view) and `/jadwal-hari-ini` (public matrix) are untouched.
- Existing `data.jadwal` route (per-room view) is NOT touched; new admin routes use `admin.jadwal.*` namespace.

---

### Task 1: Add admin routes + controller CRUD methods

**Files:**
- Modify: `routes/web.php` (add 4 routes inside the `RoleMiddleware:admin` group)
- Modify: `app/Http/Controllers/DataController.php` (add 4 methods)
- Create: `tests/Feature/JadwalAdminCrudTest.php`

**Interfaces:**
- Produces: `DataController@storeJadwal(Request)` → `JadwalRuangan::create([...])` with server-computed `jam_keluar`; on overlap returns `422 {message: 'Jadwal bentrok dengan...'}`.
- Produces: `DataController@jadwalUpdate(Request, $id)` → updates the row, recomputes `jam_keluar`; on overlap returns `422`.
- Produces: `DataController@jadwalDelete($id)` → deletes the row, returns `200 {success: true}`.

- [ ] **Step 1: Add the 4 routes to `routes/web.php`**

In `routes/web.php`, add the new import at the top:
```php
use App\Models\JadwalRuangan;
use App\Models\MataKuliah;
use Carbon\Carbon;
```
(Only add the ones that aren't already imported.)

Inside the `Route::middleware([RoleMiddleware::class.':admin'])->group(function () { ... })` block, add 4 new routes. Place them after the existing `data.ruangan.*` / `data.personal.*` routes, around line 51 (after the `data.personal.delete` line):
```php
    // jadwal perkuliahan
    Route::get('/admin/jadwal', [DataController::class, 'jadwal'])->name('admin.jadwal.list');
    Route::post('/admin/jadwal', [DataController::class, 'storeJadwal'])->name('admin.jadwal.store');
    Route::patch('/admin/jadwal/update/{id}', [DataController::class, 'jadwalUpdate'])->name('admin.jadwal.update');
    Route::delete('/admin/jadwal/delete/{id}', [DataController::class, 'jadwalDelete'])->name('admin.jadwal.delete');
```

- [ ] **Step 2: Create the failing feature test**

Create `tests/Feature/JadwalAdminCrudTest.php`:
```php
<?php

namespace Tests\Feature;

use App\Models\JadwalRuangan;
use App\Models\Kelas;
use App\Models\MataKuliah;
use Carbon\Carbon;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class JadwalAdminCrudTest extends TestCase
{
    use RefreshDatabase;

    private function makeKelas(string $id = 'TEST01', string $kode = '3.01'): Kelas
    {
        return Kelas::unguarded(function () use ($id, $kode) {
            return Kelas::create([
                'id' => $id, 'kelas' => $kode,
                'nama' => 'Ruang ' . $kode, 'lantai' => 3, 'kode_ruangan' => $kode,
            ]);
        });
    }

    private function makeMk(string $kode = 'IF101', int $sks = 3): MataKuliah
    {
        return MataKuliah::create([
            'kode_mk' => $kode, 'nama' => 'Course ' . $kode,
            'prodi' => 'IF', 'sks' => $sks,
        ]);
    }

    public function test_store_creates_row_and_auto_computes_jam_keluar(): void
    {
        $kelas = $this->makeKelas();
        $mk    = $this->makeMk('IF101', 3);

        $response = $this->postJson('/admin/jadwal', [
            'hari'           => 'Senin',
            'kelas_id'       => $kelas->id,
            'dosen'          => 'Pak Budi',
            'mata_kuliah_id' => $mk->id,
            'jam_masuk'      => '08:00',
            'kelas'          => '2A',
        ]);

        $response->assertStatus(200)->assertJson(['success' => true]);
        $row = JadwalRuangan::first();
        $this->assertSame('08:00', $row->jam_masuk->format('H:i'));
        $this->assertSame('10:30', $row->jam_keluar->format('H:i')); // 3 SKS * 50 min = 150 min
    }

    public function test_store_rejects_overlap_in_same_room_and_day(): void
    {
        $kelas = $this->makeKelas();
        $mk1 = $this->makeMk('IF101', 2);
        $mk2 = $this->makeMk('IF102', 2);
        $hari = 'Senin';

        JadwalRuangan::create([
            'hari' => $hari, 'jam_masuk' => '08:00', 'jam_keluar' => '09:40',
            'dosen' => 'Pak A', 'mata_kuliah_id' => $mk1->id,
            'kelas_id' => $kelas->id, 'kelas' => '2A',
        ]);

        $response = $this->postJson('/admin/jadwal', [
            'hari' => $hari, 'kelas_id' => $kelas->id, 'dosen' => 'Pak B',
            'mata_kuliah_id' => $mk2->id, 'jam_masuk' => '09:00', 'kelas' => '2B',
        ]);

        $response->assertStatus(422);
        $this->assertStringContainsString('bentrok', strtolower($response->json('message')));
    }

    public function test_store_allows_overlap_in_different_room(): void
    {
        $kelasA = $this->makeKelas('TEST01', '3.01');
        $kelasB = $this->makeKelas('TEST02', '3.02');
        $mk1 = $this->makeMk('IF101', 2);
        $mk2 = $this->makeMk('IF102', 2);
        $hari = 'Senin';

        JadwalRuangan::create([
            'hari' => $hari, 'jam_masuk' => '08:00', 'jam_keluar' => '09:40',
            'dosen' => 'Pak A', 'mata_kuliah_id' => $mk1->id,
            'kelas_id' => $kelasA->id, 'kelas' => '2A',
        ]);

        $response = $this->postJson('/admin/jadwal', [
            'hari' => $hari, 'kelas_id' => $kelasB->id, 'dosen' => 'Pak B',
            'mata_kuliah_id' => $mk2->id, 'jam_masuk' => '08:00', 'kelas' => '2B',
        ]);

        $response->assertStatus(200)->assertJson(['success' => true]);
        $this->assertSame(2, JadwalRuangan::count());
    }

    public function test_store_allows_overlap_in_different_day(): void
    {
        $kelas = $this->makeKelas();
        $mk1 = $this->makeMk('IF101', 2);
        $mk2 = $this->makeMk('IF102', 2);

        JadwalRuangan::create([
            'hari' => 'Senin', 'jam_masuk' => '08:00', 'jam_keluar' => '09:40',
            'dosen' => 'Pak A', 'mata_kuliah_id' => $mk1->id,
            'kelas_id' => $kelas->id, 'kelas' => '2A',
        ]);

        $response = $this->postJson('/admin/jadwal', [
            'hari' => 'Selasa', 'kelas_id' => $kelas->id, 'dosen' => 'Pak B',
            'mata_kuliah_id' => $mk2->id, 'jam_masuk' => '08:00', 'kelas' => '2B',
        ]);

        $response->assertStatus(200)->assertJson(['success' => true]);
    }

    public function test_update_does_not_conflict_with_itself(): void
    {
        $kelas = $this->makeKelas();
        $mk = $this->makeMk('IF101', 2);
        $row = JadwalRuangan::create([
            'hari' => 'Senin', 'jam_masuk' => '08:00', 'jam_keluar' => '09:40',
            'dosen' => 'Pak A', 'mata_kuliah_id' => $mk->id,
            'kelas_id' => $kelas->id, 'kelas' => '2A',
        ]);

        $response = $this->patchJson("/admin/jadwal/update/{$row->id}", [
            'hari' => 'Senin', 'kelas_id' => $kelas->id, 'dosen' => 'Pak A (updated)',
            'mata_kuliah_id' => $mk->id, 'jam_masuk' => '08:00', 'kelas' => '2A',
        ]);

        $response->assertStatus(200)->assertJson(['success' => true]);
    }

    public function test_update_rejects_overlap_with_a_different_row(): void
    {
        $kelas = $this->makeKelas();
        $mk1 = $this->makeMk('IF101', 2);
        $mk2 = $this->makeMk('IF102', 2);
        $row1 = JadwalRuangan::create([
            'hari' => 'Senin', 'jam_masuk' => '08:00', 'jam_keluar' => '09:40',
            'dosen' => 'Pak A', 'mata_kuliah_id' => $mk1->id,
            'kelas_id' => $kelas->id, 'kelas' => '2A',
        ]);
        JadwalRuangan::create([
            'hari' => 'Senin', 'jam_masuk' => '10:00', 'jam_keluar' => '11:40',
            'dosen' => 'Pak B', 'mata_kuliah_id' => $mk2->id,
            'kelas_id' => $kelas->id, 'kelas' => '2B',
        ]);

        $response = $this->patchJson("/admin/jadwal/update/{$row1->id}", [
            'hari' => 'Senin', 'kelas_id' => $kelas->id, 'dosen' => 'Pak A',
            'mata_kuliah_id' => $mk1->id, 'jam_masuk' => '10:30', 'kelas' => '2A',
        ]);

        $response->assertStatus(422);
    }

    public function test_update_recomputes_jam_keluar_when_mata_kuliah_changes(): void
    {
        $kelas = $this->makeKelas();
        $mk2 = $this->makeMk('IF101', 2);
        $mk3 = $this->makeMk('IF102', 3);
        $row = JadwalRuangan::create([
            'hari' => 'Senin', 'jam_masuk' => '08:00', 'jam_keluar' => '09:40',
            'dosen' => 'Pak A', 'mata_kuliah_id' => $mk2->id,
            'kelas_id' => $kelas->id, 'kelas' => '2A',
        ]);

        $this->patchJson("/admin/jadwal/update/{$row->id}", [
            'hari' => 'Senin', 'kelas_id' => $kelas->id, 'dosen' => 'Pak A',
            'mata_kuliah_id' => $mk3->id, 'jam_masuk' => '08:00', 'kelas' => '2A',
        ]);

        $row->refresh();
        $this->assertSame('10:30', $row->jam_keluar->format('H:i')); // 3 SKS * 50 = 150
    }

    public function test_server_ignores_tampered_jam_keluar(): void
    {
        $kelas = $this->makeKelas();
        $mk = $this->makeMk('IF101', 2);
        $hari = 'Senin';

        $existing = JadwalRuangan::create([
            'hari' => $hari, 'jam_masuk' => '08:00', 'jam_keluar' => '09:40',
            'dosen' => 'Pak A', 'mata_kuliah_id' => $mk->id,
            'kelas_id' => $kelas->id, 'kelas' => '2A',
        ]);

        // Client sends a tampered jam_keluar of '07:00' for a different day, but tries
        // to skip the conflict check by claiming no overlap. We submit the conflicting
        // jam_masuk and assert the server's response ignores the tampered jam_keluar.
        $response = $this->postJson('/admin/jadwal', [
            'hari' => $hari, 'kelas_id' => $kelas->id, 'dosen' => 'Pak B',
            'mata_kuliah_id' => $mk->id, 'jam_masuk' => '08:30',
            'jam_keluar' => '07:00', // tampered
            'kelas' => '2B',
        ]);

        $response->assertStatus(422); // server recomputed jam_keluar = 10:10, overlaps existing 08:00-09:40
    }

    public function test_delete_removes_row(): void
    {
        $kelas = $this->makeKelas();
        $mk = $this->makeMk();
        $row = JadwalRuangan::create([
            'hari' => 'Senin', 'jam_masuk' => '08:00', 'jam_keluar' => '09:40',
            'dosen' => 'Pak A', 'mata_kuliah_id' => $mk->id,
            'kelas_id' => $kelas->id, 'kelas' => '2A',
        ]);

        $response = $this->deleteJson("/admin/jadwal/delete/{$row->id}");
        $response->assertStatus(200)->assertJson(['success' => true]);
        $this->assertNull(JadwalRuangan::find($row->id));
    }
}
```

- [ ] **Step 3: Run the tests to confirm they all fail (no controller methods yet)**

Run: `php artisan test --filter=JadwalAdminCrudTest`
Expected: All tests FAIL with `BadMethodCallException` or `404` (route not found).

- [ ] **Step 4: Add the 4 controller methods to `DataController.php`**

Append the following to `app/Http/Controllers/DataController.php` (after the `personalDelete` method):
```php
    public function jadwal(\App\DataTables\JadwalDataTable $dataTable)
    {
        return $dataTable->render('data.jadwal');
    }

    public function storeJadwal(Request $request)
    {
        $validated = $request->validate([
            'hari'           => 'required|in:Senin,Selasa,Rabu,Kamis,Jumat',
            'kelas_id'       => 'required|exists:kelas,id',
            'dosen'          => 'required|string|max:255',
            'mata_kuliah_id' => 'required|exists:mata_kuliah,id',
            'jam_masuk'      => 'required|date_format:H:i',
            'kelas'          => 'required|string|max:50',
        ]);

        $mk = MataKuliah::findOrFail($validated['mata_kuliah_id']);
        $jamMasuk  = Carbon::createFromFormat('H:i', $validated['jam_masuk']);
        $jamKeluar = (clone $jamMasuk)->addMinutes((int) $mk->sks * 50);

        $bentrok = JadwalRuangan::where('kelas_id', $validated['kelas_id'])
            ->where('hari', $validated['hari'])
            ->where('jam_masuk', '<', $jamKeluar->format('H:i:s'))
            ->where('jam_keluar', '>', $jamMasuk->format('H:i:s'))
            ->first();

        if ($bentrok) {
            return response()->json([
                'message' => sprintf(
                    'Jadwal bentrok dengan %s (%s, %s–%s) pada kelas dan hari yang sama.',
                    $bentrok->dosen,
                    optional($bentrok->mata_kuliah)->nama ?? '-',
                    $bentrok->jam_masuk->format('H:i'),
                    $bentrok->jam_keluar->format('H:i'),
                ),
            ], 422);
        }

        JadwalRuangan::create([
            'hari'           => $validated['hari'],
            'kelas_id'       => $validated['kelas_id'],
            'dosen'          => $validated['dosen'],
            'mata_kuliah_id' => $validated['mata_kuliah_id'],
            'jam_masuk'      => $jamMasuk->format('H:i:s'),
            'jam_keluar'     => $jamKeluar->format('H:i:s'),
            'kelas'          => $validated['kelas'],
        ]);

        return response()->json(['success' => true, 'message' => 'Jadwal berhasil ditambahkan!']);
    }

    public function jadwalUpdate(Request $request, $id)
    {
        $row = JadwalRuangan::findOrFail($id);
        $validated = $request->validate([
            'hari'           => 'required|in:Senin,Selasa,Rabu,Kamis,Jumat',
            'kelas_id'       => 'required|exists:kelas,id',
            'dosen'          => 'required|string|max:255',
            'mata_kuliah_id' => 'required|exists:mata_kuliah,id',
            'jam_masuk'      => 'required|date_format:H:i',
            'kelas'          => 'required|string|max:50',
        ]);

        $mk = MataKuliah::findOrFail($validated['mata_kuliah_id']);
        $jamMasuk  = Carbon::createFromFormat('H:i', $validated['jam_masuk']);
        $jamKeluar = (clone $jamMasuk)->addMinutes((int) $mk->sks * 50);

        $bentrok = JadwalRuangan::where('kelas_id', $validated['kelas_id'])
            ->where('hari', $validated['hari'])
            ->where('jam_masuk', '<', $jamKeluar->format('H:i:s'))
            ->where('jam_keluar', '>', $jamMasuk->format('H:i:s'))
            ->where('id', '!=', $row->id)
            ->first();

        if ($bentrok) {
            return response()->json([
                'message' => sprintf(
                    'Jadwal bentrok dengan %s (%s, %s–%s) pada kelas dan hari yang sama.',
                    $bentrok->dosen,
                    optional($bentrok->mata_kuliah)->nama ?? '-',
                    $bentrok->jam_masuk->format('H:i'),
                    $bentrok->jam_keluar->format('H:i'),
                ),
            ], 422);
        }

        $row->update([
            'hari'           => $validated['hari'],
            'kelas_id'       => $validated['kelas_id'],
            'dosen'          => $validated['dosen'],
            'mata_kuliah_id' => $validated['mata_kuliah_id'],
            'jam_masuk'      => $jamMasuk->format('H:i:s'),
            'jam_keluar'     => $jamKeluar->format('H:i:s'),
            'kelas'          => $validated['kelas'],
        ]);

        return response()->json(['success' => true, 'message' => 'Jadwal berhasil diperbarui!']);
    }

    public function jadwalDelete($id)
    {
        $row = JadwalRuangan::findOrFail($id);
        $row->delete();
        return response()->json(['success' => true, 'message' => 'Jadwal berhasil dihapus!']);
    }
```

- [ ] **Step 5: Add the missing imports at the top of `DataController.php`**

```php
use App\Models\JadwalRuangan;
use App\Models\MataKuliah;
use Carbon\Carbon;
```
(Do not duplicate `Illuminate\Http\Request` — it should already be there.)

- [ ] **Step 6: Run the tests — most should pass, but a few will fail with "DataTable class not found"**

Run: `php artisan test --filter=JadwalAdminCrudTest`
Expected: Tests using `getJson` and `postJson` will fail because the route references `\App\DataTables\JadwalDataTable` which doesn't exist yet. Tests not touching the DataTable will also fail because the route renders a view that doesn't exist.

- [ ] **Step 7: Commit (tests + controller methods in place, even though tests fail)**

```bash
git add routes/web.php app/Http/Controllers/DataController.php tests/Feature/JadwalAdminCrudTest.php
git commit -m "feat(jadwal-admin): add routes + controller methods + CRUD/conflict tests"
```

---

### Task 2: Create JadwalDataTable + minimal view

**Files:**
- Create: `app/DataTables/JadwalDataTable.php`
- Create: `resources/views/data/jadwal.blade.php`

**Interfaces:**
- Produces: `GET /admin/jadwal` returns a Yajra DataTable JSON payload (or an HTML page rendered by the view).

- [ ] **Step 1: Create `JadwalDataTable`**

Create `app/DataTables/JadwalDataTable.php`:
```php
<?php

namespace App\DataTables;

use App\Models\JadwalRuangan;
use Illuminate\Database\Eloquent\Builder as QueryBuilder;
use Yajra\DataTables\EloquentDataTable;
use Yajra\DataTables\Html\Builder as HtmlBuilder;
use Yajra\DataTables\Html\Column;
use Yajra\DataTables\Services\DataTable;

class JadwalDataTable extends DataTable
{
    public function dataTable(QueryBuilder $query): EloquentDataTable
    {
        return (new EloquentDataTable($query))
            ->addIndexColumn()
            ->addColumn('action', function ($model) {
                return view('components.datatable.jadwal-action-btn', [
                    'item' => $model,
                    'dispatchAction' => 'jadwal-action',
                ])->render();
            })
            ->editColumn('jam_masuk', fn ($m) => $m->jam_masuk->format('H:i'))
            ->editColumn('jam_keluar', fn ($m) => $m->jam_keluar->format('H:i'))
            ->addColumn('ruang', fn ($m) => optional($m->kelas)->kode_ruangan ?? '-')
            ->addColumn('mata_kuliah', fn ($m) => optional($m->mata_kuliah)
                ? ($m->mata_kuliah->kode_mk . ' - ' . $m->mata_kuliah->nama)
                : '-')
            ->escapeColumns([]);
    }

    public function query(JadwalRuangan $model): QueryBuilder
    {
        return $model->newQuery()->with(['mata_kuliah', 'kelas'])
            ->orderByRaw("FIELD(hari, 'Senin','Selasa','Rabu','Kamis','Jumat')")
            ->orderBy('jam_masuk');
    }

    public function html(): HtmlBuilder
    {
        return $this->builder()
            ->setTableId('jadwal-table')
            ->addTableClass('stripe hover text-xs')
            ->columns($this->getColumns())
            ->minifiedAjax(route('admin.jadwal.list'))
            ->orderBy(1)
            ->parameters([
                'fixedHeader' => true,
                'scrollX' => true,
                'responsive' => false,
                'paging' => true,
                'searching' => true,
                'ordering' => true,
                'dom' => '<"flex flex-col md:flex-row items-center justify-between gap-2 md:gap-4 mb-2"<"ml-0"l><"ml-auto"f>>' .
                'rt' .
                '<"flex flex-col md:flex-row items-center justify-between gap-2 md:gap-4 mt-2"<"ml-0"i><"ml-auto"p>>',
            ]);
    }

    public function getColumns(): array
    {
        return [
            Column::make('DT_RowIndex')->title('No.')->addClass('dt-head-center dt-body-center')->orderable(false)->searchable(false),
            Column::make('hari')->addClass('dt-head-center dt-body-center'),
            Column::make('ruang')->title('Ruang')->addClass('dt-head-center dt-body-center'),
            Column::make('jam_masuk')->title('Mulai')->addClass('dt-head-center dt-body-center'),
            Column::make('jam_keluar')->title('Selesai')->addClass('dt-head-center dt-body-center'),
            Column::make('dosen')->addClass('dt-head-center dt-body-left'),
            Column::make('mata_kuliah')->title('Mata Kuliah')->addClass('dt-head-center dt-body-left'),
            Column::make('kelas')->title('Group')->addClass('dt-head-center dt-body-center'),
            Column::computed('action')->title('')->addClass('dt-head-center dt-body-center')->orderable(false)->searchable(false),
        ];
    }

    protected function filename(): string
    {
        return 'Jadwal_' . date('YmdHis');
    }
}
```

- [ ] **Step 2: Create the action button partial `components/datatable/jadwal-action-btn.blade.php`**

Create `resources/views/components/datatable/jadwal-action-btn.blade.php`:
```blade
@props(['item', 'dispatchAction' => 'jadwal-action'])

<div class="flex justify-center items-center gap-1">
    <button
        type="button"
        @click="$dispatch('{{ $dispatchAction }}', { mode: 'update', data: {
            id: {{ $item->id }},
            hari: @js($item->hari),
            kelas_id: @js($item->kelas_id),
            ruang: @js(optional($item->kelas)->kode_ruangan ?? ''),
            dosen: @js($item->dosen),
            mata_kuliah_id: {{ $item->mata_kuliah_id }},
            mata_kuliah_nama: @js(optional($item->mata_kuliah)->nama ?? ''),
            sks: {{ optional($item->mata_kuliah)->sks ?? 0 }},
            jam_masuk: @js($item->jam_masuk->format('H:i')),
            jam_keluar: @js($item->jam_keluar->format('H:i')),
            kelas: @js($item->kelas),
        } })"
        class="text-blue-700 hover:underline text-xs"
    >Edit</button>
    <span class="text-gray-300">|</span>
    <button
        type="button"
        @click="$dispatch('{{ $dispatchAction }}', { mode: 'delete', data: {
            id: {{ $item->id }},
            label: @js(optional($item->kelas)->kode_ruangan ?? '' . ' - ' . ($item->hari ?? '') . ' ' . $item->jam_masuk->format('H:i') . ' (' . $item->dosen . ')'),
        } })"
        class="text-red hover:underline text-xs"
    >Hapus</button>
</div>
```

- [ ] **Step 3: Create the placeholder `data.jadwal` Blade view**

Create `resources/views/data/jadwal.blade.php`:
```blade
<x-guest-layout>
    @push('styles')
        <link rel="stylesheet" type="text/css" href="https://cdn.datatables.net/2.3.6/css/dataTables.dataTables.css">
        <link rel="stylesheet" type="text/css" href="https://cdn.datatables.net/fixedheader/4.0.5/css/fixedHeader.dataTables.css">
    @endpush

    <div class="bg-white rounded-xl p-4 shadow-sm ring-1 ring-gray-950/5">
        <div class="flex flex-col gap-1 text-center mb-4">
            <h2 class="text-sm font-semibold">Jadwal Perkuliahan</h2>
        </div>
        {!! $dataTable->table() !!}
    </div>

    @push('scripts')
        <script src="https://code.jquery.com/jquery-3.7.1.js"></script>
        <script type="text/javascript" src="https://cdn.datatables.net/2.3.6/js/dataTables.js"></script>
        <script type="text/javascript" src="https://cdn.datatables.net/fixedheader/4.0.5/js/fixedHeader.dataTables.js"></script>
        <script type="text/javascript" src="https://cdn.datatables.net/fixedheader/4.0.5/js/dataTables.fixedHeader.js"></script>
        {!! $dataTable->scripts() !!}
    @endpush
</x-guest-layout>
```

- [ ] **Step 4: Run the tests — they should now pass**

Run: `php artisan test --filter=JadwalAdminCrudTest`
Expected: All 10 tests PASS.

- [ ] **Step 5: Commit**

```bash
git add app/DataTables/JadwalDataTable.php resources/views/components/datatable/jadwal-action-btn.blade.php resources/views/data/jadwal.blade.php
git commit -m "feat(jadwal-admin): add JadwalDataTable + data.jadwal view"
```

---

### Task 3: Create jadwalForm Alpine component (SKS auto-fill + end-time auto-calc)

**Files:**
- Create: `resources/js/alpine/components/jadwal-form.js`
- Modify: `resources/js/app.js` (register `jadwalForm`)

**Interfaces:**
- Produces: Alpine component `jadwalForm(sksMap)` with properties `mataKuliahId`, `sks`, `jamMasuk`, `jamKeluar` (read-only computed), and `sksMap` lookup.

- [ ] **Step 1: Create `jadwal-form.js`**

Create `resources/js/alpine/components/jadwal-form.js`:
```js
const pad = (n) => String(n).padStart(2, '0');

const toMinutes = (hhmm) => {
    const [h, m] = hhmm.split(':').map(Number);
    return h * 60 + m;
};

const toHHMM = (totalMinutes) => {
    const wrapped = ((totalMinutes % 1440) + 1440) % 1440;
    const h = Math.floor(wrapped / 60);
    const m = wrapped % 60;
    return `${pad(h)}:${pad(m)}`;
};

export const jadwalForm = (sksMap = {}) => ({
    sksMap,
    mataKuliahId: '',
    sks: 0,
    jamMasuk: '',
    jamKeluar: '',

    init() {
        this.recomputeSks();
        this.recomputeJamKeluar();
        this.$watch('mataKuliahId', () => this.recomputeSks());
        this.$watch('sks', () => this.recomputeJamKeluar());
        this.$watch('jamMasuk', () => this.recomputeJamKeluar());
    },

    recomputeSks() {
        const id = parseInt(this.mataKuliahId, 10);
        this.sks = this.sksMap[id] ?? 0;
    },

    recomputeJamKeluar() {
        if (!this.jamMasuk || !this.sks) {
            this.jamKeluar = '';
            return;
        }
        const masuk = toMinutes(this.jamMasuk);
        const keluar = masuk + this.sks * 50;
        this.jamKeluar = toHHMM(keluar);
    },
});
```

- [ ] **Step 2: Register the component in `app.js`**

In `resources/js/app.js`, add this import near the top of the other `alpine/components` imports:
```js
import { jadwalForm } from './alpine/components/jadwal-form';
```

Add this registration near the other `Alpine.data(...)` calls:
```js
Alpine.data('jadwalForm', jadwalForm);
```

- [ ] **Step 3: Commit**

```bash
git add resources/js/alpine/components/jadwal-form.js resources/js/app.js
git commit -m "feat(jadwal-admin): add jadwalForm Alpine component for SKS/end-time auto-calc"
```

---

### Task 4: Add jadwal create-form template + jadwal-action-modal + Jadwal tab in kelola-data

**Files:**
- Modify: `resources/views/components/form-data-create.blade.php` (add `<template x-if="title === 'jadwal'">`)
- Create: `resources/views/components/datatable/jadwal-action-modal.blade.php`
- Modify: `resources/views/app/kelola-data/index.blade.php` (add "Jadwal" tab, embed SKS map, include DataTable + modal)
- Modify: `resources/views/components/crud/form.blade.php` — only if needed to support server-error rendering (see below)

**Interfaces:**
- Produces: A working `/app/kelola-data` page with a "Jadwal" tab that lists `jadwal_ruangan` rows and has Add/Edit/Delete modals.

- [ ] **Step 1: Build the Vite assets so the new `jadwalForm` is available**

Run: `npm run build`
Expected: Build succeeds. Note: the build manifest may swap filenames; commit the new files.

- [ ] **Step 2: Add the jadwal form template in `form-data-create.blade.php`**

In `resources/views/components/form-data-create.blade.php`, append a third template after the existing `<template x-if="title === 'personal'">` block:
```blade
<template x-if="title === 'jadwal'">
    <x-crud.form :action="route('admin.jadwal.store')" table="jadwal-table" reset close>
        <input type="hidden" name="type" value="jadwal">
        <div class="flex flex-col gap-3 text-xs" x-data="jadwalForm(@js($mataKuliahSksMap ?? []))">
            <div class="flex gap-3 md:gap-4">
                <div class="flex-1">
                    <label class="block text-gray-600 mb-1">Hari</label>
                    <select name="hari" x-model="hari" required class="w-full text-xs border border-gray-300 rounded px-2 py-1.5 focus:outline-none">
                        <option value="">-- pilih hari --</option>
                        <option>Senin</option>
                        <option>Selasa</option>
                        <option>Rabu</option>
                        <option>Kamis</option>
                        <option>Jumat</option>
                    </select>
                </div>
            </div>
            <div class="flex gap-3 md:gap-4">
                <div class="flex-1">
                    <label class="block text-gray-600 mb-1">Ruang</label>
                    <select name="kelas_id" x-model="kelasId" required class="w-full text-xs border border-gray-300 rounded px-2 py-1.5 focus:outline-none">
                        <option value="">-- pilih ruang --</option>
                        @foreach ($kelasOptions ?? [] as $k)
                            <option value="{{ $k['id'] }}">{{ $k['kode_ruangan'] }} - {{ $k['nama'] }}</option>
                        @endforeach
                    </select>
                </div>
                <div class="flex-1">
                    <label class="block text-gray-600 mb-1">Group (e.g. 2A)</label>
                    <input type="text" name="kelas" x-model="kelasGroup" required maxlength="50" class="w-full text-xs border border-gray-300 rounded px-2 py-1.5 focus:outline-none">
                </div>
            </div>
            <div class="flex gap-3 md:gap-4">
                <div class="flex-1">
                    <label class="block text-gray-600 mb-1">Dosen</label>
                    <input type="text" name="dosen" x-model="dosen" required maxlength="255" class="w-full text-xs border border-gray-300 rounded px-2 py-1.5 focus:outline-none">
                </div>
            </div>
            <div class="flex gap-3 md:gap-4">
                <div class="flex-1">
                    <label class="block text-gray-600 mb-1">Mata Kuliah</label>
                    <select name="mata_kuliah_id" x-model="mataKuliahId" required class="w-full text-xs border border-gray-300 rounded px-2 py-1.5 focus:outline-none">
                        <option value="">-- pilih mata kuliah --</option>
                        @foreach ($mataKuliahOptions ?? [] as $mk)
                            <option value="{{ $mk['id'] }}">{{ $mk['kode_mk'] }} - {{ $mk['nama'] }} ({{ $mk['sks'] }} SKS)</option>
                        @endforeach
                    </select>
                </div>
            </div>
            <div class="flex gap-3 md:gap-4">
                <div class="flex-1">
                    <label class="block text-gray-600 mb-1">SKS (auto)</label>
                    <input type="text" :value="sks" readonly class="w-full text-xs border border-gray-200 bg-gray-50 rounded px-2 py-1.5 text-gray-700">
                </div>
                <div class="flex-1">
                    <label class="block text-gray-600 mb-1">Jam Masuk</label>
                    <input type="time" name="jam_masuk" x-model="jamMasuk" required class="w-full text-xs border border-gray-300 rounded px-2 py-1.5 focus:outline-none">
                </div>
                <div class="flex-1">
                    <label class="block text-gray-600 mb-1">Jam Keluar (auto)</label>
                    <input type="text" :value="jamKeluar" readonly class="w-full text-xs border border-gray-200 bg-gray-50 rounded px-2 py-1.5 text-gray-700">
                </div>
            </div>
            <div x-show="errors && Object.keys(errors).length" class="text-red text-xs" x-text="errors && Object.values(errors).flat().join(' ')"></div>
            <x-crud.button class="w-full">Simpan</x-crud.button>
        </div>
    </x-crud.form>
</template>
```

> **Note:** The form-data-create component is shared across ruangan/personal/jadwal. The `title` state is what selects which template. The `jadwal` template uses additional state vars (`kelasId`, `kelasGroup`, `dosen`) that aren't in `jadwalForm` — wrap with an additional Alpine `x-data` scope or extend `jadwalForm`. **Simplest fix:** wrap the form body in an outer `x-data="{ hari: '', kelasId: '', kelasGroup: '', dosen: '', errors: {} }"` and add the inner `x-data="jadwalForm(...)"` on the inner div only for the auto-calc fields. Adjust the above template: change the outermost `<div ... x-data="jadwalForm(...)">` to add a parent x-data that holds the other form fields, and put the jadwalForm x-data on a child wrapper. For example:
>
> ```blade
> <div class="flex flex-col gap-3 text-xs"
>      x-data="{ hari: '', kelasId: '', kelasGroup: '', dosen: '', errors: {} }">
>     <div x-data="jadwalForm(@js($mataKuliahSksMap ?? []))">
>         ...all the jadwal fields using `x-model` bound to the outer scope...
>     </div>
> </div>
> ```
>
> All `x-model="hari"`, `x-model="kelasId"`, etc. on inputs/selects bind to the outer scope. The `sks`, `jamMasuk`, `jamKeluar` live on the inner `jadwalForm` and are referenced via `x-model` from the outer. Adjust the `x-model` values accordingly: use `x-model="hari"` (outer), `x-model="mataKuliahId"` (inner → write to outer via `$store` or move the field into the inner x-data).

> **Implementation decision:** to keep the template simple and self-contained, declare **all** form fields inside the inner `jadwalForm` x-data (extend the component to carry `hari`, `kelasId`, `kelasGroup`, `dosen`, `errors`). Update `jadwal-form.js` in the same task to include these fields. The form-data-create template will then use only the `jadwalForm(...)` x-data wrapper.

- [ ] **Step 3: Update `jadwal-form.js` to carry all form fields**

Replace `resources/js/alpine/components/jadwal-form.js` with:
```js
const pad = (n) => String(n).padStart(2, '0');

const toMinutes = (hhmm) => {
    const [h, m] = hhmm.split(':').map(Number);
    return h * 60 + m;
};

const toHHMM = (totalMinutes) => {
    const wrapped = ((totalMinutes % 1440) + 1440) % 1440;
    const h = Math.floor(wrapped / 60);
    const m = wrapped % 60;
    return `${pad(h)}:${pad(m)}`;
};

export const jadwalForm = (sksMap = {}) => ({
    sksMap,
    hari: '',
    kelasId: '',
    kelasGroup: '',
    dosen: '',
    mataKuliahId: '',
    sks: 0,
    jamMasuk: '',
    jamKeluar: '',
    errors: {},

    init() {
        this.recomputeSks();
        this.recomputeJamKeluar();
        this.$watch('mataKuliahId', () => this.recomputeSks());
        this.$watch('sks', () => this.recomputeJamKeluar());
        this.$watch('jamMasuk', () => this.recomputeJamKeluar());
    },

    recomputeSks() {
        const id = parseInt(this.mataKuliahId, 10);
        this.sks = this.sksMap[id] ?? 0;
    },

    recomputeJamKeluar() {
        if (!this.jamMasuk || !this.sks) {
            this.jamKeluar = '';
            return;
        }
        const masuk = toMinutes(this.jamMasuk);
        const keluar = masuk + this.sks * 50;
        this.jamKeluar = toHHMM(keluar);
    },
});
```

And the form template (from Step 2) collapses to:
```blade
<template x-if="title === 'jadwal'">
    <x-crud.form :action="route('admin.jadwal.store')" table="jadwal-table" reset close>
        <input type="hidden" name="type" value="jadwal">
        <div class="flex flex-col gap-3 text-xs" x-data="jadwalForm(@js($mataKuliahSksMap ?? []))">
            <div class="flex gap-3 md:gap-4">
                <div class="flex-1">
                    <label class="block text-gray-600 mb-1">Hari</label>
                    <select name="hari" x-model="hari" required class="w-full text-xs border border-gray-300 rounded px-2 py-1.5 focus:outline-none">
                        <option value="">-- pilih hari --</option>
                        <option>Senin</option>
                        <option>Selasa</option>
                        <option>Rabu</option>
                        <option>Kamis</option>
                        <option>Jumat</option>
                    </select>
                </div>
            </div>
            <div class="flex gap-3 md:gap-4">
                <div class="flex-1">
                    <label class="block text-gray-600 mb-1">Ruang</label>
                    <select name="kelas_id" x-model="kelasId" required class="w-full text-xs border border-gray-300 rounded px-2 py-1.5 focus:outline-none">
                        <option value="">-- pilih ruang --</option>
                        @foreach ($kelasOptions ?? [] as $k)
                            <option value="{{ $k['id'] }}">{{ $k['kode_ruangan'] }} - {{ $k['nama'] }}</option>
                        @endforeach
                    </select>
                </div>
                <div class="flex-1">
                    <label class="block text-gray-600 mb-1">Group (e.g. 2A)</label>
                    <input type="text" name="kelas" x-model="kelasGroup" required maxlength="50" class="w-full text-xs border border-gray-300 rounded px-2 py-1.5 focus:outline-none">
                </div>
            </div>
            <div class="flex gap-3 md:gap-4">
                <div class="flex-1">
                    <label class="block text-gray-600 mb-1">Dosen</label>
                    <input type="text" name="dosen" x-model="dosen" required maxlength="255" class="w-full text-xs border border-gray-300 rounded px-2 py-1.5 focus:outline-none">
                </div>
            </div>
            <div class="flex gap-3 md:gap-4">
                <div class="flex-1">
                    <label class="block text-gray-600 mb-1">Mata Kuliah</label>
                    <select name="mata_kuliah_id" x-model="mataKuliahId" required class="w-full text-xs border border-gray-300 rounded px-2 py-1.5 focus:outline-none">
                        <option value="">-- pilih mata kuliah --</option>
                        @foreach ($mataKuliahOptions ?? [] as $mk)
                            <option value="{{ $mk['id'] }}">{{ $mk['kode_mk'] }} - {{ $mk['nama'] }} ({{ $mk['sks'] }} SKS)</option>
                        @endforeach
                    </select>
                </div>
            </div>
            <div class="flex gap-3 md:gap-4">
                <div class="flex-1">
                    <label class="block text-gray-600 mb-1">SKS (auto)</label>
                    <input type="text" :value="sks" readonly class="w-full text-xs border border-gray-200 bg-gray-50 rounded px-2 py-1.5 text-gray-700">
                </div>
                <div class="flex-1">
                    <label class="block text-gray-600 mb-1">Jam Masuk</label>
                    <input type="time" name="jam_masuk" x-model="jamMasuk" required class="w-full text-xs border border-gray-300 rounded px-2 py-1.5 focus:outline-none">
                </div>
                <div class="flex-1">
                    <label class="block text-gray-600 mb-1">Jam Keluar (auto)</label>
                    <input type="text" :value="jamKeluar" readonly class="w-full text-xs border border-gray-200 bg-gray-50 rounded px-2 py-1.5 text-gray-700">
                </div>
            </div>
            <div x-show="errors && Object.keys(errors).length" class="text-red text-xs" x-text="errors && Object.values(errors).flat().join(' ')"></div>
            <x-crud.button class="w-full">Simpan</x-crud.button>
        </div>
    </x-crud.form>
</template>
```

- [ ] **Step 4: Update `form-data-create.blade.php` to pass the options from the controller**

The form template uses `$kelasOptions`, `$mataKuliahOptions`, and `$mataKuliahSksMap` which need to be passed from the controller. Update `app/Http/Controllers/DataController.php` — in the `index` method, pass:
```php
'kelasOptions'       => Kelas::orderBy('kode_ruangan')->get(['id', 'kode_ruangan', 'nama'])->toArray(),
'mataKuliahOptions'  => MataKuliah::orderBy('kode_mk')->get(['id', 'kode_mk', 'nama', 'sks'])->toArray(),
'mataKuliahSksMap'  => MataKuliah::pluck('sks', 'id')->toArray(),
```

- [ ] **Step 5: Create `jadwal-action-modal.blade.php`**

Create `resources/views/components/datatable/jadwal-action-modal.blade.php`:
```blade
@props(['kelasOptions' => [], 'mataKuliahOptions' => [], 'mataKuliahSksMap' => []])

<div
    x-data="{
        open: false,
        mode: '',
        data: {},
        errors: {},
        sks: 0,
        sksMap: @js($mataKuliahSksMap),
        jamKeluar: '',
        kelasOptions: @js($kelasOptions),
        mataKuliahOptions: @js($mataKuliahOptions),

        init() {
            this.$watch('data', () => {
                this.errors = {};
                this.recompute();
            });
        },

        recompute() {
            const id = parseInt(this.data.mata_kuliah_id, 10);
            this.sks = this.sksMap[id] ?? 0;
            if (this.data.jam_masuk && this.sks) {
                const [h, m] = this.data.jam_masuk.split(':').map(Number);
                const total = h * 60 + m + this.sks * 50;
                const wrapped = ((total % 1440) + 1440) % 1440;
                const hh = String(Math.floor(wrapped / 60)).padStart(2, '0');
                const mm = String(wrapped % 60).padStart(2, '0');
                this.jamKeluar = `${hh}:${mm}`;
            } else {
                this.jamKeluar = '';
            }
        },

        initial(m, d) { this.mode = m; this.data = d; this.open = true; },
        close() { this.open = false; this.errors = {}; },

        async submitUpdate() {
            this.errors = {};
            try {
                const fd = new FormData();
                fd.append('_token', '{{ csrf_token() }}');
                fd.append('_method', 'patch');
                fd.append('hari', this.data.hari);
                fd.append('kelas_id', this.data.kelas_id);
                fd.append('dosen', this.data.dosen);
                fd.append('mata_kuliah_id', this.data.mata_kuliah_id);
                fd.append('jam_masuk', this.data.jam_masuk);
                fd.append('kelas', this.data.kelas);
                const res = await fetch(`{{ url('/admin/jadwal/update') }}/${this.data.id}`, {
                    method: 'POST',
                    headers: { 'X-CSRF-TOKEN': '{{ csrf_token() }}', 'Accept': 'application/json' },
                    body: fd,
                });
                const result = await res.json();
                if (result.success) {
                    this.open = false;
                    this.data = {};
                    $('#jadwal-table').DataTable().ajax.reload(null, false);
                    $store.toast.success(result.message || 'Data Jadwal berhasil diperbarui!');
                } else if (result.errors) {
                    this.errors = result.errors;
                    $store.toast.error(result.message || 'Gagal memperbarui Data Jadwal.');
                } else {
                    $store.toast.error(result.message || 'Gagal memperbarui Data Jadwal.');
                }
            } catch (e) {
                $store.toast.error('Terjadi kesalahan: ' + e.message);
            }
        },

        async submitDelete() {
            try {
                const fd = new FormData();
                fd.append('_token', '{{ csrf_token() }}');
                fd.append('_method', 'delete');
                const res = await fetch(`{{ url('/admin/jadwal/delete') }}/${this.data.id}`, {
                    method: 'POST',
                    headers: { 'X-CSRF-TOKEN': '{{ csrf_token() }}', 'Accept': 'application/json' },
                    body: fd,
                });
                const result = await res.json();
                if (result.success) {
                    this.open = false;
                    this.data = {};
                    $('#jadwal-table').DataTable().ajax.reload(null, false);
                    $store.toast.success(result.message || 'Data Jadwal berhasil dihapus!');
                } else {
                    $store.toast.error(result.message || 'Gagal menghapus Data Jadwal.');
                }
            } catch (e) {
                $store.toast.error('Terjadi kesalahan: ' + e.message);
            }
        },
    }"
    @jadwal-action.window="initial($event.detail.mode, $event.detail.data)"
    x-show="open"
    x-transition.opacity
    x-cloak
>
    <x-modal-shell show="open" close="close">
        <template x-if="mode === 'update'">
            <div class="flex flex-col gap-2">
                <h2 class="text-center text-xs font-semibold">Edit Jadwal</h2>
                <form @submit.prevent="submitUpdate" class="flex flex-col gap-2 text-xs">
                    <div class="flex flex-col gap-1">
                        <label class="text-gray-600">Hari</label>
                        <select x-model="data.hari" name="hari" required class="text-xs border border-gray-300 rounded px-2 py-1.5">
                            <option value="">-- pilih hari --</option>
                            <option>Senin</option>
                            <option>Selasa</option>
                            <option>Rabu</option>
                            <option>Kamis</option>
                            <option>Jumat</option>
                        </select>
                    </div>
                    <div class="flex flex-col gap-1">
                        <label class="text-gray-600">Ruang</label>
                        <select x-model="data.kelas_id" name="kelas_id" required class="text-xs border border-gray-300 rounded px-2 py-1.5">
                            <option value="">-- pilih ruang --</option>
                            <template x-for="k in kelasOptions" :key="k.id">
                                <option :value="k.id" x-text="`${k.kode_ruangan} - ${k.nama}`"></option>
                            </template>
                        </select>
                    </div>
                    <div class="flex flex-col gap-1">
                        <label class="text-gray-600">Group (e.g. 2A)</label>
                        <input type="text" x-model="data.kelas" name="kelas" required maxlength="50" class="text-xs border border-gray-300 rounded px-2 py-1.5">
                    </div>
                    <div class="flex flex-col gap-1">
                        <label class="text-gray-600">Dosen</label>
                        <input type="text" x-model="data.dosen" name="dosen" required maxlength="255" class="text-xs border border-gray-300 rounded px-2 py-1.5">
                    </div>
                    <div class="flex flex-col gap-1">
                        <label class="text-gray-600">Mata Kuliah</label>
                        <select x-model="data.mata_kuliah_id" @change="recompute()" name="mata_kuliah_id" required class="text-xs border border-gray-300 rounded px-2 py-1.5">
                            <option value="">-- pilih mata kuliah --</option>
                            <template x-for="mk in mataKuliahOptions" :key="mk.id">
                                <option :value="mk.id" x-text="`${mk.kode_mk} - ${mk.nama} (${mk.sks} SKS)`"></option>
                            </template>
                        </select>
                    </div>
                    <div class="grid grid-cols-3 gap-1">
                        <div class="flex flex-col gap-1">
                            <label class="text-gray-600">SKS</label>
                            <input type="text" :value="sks" readonly class="text-xs border border-gray-200 bg-gray-50 rounded px-2 py-1.5 text-gray-700">
                        </div>
                        <div class="flex flex-col gap-1">
                            <label class="text-gray-600">Jam Masuk</label>
                            <input type="time" x-model="data.jam_masuk" @input="recompute()" name="jam_masuk" required class="text-xs border border-gray-300 rounded px-2 py-1.5">
                        </div>
                        <div class="flex flex-col gap-1">
                            <label class="text-gray-600">Jam Keluar</label>
                            <input type="text" :value="jamKeluar" readonly class="text-xs border border-gray-200 bg-gray-50 rounded px-2 py-1.5 text-gray-700">
                        </div>
                    </div>
                    <div x-show="errors && Object.keys(errors).length" class="text-red text-xs" x-text="Object.values(errors).flat().join(' ')"></div>
                    <div x-show="data._error" class="text-red text-xs" x-text="data._error"></div>
                    <button type="submit" class="bg-blue-700 text-white text-xs font-semibold hover:opacity-75 flex justify-center items-center gap-1.5 rounded-lg py-1.5">Simpan</button>
                </form>
            </div>
        </template>
        <template x-if="mode === 'delete'">
            <div class="flex flex-col items-center gap-2">
                <p class="text-xs text-center">Hapus jadwal <span class="font-medium" x-text="data.label"></span>?</p>
                <div class="flex gap-2">
                    <button @click="submitDelete" class="bg-red text-white px-3 py-1 rounded-md text-xs font-semibold">Hapus</button>
                    <button @click="close" class="bg-white text-slate-900 ring-1 ring-gray-300 px-3 py-1 rounded-md text-xs font-semibold">Batal</button>
                </div>
            </div>
        </template>
    </x-modal-shell>
</div>
```

- [ ] **Step 6: Add the Jadwal tab in `kelola-data/index.blade.php`**

In `resources/views/app/kelola-data/index.blade.php`:
1. Move the existing "Ruangan" and "Personal" tabs to the right of the new "Jadwal" tab (so Jadwal is first). Update the tab list block to:
```blade
            <div class="flex space-x-4 border-b">
                <a class="text-xs md:text-sm py-2 px-4" 
                    :class="activeTab === '#jadwal' ? 'border-b-2 border-red font-bold text-red' : 'text-gray-500'"
                    href="#jadwal" x-on:click.prevent="activeTab='#jadwal'" @click="$dispatch('set-title', 'jadwal')">
                    Jadwal
                </a>
                <a class="text-xs md:text-sm py-2 px-4" 
                    :class="activeTab === '#ruangan' ? 'border-b-2 border-red font-bold text-red' : 'text-gray-500'"
                    href="#ruangan" x-on:click.prevent="activeTab='#ruangan'" @click="$dispatch('set-title', 'ruangan')">
                    Ruangan
                </a>
                <a class="text-xs md:text-sm py-2 px-4" 
                    :class="activeTab === '#personal' ? 'border-b-2 border-red font-bold text-red' : 'text-gray-500'"
                    href="#personal" x-on:click.prevent="activeTab='#personal'" @click="$dispatch('set-title', 'personal')">
                    Personal
                </a>
            </div>
```
2. Change the default tab. Find the `<div x-data="{ activeTab: '#ruangan' }">` and change it to `<div x-data="{ activeTab: window.location.hash ? window.location.hash.substring(1) : '#jadwal' }">`.
3. Add a new tab content panel inside the existing `<div class="text-xs mt-4">`, before the ruangan panel:
```blade
                <div x-show="activeTab === '#jadwal'">
                    {!! $jadwalDataTable->table() !!}
                    <x-datatable.jadwal-action-modal
                        :kelas-options="$kelasOptions"
                        :mata-kuliah-options="$mataKuliahOptions"
                        :mata-kuliah-sks-map="$mataKuliahSksMap"
                    />
                </div>
```
4. At the bottom of the file, in the `@push('scripts')` block, add `{!! $jadwalDataTable->scripts() !!}` after the existing ruangan/personal scripts.

- [ ] **Step 7: Update `DataController::index` to pass the new DataTable and the option arrays**

In `app/Http/Controllers/DataController.php`, change the `index` method signature and body to:
```php
    public function index(\App\DataTables\JadwalDataTable $jadwalDataTable, \App\DataTables\RuanganDataTable $ruanganDataTable, \App\DataTables\PersonalDataTable $personalDataTable)
    {
        $kelasOptions       = Kelas::orderBy('kode_ruangan')->get(['id', 'kode_ruangan', 'nama'])->toArray();
        $mataKuliahOptions  = MataKuliah::orderBy('kode_mk')->get(['id', 'kode_mk', 'nama', 'sks'])->toArray();
        $mataKuliahSksMap   = MataKuliah::pluck('sks', 'id')->toArray();

        return view('app.kelola-data.index', [
            'jadwalDataTable'  => $jadwalDataTable->html(),
            'ruanganDataTable'  => $ruanganDataTable->html(),
            'personalDataTable' => $personalDataTable->html(),
            'kelasOptions'      => $kelasOptions,
            'mataKuliahOptions' => $mataKuliahOptions,
            'mataKuliahSksMap'  => $mataKuliahSksMap,
        ]);
    }
```

- [ ] **Step 8: Build the assets and run all tests**

Run:
```bash
php artisan config:clear
php artisan route:clear
npm run build
php artisan test --filter=JadwalAdminCrudTest
```
Expected: All 10 tests still PASS; the build succeeds.

- [ ] **Step 9: Commit**

```bash
git add resources/views/components/form-data-create.blade.php resources/views/components/datatable/jadwal-action-modal.blade.php resources/views/app/kelola-data/index.blade.php resources/js/alpine/components/jadwal-form.js app/Http/Controllers/DataController.php public/build/
git commit -m "feat(jadwal-admin): add Jadwal tab, modal, and Alpine auto-calc form"
```

---

### Task 5: Manual visual verification

- [ ] **Step 1: Start the dev server**

Run: `php artisan serve`
Expected: server starts.

- [ ] **Step 2: Open `/app/kelola-data` in a browser (admin user)**

Expected:
- "Jadwal" tab is selected by default
- DataTable lists existing `jadwal_ruangan` rows (or is empty if none)
- "Data baru" button opens the create modal
- Form has: Hari, Ruang, Group, Dosen, Mata Kuliah, SKS (auto), Jam Masuk, Jam Keluar (auto)
- Selecting a Mata Kuliah auto-fills SKS
- Entering Jam Masuk auto-calculates Jam Keluar

- [ ] **Step 3: Try to add a conflicting row**

Expected: red error banner with the message "Jadwal bentrok dengan ...". No row is created.

- [ ] **Step 4: Edit a row**

Expected: modal pre-fills with the row's values. Changing Mata Kuliah updates SKS. Changing Jam Masuk updates Jam Keluar. Saving works.

- [ ] **Step 5: Delete a row**

Expected: confirmation modal appears. Confirming removes the row from the table and shows a success toast.

---

## Self-Review

**1. Spec coverage:**
- New "Jadwal" tab in `/app/kelola-data` (first) → Task 4 ✓
- Full CRUD: list, add, edit, delete → Tasks 1, 2, 4 ✓
- One flat list, DataTable search/filter → Task 2 ✓
- SKS auto-fill from `mata_kuliah` → Tasks 3, 4 ✓
- End-time = start + (sks × 50 min) client + server → Tasks 1, 3, 4 ✓
- Conflict detection in same `(kelas_id, hari)` → Task 1 (server), Task 4 (UI error banner) ✓
- Hard reject on conflict → Task 1 ✓
- Server ignores tampered `jam_keluar` → Task 1 (test) + controller logic ✓
- `hari` validates to 5 allowed days → Task 1 ✓
- `dosen` is free text → Tasks 1, 4 ✓
- `kelas` (group) is free text → Tasks 1, 4 ✓
- Tab order Jadwal → Ruangan → Personal → Task 4 ✓
- Existing `data.jadwal` route untouched → Task 1 (new namespace `admin.jadwal.*`) ✓
- Existing `JadwalRuanganDataTable` and `/jadwal-hari-ini` untouched → ✓
- 13 test cases → Task 1 (10 cases) + visual verification (Task 5) ✓

**2. Placeholder scan:** No TBDs, no "implement later", no "add appropriate error handling". Every code step has the full code.

**3. Type/name consistency:**
- Controller method names: `jadwal`, `storeJadwal`, `jadwalUpdate`, `jadwalDelete` — consistent across Task 1, 4, and routes.
- Route names: `admin.jadwal.list`, `admin.jadwal.store`, `admin.jadwal.update`, `admin.jadwal.delete` — consistent in Task 1 routes, Task 4 modal, and Task 2 DataTable `minifiedAjax`.
- DataTable ID `jadwal-table` — consistent in Task 2 (`setTableId`), Task 4 modal (`#jadwal-table`), and `x-crud.form` (`table="jadwal-table"`).
- Variable names in `jadwalForm` (`sksMap`, `mataKuliahId`, `sks`, `jamMasuk`, `jamKeluar`) — consistent in Task 3 and Task 4 form template.
- Window event name `jadwal-action` — consistent in Task 2 action button, Task 4 modal listener, and Task 4 DataTable trigger.
- Form-data-create's `title === 'jadwal'` branch — consistent in Task 4 form template and the `set-title` event in the tab.
