diff --git a/app/Http/Controllers/Admin/Manage/MaterialController.php b/app/Http/Controllers/Admin/Manage/MaterialController.php new file mode 100644 index 0000000..90969aa --- /dev/null +++ b/app/Http/Controllers/Admin/Manage/MaterialController.php @@ -0,0 +1,56 @@ + $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(); + } +} diff --git a/app/Http/Requests/Admin/Manage/MaterialRequest.php b/app/Http/Requests/Admin/Manage/MaterialRequest.php new file mode 100644 index 0000000..453760a --- /dev/null +++ b/app/Http/Requests/Admin/Manage/MaterialRequest.php @@ -0,0 +1,30 @@ + ['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', + ], + ]; + } +} diff --git a/app/Models/CourseClass.php b/app/Models/CourseClass.php index b306c69..08fcef5 100644 --- a/app/Models/CourseClass.php +++ b/app/Models/CourseClass.php @@ -41,4 +41,9 @@ public function enrollments(): HasMany { return $this->hasMany(ClassEnrollment::class); } + + public function materials(): HasMany + { + return $this->hasMany(Material::class); + } } diff --git a/app/Models/Material.php b/app/Models/Material.php new file mode 100644 index 0000000..df4676a --- /dev/null +++ b/app/Models/Material.php @@ -0,0 +1,44 @@ +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, + ); + } +} diff --git a/app/Services/Admin/Manage/CourseClassService.php b/app/Services/Admin/Manage/CourseClassService.php index 938abb6..bbb54d9 100644 --- a/app/Services/Admin/Manage/CourseClassService.php +++ b/app/Services/Admin/Manage/CourseClassService.php @@ -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() diff --git a/app/Services/Admin/Manage/MaterialService.php b/app/Services/Admin/Manage/MaterialService.php new file mode 100644 index 0000000..b6af2b9 --- /dev/null +++ b/app/Services/Admin/Manage/MaterialService.php @@ -0,0 +1,56 @@ +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(); + } +} diff --git a/database/migrations/2026_08_21_000004_create_materials_table.php b/database/migrations/2026_08_21_000004_create_materials_table.php new file mode 100644 index 0000000..8a31702 --- /dev/null +++ b/database/migrations/2026_08_21_000004_create_materials_table.php @@ -0,0 +1,26 @@ +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'); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 92f215e..1b1f13d 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -20,6 +20,7 @@ public function run(): void CourseSeeder::class, CourseClassSeeder::class, ClassEnrollmentSeeder::class, + MaterialSeeder::class, ]); } } diff --git a/database/seeders/MaterialSeeder.php b/database/seeders/MaterialSeeder.php new file mode 100644 index 0000000..b5ceb1d --- /dev/null +++ b/database/seeders/MaterialSeeder.php @@ -0,0 +1,31 @@ + 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', + ], + ]); + } +} diff --git a/resources/js/components/app-sidebar.tsx b/resources/js/components/app-sidebar.tsx index 04235f3..1b475f6 100644 --- a/resources/js/components/app-sidebar.tsx +++ b/resources/js/components/app-sidebar.tsx @@ -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: [ diff --git a/resources/js/components/file-upload-field.tsx b/resources/js/components/file-upload-field.tsx new file mode 100644 index 0000000..7d4dee8 --- /dev/null +++ b/resources/js/components/file-upload-field.tsx @@ -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(null); + const [selectedFile, setSelectedFile] = useState(null); + + function handleFileChange(e: React.ChangeEvent) { + 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 ( +
+ + + + + inputRef.current?.click()} /> + + + + + + {displayName ?? 'Klik untuk pilih file'} + + + {displayName + ? hasExistingFile + ? 'File saat ini — klik untuk mengganti' + : 'Klik untuk mengganti file' + : helpText} + + + + {displayUrl && ( + + e.stopPropagation()} + > + + + + )} + {hasNewFile && ( + { + e.stopPropagation(); + handleReset(); + }} + > + {hasExistingFile ? : } + + )} + + + + +
+ ); +} diff --git a/resources/js/components/ui/attachment.tsx b/resources/js/components/ui/attachment.tsx new file mode 100644 index 0000000..5bdd1ce --- /dev/null +++ b/resources/js/components/ui/attachment.tsx @@ -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 & { + state?: "idle" | "uploading" | "processing" | "error" | "done" + }) { + return ( +
+ ) +} + +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) { + return ( +
+ ) +} + +function AttachmentContent({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AttachmentTitle({ + className, + ...props +}: React.ComponentProps<"span">) { + return ( + + ) +} + +function AttachmentDescription({ + className, + ...props +}: React.ComponentProps<"span">) { + return ( + + ) +} + +function AttachmentActions({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AttachmentAction({ + className, + variant, + size = "icon-xs", + ...props +}: React.ComponentProps) { + return ( + +

+ ) + } + actions={ + + + } + /> + + + + { + if (!open) { + setEditing(null); + } + }} + editing={editing} + courseClasses={courseClasses} + /> + + + + { + if (!open) { + setDeleting(null); + } + }} + title="Hapus Materi" + description={(material) => + `Apakah Anda yakin ingin menghapus materi "${material.title}"? Tindakan ini tidak dapat dibatalkan.` + } + onConfirm={handleDelete} + /> +
+ + ); +} + +function CreateForm({ + open, + onOpenChange, + courseClasses, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + courseClasses: CourseClassOption[]; +}) { + return ( + onOpenChange(false)} + > + {({ errors }) => ( +
+
+ + + + +
+
+ + + +
+
+ +