diff --git a/app/Enums/Semester.php b/app/Enums/Semester.php new file mode 100644 index 0000000..9111701 --- /dev/null +++ b/app/Enums/Semester.php @@ -0,0 +1,17 @@ + 'Ganjil', + self::even => 'Genap', + }; + } +} diff --git a/app/Http/Controllers/Admin/Master/AcademicTermController.php b/app/Http/Controllers/Admin/Master/AcademicTermController.php new file mode 100644 index 0000000..49926f9 --- /dev/null +++ b/app/Http/Controllers/Admin/Master/AcademicTermController.php @@ -0,0 +1,53 @@ + $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'); + } +} diff --git a/app/Http/Requests/Admin/Master/AcademicTermRequest.php b/app/Http/Requests/Admin/Master/AcademicTermRequest.php new file mode 100644 index 0000000..1fd0e14 --- /dev/null +++ b/app/Http/Requests/Admin/Master/AcademicTermRequest.php @@ -0,0 +1,84 @@ + [ + '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'], + ]; + } +} diff --git a/app/Http/Requests/PaginatedRequest.php b/app/Http/Requests/PaginatedRequest.php new file mode 100644 index 0000000..73fb021 --- /dev/null +++ b/app/Http/Requests/PaginatedRequest.php @@ -0,0 +1,36 @@ + ['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', + ]; + } +} diff --git a/app/Models/AcademicTerm.php b/app/Models/AcademicTerm.php new file mode 100644 index 0000000..b4dc745 --- /dev/null +++ b/app/Models/AcademicTerm.php @@ -0,0 +1,41 @@ + 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'), + ); + } +} diff --git a/app/Services/Admin/Master/AcademicTermService.php b/app/Services/Admin/Master/AcademicTermService.php new file mode 100644 index 0000000..36997c2 --- /dev/null +++ b/app/Services/Admin/Master/AcademicTermService.php @@ -0,0 +1,46 @@ +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(); + } +} diff --git a/database/migrations/2026_08_03_000003_create_academic_terms_table.php b/database/migrations/2026_08_03_000003_create_academic_terms_table.php new file mode 100644 index 0000000..b1d6f2a --- /dev/null +++ b/database/migrations/2026_08_03_000003_create_academic_terms_table.php @@ -0,0 +1,27 @@ +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'); + } +}; diff --git a/database/seeders/AcademicTermSeeder.php b/database/seeders/AcademicTermSeeder.php new file mode 100644 index 0000000..63cdee2 --- /dev/null +++ b/database/seeders/AcademicTermSeeder.php @@ -0,0 +1,34 @@ + '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(), + ], + ]); + } +} diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index d71892e..37c3e5b 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -13,6 +13,7 @@ public function run(): void { $this->call([ UserSeeder::class, + AcademicTermSeeder::class, ]); } } diff --git a/resources/js/components/app-sidebar.tsx b/resources/js/components/app-sidebar.tsx index 4551cc8..ab1d120 100644 --- a/resources/js/components/app-sidebar.tsx +++ b/resources/js/components/app-sidebar.tsx @@ -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, }, ], }, diff --git a/resources/js/components/confirm-dialog.tsx b/resources/js/components/confirm-dialog.tsx new file mode 100644 index 0000000..f52a26e --- /dev/null +++ b/resources/js/components/confirm-dialog.tsx @@ -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 ( + + + + {title} + {description} + + + + + + + + ); +} diff --git a/resources/js/components/nav-main.tsx b/resources/js/components/nav-main.tsx index bac757a..e0759b7 100644 --- a/resources/js/components/nav-main.tsx +++ b/resources/js/components/nav-main.tsx @@ -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 ( - - - - {items.map((item) => ( - - - {item.icon && } - {item.title} - - - ))} - - - + <> + {items.map((item, index) => { + if ("label" in item) { + return ( + + {item.label} + + {item.items.map((child) => ( + + + + + {child.name} + + + + ))} + + + ) + } + + return ( + + + + + + + {item.name} + + + + + + ) + })} + ) } diff --git a/resources/js/pages/admin/master/academic-terms/columns.tsx b/resources/js/pages/admin/master/academic-terms/columns.tsx new file mode 100644 index 0000000..a801476 --- /dev/null +++ b/resources/js/pages/admin/master/academic-terms/columns.tsx @@ -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[] { + const { handleEdit, handleDeleteClick } = params; + + return [ + { + accessorKey: 'name', + header: () => Nama, + cell: ({ row }) => ( + + {row.getValue('name') as string} + + ), + }, + { + accessorKey: 'semester', + header: () => Semester, + cell: ({ row }) => { + const semester = row.getValue('semester') as Semester; + return {SemesterLabels[semester]}; + }, + }, + { + accessorKey: 'formatted_start_date', + header: () => Tanggal Mulai, + cell: ({ row }) => ( + {row.getValue('formatted_start_date') as string | null} + ), + }, + { + accessorKey: 'formatted_end_date', + header: () => Tanggal Selesai, + cell: ({ row }) => ( + {row.getValue('formatted_end_date') as string | null} + ), + }, + { + accessorKey: 'is_active', + header: () => Status, + meta: { + className: 'w-[100px] text-center', + headerClassName: 'w-[100px] text-center', + }, + cell: ({ row }) => { + const isActive = row.getValue('is_active') as boolean; + return ( +
+ + {isActive ? 'Aktif' : 'Nonaktif'} + +
+ ); + }, + }, + { + id: 'actions', + header: () => Aksi, + meta: { + className: 'w-[100px] text-center', + headerClassName: 'w-[100px] text-center', + }, + cell: ({ row }) => { + const academicTerm = row.original; + + return ( + , + onClick: () => handleEdit(academicTerm), + }, + { + label: 'Hapus', + icon: ( + + ), + onClick: () => handleDeleteClick(academicTerm), + }, + ]} + /> + ); + }, + }, + ]; +} diff --git a/resources/js/pages/admin/master/academic-terms/index.tsx b/resources/js/pages/admin/master/academic-terms/index.tsx new file mode 100644 index 0000000..d83dd9f --- /dev/null +++ b/resources/js/pages/admin/master/academic-terms/index.tsx @@ -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(null); + const [deleting, setDeleting] = useState(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 ( + <> + + +
+ + Menampilkan periode akademik dari notifikasi. + +

+ ) + } + actions={ + + + } + /> + + + + { + if (!open) { + setEditing(null); + } + }} + editing={editing} + /> + + + + { + 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} + /> +
+ + ); +} + +function CreateForm({ + open, + onOpenChange, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + const [startDate, setStartDate] = useState(); + const [endDate, setEndDate] = useState(); + + return ( + { + onOpenChange(false); + setStartDate(undefined); + setEndDate(undefined); + }} + > + {({ errors }) => ( +
+
+ + + +
+
+ + + {Object.values(Semester).map((value) => ( +
+ + +
+ ))} +
+ +
+
+
+ + + + +
+
+ + + + +
+
+
+ + + +
+
+ )} +
+ ); +} + +function EditForm({ + open, + onOpenChange, + editing, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + editing: AcademicTerm | null; +}) { + const [startDate, setStartDate] = useState( + editing?.start_date ? new Date(editing.start_date) : undefined, + ); + const [endDate, setEndDate] = useState( + editing?.end_date ? new Date(editing.end_date) : undefined, + ); + + return ( + { + onOpenChange(false); + setStartDate(undefined); + setEndDate(undefined); + }} + > + {({ errors }) => + editing && ( +
+
+ + + +
+
+ + + {Object.values(Semester).map((value) => ( +
+ + +
+ ))} +
+ +
+
+
+ + + + +
+
+ + + + +
+
+
+ + + +
+
+ ) + } +
+ ); +} diff --git a/resources/js/pages/dashboard.tsx b/resources/js/pages/dashboard.tsx index c43031f..f973a20 100644 --- a/resources/js/pages/dashboard.tsx +++ b/resources/js/pages/dashboard.tsx @@ -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 ( -
-
-
- -
- + + + + +
+
+
+ +
+ +
+
-
-
-
+ + ) } diff --git a/resources/js/types/academic-term.ts b/resources/js/types/academic-term.ts new file mode 100644 index 0000000..741115e --- /dev/null +++ b/resources/js/types/academic-term.ts @@ -0,0 +1,22 @@ +export enum Semester { + Odd = 'odd', + Even = 'even', +} + +export const SemesterLabels: Record = { + [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; +}; diff --git a/resources/js/types/index.ts b/resources/js/types/index.ts index b1f0ee6..dbb91da 100644 --- a/resources/js/types/index.ts +++ b/resources/js/types/index.ts @@ -1,3 +1,4 @@ +export type * from './academic-term'; export type * from './auth'; export type * from './navigation'; export type * from './ui'; diff --git a/routes/admin.php b/routes/admin.php new file mode 100644 index 0000000..64d6406 --- /dev/null +++ b/routes/admin.php @@ -0,0 +1,8 @@ +prefix('admin/master')->name('admin.master.')->group(function () { + Route::resource('academic-terms', AcademicTermController::class)->except(['create', 'edit', 'show']); +}); diff --git a/routes/web.php b/routes/web.php index 2085b21..04a46f3 100644 --- a/routes/web.php +++ b/routes/web.php @@ -9,3 +9,4 @@ }); require __DIR__.'/settings.php'; +require __DIR__.'/admin.php';