feat: implement material management functionality with CRUD operations

This commit is contained in:
Yoga Pangestu 2026-08-21 20:28:03 +07:00
parent ff4173bb36
commit 7c50a493c3
16 changed files with 1100 additions and 0 deletions

View File

@ -0,0 +1,56 @@
<?php
namespace App\Http\Controllers\Admin\Manage;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\Manage\MaterialRequest;
use App\Http\Requests\PaginatedRequest;
use App\Models\Material;
use App\Services\Admin\Manage\CourseClassService;
use App\Services\Admin\Manage\MaterialService;
use Illuminate\Http\RedirectResponse;
use Inertia\Inertia;
use Inertia\Response;
class MaterialController extends Controller
{
public function __construct(
private readonly MaterialService $service,
private readonly CourseClassService $courseClassService,
) {}
public function index(PaginatedRequest $request): Response
{
return Inertia::render('admin/manage/materials/index', [
'materials' => $this->service->paginated(...$request->validatedWithDefaults()),
'courseClasses' => $this->courseClassService->getAllForSelect(),
]);
}
public function store(MaterialRequest $request): RedirectResponse
{
$this->service->create($request->validated(), $request->file('file'));
Inertia::flash('toast', ['type' => 'success', 'message' => 'Materi berhasil ditambahkan.']);
return to_route('admin.manage.materials.index');
}
public function update(MaterialRequest $request, Material $material): RedirectResponse
{
$this->service->update($material, $request->validated(), $request->file('file'));
Inertia::flash('toast', ['type' => 'success', 'message' => 'Materi berhasil diperbarui.']);
return to_route('admin.manage.materials.index');
}
public function destroy(Material $material): RedirectResponse
{
$this->service->delete($material);
Inertia::flash('toast', ['type' => 'success', 'message' => 'Materi berhasil dihapus.']);
return back();
}
}

View File

@ -0,0 +1,30 @@
<?php
namespace App\Http\Requests\Admin\Manage;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class MaterialRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'course_class_id' => ['required', 'integer', Rule::exists('course_classes', 'id')],
'title' => ['required', 'string', 'max:150'],
'description' => ['nullable', 'string'],
'meeting_number' => ['nullable', 'integer', 'min:1'],
'file' => [
'nullable',
'file',
'max:10240',
'mimes:pdf,doc,docx,ppt,pptx,xls,xlsx,jpg,jpeg,png,mp4,zip',
],
];
}
}

View File

@ -41,4 +41,9 @@ public function enrollments(): HasMany
{
return $this->hasMany(ClassEnrollment::class);
}
public function materials(): HasMany
{
return $this->hasMany(Material::class);
}
}

44
app/Models/Material.php Normal file
View File

@ -0,0 +1,44 @@
<?php
namespace App\Models;
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;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
use Spatie\MediaLibrary\HasMedia;
use Spatie\MediaLibrary\InteractsWithMedia;
#[Guarded(['id'])]
#[Appends(['file_url', 'file_name'])]
class Material extends Model implements HasMedia
{
use HasFactory, InteractsWithMedia, SoftDeletes;
public function registerMediaCollections(): void
{
$this->addMediaCollection('materials')->singleFile();
}
public function courseClass(): BelongsTo
{
return $this->belongsTo(CourseClass::class);
}
protected function fileUrl(): Attribute
{
return Attribute::make(
get: fn () => $this->getFirstMediaUrl('materials') ?: null,
);
}
protected function fileName(): Attribute
{
return Attribute::make(
get: fn () => $this->getFirstMedia('materials')?->file_name,
);
}
}

View File

