Refactor Letter Request Management

- Created LetterRequestRequest and LetterRequestStatusRequest for handling form requests.
- Removed the old LetterRequestRequest class and updated the authorization logic.
- Changed the student relationship to user in the LetterRequest model.
- Updated LetterRequestService to reflect changes in the model and request handling.
- Modified permission catalog to include new permissions for letter request status updates.
- Updated database migrations and seeders to use user_id instead of student_id.
- Removed student-specific letter request pages and components, consolidating functionality under admin routes.
- Adjusted frontend components to accommodate new data structure and permissions.
- Enhanced the letter request columns to support status updates directly from the table.
This commit is contained in:
Yoga Pangestu 2026-09-01 00:03:13 +07:00
parent 7353b46975
commit 93c5514c22
19 changed files with 290 additions and 672 deletions

View File

@ -2,15 +2,14 @@
namespace App\Http\Controllers\Admin\Services;
use App\Enums\LetterStatus;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\Services\LetterRequestRequest;
use App\Http\Requests\Admin\Services\LetterRequest\LetterRequestRequest;
use App\Http\Requests\Admin\Services\LetterRequest\LetterRequestStatusRequest;
use App\Http\Requests\PaginatedRequest;
use App\Models\LetterRequest;
use App\Models\Student;
use App\Services\Admin\Services\LetterRequestService;
use App\Services\Admin\Users\StudentService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
@ -18,7 +17,6 @@ class LetterRequestController extends Controller
{
public function __construct(
private readonly LetterRequestService $service,
private readonly StudentService $studentService,
) {}
public function index(PaginatedRequest $request): Response
@ -28,60 +26,25 @@ public function index(PaginatedRequest $request): Response
...$request->validatedWithDefaults(),
status: $request->validated('status'),
),
'students' => $this->studentService->getAllForSelect(),
'filters' => $request->only(['status']),
]);
}
public function mine(Request $request): Response
{
return Inertia::render('student/letter-requests/index', [
'letterRequests' => $this->service->mine($this->currentStudent($request)),
]);
}
public function create(): Response
{
return Inertia::render('student/letter-requests/create');
}
public function show(Request $request, LetterRequest $letterRequest): Response
{
$student = $this->currentStudent($request);
abort_if($letterRequest->student_id !== $student->id, 403);
return Inertia::render('student/letter-requests/show', [
'letterRequest' => $this->service->withDetails($letterRequest),
]);
}
public function store(LetterRequestRequest $request): RedirectResponse
{
if ($request->routeIs('student.*')) {
$student = $this->currentStudent($request);
$this->service->create([
'user_id' => auth()->id(),
...$request->validated(),
]);
$letterRequest = $this->service->create([
'student_id' => $student->id,
'letter_type' => $request->validated('letter_type'),
'purpose' => $request->validated('purpose'),
], null);
Inertia::flash('toast', ['type' => 'success', 'message' => 'Surat permohonan berhasil diajukan.']);
return to_route('student.letter-requests.show', $letterRequest);
}
$this->service->create($request->validated(), $request->file('result'));
Inertia::flash('toast', ['type' => 'success', 'message' => 'Surat permohonan berhasil ditambahkan.']);
Inertia::flash('toast', ['type' => 'success', 'message' => 'Surat permohonan berhasil diajukan.']);
return to_route('admin.services.letter-requests.index');
}
public function update(LetterRequestRequest $request, LetterRequest $letterRequest): RedirectResponse
{
$this->service->update($letterRequest, $request->validated(), $request->file('result'));
$this->service->update($letterRequest, $request->validated());
Inertia::flash('toast', ['type' => 'success', 'message' => 'Surat permohonan berhasil diperbarui.']);
@ -90,17 +53,17 @@ public function update(LetterRequestRequest $request, LetterRequest $letterReque
public function destroy(LetterRequest $letterRequest): RedirectResponse
{
abort_if($letterRequest->status !== LetterStatus::Submitted->value, 403);
$this->service->delete($letterRequest);
return Inertia::flash('toast', ['type' => 'success', 'message' => 'Surat permohonan berhasil dihapus.'])->back();
}
private function currentStudent(Request $request): Student
public function updateStatus(LetterRequestStatusRequest $request, LetterRequest $letterRequest): RedirectResponse
{
$student = $request->user()->student;
$this->service->updateStatus($letterRequest, $request->validated('status'), $request->file('result'));
abort_if(! $student, 403);
return $student;
return Inertia::flash('toast', ['type' => 'success', 'message' => 'Status surat permohonan berhasil diperbarui.'])->back();
}
}

View File

@ -0,0 +1,30 @@
<?php
namespace App\Http\Requests\Admin\Services\LetterRequest;
use App\Enums\LetterStatus;
use Illuminate\Foundation\Http\FormRequest;
class LetterRequestRequest extends FormRequest
{
public function authorize(): bool
{
if ($this->isMethod('post')) {
return $this->user()->can('create-letter-requests');
}
$letterRequest = $this->route('letter_request');
return $this->user()->can('update-letter-requests')
&& $letterRequest->user_id === $this->user()->id
&& $letterRequest->status === LetterStatus::Submitted->value;
}
public function rules(): array
{
return [
'letter_type' => ['required', 'string', 'max:100'],
'purpose' => ['nullable', 'string'],
];
}
}

View File

@ -0,0 +1,23 @@
<?php
namespace App\Http\Requests\Admin\Services\LetterRequest;
use App\Enums\LetterStatus;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class LetterRequestStatusRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()->can('update-letter-requests-status');
}
public function rules(): array
{
return [
'status' => ['nullable', 'required_without:result', Rule::enum(LetterStatus::class)],
'result' => ['nullable', 'file', 'max:10240', 'mimes:pdf,jpg,jpeg,png,doc,docx'],
];
}
}

