Merge pull request 'feat: implement material management functionality with CRUD operations' (#20) from feat/implement-material-crud into dev
Reviewed-on: itm/siakad-itm#20
This commit is contained in:
commit
2103b4ae11
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);
|
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 App\Models\CourseClass;
|
||||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||||
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
|
|
||||||
class CourseClassService
|
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
|
public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', array $filters = []): LengthAwarePaginator
|
||||||
{
|
{
|
||||||
return CourseClass::query()
|
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,
|
CourseSeeder::class,
|
||||||
CourseClassSeeder::class,
|
CourseClassSeeder::class,
|
||||||
ClassEnrollmentSeeder::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';
|
} from '@/components/ui/sidebar';
|
||||||
import { index as courseClassesRoute } from '@/routes/admin/manage/course-classes';
|
import { index as courseClassesRoute } from '@/routes/admin/manage/course-classes';
|
||||||
import { index as coursesRoute } from '@/routes/admin/manage/courses';
|
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 academicTerm } from '@/routes/admin/master/academic-terms';
|
||||||
import { index as departmentsRoute } from '@/routes/admin/master/departments';
|
import { index as departmentsRoute } from '@/routes/admin/master/departments';
|
||||||
import { index as lecturersRoute } from '@/routes/admin/users/lecturers';
|
import { index as lecturersRoute } from '@/routes/admin/users/lecturers';
|
||||||
@ -30,6 +31,7 @@ import {
|
|||||||
BookOpen,
|
BookOpen,
|
||||||
Building2,
|
Building2,
|
||||||
Calendar,
|
Calendar,
|
||||||
|
FileText,
|
||||||
GraduationCap,
|
GraduationCap,
|
||||||
School,
|
School,
|
||||||
User,
|
User,
|
||||||
@ -76,6 +78,16 @@ const data: {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: 'Kelas',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
name: 'Materi',
|
||||||
|
url: materialsRoute.url(),
|
||||||
|
icon: FileText,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: 'Pengguna',
|
label: 'Pengguna',
|
||||||
items: [
|
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\ClassEnrollmentController;
|
||||||
use App\Http\Controllers\Admin\Manage\CourseClassController;
|
use App\Http\Controllers\Admin\Manage\CourseClassController;
|
||||||
use App\Http\Controllers\Admin\Manage\CourseController;
|
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\AcademicTermController;
|
||||||
use App\Http\Controllers\Admin\Master\DepartmentController;
|
use App\Http\Controllers\Admin\Master\DepartmentController;
|
||||||
use App\Http\Controllers\Admin\Users\AdministratorController;
|
use App\Http\Controllers\Admin\Users\AdministratorController;
|
||||||
@ -25,6 +26,8 @@
|
|||||||
Route::post('/', [ClassEnrollmentController::class, 'store'])->name('store');
|
Route::post('/', [ClassEnrollmentController::class, 'store'])->name('store');
|
||||||
Route::delete('{enrollment}', [ClassEnrollmentController::class, 'destroy'])->name('destroy');
|
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 () {
|
Route::prefix('admin/users')->name('admin.users.')->group(function () {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user