@ -4,9 +4,17 @@
use App\Models\CourseClass;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Collection;
class CourseClassService
{
public function getAllForSelect(): Collection
{
return CourseClass::select(['id', 'course_id', 'class_name'])
->with('course:id,code,name')
->get();
}
public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', array $filters = []): LengthAwarePaginator
{
return CourseClass::query()

View File

@ -0,0 +1,56 @@
<?php
namespace App\Services\Admin\Manage;
use App\Models\Material;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Http\UploadedFile;
class MaterialService
{
public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', array $filters = []): LengthAwarePaginator
{
return Material::query()
->select(['id', 'course_class_id', 'title', 'description', 'meeting_number'])
->with('courseClass.course:id,code,name')
->when($search, fn ($q) => $q->where('title', 'like', "%{$search}%"))
->orderBy($sort, $direction)
->paginate($perPage);
}
public function create(array $data, ?UploadedFile $file): Material
{
$material = Material::create([
'course_class_id' => $data['course_class_id'],
'title' => $data['title'],
'description' => $data['description'] ?? null,
'meeting_number' => $data['meeting_number'] ?? null,
]);
if ($file) {
$material->addMedia($file)->toMediaCollection('materials');
}
return $material;
}
public function update(Material $material, array $data, ?UploadedFile $file): Material
{
$material->course_class_id = $data['course_class_id'];
$material->title = $data['title'];
$material->description = $data['description'] ?? null;
$material->meeting_number = $data['meeting_number'] ?? null;
$material->update();
if ($file) {
$material->addMedia($file)->toMediaCollection('materials');
}
return $material;
}
public function delete(Material $material): bool
{
return $material->delete();
}
}

View File

@ -0,0 +1,26 @@
<?php
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('materials', function (Blueprint $table) {
$table->id();
$table->foreignId('course_class_id')->constrained()->cascadeOnDelete();
$table->string('title', 150);
$table->text('description')->nullable();
$table->integer('meeting_number')->nullable();
$table->timestamps();
$table->softDeletes();
});
}
public function down(): void
{
Schema::dropIfExists('materials');
}
};

View File

@ -20,6 +20,7 @@ public function run(): void
CourseSeeder::class,
CourseClassSeeder::class,
ClassEnrollmentSeeder::class,
MaterialSeeder::class,
]);
}
}

View File

@ -0,0 +1,31 @@
<?php
namespace Database\Seeders;
use App\Models\Material;
use Illuminate\Database\Seeder;
class MaterialSeeder extends Seeder
{
public function run(): void
{
Material::insert([
[
'course_class_id' => 1,
'title' => 'Pengantar HTML & CSS',
'description' => 'Materi dasar struktur halaman web',
'meeting_number' => 1,
'created_at' => '2026-08-01 09:00:00',
'updated_at' => '2026-08-01 09:00:00',
],
[
'course_class_id' => 2,
'title' => 'Konsep Six Sigma',
'description' => 'Pengantar metode pengendalian kualitas',
'meeting_number' => 1,
'created_at' => '2026-08-01 10:00:00',
'updated_at' => '2026-08-01 10:00:00',
],
]);
}
}

View File