View File

@ -1,37 +0,0 @@
<?php
namespace App\Http\Requests\Admin\Services;
use App\Enums\LetterStatus;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class LetterRequestRequest extends FormRequest
{
public function authorize(): bool
{
if ($this->routeIs('student.*')) {
return (bool) $this->user()->student;
}
return $this->user()->can($this->isMethod('post') ? 'create-letter-requests' : 'update-letter-requests');
}
public function rules(): array
{
if ($this->routeIs('student.*')) {
return [
'letter_type' => ['required', 'string', 'max:100'],
'purpose' => ['nullable', 'string'],
];
}
return [
'student_id' => ['required', 'integer', Rule::exists('students', 'id')],
'letter_type' => ['required', 'string', 'max:100'],
'purpose' => ['nullable', 'string'],
'status' => ['required', Rule::enum(LetterStatus::class)],
'result' => ['nullable', 'file', 'max:10240', 'mimes:pdf,jpg,jpeg,png,doc,docx'],
];
}
}

View File

@ -32,9 +32,9 @@ public function registerMediaCollections(): void
$this->addMediaCollection('letter_result')->singleFile();
}
public function student(): BelongsTo
public function user(): BelongsTo
{
return $this->belongsTo(Student::class);
return $this->belongsTo(User::class);
}
public function processor(): BelongsTo

View File

