feat: implement material management functionality with CRUD operations
This commit is contained in:
parent
ff4173bb36
commit
7c50a493c3
56
app/Http/Controllers/Admin/Manage/MaterialController.php
Normal file
56
app/Http/Controllers/Admin/Manage/MaterialController.php
Normal 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();
|
||||
}
|
||||
}
|
||||
30
app/Http/Requests/Admin/Manage/MaterialRequest.php
Normal file
30
app/Http/Requests/Admin/Manage/MaterialRequest.php
Normal 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',
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -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
44
app/Models/Material.php
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
|
||||
56
app/Services/Admin/Manage/MaterialService.php
Normal file
56
app/Services/Admin/Manage/MaterialService.php
Normal 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();
|
||||
}
|
||||
}
|
||||
@ -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');
|
||||
}
|
||||
};
|
||||
@ -20,6 +20,7 @@ public function run(): void
|
||||
CourseSeeder::class,
|
||||
CourseClassSeeder::class,
|
||||
ClassEnrollmentSeeder::class,
|
||||
MaterialSeeder::class,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
31
database/seeders/MaterialSeeder.php
Normal file
31
database/seeders/MaterialSeeder.php
Normal 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',
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@ -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: [
|
||||
|
||||
115
resources/js/components/file-upload-field.tsx
Normal file
115
resources/js/components/file-upload-field.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
204
resources/js/components/ui/attachment.tsx
Normal file
204
resources/js/components/ui/attachment.tsx
Normal 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,
|
||||
}
|
||||
122
resources/js/pages/admin/manage/materials/columns.tsx
Normal file
122
resources/js/pages/admin/manage/materials/columns.tsx
Normal 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),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
371
resources/js/pages/admin/manage/materials/index.tsx
Normal file
371
resources/js/pages/admin/manage/materials/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
16
resources/js/types/material.ts
Normal file
16
resources/js/types/material.ts
Normal 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;
|
||||
};
|
||||
@ -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 () {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user