@ -21,6 +21,7 @@ import {
} from '@/components/ui/sidebar';
import { index as courseClassesRoute } from '@/routes/admin/manage/course-classes';
import { index as coursesRoute } from '@/routes/admin/manage/courses';
import { index as materialsRoute } from '@/routes/admin/manage/materials';
import { index as academicTerm } from '@/routes/admin/master/academic-terms';
import { index as departmentsRoute } from '@/routes/admin/master/departments';
import { index as lecturersRoute } from '@/routes/admin/users/lecturers';
@ -30,6 +31,7 @@ import {
BookOpen,
Building2,
Calendar,
FileText,
GraduationCap,
School,
User,
@ -76,6 +78,16 @@ const data: {
},
],
},
{
label: 'Kelas',
items: [
{
name: 'Materi',
url: materialsRoute.url(),
icon: FileText,
},
],
},
{
label: 'Pengguna',
items: [

View File

@ -0,0 +1,115 @@
import { FileIcon, RotateCcw, UploadIcon, XIcon } from 'lucide-react';
import { useRef, useState } from 'react';
import InputError from '@/components/input-error';
import {
Attachment,
AttachmentAction,
AttachmentActions,
AttachmentContent,
AttachmentDescription,
AttachmentMedia,
AttachmentTitle,
AttachmentTrigger,
} from '@/components/ui/attachment';
import { Label } from '@/components/ui/label';
type FileUploadFieldProps = {
label?: string;
/** Form field name for the native file input. */
name?: string;
existingFileName?: string | null;
existingFileUrl?: string | null;
error?: string;
accept?: string;
helpText?: string;
};
export function FileUploadField({
label = 'File',
name = 'file',
existingFileName,
existingFileUrl,
error,
accept,
helpText = 'PDF, Word, PPT, Excel, gambar, video, atau zip (maks 10MB)',
}: FileUploadFieldProps) {
const inputRef = useRef<HTMLInputElement>(null);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
setSelectedFile(e.target.files?.[0] ?? null);
}
function handleReset() {
setSelectedFile(null);
if (inputRef.current) {
inputRef.current.value = '';
}
}
const hasNewFile = selectedFile !== null;
const hasExistingFile = !hasNewFile && !!existingFileName;
const displayName = selectedFile?.name ?? existingFileName ?? null;
const displayUrl = hasNewFile ? null : existingFileUrl;
const state = error ? 'error' : displayName ? 'done' : 'idle';
return (
<div className="grid gap-2">
<Label>{label}</Label>
<input
ref={inputRef}
type="file"
name={name}
accept={accept}
onChange={handleFileChange}
className="hidden"
/>
<Attachment state={state}>
<AttachmentTrigger onClick={() => inputRef.current?.click()} />
<AttachmentMedia>
<FileIcon />
</AttachmentMedia>
<AttachmentContent>
<AttachmentTitle>
{displayName ?? 'Klik untuk pilih file'}
</AttachmentTitle>
<AttachmentDescription>
{displayName
? hasExistingFile
? 'File saat ini — klik untuk mengganti'
: 'Klik untuk mengganti file'
: helpText}
</AttachmentDescription>
</AttachmentContent>
<AttachmentActions>
{displayUrl && (
<AttachmentAction asChild>
<a
href={displayUrl}
target="_blank"
rel="noreferrer"
onClick={(e) => e.stopPropagation()}
>
<UploadIcon className="rotate-180" />
</a>
</AttachmentAction>
)}
{hasNewFile && (
<AttachmentAction
onClick={(e) => {
e.stopPropagation();
handleReset();
}}
>
{hasExistingFile ? <RotateCcw /> : <XIcon />}
</AttachmentAction>
)}
</AttachmentActions>
</Attachment>
<InputError message={error} />
</div>
);
}

View File

@ -0,0 +1,204 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
const attachmentVariants = cva(
"group/attachment relative flex w-fit max-w-full min-w-0 shrink-0 flex-wrap rounded-xl border bg-card text-card-foreground transition-colors focus-within:ring-1 focus-within:ring-ring/50 has-[>a,>button]:hover:bg-muted/50 data-[state=error]:border-destructive/30 data-[state=idle]:border-dashed",
{
variants: {
size: {
default:
"gap-2 text-sm has-data-[slot=attachment-content]:px-2.5 has-data-[slot=attachment-content]:py-2 has-data-[slot=attachment-media]:p-2",
sm: "gap-2.5 text-xs has-data-[slot=attachment-content]:px-2 has-data-[slot=attachment-content]:py-1.5 has-data-[slot=attachment-media]:p-1.5",
xs: "gap-1.5 rounded-lg text-xs has-data-[slot=attachment-content]:px-1.5 has-data-[slot=attachment-content]:py-1 has-data-[slot=attachment-media]:p-1",
},
orientation: {
horizontal: "min-w-40 items-center",
vertical: "w-24 flex-col has-data-[slot=attachment-content]:w-30",
},
},
}
)
function Attachment({
className,
state = "done",
size = "default",
orientation = "horizontal",
...props
}: React.ComponentProps<"div"> &
VariantProps<typeof attachmentVariants> & {
state?: "idle" | "uploading" | "processing" | "error" | "done"
}) {
return (
<div
data-slot="attachment"
data-state={state}
data-size={size}
data-orientation={orientation}
className={cn(attachmentVariants({ size, orientation }), className)}
{...props}
/>
)
}
const attachmentMediaVariants = cva(
"relative flex aspect-square w-10 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-muted text-foreground group-data-[orientation=vertical]/attachment:w-full group-data-[size=sm]/attachment:w-8 group-data-[size=xs]/attachment:w-7 group-data-[size=xs]/attachment:rounded-md group-data-[state=error]/attachment:bg-destructive/10 group-data-[state=error]/attachment:text-destructive group-data-[orientation=vertical]/attachment:*:data-[slot=spinner]:size-6! [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 group-data-[orientation=vertical]/attachment:[&_svg:not([class*='size-'])]:size-6 group-data-[size=xs]/attachment:[&_svg:not([class*='size-'])]:size-3.5",
{
variants: {
variant: {
icon: "",
image:
"opacity-60 group-data-[state=done]/attachment:opacity-100 group-data-[state=idle]/attachment:opacity-100 *:[img]:aspect-square *:[img]:w-full *:[img]:object-cover",
},
},
defaultVariants: {
variant: "icon",
},
}
)
function AttachmentMedia({
className,
variant = "icon",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof attachmentMediaVariants>) {
return (
<div
data-slot="attachment-media"
data-variant={variant}
className={cn(attachmentMediaVariants({ variant }), className)}
{...props}
/>
)
}
function AttachmentContent({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="attachment-content"
className={cn(
"max-w-full min-w-0 flex-1 leading-tight group-data-[orientation=vertical]/attachment:px-1",
className
)}
{...props}
/>
)
}
function AttachmentTitle({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="attachment-title"
className={cn(
"block max-w-full min-w-0 truncate font-medium group-data-[state=processing]/attachment:shimmer group-data-[state=uploading]/attachment:shimmer",
className
)}
{...props}
/>
)
}
function AttachmentDescription({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="attachment-description"
className={cn(
"mt-0.5 block min-w-0 truncate text-xs text-muted-foreground group-data-[state=error]/attachment:text-destructive/80",
"max-w-full",
className
)}
{...props}
/>
)
}
function AttachmentActions({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="attachment-actions"
className={cn(
"relative z-20 flex shrink-0 items-center group-data-[orientation=vertical]/attachment:absolute group-data-[orientation=vertical]/attachment:top-3 group-data-[orientation=vertical]/attachment:right-3 group-data-[orientation=vertical]/attachment:gap-1",
className
)}
{...props}
/>
)
}
function AttachmentAction({
className,
variant,
size = "icon-xs",
...props
}: React.ComponentProps<typeof Button>) {
return (
<Button
data-slot="attachment-action"
variant={variant ?? "ghost"}
size={size}
className={cn(className)}
{...props}
/>
)
}
function AttachmentTrigger({
className,
asChild = false,
type,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="attachment-trigger"
type={asChild ? undefined : (type ?? "button")}
className={cn("absolute inset-0 z-10 outline-none", className)}
{...props}
/>
)
}
function AttachmentGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="attachment-group"
className={cn(
"flex min-w-0 scroll-fade-x snap-x snap-mandatory scroll-px-1 scrollbar-none gap-3 overflow-x-auto overscroll-x-contain py-1 *:data-[slot=attachment]:flex-none *:data-[slot=attachment]:snap-start",
className
)}
{...props}
/>
)
}
export {
Attachment,
AttachmentGroup,
AttachmentMedia,
AttachmentContent,
AttachmentTitle,
AttachmentDescription,
AttachmentActions,
AttachmentAction,
AttachmentTrigger,
}

