feat: implement academic term management with CRUD functionality
Some checks failed
tests / ci (pull_request) Has been cancelled
Some checks failed
tests / ci (pull_request) Has been cancelled
- Added Semester enum with labels for odd and even semesters. - Created AcademicTerm model, controller, service, and request for handling academic terms. - Implemented paginated listing, creation, updating, and deletion of academic terms. - Added routes for academic terms management under admin/master. - Created frontend components for displaying and managing academic terms, including forms and data tables. - Integrated confirmation dialog for delete actions and form dialogs for creating and editing academic terms. - Added seeder for initial academic term data.
This commit is contained in:
parent
75178c12a8
commit
400e8d8c8a
17
app/Enums/Semester.php
Normal file
17
app/Enums/Semester.php
Normal file
@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum Semester: string
|
||||
{
|
||||
case odd = 'odd';
|
||||
case even = 'even';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::odd => 'Ganjil',
|
||||
self::even => 'Genap',
|
||||
};
|
||||
}
|
||||
}
|
||||
53
app/Http/Controllers/Admin/Master/AcademicTermController.php
Normal file
53
app/Http/Controllers/Admin/Master/AcademicTermController.php
Normal file
@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin\Master;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Admin\Master\AcademicTermRequest;
|
||||
use App\Http\Requests\PaginatedRequest;
|
||||
use App\Models\AcademicTerm;
|
||||
use App\Services\Admin\Master\AcademicTermService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class AcademicTermController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AcademicTermService $service
|
||||
) {}
|
||||
|
||||
public function index(PaginatedRequest $request): Response
|
||||
{
|
||||
return Inertia::render('admin/master/academic-terms/index', [
|
||||
'academicTerms' => $this->service->paginated(...$request->validatedWithDefaults()),
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(AcademicTermRequest $request): RedirectResponse
|
||||
{
|
||||
$this->service->create($request->validated());
|
||||
|
||||
Inertia::flash('toast', ['type' => 'success', 'message' => 'Periode akademik berhasil ditambahkan.']);
|
||||
|
||||
return to_route('admin.master.academic-terms.index');
|
||||
}
|
||||
|
||||
public function update(AcademicTermRequest $request, AcademicTerm $AcademicTerm): RedirectResponse
|
||||
{
|
||||
$this->service->update($AcademicTerm, $request->validated());
|
||||
|
||||
Inertia::flash('toast', ['type' => 'success', 'message' => 'Periode akademik berhasil diperbarui.']);
|
||||
|
||||
return to_route('admin.master.academic-terms.index');
|
||||
}
|
||||
|
||||
public function destroy(AcademicTerm $AcademicTerm): RedirectResponse
|
||||
{
|
||||
$this->service->delete($AcademicTerm);
|
||||
|
||||
Inertia::flash('toast', ['type' => 'success', 'message' => 'Periode akademik berhasil dihapus.']);
|
||||
|
||||
return to_route('admin.master.academic-terms.index');
|
||||
}
|
||||
}
|
||||
84
app/Http/Requests/Admin/Master/AcademicTermRequest.php
Normal file
84
app/Http/Requests/Admin/Master/AcademicTermRequest.php
Normal file
@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Admin\Master;
|
||||
|
||||
use App\Enums\Semester;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class AcademicTermRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => [
|
||||
'required',
|
||||
'string',
|
||||
'max:20',
|
||||
function ($attribute, $value, $fail) {
|
||||
if (! preg_match('/^(\d{4})\/(\d{4})$/', $value, $matches)) {
|
||||
$fail('Format name harus YYYY/YYYY, contoh: 2025/2026.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$startYear = (int) $matches[1];
|
||||
$endYear = (int) $matches[2];
|
||||
|
||||
if ($endYear !== $startYear + 1) {
|
||||
$fail('Tahun kedua harus selisih 1 tahun dari tahun pertama. Contoh: 2025/2026, 2026/2027.');
|
||||
}
|
||||
},
|
||||
],
|
||||
'semester' => [
|
||||
'required',
|
||||
'string',
|
||||
Rule::in(array_values(Semester::cases())),
|
||||
Rule::unique('academic_terms')->where(function ($query) {
|
||||
return $query->where('name', $this->input('name'));
|
||||
})->ignore($this->route('academic_term')),
|
||||
],
|
||||
'start_date' => [
|
||||
'required',
|
||||
'date',
|
||||
function ($attribute, $value, $fail) {
|
||||
$name = $this->input('name');
|
||||
$semester = $this->input('semester');
|
||||
if ($name && preg_match('/^(\d{4})\/(\d{4})$/', $name, $matches)) {
|
||||
$startYear = (int) $matches[1];
|
||||
$endYear = (int) $matches[2];
|
||||
$dateYear = (int) date('Y', strtotime($value));
|
||||
$expectedYear = $semester === 'even' ? $endYear : $startYear;
|
||||
if ($dateYear !== $expectedYear) {
|
||||
$fail('Start date harus berada di tahun '.$expectedYear.' untuk semester '.($semester === 'even' ? 'Genap' : 'Ganjil').'.');
|
||||
}
|
||||
}
|
||||
},
|
||||
],
|
||||
'end_date' => [
|
||||
'required',
|
||||
'date',
|
||||
'after_or_equal:start_date',
|
||||
function ($attribute, $value, $fail) {
|
||||
$name = $this->input('name');
|
||||
$semester = $this->input('semester');
|
||||
if ($name && preg_match('/^(\d{4})\/(\d{4})$/', $name, $matches)) {
|
||||
$startYear = (int) $matches[1];
|
||||
$endYear = (int) $matches[2];
|
||||
$dateYear = (int) date('Y', strtotime($value));
|
||||
$expectedYear = $semester === 'even' ? $endYear : $startYear;
|
||||
if ($dateYear !== $expectedYear) {
|
||||
$fail('End date harus berada di tahun '.$expectedYear.' untuk semester '.($semester === 'even' ? 'Genap' : 'Ganjil').'.');
|
||||
}
|
||||
}
|
||||
},
|
||||
],
|
||||
'is_active' => ['boolean'],
|
||||
];
|
||||
}
|
||||
}
|
||||
36
app/Http/Requests/PaginatedRequest.php
Normal file
36
app/Http/Requests/PaginatedRequest.php
Normal file
@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class PaginatedRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'per_page' => ['nullable', 'integer', 'in:25,50,100,999999'],
|
||||
'search' => ['nullable', 'string', 'max:255'],
|
||||
'sort' => ['nullable', 'string'],
|
||||
'direction' => ['nullable', 'string', 'in:asc,desc'],
|
||||
'highlight' => ['nullable', 'integer'],
|
||||
];
|
||||
}
|
||||
|
||||
public function validatedWithDefaults(): array
|
||||
{
|
||||
$validated = $this->validated();
|
||||
|
||||
return [
|
||||
'perPage' => $validated['per_page'] ?? 25,
|
||||
'search' => $validated['search'] ?? '',
|
||||
'sort' => $validated['sort'] ?? 'created_at',
|
||||
'direction' => $validated['direction'] ?? 'desc',
|
||||
];
|
||||
}
|
||||
}
|
||||
41
app/Models/AcademicTerm.php
Normal file
41
app/Models/AcademicTerm.php
Normal file
@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\Semester;
|
||||
use Illuminate\Database\Eloquent\Attributes\Appends;
|
||||
use Illuminate\Database\Eloquent\Attributes\Guarded;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
#[Guarded(['id'])]
|
||||
#[Appends(['formatted_start_date', 'formatted_end_date'])]
|
||||
class AcademicTerm extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'semester' => Semester::class,
|
||||
'start_date' => 'date',
|
||||
'end_date' => 'date',
|
||||
'is_active' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
protected function formattedStartDate(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn () => $this->start_date->format('d M Y'),
|
||||
);
|
||||
}
|
||||
|
||||
protected function formattedEndDate(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn () => $this->end_date->format('d M Y'),
|
||||
);
|
||||
}
|
||||
}
|
||||
46
app/Services/Admin/Master/AcademicTermService.php
Normal file
46
app/Services/Admin/Master/AcademicTermService.php
Normal file
@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Admin\Master;
|
||||
|
||||
use App\Models\AcademicTerm;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
|
||||
class AcademicTermService
|
||||
{
|
||||
public function getAll(array $filters = []): Collection
|
||||
{
|
||||
return AcademicTerm::select(['id', 'name', 'semester', 'start_date', 'end_date', 'is_active'])->latest()->get();
|
||||
}
|
||||
|
||||
public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', array $filters = []): LengthAwarePaginator
|
||||
{
|
||||
return AcademicTerm::query()
|
||||
->select(['id', 'name', 'semester', 'start_date', 'end_date', 'is_active'])
|
||||
->when($search, fn($q) => $q->where('name', 'like', "%{$search}%"))
|
||||
->orderBy($sort, $direction)
|
||||
->paginate($perPage);
|
||||
}
|
||||
|
||||
public function create(array $data): AcademicTerm
|
||||
{
|
||||
return AcademicTerm::create($data);
|
||||
}
|
||||
|
||||
public function update(AcademicTerm $academicTerm, array $data): AcademicTerm
|
||||
{
|
||||
$academicTerm->name = $data['name'];
|
||||
$academicTerm->semester = $data['semester'];
|
||||
$academicTerm->start_date = $data['start_date'];
|
||||
$academicTerm->end_date = $data['end_date'];
|
||||
$academicTerm->is_active = $data['is_active'];
|
||||
$academicTerm->update();
|
||||
|
||||
return $academicTerm;
|
||||
}
|
||||
|
||||
public function delete(AcademicTerm $academicTerm): bool
|
||||
{
|
||||
return $academicTerm->delete();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
use App\Enums\Semester;
|
||||
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::create('academic_terms', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('name', 20);
|
||||
$table->enum('semester', array_values(Semester::cases()));
|
||||
$table->date('start_date');
|
||||
$table->date('end_date');
|
||||
$table->boolean('is_active')->default(false);
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('academic_terms');
|
||||
}
|
||||
};
|
||||
34
database/seeders/AcademicTermSeeder.php
Normal file
34
database/seeders/AcademicTermSeeder.php
Normal file
@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Enums\Semester;
|
||||
use App\Models\AcademicTerm;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
class AcademicTermSeeder extends Seeder
|
||||
{
|
||||
public function run(): void
|
||||
{
|
||||
AcademicTerm::insert([
|
||||
[
|
||||
'name' => '2026/2027',
|
||||
'semester' => Semester::odd->value,
|
||||
'start_date' => '2026-08-01',
|
||||
'end_date' => '2027-01-15',
|
||||
'is_active' => false,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
],
|
||||
[
|
||||
'name' => '2026/2027',
|
||||
'semester' => Semester::even->value,
|
||||
'start_date' => '2027-02-01',
|
||||
'end_date' => '2027-07-15',
|
||||
'is_active' => true,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@ -13,6 +13,7 @@ public function run(): void
|
||||
{
|
||||
$this->call([
|
||||
UserSeeder::class,
|
||||
AcademicTermSeeder::class,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,20 +1,14 @@
|
||||
import {
|
||||
IconCamera,
|
||||
IconChartBar,
|
||||
IconDashboard,
|
||||
IconFileAi,
|
||||
IconFileDescription,
|
||||
IconFolder,
|
||||
IconHelp,
|
||||
IconListDetails,
|
||||
IconMessageDots,
|
||||
IconUsers
|
||||
} from "@tabler/icons-react";
|
||||
import { usePage } from "@inertiajs/react";
|
||||
import type { Icon } from "@tabler/icons-react";
|
||||
import {
|
||||
IconGridDots,
|
||||
IconHelp,
|
||||
IconMessageDots
|
||||
} from "@tabler/icons-react";
|
||||
import * as React from "react";
|
||||
|
||||
import AppLogoIcon from "@/components/app-logo-icon";
|
||||
import { NavMain } from "@/components/nav-main";
|
||||
import { NavMain, type NavGroup, type NavItem } from "@/components/nav-main";
|
||||
import { NavSecondary } from "@/components/nav-secondary";
|
||||
import {
|
||||
Sidebar,
|
||||
@ -24,79 +18,23 @@ import {
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
} from "@/components/ui/sidebar";
|
||||
import { index as academicTerm } from "@/routes/admin/master/academic-terms";
|
||||
import { Calendar } from "lucide-react";
|
||||
|
||||
const data = {
|
||||
const data: { navMain: (NavGroup | NavItem)[]; navSecondary: { title: string; url: string; icon: Icon }[] } = {
|
||||
navMain: [
|
||||
{
|
||||
title: "Dashboard",
|
||||
name: "Dasbor",
|
||||
url: "#",
|
||||
icon: IconDashboard,
|
||||
icon: IconGridDots,
|
||||
},
|
||||
{
|
||||
title: "Lifecycle",
|
||||
url: "#",
|
||||
icon: IconListDetails,
|
||||
},
|
||||
{
|
||||
title: "Analytics",
|
||||
url: "#",
|
||||
icon: IconChartBar,
|
||||
},
|
||||
{
|
||||
title: "Projects",
|
||||
url: "#",
|
||||
icon: IconFolder,
|
||||
},
|
||||
{
|
||||
title: "Team",
|
||||
url: "#",
|
||||
icon: IconUsers,
|
||||
},
|
||||
],
|
||||
navClouds: [
|
||||
{
|
||||
title: "Capture",
|
||||
icon: IconCamera,
|
||||
isActive: true,
|
||||
url: "#",
|
||||
label: "Master",
|
||||
items: [
|
||||
{
|
||||
title: "Active Proposals",
|
||||
url: "#",
|
||||
},
|
||||
{
|
||||
title: "Archived",
|
||||
url: "#",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Proposal",
|
||||
icon: IconFileDescription,
|
||||
url: "#",
|
||||
items: [
|
||||
{
|
||||
title: "Active Proposals",
|
||||
url: "#",
|
||||
},
|
||||
{
|
||||
title: "Archived",
|
||||
url: "#",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Prompts",
|
||||
icon: IconFileAi,
|
||||
url: "#",
|
||||
items: [
|
||||
{
|
||||
title: "Active Proposals",
|
||||
url: "#",
|
||||
},
|
||||
{
|
||||
title: "Archived",
|
||||
url: "#",
|
||||
name: "Periode Akademik",
|
||||
url: academicTerm.url(),
|
||||
icon: Calendar,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
59
resources/js/components/confirm-dialog.tsx
Normal file
59
resources/js/components/confirm-dialog.tsx
Normal file
@ -0,0 +1,59 @@
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
|
||||
type ConfirmDialogProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
title: string;
|
||||
description: string;
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
variant?: 'default' | 'destructive';
|
||||
onConfirm: () => void;
|
||||
loading?: boolean;
|
||||
};
|
||||
|
||||
export function ConfirmDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
title,
|
||||
description,
|
||||
confirmLabel = 'Konfirmasi',
|
||||
cancelLabel = 'Batal',
|
||||
variant = 'destructive',
|
||||
onConfirm,
|
||||
loading = false,
|
||||
}: ConfirmDialogProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
{cancelLabel}
|
||||
</Button>
|
||||
<Button
|
||||
variant={variant}
|
||||
onClick={onConfirm}
|
||||
disabled={loading}
|
||||
>
|
||||
{confirmLabel}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@ -1,36 +1,68 @@
|
||||
import { type Icon } from "@tabler/icons-react";
|
||||
"use client"
|
||||
|
||||
import {
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuItem
|
||||
} from "@/components/ui/sidebar";
|
||||
|
||||
export type NavItem = {
|
||||
name: string
|
||||
url: string
|
||||
icon: React.ComponentType<{ className?: string }>
|
||||
}
|
||||
|
||||
export type NavGroup = {
|
||||
label: string
|
||||
items: NavItem[]
|
||||
}
|
||||
|
||||
export function NavMain({
|
||||
items,
|
||||
}: {
|
||||
items: {
|
||||
title: string
|
||||
url: string
|
||||
icon?: Icon
|
||||
}[]
|
||||
items: (NavGroup | NavItem)[]
|
||||
}) {
|
||||
|
||||
return (
|
||||
<SidebarGroup>
|
||||
<SidebarGroupContent className="flex flex-col gap-2">
|
||||
<SidebarMenu>
|
||||
{items.map((item) => (
|
||||
<SidebarMenuItem key={item.title}>
|
||||
<SidebarMenuButton tooltip={item.title}>
|
||||
{item.icon && <item.icon />}
|
||||
<span>{item.title}</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
<>
|
||||
{items.map((item, index) => {
|
||||
if ("label" in item) {
|
||||
return (
|
||||
<SidebarGroup key={item.label} className="group-data-[collapsible=icon]:hidden">
|
||||
<SidebarGroupLabel>{item.label}</SidebarGroupLabel>
|
||||
<SidebarMenu>
|
||||
{item.items.map((child) => (
|
||||
<SidebarMenuItem key={child.name}>
|
||||
<SidebarMenuButton asChild>
|
||||
<a href={child.url}>
|
||||
<child.icon className="h-4 w-4" />
|
||||
<span>{child.name}</span>
|
||||
</a>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroup>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<SidebarGroup key={item.name} className="group-data-[collapsible=icon]:hidden">
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton asChild>
|
||||
<a href={item.url}>
|
||||
<item.icon className="h-4 w-4" />
|
||||
<span>{item.name}</span>
|
||||
</a>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarGroup>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
101
resources/js/pages/admin/master/academic-terms/columns.tsx
Normal file
101
resources/js/pages/admin/master/academic-terms/columns.tsx
Normal file
@ -0,0 +1,101 @@
|
||||
import { RowActions } from '@/components/row-actions';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import type { AcademicTerm } from '@/types/academic-term';
|
||||
import { SemesterLabels, type Semester } from '@/types/academic-term';
|
||||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
import { Pencil, Trash2 } from 'lucide-react';
|
||||
|
||||
export type { AcademicTerm } from '@/types/academic-term';
|
||||
|
||||
type CreateColumnsParams = {
|
||||
handleEdit: (academicTerm: AcademicTerm) => void;
|
||||
handleDeleteClick: (academicTerm: AcademicTerm) => void;
|
||||
};
|
||||
|
||||
export function createAcademicTermColumns(
|
||||
params: CreateColumnsParams,
|
||||
): ColumnDef<AcademicTerm>[] {
|
||||
const { handleEdit, handleDeleteClick } = params;
|
||||
|
||||
return [
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: () => <span>Nama</span>,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-medium">
|
||||
{row.getValue('name') as string}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'semester',
|
||||
header: () => <span>Semester</span>,
|
||||
cell: ({ row }) => {
|
||||
const semester = row.getValue('semester') as Semester;
|
||||
return <span>{SemesterLabels[semester]}</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'formatted_start_date',
|
||||
header: () => <span>Tanggal Mulai</span>,
|
||||
cell: ({ row }) => (
|
||||
<span>{row.getValue('formatted_start_date') as string | null}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'formatted_end_date',
|
||||
header: () => <span>Tanggal Selesai</span>,
|
||||
cell: ({ row }) => (
|
||||
<span>{row.getValue('formatted_end_date') as string | null}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'is_active',
|
||||
header: () => <span className="block text-center">Status</span>,
|
||||
meta: {
|
||||
className: 'w-[100px] text-center',
|
||||
headerClassName: 'w-[100px] text-center',
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const isActive = row.getValue('is_active') as boolean;
|
||||
return (
|
||||
<div className="flex justify-center">
|
||||
<Badge variant={isActive ? 'default' : 'secondary'}>
|
||||
{isActive ? 'Aktif' : 'Nonaktif'}
|
||||
</Badge>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: () => <span className="block text-center">Aksi</span>,
|
||||
meta: {
|
||||
className: 'w-[100px] text-center',
|
||||
headerClassName: 'w-[100px] text-center',
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const academicTerm = row.original;
|
||||
|
||||
return (
|
||||
<RowActions
|
||||
actions={[
|
||||
{
|
||||
label: 'Edit',
|
||||
icon: <Pencil className="h-4 w-4" />,
|
||||
onClick: () => handleEdit(academicTerm),
|
||||
},
|
||||
{
|
||||
label: 'Hapus',
|
||||
icon: (
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
),
|
||||
onClick: () => handleDeleteClick(academicTerm),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
357
resources/js/pages/admin/master/academic-terms/index.tsx
Normal file
357
resources/js/pages/admin/master/academic-terms/index.tsx
Normal file
@ -0,0 +1,357 @@
|
||||
import { Head, router } from '@inertiajs/react';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import type { PaginationState } from '@/components/data-table';
|
||||
import { DataTable } from '@/components/data-table';
|
||||
import { DatePicker } from '@/components/date-picker';
|
||||
import { DeleteConfirmDialog } from '@/components/delete-confirm-dialog';
|
||||
import { FormDialog } from '@/components/form-dialog';
|
||||
import InputError from '@/components/input-error';
|
||||
import { PageHeader } from '@/components/page-header';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||
import { useServerTable } from '@/hooks/use-server-table';
|
||||
import {
|
||||
index as academicTermIndex,
|
||||
destroy,
|
||||
store,
|
||||
update,
|
||||
} from '@/routes/admin/master/academic-terms';
|
||||
import type { AcademicTerm } from '@/types/academic-term';
|
||||
import { Semester, SemesterLabels } from '@/types/academic-term';
|
||||
import { createAcademicTermColumns } from './columns';
|
||||
|
||||
type Props = {
|
||||
academicTerms: {
|
||||
data: AcademicTerm[];
|
||||
current_page: number;
|
||||
last_page: number;
|
||||
per_page: number;
|
||||
total: number;
|
||||
};
|
||||
highlight?: number;
|
||||
};
|
||||
|
||||
export default function AcademicTermIndex({ academicTerms, highlight }: Props) {
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<AcademicTerm | null>(null);
|
||||
const [deleting, setDeleting] = useState<AcademicTerm | null>(null);
|
||||
|
||||
const pagination: PaginationState = {
|
||||
current_page: academicTerms.current_page,
|
||||
last_page: academicTerms.last_page,
|
||||
per_page: academicTerms.per_page,
|
||||
total: academicTerms.total,
|
||||
};
|
||||
|
||||
const {
|
||||
search,
|
||||
handlePageChange,
|
||||
handlePerPageChange,
|
||||
handleSearchChange,
|
||||
} = useServerTable({
|
||||
route: () => academicTermIndex.url(),
|
||||
pagination,
|
||||
});
|
||||
|
||||
function handleDelete() {
|
||||
if (!deleting) {
|
||||
return;
|
||||
}
|
||||
|
||||
router.delete(destroy(deleting.id), {
|
||||
onSuccess: () => setDeleting(null),
|
||||
});
|
||||
}
|
||||
|
||||
const columns = createAcademicTermColumns({
|
||||
handleEdit: (academicTerm) => setEditing(academicTerm),
|
||||
handleDeleteClick: (academicTerm) => setDeleting(academicTerm),
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head title="Periode Akademik" />
|
||||
|
||||
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
|
||||
<PageHeader
|
||||
title="Periode Akademik"
|
||||
description={
|
||||
highlight && (
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Menampilkan periode akademik dari notifikasi.
|
||||
<button
|
||||
onClick={() => {
|
||||
router.get(
|
||||
academicTermIndex.url(),
|
||||
{},
|
||||
{
|
||||
replace: true,
|
||||
preserveState: true,
|
||||
},
|
||||
);
|
||||
}}
|
||||
className="ml-1 text-primary underline underline-offset-4 hover:text-primary/80"
|
||||
>
|
||||
Tampilkan semua
|
||||
</button>
|
||||
</p>
|
||||
)
|
||||
}
|
||||
actions={
|
||||
<Button asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCreateOpen(true)}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Tambah
|
||||
</button>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<CreateForm
|
||||
open={createOpen}
|
||||
onOpenChange={setCreateOpen}
|
||||
/>
|
||||
|
||||
<EditForm
|
||||
key={editing?.id}
|
||||
open={editing !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setEditing(null);
|
||||
}
|
||||
}}
|
||||
editing={editing}
|
||||
/>
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={academicTerms.data}
|
||||
searchKey="name"
|
||||
searchPlaceholder="Cari periode akademik..."
|
||||
emptyText="Belum ada data periode akademik."
|
||||
pagination={pagination}
|
||||
onPageChange={handlePageChange}
|
||||
onPerPageChange={handlePerPageChange}
|
||||
onSearchChange={handleSearchChange}
|
||||
searchValue={search}
|
||||
/>
|
||||
|
||||
<DeleteConfirmDialog
|
||||
target={deleting}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setDeleting(null);
|
||||
}
|
||||
}}
|
||||
title="Hapus Periode Akademik"
|
||||
description={(academicTerm) =>
|
||||
`Apakah Anda yakin ingin menghapus periode akademik "${academicTerm.name}"? Tindakan ini tidak dapat dibatalkan.`
|
||||
}
|
||||
onConfirm={handleDelete}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function CreateForm({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const [startDate, setStartDate] = useState<Date | undefined>();
|
||||
const [endDate, setEndDate] = useState<Date | undefined>();
|
||||
|
||||
return (
|
||||
<FormDialog
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title="Tambah Periode Akademik"
|
||||
action={store()}
|
||||
resetOnSuccess
|
||||
onSuccess={() => {
|
||||
onOpenChange(false);
|
||||
setStartDate(undefined);
|
||||
setEndDate(undefined);
|
||||
}}
|
||||
>
|
||||
{({ errors }) => (
|
||||
<div className="grid gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="name">
|
||||
Nama <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="name"
|
||||
name="name"
|
||||
placeholder="Masukkan nama periode akademik"
|
||||
/>
|
||||
<InputError message={errors.name} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>
|
||||
Semester <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<RadioGroup
|
||||
name="semester"
|
||||
defaultValue=""
|
||||
className="flex flex-row gap-4"
|
||||
>
|
||||
{Object.values(Semester).map((value) => (
|
||||
<div key={value} className="flex items-center gap-2">
|
||||
<RadioGroupItem value={value} id={`create-${value}`} />
|
||||
<Label htmlFor={`create-${value}`} className="font-normal">
|
||||
{SemesterLabels[value]}
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</RadioGroup>
|
||||
<InputError message={errors.semester} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label>
|
||||
Tanggal Mulai <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<input type="hidden" name="start_date" value={startDate ? startDate.toISOString().split('T')[0] : ''} />
|
||||
<DatePicker
|
||||
value={startDate}
|
||||
onChange={setStartDate}
|
||||
placeholder="Pilih tanggal mulai"
|
||||
/>
|
||||
<InputError message={errors.start_date} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Tanggal Selesai <span className="text-destructive">*</span></Label>
|
||||
<input type="hidden" name="end_date" value={endDate ? endDate.toISOString().split('T')[0] : ''} />
|
||||
<DatePicker
|
||||
value={endDate}
|
||||
onChange={setEndDate}
|
||||
placeholder="Pilih tanggal selesai"
|
||||
/>
|
||||
<InputError message={errors.end_date} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input type="hidden" name="is_active" value="0" />
|
||||
<Checkbox id="is_active" name="is_active" value="1" />
|
||||
<Label htmlFor="is_active">Aktif</Label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</FormDialog>
|
||||
);
|
||||
}
|
||||
|
||||
function EditForm({
|
||||
open,
|
||||
onOpenChange,
|
||||
editing,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
editing: AcademicTerm | null;
|
||||
}) {
|
||||
const [startDate, setStartDate] = useState<Date | undefined>(
|
||||
editing?.start_date ? new Date(editing.start_date) : undefined,
|
||||
);
|
||||
const [endDate, setEndDate] = useState<Date | undefined>(
|
||||
editing?.end_date ? new Date(editing.end_date) : undefined,
|
||||
);
|
||||
|
||||
return (
|
||||
<FormDialog
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title="Edit Periode Akademik"
|
||||
action={editing ? update(editing.id) : ''}
|
||||
resetOnSuccess
|
||||
onSuccess={() => {
|
||||
onOpenChange(false);
|
||||
setStartDate(undefined);
|
||||
setEndDate(undefined);
|
||||
}}
|
||||
>
|
||||
{({ errors }) =>
|
||||
editing && (
|
||||
<div className="grid gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="edit-name">
|
||||
Nama{' '}
|
||||
<span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="edit-name"
|
||||
name="name"
|
||||
placeholder="Masukkan nama periode akademik"
|
||||
defaultValue={editing.name}
|
||||
/>
|
||||
<InputError message={errors.name} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>
|
||||
Semester <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<RadioGroup
|
||||
name="semester"
|
||||
defaultValue={editing.semester}
|
||||
className="flex flex-row gap-4"
|
||||
>
|
||||
{Object.values(Semester).map((value) => (
|
||||
<div key={value} className="flex items-center gap-2">
|
||||
<RadioGroupItem value={value} id={`edit-${value}`} />
|
||||
<Label htmlFor={`edit-${value}`} className="font-normal">
|
||||
{SemesterLabels[value]}
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</RadioGroup>
|
||||
<InputError message={errors.semester} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label>Tanggal Mulai <span className="text-destructive">*</span></Label>
|
||||
<input type="hidden" name="start_date" value={startDate ? startDate.toISOString().split('T')[0] : ''} />
|
||||
<DatePicker
|
||||
value={startDate}
|
||||
onChange={setStartDate}
|
||||
placeholder="Pilih tanggal mulai"
|
||||
/>
|
||||
<InputError message={errors.start_date} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Tanggal Selesai <span className="text-destructive">*</span></Label>
|
||||
<input type="hidden" name="end_date" value={endDate ? endDate.toISOString().split('T')[0] : ''} />
|
||||
<DatePicker
|
||||
value={endDate}
|
||||
onChange={setEndDate}
|
||||
placeholder="Pilih tanggal selesai"
|
||||
/>
|
||||
<InputError message={errors.end_date} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input type="hidden" name="is_active" value="0" />
|
||||
<Checkbox
|
||||
id="edit-is_active"
|
||||
name="is_active"
|
||||
value="1"
|
||||
defaultChecked={editing.is_active}
|
||||
/>
|
||||
<Label htmlFor="edit-is_active">Aktif</Label>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</FormDialog>
|
||||
);
|
||||
}
|
||||
@ -1,21 +1,36 @@
|
||||
import { AppSidebar } from "@/components/app-sidebar";
|
||||
import { ChartAreaInteractive } from "@/components/chart-area-interactive";
|
||||
import { DataTable } from "@/components/data-table";
|
||||
import { SectionCards } from "@/components/section-cards";
|
||||
|
||||
import data from "./data/data.json";
|
||||
import { SiteHeader } from "@/components/site-header";
|
||||
import {
|
||||
SidebarInset,
|
||||
SidebarProvider,
|
||||
} from "@/components/ui/sidebar";
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col">
|
||||
<div className="@container/main flex flex-1 flex-col gap-2">
|
||||
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6">
|
||||
<SectionCards />
|
||||
<div className="px-4 lg:px-6">
|
||||
<ChartAreaInteractive />
|
||||
<SidebarProvider
|
||||
style={
|
||||
{
|
||||
"--sidebar-width": "calc(var(--spacing) * 72)",
|
||||
"--header-height": "calc(var(--spacing) * 12)",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<AppSidebar variant="inset" />
|
||||
<SidebarInset>
|
||||
<SiteHeader />
|
||||
<div className="flex flex-1 flex-col">
|
||||
<div className="@container/main flex flex-1 flex-col gap-2">
|
||||
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6">
|
||||
<SectionCards />
|
||||
<div className="px-4 lg:px-6">
|
||||
<ChartAreaInteractive />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DataTable data={data} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
)
|
||||
}
|
||||
|
||||
22
resources/js/types/academic-term.ts
Normal file
22
resources/js/types/academic-term.ts
Normal file
@ -0,0 +1,22 @@
|
||||
export enum Semester {
|
||||
Odd = 'odd',
|
||||
Even = 'even',
|
||||
}
|
||||
|
||||
export const SemesterLabels: Record<Semester, string> = {
|
||||
[Semester.Odd]: 'Ganjil',
|
||||
[Semester.Even]: 'Genap',
|
||||
};
|
||||
|
||||
export type AcademicTerm = {
|
||||
id: number;
|
||||
name: string;
|
||||
semester: Semester;
|
||||
start_date: string;
|
||||
formatted_start_date: string;
|
||||
end_date: string;
|
||||
formatted_end_date: string;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
@ -1,3 +1,4 @@
|
||||
export type * from './academic-term';
|
||||
export type * from './auth';
|
||||
export type * from './navigation';
|
||||
export type * from './ui';
|
||||
|
||||
8
routes/admin.php
Normal file
8
routes/admin.php
Normal file
@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\Admin\Master\AcademicTermController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::middleware(['auth', 'verified'])->prefix('admin/master')->name('admin.master.')->group(function () {
|
||||
Route::resource('academic-terms', AcademicTermController::class)->except(['create', 'edit', 'show']);
|
||||
});
|
||||
@ -9,3 +9,4 @@
|
||||
});
|
||||
|
||||
require __DIR__.'/settings.php';
|
||||
require __DIR__.'/admin.php';
|
||||
|
||||
Loading…
Reference in New Issue
Block a user