@ -4,71 +4,56 @@
use App\Enums\LetterStatus;
use App\Models\LetterRequest;
use App\Models\Student;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Http\UploadedFile;
class LetterRequestService
{
/**
* @return Collection<int, LetterRequest>
*/
public function mine(Student $student): Collection
{
return LetterRequest::query()
->where('student_id', $student->id)
->with('processor.profile')
->latest()
->get();
}
public function withDetails(LetterRequest $letterRequest): LetterRequest
{
return $letterRequest->load('processor.profile');
}
public function paginated(int $perPage = 25, string $search = '', ?string $status = null): LengthAwarePaginator
{
$user = auth()->user();
return LetterRequest::query()
->select(['id', 'student_id', 'letter_type', 'purpose', 'status', 'processed_by', 'submitted_at', 'completed_at'])
->with(['student.user.profile', 'student.department', 'processor.profile'])
->select(['id', 'user_id', 'letter_type', 'purpose', 'status', 'processed_by', 'submitted_at', 'completed_at'])
->with(['user.profile', 'user.student.department', 'processor.profile'])
->when($search, fn ($q) => $q->where('letter_type', 'like', "%{$search}%")
->orWhereHas('student', fn ($q) => $q->where('student_number', 'like', "%{$search}%")
->orWhereHas('user.profile', fn ($q) => $q->where('full_name', 'like', "%{$search}%"))))
->orWhereHas('user', fn ($q) => $q->whereHas('profile', fn ($q) => $q->where('full_name', 'like', "%{$search}%"))
->orWhereHas('student', fn ($q) => $q->where('student_number', 'like', "%{$search}%"))))
->when($status, fn ($q) => $q->where('status', $status))
->when($user->hasRole('mahasiswa'), fn ($q) => $q->where('user_id', $user->id))
->latest()
->paginate($perPage);
}
public function create(array $data, ?UploadedFile $result): LetterRequest
public function create(array $data): LetterRequest
{
$status = $data['status'] ?? LetterStatus::Submitted->value;
$letterRequest = LetterRequest::create([
'student_id' => $data['student_id'],
return LetterRequest::create([
'user_id' => $data['user_id'],
'letter_type' => $data['letter_type'],
'purpose' => $data['purpose'] ?? null,
'status' => $status,
'processed_by' => $status !== LetterStatus::Submitted->value ? auth()->id() : null,
'status' => LetterStatus::Submitted->value,
'submitted_at' => now(),
'completed_at' => $status === LetterStatus::Completed->value ? now() : null,
]);
}
if ($result) {
$letterRequest->addMedia($result)->toMediaCollection('letter_result');
}
public function update(LetterRequest $letterRequest, array $data): LetterRequest
{
$letterRequest->letter_type = $data['letter_type'];
$letterRequest->purpose = $data['purpose'] ?? null;
$letterRequest->update();
return $letterRequest;
}
public function update(LetterRequest $letterRequest, array $data, ?UploadedFile $result): LetterRequest
public function delete(LetterRequest $letterRequest): bool
{
$status = $data['status'] ?? LetterStatus::Submitted->value;
return $letterRequest->delete();
}
public function updateStatus(LetterRequest $letterRequest, ?string $status, ?UploadedFile $result): LetterRequest
{
$status = $result ? LetterStatus::Completed->value : $status;
$letterRequest->student_id = $data['student_id'];
$letterRequest->letter_type = $data['letter_type'];
$letterRequest->purpose = $data['purpose'] ?? null;
$letterRequest->status = $status;
$letterRequest->processed_by = $status !== LetterStatus::Submitted->value ? auth()->id() : null;
$letterRequest->completed_at = $status === LetterStatus::Completed->value
@ -82,9 +67,4 @@ public function update(LetterRequest $letterRequest, array $data, ?UploadedFile
return $letterRequest;
}
public function delete(LetterRequest $letterRequest): bool
{
return $letterRequest->delete();
}
}

View File

@ -33,7 +33,7 @@ class PermissionCatalog
];
public const SERVICES = [
'view-letter-requests', 'create-letter-requests', 'update-letter-requests', 'delete-letter-requests',
'view-letter-requests', 'create-letter-requests', 'update-letter-requests', 'update-letter-requests-status', 'delete-letter-requests',
'view-academic-advising-logs', 'create-academic-advising-logs', 'update-academic-advising-logs', 'delete-academic-advising-logs',
];

View File

@ -11,7 +11,7 @@ public function up(): void
{
Schema::create('letter_requests', function (Blueprint $table) {
$table->id();
$table->foreignId('student_id')->constrained()->cascadeOnDelete();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('letter_type', 100);
$table->text('purpose')->nullable();
$table->enum('status', LetterStatus::values())->nullable()->default(LetterStatus::Submitted->value);

View File

@ -11,7 +11,7 @@ public function run(): void
{
LetterRequest::insert([
[
'student_id' => 1,
'user_id' => 1,
'letter_type' => 'Surat Aktif Kuliah',
'purpose' => 'Persyaratan pengajuan beasiswa',
'status' => 'completed',
@ -22,7 +22,7 @@ public function run(): void
'updated_at' => '2026-07-02 14:00:00',
],
[
'student_id' => 2,
'user_id' => 2,
'letter_type' => 'Surat Keterangan Lulus Sementara',
'purpose' => 'Persyaratan melamar magang',
'status' => 'in_process',

View File

@ -22,7 +22,10 @@ public function run(): void
$users = PermissionCatalog::USERS;
$feedbackSelfService = [
'view-feedback', 'create-feedback', 'update-feedback', 'delete-feedback',
'view-feedback',
'create-feedback',
'update-feedback',
'delete-feedback',
];
$permissionNames = PermissionCatalog::all();
@ -34,14 +37,27 @@ public function run(): void
app()[PermissionRegistrar::class]->forgetCachedPermissions();
$roles = [
'mahasiswa' => ['view-dashboard', ...$feedbackSelfService],
'dosen' => ['view-dashboard', ...$feedbackSelfService],
'mahasiswa' => [
'view-dashboard',
'view-academic-terms',
'view-courses',
'view-letter-requests',
'create-letter-requests',
'update-letter-requests',
'delete-letter-requests',
...$feedbackSelfService,
],
'dosen' => [
'view-dashboard',
'view-courses',
...$feedbackSelfService,
],
'staff-admin' => [
'view-dashboard',
...$master,
...$academicClasses,
...$manage,
...$services,
...array_diff($services, ['create-letter-requests', 'update-letter-requests']),
...$users,
...$feedbackSelfService,
'update-feedback-status',

View File

@ -56,7 +56,6 @@ import { index as lecturersRoute } from '@/routes/admin/users/lecturers';
import { index as studentsRoute } from '@/routes/admin/users/students';
import { index as lecturerAcademicAdvisingLogsRoute } from '@/routes/lecturer/academic-advising-logs';
import { index as studentCourseRegistrationsRoute } from '@/routes/student/course-registrations';
import { index as studentLetterRequestsRoute } from '@/routes/student/letter-requests';
import type { Auth } from '@/types/auth';
const STAFF_ROLES = ['developer', 'staff-admin', 'staff-keuangan', 'kaprodi'];
@ -198,13 +197,11 @@ function buildNavMain({
];
const layananItems: NavItem[] = [
...(isMahasiswa || can('view-letter-requests')
...(can('view-letter-requests')
? [
{
name: 'Surat Permohonan',
url: isMahasiswa
? studentLetterRequestsRoute.url()
: letterRequestsRoute.url(),
url: letterRequestsRoute.url(),
icon: Mail,
},
]

View File

@ -2,39 +2,63 @@ import type { ColumnDef } from '@tanstack/react-table';
import { format } from 'date-fns';
import { Paperclip, Pencil, Trash2 } from 'lucide-react';
import { RowActions } from '@/components/row-actions';
import { Badge } from '@/components/ui/badge';
import type { LetterRequest, LetterStatus } from '@/types/letter-request';
import { LetterStatusLabels } from '@/types/letter-request';
import { StatusBadge } from '@/components/status-badge';
import type { LetterRequest } from '@/types/letter-request';
import { LetterStatuses, LetterStatusLabels } from '@/types/letter-request';
export type { LetterRequest } from '@/types/letter-request';
const LetterStatusVariants: Record<
string,
'default' | 'secondary' | 'destructive' | 'outline'
> = {
submitted: 'outline',
in_process: 'secondary',
completed: 'default',
rejected: 'destructive',
};
const letterStatusOptions = LetterStatuses.map((status) => ({
value: status,
label: LetterStatusLabels[status],
}));
type CreateColumnsParams = {
handleEdit: (letterRequest: LetterRequest) => void;
handleDeleteClick: (letterRequest: LetterRequest) => void;
handleStatusChange: (letterRequest: LetterRequest, status: string) => void;
canUpdate: boolean;
canDelete: boolean;
canProcess: boolean;
};
export function createLetterRequestColumns(
params: CreateColumnsParams,
): ColumnDef<LetterRequest>[] {
const { handleEdit, handleDeleteClick, canUpdate, canDelete } = params;
const {
handleEdit,
handleDeleteClick,
handleStatusChange,
canUpdate,
canDelete,
canProcess,
} = params;
const columns: ColumnDef<LetterRequest>[] = [
{
accessorKey: 'student.student_number',
accessorKey: 'user.student.student_number',
header: () => <span>Mahasiswa</span>,
cell: ({ row }) => {
const student = row.original.student;
const user = row.original.user;
return (
<div>
<p className="font-medium">
{student?.user?.profile?.full_name ?? 'N/A'}
{user?.profile?.full_name ?? 'N/A'}
</p>
<p className="text-xs text-muted-foreground">
{student?.student_number} &middot;{' '}
{student?.department?.name ?? '-'}
{user?.student?.student_number} &middot;{' '}
{user?.student?.department?.name ?? '-'}
</p>
</div>
);
@ -67,30 +91,19 @@ export function createLetterRequestColumns(
className: 'w-[120px] text-center',
headerClassName: 'w-[120px] text-center',
},
cell: ({ row }) => {
const status = row.getValue('status') as LetterStatus | null;
const variant =
status === 'completed'
? 'default'
: status === 'rejected'
? 'destructive'
: status === 'in_process'
? 'secondary'
: 'outline';
return (
<div className="flex justify-center">
{status ? (
<Badge variant={variant}>
{LetterStatusLabels[status]}
</Badge>
) : (
<span className="text-muted-foreground">-</span>
)}
</div>
);
},
cell: ({ row }) => (
<div className="flex justify-center">
<StatusBadge
status={row.original.status}
statuses={letterStatusOptions}
variants={LetterStatusVariants}
onChange={(status) =>
handleStatusChange(row.original, status)
}
disabled={!canProcess}
/>
</div>
),
},
{
accessorKey: 'processor.profile.full_name',
@ -142,26 +155,30 @@ export function createLetterRequestColumns(
className: 'w-[100px] text-center',
headerClassName: 'w-[100px] text-center',
},
cell: ({ row }) => (
<RowActions
actions={[
{
label: 'Edit',
icon: <Pencil className="h-4 w-4" />,
show: canUpdate,
onClick: () => handleEdit(row.original),
},
{
label: 'Hapus',
icon: (
<Trash2 className="h-4 w-4 text-destructive" />
),
show: canDelete,
onClick: () => handleDeleteClick(row.original),
},
]}
/>
),
cell: ({ row }) => {
const isLocked = row.original.status !== 'submitted';
return (
<RowActions
actions={[
{
label: 'Edit',
icon: <Pencil className="h-4 w-4" />,
show: canUpdate && !isLocked,
onClick: () => handleEdit(row.original),
},
{
label: 'Hapus',
icon: (
<Trash2 className="h-4 w-4 text-destructive" />
),
show: canDelete && !isLocked,
onClick: () => handleDeleteClick(row.original),
},
]}
/>
);
},
});
}

View File

@ -1,6 +1,3 @@
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';
@ -10,38 +7,25 @@ import { FilterDialog } from '@/components/filter-dialog';
import { FormDialog } from '@/components/form-dialog';
import InputError from '@/components/input-error';
import { PageHeader } from '@/components/page-header';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Button } from '@/components/ui/button';
import {
Combobox,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxList,
} from '@/components/ui/combobox';
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 { usePermissions } from '@/hooks/use-permissions';
import { useServerTable } from '@/hooks/use-server-table';
import {
index as letterRequestIndex,
destroy,
index as letterRequestIndex,
store,
update,
update_status,
} from '@/routes/admin/services/letter-requests';
import type {
LetterRequest,
LetterRequestStudent,
} from '@/types/letter-request';
import type { LetterRequest } from '@/types/letter-request';
import { LetterStatuses, LetterStatusLabels } from '@/types/letter-request';
import { Head, router } from '@inertiajs/react';
import { Info, Plus } from 'lucide-react';
import { useState } from 'react';
import { createLetterRequestColumns } from './columns';
type Props = {
@ -52,30 +36,26 @@ type Props = {
per_page: number;
total: number;
};
students: LetterRequestStudent[];
highlight?: number;
filters: {
status?: string;
};
};
function studentLabel(student: LetterRequestStudent): string {
return `${student.user?.profile?.full_name ?? 'N/A'} - ${student.student_number}`;
}
export default function LetterRequestIndex({
letterRequests,
students,
highlight,
filters,
}: Props) {
const [createOpen, setCreateOpen] = useState(false);
const [editing, setEditing] = useState<LetterRequest | null>(null);
const [deleting, setDeleting] = useState<LetterRequest | null>(null);
const [processing, setProcessing] = useState<LetterRequest | null>(null);
const { hasPermission } = usePermissions();
const canCreate = hasPermission('create-letter-requests');
const canUpdate = hasPermission('update-letter-requests');
const canDelete = hasPermission('delete-letter-requests');
const canProcess = hasPermission('update-letter-requests-status');
const filterFields: FilterField[] = [
{
@ -117,11 +97,27 @@ export default function LetterRequestIndex({
});
}
function handleStatusChange(letterRequest: LetterRequest, status: string) {
if (status === 'completed') {
setProcessing(letterRequest);
return;
}
router.patch(
update_status.url(letterRequest.id),
{ status },
{ preserveScroll: true },
);
}
const columns = createLetterRequestColumns({
handleEdit: (letterRequest) => setEditing(letterRequest),
handleDeleteClick: (letterRequest) => setDeleting(letterRequest),
handleStatusChange,
canUpdate,
canDelete,
canProcess,
});
return (
@ -146,18 +142,14 @@ export default function LetterRequestIndex({
onClick={() => setCreateOpen(true)}
>
<Plus className="h-4 w-4" />
Tambah
Ajukan
</button>
</Button>
)
}
/>
<CreateForm
open={createOpen}
onOpenChange={setCreateOpen}
students={students}
/>
<CreateForm open={createOpen} onOpenChange={setCreateOpen} />
<EditForm
key={editing?.id}
@ -168,9 +160,30 @@ export default function LetterRequestIndex({
}
}}
editing={editing}
students={students}
/>
<ProcessForm
key={processing?.id}
open={processing !== null}
onOpenChange={(open) => {
if (!open) {
setProcessing(null);
}
}}
processing={processing}
/>
{canProcess && (
<Alert>
<Info />
<AlertTitle>Ubah status permohonan</AlertTitle>
<AlertDescription>
Klik badge Status pada tabel untuk mengubah status
permohonan secara langsung.
</AlertDescription>
</Alert>
)}
<DataTable
columns={columns}
data={letterRequests.data}
@ -198,7 +211,7 @@ export default function LetterRequestIndex({
}}
title="Hapus Surat Permohonan"
description={(letterRequest) =>
`Apakah Anda yakin ingin menghapus permohonan "${letterRequest.letter_type}" milik "${letterRequest.student?.user?.profile?.full_name ?? 'mahasiswa ini'}"? Tindakan ini tidak dapat dibatalkan.`
`Apakah Anda yakin ingin menghapus permohonan "${letterRequest.letter_type}" milik "${letterRequest.user?.profile?.full_name ?? 'mahasiswa ini'}"? Tindakan ini tidak dapat dibatalkan.`
}
onConfirm={handleDelete}
/>
@ -210,55 +223,12 @@ export default function LetterRequestIndex({
function LetterRequestFields({
errors,
editing,
students,
resetKey,
}: {
errors: Record<string, string>;
editing?: LetterRequest;
students: LetterRequestStudent[];
resetKey?: string;
}) {
const [student, setStudent] = useState<LetterRequestStudent | null>(
editing?.student ?? null,
);
return (
<>
<div className="grid gap-2">
<Label>
Mahasiswa <span className="text-destructive">*</span>
</Label>
<input
type="hidden"
name="student_id"
value={student?.id ?? ''}
/>
<Combobox
items={students}
value={student}
onValueChange={setStudent}
itemToStringLabel={(s) => studentLabel(s)}
isItemEqualToValue={(a, b) => a.id === b.id}
>
<ComboboxInput
placeholder="Pilih mahasiswa"
className="w-full"
/>
<ComboboxContent>
<ComboboxEmpty>
Mahasiswa tidak ditemukan.
</ComboboxEmpty>
<ComboboxList>
{(s: LetterRequestStudent) => (
<ComboboxItem key={s.id} value={s}>
{studentLabel(s)}
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
<InputError message={errors.student_id} />
</div>
<div className="grid gap-2">
<Label htmlFor="letter_type">
Jenis Surat <span className="text-destructive">*</span>
@ -283,36 +253,6 @@ function LetterRequestFields({
/>
<InputError message={errors.purpose} />
</div>
<div className="grid gap-2">
<Label>
Status <span className="text-destructive">*</span>
</Label>
<input type="hidden" name="status" />
<Select
name="status"
defaultValue={editing?.status ?? 'submitted'}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Pilih status" />
</SelectTrigger>
<SelectContent>
{LetterStatuses.map((status) => (
<SelectItem key={status} value={status}>
{LetterStatusLabels[status]}
</SelectItem>
))}
</SelectContent>
</Select>
<InputError message={errors.status} />
</div>
<FileUploadField
key={resetKey}
name="result"
label="Dokumen Hasil"
existingFileName={editing?.result_name}
existingFileUrl={editing?.result_url}
error={errors.result}
/>
</>
);
}
@ -320,45 +260,75 @@ function LetterRequestFields({
function CreateForm({
open,
onOpenChange,
students,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
students: LetterRequestStudent[];
}) {
return (
<FormDialog
open={open}
onOpenChange={onOpenChange}
title="Tambah Surat Permohonan"
title="Ajukan Surat Permohonan"
action={store()}
resetOnSuccess
onSuccess={() => onOpenChange(false)}
>
{({ errors }) => (
<div className="grid gap-4">
<LetterRequestFields
key={open ? 'open' : 'closed'}
errors={errors}
students={students}
resetKey={open ? 'open' : 'closed'}
/>
<LetterRequestFields errors={errors} />
</div>
)}
</FormDialog>
);
}
function ProcessForm({
open,
onOpenChange,
processing,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
processing: LetterRequest | null;
}) {
return (
<FormDialog
open={open}
onOpenChange={onOpenChange}
title="Selesaikan Surat Permohonan"
action={processing ? update_status(processing.id) : ''}
resetOnSuccess
onSuccess={() => onOpenChange(false)}
>
{({ errors }) =>
processing && (
<div className="grid gap-4">
<p className="text-sm text-muted-foreground">
Unggah dokumen hasil untuk menandai surat ini
sebagai selesai.
</p>
<FileUploadField
name="result"
label="Dokumen Hasil"
existingFileName={processing.result_name}
existingFileUrl={processing.result_url}
error={errors.result}
/>
</div>
)
}
</FormDialog>
);
}
function EditForm({
open,
onOpenChange,
editing,
students,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
editing: LetterRequest | null;
students: LetterRequestStudent[];
}) {
return (
<FormDialog
@ -372,11 +342,7 @@ function EditForm({
{({ errors }) =>
editing && (
<div className="grid gap-4">
<LetterRequestFields
errors={errors}
editing={editing}
students={students}
/>
<LetterRequestFields errors={errors} editing={editing} />
</div>
)
}

View File

@ -1,91 +0,0 @@
import type { ColumnDef } from '@tanstack/react-table';
import { format } from 'date-fns';
import { Eye } from 'lucide-react';
import { RowActions } from '@/components/row-actions';
import { Badge } from '@/components/ui/badge';
import { show } from '@/routes/student/letter-requests';
import type { LetterRequest, LetterStatus } from '@/types/letter-request';
import { LetterStatusLabels } from '@/types/letter-request';
export const letterRequestColumns: ColumnDef<LetterRequest>[] = [
{
accessorKey: 'letter_type',
header: () => <span>Jenis Surat</span>,
cell: ({ row }) => {
const letterRequest = row.original;
return (
<div>
<p className="font-medium">{letterRequest.letter_type}</p>
{letterRequest.purpose && (
<p className="line-clamp-1 max-w-xs text-xs text-muted-foreground">
{letterRequest.purpose}
</p>
)}
</div>
);
},
},
{
accessorKey: 'status',
header: () => <span className="block text-center">Status</span>,
meta: {
className: 'w-[130px] text-center',
headerClassName: 'w-[130px] text-center',
},
cell: ({ row }) => {
const status = row.original.status as LetterStatus | null;
const variant =
status === 'completed'
? 'default'
: status === 'rejected'
? 'destructive'
: status === 'in_process'
? 'secondary'
: 'outline';
return (
<div className="flex justify-center">
{status ? (
<Badge variant={variant}>
{LetterStatusLabels[status]}
</Badge>
) : (
<span className="text-muted-foreground">-</span>
)}
</div>
);
},
},
{
accessorKey: 'submitted_at',
header: () => <span>Diajukan</span>,
cell: ({ row }) => {
const submittedAt = row.original.submitted_at;
return submittedAt
? format(new Date(submittedAt), 'd MMM yyyy, HH:mm')
: '-';
},
},
{
id: 'actions',
header: () => <span className="block text-center">Aksi</span>,
meta: {
className: 'w-[80px] text-center',
headerClassName: 'w-[80px] text-center',
},
cell: ({ row }) => (
<RowActions
actions={[
{
label: 'Lihat Detail',
icon: <Eye className="h-4 w-4" />,
href: show.url(row.original.id),
},
]}
/>
),
},
];

View File

@ -1,79 +0,0 @@
import { Form, Head, Link } from '@inertiajs/react';
import { ArrowLeft } from 'lucide-react';
import InputError from '@/components/input-error';
import { PageHeader } from '@/components/page-header';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardFooter,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { index, store } from '@/routes/student/letter-requests';
export default function LetterRequestCreate() {
return (
<>
<Head title="Ajukan Surat Permohonan" />
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
<PageHeader
title="Ajukan Surat Permohonan"
actions={
<Button variant="outline" asChild>
<Link href={index.url()}>
<ArrowLeft className="h-4 w-4" />
Kembali
</Link>
</Button>
}
/>
<Form action={store()}>
{({ errors, processing }) => (
<Card>
<CardHeader>
<CardTitle>Detail Permohonan</CardTitle>
</CardHeader>
<CardContent className="grid gap-4">
<div className="grid gap-2">
<Label htmlFor="letter_type">
Jenis Surat{' '}
<span className="text-destructive">
*
</span>
</Label>
<Input
id="letter_type"
name="letter_type"
placeholder="Contoh: Surat Aktif Kuliah"
aria-invalid={!!errors.letter_type}
/>
<InputError message={errors.letter_type} />
</div>
<div className="grid gap-2">
<Label htmlFor="purpose">Keperluan</Label>
<Textarea
id="purpose"
name="purpose"
placeholder="Jelaskan keperluan surat ini"
/>
<InputError message={errors.purpose} />
</div>
</CardContent>
<CardFooter className="justify-end">
<Button type="submit" disabled={processing}>
Ajukan
</Button>
</CardFooter>
</Card>
)}
</Form>
</div>
</>
);
}

View File

@ -1,40 +0,0 @@
import { Head, Link } from '@inertiajs/react';
import { Plus } from 'lucide-react';
import { DataTable } from '@/components/data-table';
import { PageHeader } from '@/components/page-header';
import { Button } from '@/components/ui/button';
import { create } from '@/routes/student/letter-requests';
import type { LetterRequest } from '@/types/letter-request';
import { letterRequestColumns } from './columns';
type Props = {
letterRequests: LetterRequest[];
};
export default function LetterRequestIndex({ letterRequests }: Props) {
return (
<>
<Head title="Surat Permohonan" />
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
<PageHeader
title="Surat Permohonan"
actions={
<Button asChild>
<Link href={create.url()}>
<Plus className="h-4 w-4" />
Ajukan Surat
</Link>
</Button>
}
/>
<DataTable
columns={letterRequestColumns}
data={letterRequests}
emptyText="Anda belum pernah mengajukan surat permohonan."
/>
</div>
</>
);
}

View File

@ -1,123 +0,0 @@
import { Head, Link } from '@inertiajs/react';
import { format } from 'date-fns';
import { ArrowLeft, Paperclip } from 'lucide-react';
import { PageHeader } from '@/components/page-header';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { index } from '@/routes/student/letter-requests';
import type { LetterRequest, LetterStatus } from '@/types/letter-request';
import { LetterStatusLabels } from '@/types/letter-request';
type Props = {
letterRequest: LetterRequest;
};
export default function LetterRequestShow({ letterRequest }: Props) {
const status = letterRequest.status as LetterStatus | null;
const variant =
status === 'completed'
? 'default'
: status === 'rejected'
? 'destructive'
: status === 'in_process'
? 'secondary'
: 'outline';
return (
<>
<Head title="Detail Surat Permohonan" />
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
<PageHeader
title="Detail Surat Permohonan"
actions={
<Button variant="outline" asChild>
<Link href={index.url()}>
<ArrowLeft className="h-4 w-4" />
Kembali
</Link>
</Button>
}
/>
<Card>
<CardHeader className="flex flex-row items-start justify-between gap-4">
<div>
<CardTitle>{letterRequest.letter_type}</CardTitle>
{letterRequest.submitted_at && (
<p className="mt-1 text-sm text-muted-foreground">
Diajukan pada{' '}
{format(
new Date(letterRequest.submitted_at),
'd MMM yyyy, HH:mm',
)}
</p>
)}
</div>
{status && (
<Badge variant={variant}>
{LetterStatusLabels[status]}
</Badge>
)}
</CardHeader>
<CardContent className="grid gap-4 border-t pt-4 text-sm">
<div>
<p className="font-medium text-muted-foreground">
Keperluan
</p>
<p className="mt-1">
{letterRequest.purpose || '-'}
</p>
</div>
{letterRequest.processor && (
<div>
<p className="font-medium text-muted-foreground">
Diproses Oleh
</p>
<p className="mt-1">
{letterRequest.processor.profile
?.full_name ?? '-'}
</p>
</div>
)}
{letterRequest.completed_at && (
<div>
<p className="font-medium text-muted-foreground">
Selesai Pada
</p>
<p className="mt-1">
{format(
new Date(letterRequest.completed_at),
'd MMM yyyy, HH:mm',
)}
</p>
</div>
)}
<div>
<p className="font-medium text-muted-foreground">
Dokumen
</p>
{letterRequest.result_url ? (
<a
href={letterRequest.result_url}
target="_blank"
rel="noreferrer"
className="mt-1 inline-flex items-center gap-1 text-primary underline underline-offset-4 hover:text-primary/80"
>
<Paperclip className="h-3.5 w-3.5" />
{letterRequest.result_name}
</a>
) : (
<p className="mt-1 text-muted-foreground">
Belum tersedia.
</p>
)}
</div>
</CardContent>
</Card>
</div>
</>
);
}

View File

@ -14,11 +14,13 @@ export const LetterStatusLabels: Record<LetterStatus, string> = {
rejected: 'Ditolak',
};
export type LetterRequestStudent = {
export type LetterRequestUser = {
id: number;
student_number: string;
department: { id: number; name: string } | null;
user: { profile: { full_name: string } | null } | null;
profile: { full_name: string } | null;
student: {
student_number: string;
department: { id: number; name: string } | null;
} | null;
};
export type LetterRequestProcessor = {
@ -28,8 +30,8 @@ export type LetterRequestProcessor = {
export type LetterRequest = {
id: number;
student_id: number;
student: LetterRequestStudent | null;
user_id: number;
user: LetterRequestUser | null;
letter_type: string;
purpose: string | null;
status: LetterStatus | null;

View File

@ -137,13 +137,6 @@
Route::post('/', [CourseRegistrationController::class, 'store'])->name('store');
Route::get('{submission}', [CourseRegistrationController::class, 'show'])->name('show');
});
Route::prefix('letter-requests')->name('student.letter-requests.')->group(function () {
Route::get('/', [LetterRequestController::class, 'mine'])->name('index');
Route::get('create', [LetterRequestController::class, 'create'])->name('create');
Route::post('/', [LetterRequestController::class, 'store'])->name('store');
Route::get('{letter_request}', [LetterRequestController::class, 'show'])->name('show');
});
});
Route::middleware('role:dosen')->prefix('academic-advising-logs')->name('lecturer.academic-advising-logs.')->group(function () {
@ -174,6 +167,7 @@
->middlewareFor(['store'], 'permission:create-letter-requests')
->middlewareFor(['update'], 'permission:update-letter-requests')
->middlewareFor(['destroy'], 'permission:delete-letter-requests');
Route::patch('letter-requests/{letter_request}/status', [LetterRequestController::class, 'updateStatus'])->name('letter-requests.update_status')->middleware('permission:update-letter-requests-status');
Route::resource('academic-advising-logs', AcademicAdvisingLogController::class)
->except(['create', 'edit', 'show'])