View File

@ -0,0 +1,122 @@
import type { ColumnDef } from '@tanstack/react-table';
import { Paperclip, Pencil, Trash2 } from 'lucide-react';
import { RowActions } from '@/components/row-actions';
import { Badge } from '@/components/ui/badge';
import type { Material } from '@/types/material';
export type { Material } from '@/types/material';
type CreateColumnsParams = {
handleEdit: (material: Material) => void;
handleDeleteClick: (material: Material) => void;
};
export function createMaterialColumns(
params: CreateColumnsParams,
): ColumnDef<Material>[] {
const { handleEdit, handleDeleteClick } = params;
return [
{
accessorKey: 'meeting_number',
header: () => <span className="block text-center">Pertemuan</span>,
meta: {
className: 'w-[100px] text-center',
headerClassName: 'w-[100px] text-center',
},
cell: ({ row }) => {
const meetingNumber = row.getValue('meeting_number') as
number | null;
return (
<div className="flex justify-center">
{meetingNumber ? (
<Badge variant="secondary">{meetingNumber}</Badge>
) : (
<span className="text-muted-foreground">-</span>
)}
</div>
);
},
},
{
accessorKey: 'title',
header: () => <span>Judul</span>,
cell: ({ row }) => (
<span className="font-medium">
{row.getValue('title') as string}
</span>
),
},
{
accessorKey: 'course_class.class_name',
header: () => <span>Kelas</span>,
cell: ({ row }) => {
const courseClass = row.original.course_class;
if (!courseClass) {
return '-';
}
const label = courseClass.class_name
? `${courseClass.class_name} - `
: '';
return `${label}${courseClass.course?.code ?? ''} ${courseClass.course?.name ?? ''}`;
},
},
{
accessorKey: 'file_name',
header: () => <span>File</span>,
cell: ({ row }) => {
const material = row.original;
if (!material.file_url) {
return <span className="text-muted-foreground">-</span>;
}
return (
<a
href={material.file_url}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 text-primary underline underline-offset-4 hover:text-primary/80"
>
<Paperclip className="h-3.5 w-3.5" />
{material.file_name}
</a>
);
},
},
{
id: 'actions',
header: () => <span className="block text-center">Aksi</span>,
meta: {
className: 'w-[100px] text-center',
headerClassName: 'w-[100px] text-center',
},
cell: ({ row }) => {
const material = row.original;
return (
<RowActions
actions={[
{
label: 'Edit',
icon: <Pencil className="h-4 w-4" />,
onClick: () => handleEdit(material),
},
{
label: 'Hapus',
icon: (
<Trash2 className="h-4 w-4 text-destructive" />
),
onClick: () => handleDeleteClick(material),
},
]}
/>
);
},
},
];
}

