feat: implement academic term management with CRUD functionality
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:
Yoga Pangestu 2026-08-05 13:48:36 +07:00
parent 75178c12a8
commit 400e8d8c8a
19 changed files with 985 additions and 112 deletions

17
app/Enums/Semester.php Normal file
View 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',
};
}
}

View 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');
}
}

View 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'],
];
}
}

View 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',
];
}
}

View 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'),
);
}
}

View 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();
}
}

View File

@ -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');
}
};

View 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(),
],
]);
}
}

View File

@ -13,6 +13,7 @@ public function run(): void
{
$this->call([
UserSeeder::class,
AcademicTermSeeder::class,
]);
}
}

View File

@ -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,
},
],
},

View 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>
);
}

View File

@ -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>
)
})}
</>
)
}

View 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),
},
]}
/>
);
},
},
];
}

View 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>
);
}

View File

@ -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>
)
}

View 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;
};

View File

@ -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
View 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']);
});

View File

@ -9,3 +9,4 @@
});
require __DIR__.'/settings.php';
require __DIR__.'/admin.php';