View File

@ -0,0 +1,371 @@
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 { DeleteConfirmDialog } from '@/components/delete-confirm-dialog';
import { FileUploadField } from '@/components/file-upload-field';
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 { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import { useServerTable } from '@/hooks/use-server-table';
import {
index as materialIndex,
destroy,
store,
update,
} from '@/routes/admin/manage/materials';
import type { Material } from '@/types/material';
import { createMaterialColumns } from './columns';
type CourseClassOption = {
id: number;
class_name: string | null;
course: { id: number; code: string; name: string } | null;
};
type Props = {
materials: {
data: Material[];
current_page: number;
last_page: number;
per_page: number;
total: number;
};
courseClasses: CourseClassOption[];
highlight?: number;
};
function courseClassLabel(courseClass: CourseClassOption): string {
const namePart = courseClass.class_name
? `${courseClass.class_name} - `
: '';
return `${namePart}${courseClass.course?.code ?? ''} ${courseClass.course?.name ?? ''}`;
}
export default function MaterialIndex({
materials,
courseClasses,
highlight,
}: Props) {
const [createOpen, setCreateOpen] = useState(false);
const [editing, setEditing] = useState<Material | null>(null);
const [deleting, setDeleting] = useState<Material | null>(null);
const pagination: PaginationState = {
current_page: materials.current_page,
last_page: materials.last_page,
per_page: materials.per_page,
total: materials.total,
};
const {
search,
handlePageChange,
handlePerPageChange,
handleSearchChange,
} = useServerTable({
route: () => materialIndex.url(),
pagination,
});
function handleDelete() {
if (!deleting) {
return;
}
router.delete(destroy(deleting.id), {
onSuccess: () => setDeleting(null),
});
}
const columns = createMaterialColumns({
handleEdit: (material) => setEditing(material),
handleDeleteClick: (material) => setDeleting(material),
});
return (
<>
<Head title="Materi" />
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
<PageHeader
title="Materi"
description={
highlight && (
<p className="mt-1 text-sm text-muted-foreground">
Menampilkan materi dari notifikasi.
<button
onClick={() => {
router.get(
materialIndex.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}
courseClasses={courseClasses}
/>
<EditForm
key={editing?.id}
open={editing !== null}
onOpenChange={(open) => {
if (!open) {
setEditing(null);
}
}}
editing={editing}
courseClasses={courseClasses}
/>
<DataTable
columns={columns}
data={materials.data}
searchKey="title"
searchPlaceholder="Cari materi..."
emptyText="Belum ada data materi."
pagination={pagination}
onPageChange={handlePageChange}
onPerPageChange={handlePerPageChange}
onSearchChange={handleSearchChange}
searchValue={search}
/>
<DeleteConfirmDialog
target={deleting}
onOpenChange={(open) => {
if (!open) {
setDeleting(null);
}
}}
title="Hapus Materi"
description={(material) =>
`Apakah Anda yakin ingin menghapus materi "${material.title}"? Tindakan ini tidak dapat dibatalkan.`
}
onConfirm={handleDelete}
/>
</div>
</>
);
}
function CreateForm({
open,
onOpenChange,
courseClasses,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
courseClasses: CourseClassOption[];
}) {
return (
<FormDialog
open={open}
onOpenChange={onOpenChange}
title="Tambah Materi"
action={store()}
resetOnSuccess
onSuccess={() => onOpenChange(false)}
>
{({ errors }) => (
<div className="grid gap-4">
<div className="grid gap-2">
<Label>
Kelas <span className="text-destructive">*</span>
</Label>
<input type="hidden" name="course_class_id" />
<Select name="course_class_id">
<SelectTrigger className="w-full">
<SelectValue placeholder="Pilih kelas" />
</SelectTrigger>
<SelectContent>
{courseClasses.map((courseClass) => (
<SelectItem
key={courseClass.id}
value={String(courseClass.id)}
>
{courseClassLabel(courseClass)}
</SelectItem>
))}
</SelectContent>
</Select>
<InputError message={errors.course_class_id} />
</div>
<div className="grid gap-2">
<Label htmlFor="title">
Judul <span className="text-destructive">*</span>
</Label>
<Input
id="title"
name="title"
placeholder="Masukkan judul materi"
/>
<InputError message={errors.title} />
</div>
<div className="grid gap-2">
<Label htmlFor="description">Deskripsi</Label>
<Textarea
id="description"
name="description"
placeholder="Masukkan deskripsi materi"
/>
<InputError message={errors.description} />
</div>
<div className="grid gap-2">
<Label htmlFor="meeting_number">Pertemuan Ke-</Label>
<Input
id="meeting_number"
name="meeting_number"
type="number"
min={1}
placeholder="Contoh: 1"
/>
<InputError message={errors.meeting_number} />
</div>
<FileUploadField
key={open ? 'open' : 'closed'}
label="File Materi"
error={errors.file}
/>
</div>
)}
</FormDialog>
);
}
function EditForm({
open,
onOpenChange,
editing,
courseClasses,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
editing: Material | null;
courseClasses: CourseClassOption[];
}) {
return (
<FormDialog
open={open}
onOpenChange={onOpenChange}
title="Edit Materi"
action={editing ? update(editing.id) : ''}
resetOnSuccess
onSuccess={() => onOpenChange(false)}
>
{({ errors }) =>
editing && (
<div className="grid gap-4">
<div className="grid gap-2">
<Label>
Kelas{' '}
<span className="text-destructive">*</span>
</Label>
<Select
name="course_class_id"
defaultValue={String(editing.course_class_id)}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Pilih kelas" />
</SelectTrigger>
<SelectContent>
{courseClasses.map((courseClass) => (
<SelectItem
key={courseClass.id}
value={String(courseClass.id)}
>
{courseClassLabel(courseClass)}
</SelectItem>
))}
</SelectContent>
</Select>
<InputError message={errors.course_class_id} />
</div>
<div className="grid gap-2">
<Label htmlFor="edit-title">
Judul{' '}
<span className="text-destructive">*</span>
</Label>
<Input
id="edit-title"
name="title"
placeholder="Masukkan judul materi"
defaultValue={editing.title}
/>
<InputError message={errors.title} />
</div>
<div className="grid gap-2">
<Label htmlFor="edit-description">Deskripsi</Label>
<Textarea
id="edit-description"
name="description"
placeholder="Masukkan deskripsi materi"
defaultValue={editing.description ?? ''}
/>
<InputError message={errors.description} />
</div>
<div className="grid gap-2">
<Label htmlFor="edit-meeting_number">
Pertemuan Ke-
</Label>
<Input
id="edit-meeting_number"
name="meeting_number"
type="number"
min={1}
defaultValue={
editing.meeting_number ?? undefined
}
/>
<InputError message={errors.meeting_number} />
</div>
<FileUploadField
label="File Materi"
existingFileName={editing.file_name}
existingFileUrl={editing.file_url}
error={errors.file}
/>
</div>
)
}
</FormDialog>
);
}

View File

@ -0,0 +1,16 @@
export type Material = {
id: number;
course_class_id: number;
course_class: {
id: number;
class_name: string | null;
course: { id: number; code: string; name: string } | null;
} | null;
title: string;
description: string | null;
meeting_number: number | null;
file_url: string | null;
file_name: string | null;
created_at: string;
updated_at: string;
};

View File

@ -3,6 +3,7 @@
use App\Http\Controllers\Admin\Manage\ClassEnrollmentController;
use App\Http\Controllers\Admin\Manage\CourseClassController;
use App\Http\Controllers\Admin\Manage\CourseController;
use App\Http\Controllers\Admin\Manage\MaterialController;
use App\Http\Controllers\Admin\Master\AcademicTermController;
use App\Http\Controllers\Admin\Master\DepartmentController;
use App\Http\Controllers\Admin\Users\AdministratorController;
@ -25,6 +26,8 @@
Route::post('/', [ClassEnrollmentController::class, 'store'])->name('store');
Route::delete('{enrollment}', [ClassEnrollmentController::class, 'destroy'])->name('destroy');
});
Route::resource('materials', MaterialController::class)->except(['create', 'edit', 'show']);
});
Route::prefix('admin/users')->name('admin.users.')->group(function () {