From ddbbf1cc7fcfa657c8477d9c2192e81486124d60 Mon Sep 17 00:00:00 2001 From: Yoga Pangestu Date: Mon, 31 Aug 2026 15:19:56 +0700 Subject: [PATCH] feat: implement feedback management with status updates and permissions --- .../Controllers/Admin/FeedbackController.php | 10 +- .../Admin/{ => Feedback}/FeedbackRequest.php | 4 +- .../Feedback/UpdateFeedbackStatusRequest.php | 22 +++ app/Models/Feedback.php | 5 + app/Services/Admin/FeedbackService.php | 17 +- app/Support/PermissionCatalog.php | 5 + ...26_08_25_000006_create_feedbacks_table.php | 1 + ..._add_created_by_to_notifications_table.php | 22 --- database/seeders/RolePermissionSeeder.php | 12 +- resources/js/components/form-dialog.tsx | 14 +- ...dent-status-badge.tsx => status-badge.tsx} | 23 +-- resources/js/pages/admin/feedback/columns.tsx | 73 ++++++--- resources/js/pages/admin/feedback/index.tsx | 155 ++++++++++++++++-- .../js/pages/admin/users/students/columns.tsx | 15 +- resources/js/types/feedback.ts | 7 + routes/admin.php | 7 +- 16 files changed, 302 insertions(+), 90 deletions(-) rename app/Http/Requests/Admin/{ => Feedback}/FeedbackRequest.php (76%) create mode 100644 app/Http/Requests/Admin/Feedback/UpdateFeedbackStatusRequest.php delete mode 100644 database/migrations/2026_08_30_150000_add_created_by_to_notifications_table.php rename resources/js/components/{student-status-badge.tsx => status-badge.tsx} (81%) diff --git a/app/Http/Controllers/Admin/FeedbackController.php b/app/Http/Controllers/Admin/FeedbackController.php index 3f1d632..dd300fe 100644 --- a/app/Http/Controllers/Admin/FeedbackController.php +++ b/app/Http/Controllers/Admin/FeedbackController.php @@ -5,7 +5,8 @@ use App\Enums\FeedbackStatus; use App\Enums\FeedbackType; use App\Http\Controllers\Controller; -use App\Http\Requests\Admin\FeedbackRequest; +use App\Http\Requests\Admin\Feedback\FeedbackRequest; +use App\Http\Requests\Admin\Feedback\UpdateFeedbackStatusRequest; use App\Http\Requests\PaginatedRequest; use App\Models\Feedback; use App\Services\Admin\FeedbackService; @@ -55,6 +56,13 @@ public function update(FeedbackRequest $request, Feedback $feedback): RedirectRe return to_route('admin.feedback.index'); } + public function updateStatus(UpdateFeedbackStatusRequest $request, Feedback $feedback): RedirectResponse + { + $this->service->updateStatus($feedback, $request->validated('status')); + + return Inertia::flash('toast', ['type' => 'success', 'message' => 'Status masukan berhasil diperbarui.'])->back(); + } + public function destroy(Request $request, Feedback $feedback): RedirectResponse { abort_unless($feedback->user_id === $request->user()->id, 403); diff --git a/app/Http/Requests/Admin/FeedbackRequest.php b/app/Http/Requests/Admin/Feedback/FeedbackRequest.php similarity index 76% rename from app/Http/Requests/Admin/FeedbackRequest.php rename to app/Http/Requests/Admin/Feedback/FeedbackRequest.php index 31b04a0..f72828b 100644 --- a/app/Http/Requests/Admin/FeedbackRequest.php +++ b/app/Http/Requests/Admin/Feedback/FeedbackRequest.php @@ -1,6 +1,6 @@ user()->can($this->isMethod('post') ? 'create-feedback' : 'update-feedback'); } public function rules(): array diff --git a/app/Http/Requests/Admin/Feedback/UpdateFeedbackStatusRequest.php b/app/Http/Requests/Admin/Feedback/UpdateFeedbackStatusRequest.php new file mode 100644 index 0000000..54fef9f --- /dev/null +++ b/app/Http/Requests/Admin/Feedback/UpdateFeedbackStatusRequest.php @@ -0,0 +1,22 @@ +user()->can('update-feedback-status'); + } + + public function rules(): array + { + return [ + 'status' => ['required', Rule::enum(FeedbackStatus::class)], + ]; + } +} diff --git a/app/Models/Feedback.php b/app/Models/Feedback.php index c5ad8e1..866363f 100644 --- a/app/Models/Feedback.php +++ b/app/Models/Feedback.php @@ -28,4 +28,9 @@ public function user(): BelongsTo { return $this->belongsTo(User::class); } + + public function handler(): BelongsTo + { + return $this->belongsTo(User::class, 'handled_by'); + } } diff --git a/app/Services/Admin/FeedbackService.php b/app/Services/Admin/FeedbackService.php index 5053255..2f5e33e 100644 --- a/app/Services/Admin/FeedbackService.php +++ b/app/Services/Admin/FeedbackService.php @@ -45,9 +45,10 @@ public function paginated(User $user, int $perPage = 25, string $search = '', ?s { return Feedback::query() ->where('user_id', $user->id) - ->when($search, fn ($q) => $q->where('subject', 'like', "%{$search}%")) - ->when($type, fn ($q) => $q->where('type', $type)) - ->when($status, fn ($q) => $q->where('status', $status)) + ->with('handler.profile') + ->when($search, fn($q) => $q->where('subject', 'like', "%{$search}%")) + ->when($type, fn($q) => $q->where('type', $type)) + ->when($status, fn($q) => $q->where('status', $status)) ->latest() ->paginate($perPage); } @@ -80,6 +81,16 @@ public function update(Feedback $feedback, array $data): Feedback return $feedback; } + public function updateStatus(Feedback $feedback, string $status): Feedback + { + $feedback->update([ + 'status' => $status, + 'handled_by' => auth()->id(), + ]); + + return $feedback; + } + public function delete(Feedback $feedback): bool { return $feedback->delete(); diff --git a/app/Support/PermissionCatalog.php b/app/Support/PermissionCatalog.php index 66d578c..e300ae6 100644 --- a/app/Support/PermissionCatalog.php +++ b/app/Support/PermissionCatalog.php @@ -48,6 +48,10 @@ class PermissionCatalog 'view-roles', 'update-roles', ]; + public const FEEDBACK = [ + 'view-feedback', 'create-feedback', 'update-feedback', 'delete-feedback', 'update-feedback-status', + ]; + /** * @return array> */ @@ -61,6 +65,7 @@ public static function groups(): array 'Keuangan' => self::FINANCES, 'Layanan' => self::SERVICES, 'Pengguna' => self::USERS, + 'Kritik & Saran' => self::FEEDBACK, 'Pengembang' => self::DEVELOPER, ]; } diff --git a/database/migrations/2026_08_25_000006_create_feedbacks_table.php b/database/migrations/2026_08_25_000006_create_feedbacks_table.php index 1f95450..b756bca 100644 --- a/database/migrations/2026_08_25_000006_create_feedbacks_table.php +++ b/database/migrations/2026_08_25_000006_create_feedbacks_table.php @@ -17,6 +17,7 @@ public function up(): void $table->string('subject', 150); $table->text('message'); $table->enum('status', FeedbackStatus::values())->default(FeedbackStatus::Submitted->value); + $table->foreignId('handled_by')->nullable()->constrained('users')->nullOnDelete(); $table->text('admin_notes')->nullable(); $table->timestamps(); }); diff --git a/database/migrations/2026_08_30_150000_add_created_by_to_notifications_table.php b/database/migrations/2026_08_30_150000_add_created_by_to_notifications_table.php deleted file mode 100644 index 9ff53ea..0000000 --- a/database/migrations/2026_08_30_150000_add_created_by_to_notifications_table.php +++ /dev/null @@ -1,22 +0,0 @@ -foreignId('created_by')->nullable()->after('user_id')->constrained('users')->nullOnDelete(); - }); - } - - public function down(): void - { - Schema::table('notifications', function (Blueprint $table) { - $table->dropConstrainedForeignId('created_by'); - }); - } -}; diff --git a/database/seeders/RolePermissionSeeder.php b/database/seeders/RolePermissionSeeder.php index 28eaaf9..b261a77 100644 --- a/database/seeders/RolePermissionSeeder.php +++ b/database/seeders/RolePermissionSeeder.php @@ -21,6 +21,10 @@ public function run(): void $services = PermissionCatalog::SERVICES; $users = PermissionCatalog::USERS; + $feedbackSelfService = [ + 'view-feedback', 'create-feedback', 'update-feedback', 'delete-feedback', + ]; + $permissionNames = PermissionCatalog::all(); foreach ($permissionNames as $name) { @@ -30,8 +34,8 @@ public function run(): void app()[PermissionRegistrar::class]->forgetCachedPermissions(); $roles = [ - 'mahasiswa' => ['view-dashboard'], - 'dosen' => ['view-dashboard'], + 'mahasiswa' => ['view-dashboard', ...$feedbackSelfService], + 'dosen' => ['view-dashboard', ...$feedbackSelfService], 'staff-admin' => [ 'view-dashboard', ...$master, @@ -39,10 +43,13 @@ public function run(): void ...$manage, ...$services, ...$users, + ...$feedbackSelfService, + 'update-feedback-status', ], 'staff-keuangan' => [ 'view-dashboard', ...$finances, + ...$feedbackSelfService, ], 'kaprodi' => [ 'view-dashboard', @@ -51,6 +58,7 @@ public function run(): void 'view-course-classes', 'view-course-registrations', 'view-students', + ...$feedbackSelfService, ], 'developer' => $permissionNames, ]; diff --git a/resources/js/components/form-dialog.tsx b/resources/js/components/form-dialog.tsx index 578f0f8..faf6e35 100644 --- a/resources/js/components/form-dialog.tsx +++ b/resources/js/components/form-dialog.tsx @@ -20,12 +20,13 @@ type FormDialogProps = { submitDisabled?: boolean; submitLabel?: ReactNode; submittingLabel?: ReactNode; + contentClassName?: string; children: - | ReactNode - | ((ctx: { - errors: Record; - processing: boolean; - }) => ReactNode); + | ReactNode + | ((ctx: { + errors: Record; + processing: boolean; + }) => ReactNode); }; export function FormDialog({ @@ -38,11 +39,12 @@ export function FormDialog({ submitDisabled = false, submitLabel = 'Simpan', submittingLabel = 'Menyimpan...', + contentClassName, children, }: FormDialogProps) { return ( - +
void; disabled?: boolean; + variants?: Record< + string, + 'default' | 'secondary' | 'destructive' | 'outline' + >; }; -const StudentStatusVariants: Record< - string, - 'default' | 'secondary' | 'destructive' | 'outline' -> = { - active: 'default', - on_leave: 'secondary', - graduated: 'outline', - dropped_out: 'destructive', -}; - -export function StudentStatusBadge({ +export function StatusBadge({ status, statuses, onChange, disabled, -}: StudentStatusBadgeProps) { + variants, +}: StatusBadgeProps) { const label = statuses.find((option) => option.value === status)?.label ?? status ?? '-'; - const variant = (status && StudentStatusVariants[status]) || 'outline'; + const variant = (status && variants?.[status]) || 'outline'; return ( diff --git a/resources/js/pages/admin/feedback/columns.tsx b/resources/js/pages/admin/feedback/columns.tsx index 24516dc..fdbba2b 100644 --- a/resources/js/pages/admin/feedback/columns.tsx +++ b/resources/js/pages/admin/feedback/columns.tsx @@ -1,16 +1,34 @@ import type { ColumnDef } from '@tanstack/react-table'; import { format } from 'date-fns'; -import { Pencil, Trash2 } from 'lucide-react'; +import { Eye, Pencil, Trash2 } from 'lucide-react'; import { RowActions } from '@/components/row-actions'; +import { StatusBadge } from '@/components/status-badge'; import { Badge } from '@/components/ui/badge'; -import type { Feedback, FeedbackStatusValue } from '@/types/feedback'; -import { FeedbackStatusLabels, FeedbackTypeLabels } from '@/types/feedback'; +import type { Feedback } from '@/types/feedback'; +import { FeedbackTypeLabels } from '@/types/feedback'; export type { Feedback } from '@/types/feedback'; +type StatusOption = { value: string; label: string }; + type CreateColumnsParams = { + handleView: (feedback: Feedback) => void; handleEdit: (feedback: Feedback) => void; handleDeleteClick: (feedback: Feedback) => void; + handleStatusChange: (feedback: Feedback, status: string) => void; + statuses: StatusOption[]; + canUpdateStatus: boolean; + canUpdate: boolean; + canDelete: boolean; +}; + +export const FeedbackStatusVariants: Record< + string, + 'default' | 'secondary' | 'destructive' | 'outline' +> = { + submitted: 'outline', + in_review: 'secondary', + resolved: 'default', }; function stripHtml(html: string): string { @@ -20,24 +38,19 @@ function stripHtml(html: string): string { .trim(); } -function statusBadgeVariant( - status: FeedbackStatusValue, -): 'outline' | 'secondary' | 'default' { - if (status === 'resolved') { - return 'default'; - } - - if (status === 'in_review') { - return 'secondary'; - } - - return 'outline'; -} - export function createFeedbackColumns( params: CreateColumnsParams, ): ColumnDef[] { - const { handleEdit, handleDeleteClick } = params; + const { + handleView, + handleEdit, + handleDeleteClick, + handleStatusChange, + statuses, + canUpdateStatus, + canUpdate, + canDelete, + } = params; return [ { @@ -80,12 +93,23 @@ export function createFeedbackColumns( }, cell: ({ row }) => (
- - {FeedbackStatusLabels[row.original.status]} - + + handleStatusChange(row.original, status) + } + disabled={!canUpdateStatus} + />
), }, + { + accessorKey: 'handler.profile.full_name', + header: () => Ditindaklanjuti Oleh, + cell: ({ row }) => row.original.handler?.profile?.full_name ?? '-', + }, { accessorKey: 'created_at', header: () => Dikirim, @@ -102,9 +126,15 @@ export function createFeedbackColumns( cell: ({ row }) => ( , + onClick: () => handleView(row.original), + }, { label: 'Edit', icon: , + show: canUpdate, onClick: () => handleEdit(row.original), }, { @@ -112,6 +142,7 @@ export function createFeedbackColumns( icon: ( ), + show: canDelete, onClick: () => handleDeleteClick(row.original), }, ]} diff --git a/resources/js/pages/admin/feedback/index.tsx b/resources/js/pages/admin/feedback/index.tsx index 3eeb278..da0f8e0 100644 --- a/resources/js/pages/admin/feedback/index.tsx +++ b/resources/js/pages/admin/feedback/index.tsx @@ -1,4 +1,5 @@ import { Head, router } from '@inertiajs/react'; +import { format } from 'date-fns'; import { Plus } from 'lucide-react'; import { useState } from 'react'; import type { PaginationState } from '@/components/data-table'; @@ -10,7 +11,14 @@ import { FormDialog } from '@/components/form-dialog'; import InputError from '@/components/input-error'; import { PageHeader } from '@/components/page-header'; import TiptapEditor from '@/components/rich-text-editor'; +import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { @@ -20,15 +28,18 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; +import { usePermissions } from '@/hooks/use-permissions'; import { useServerTable } from '@/hooks/use-server-table'; import { index as feedbackIndex, destroy, store, update, + update_status, } from '@/routes/admin/feedback'; import type { Feedback } from '@/types/feedback'; -import { createFeedbackColumns } from './columns'; +import { FeedbackStatusLabels, FeedbackTypeLabels } from '@/types/feedback'; +import { createFeedbackColumns, FeedbackStatusVariants } from './columns'; type FeedbackTypeOption = { value: string; label: string }; @@ -57,6 +68,12 @@ export default function FeedbackIndex({ const [createOpen, setCreateOpen] = useState(false); const [editing, setEditing] = useState(null); const [deleting, setDeleting] = useState(null); + const [viewing, setViewing] = useState(null); + const { hasPermission } = usePermissions(); + const canCreate = hasPermission('create-feedback'); + const canUpdate = hasPermission('update-feedback'); + const canDelete = hasPermission('delete-feedback'); + const canUpdateStatus = hasPermission('update-feedback-status'); const filterFields: FilterField[] = [ { @@ -100,9 +117,23 @@ export default function FeedbackIndex({ }); } + function handleStatusChange(feedback: Feedback, status: string) { + router.patch( + update_status.url(feedback.id), + { status }, + { preserveScroll: true }, + ); + } + const columns = createFeedbackColumns({ + handleView: (feedback) => setViewing(feedback), handleEdit: (feedback) => setEditing(feedback), handleDeleteClick: (feedback) => setDeleting(feedback), + handleStatusChange, + statuses, + canUpdateStatus, + canUpdate, + canDelete, }); return ( @@ -112,22 +143,18 @@ export default function FeedbackIndex({
- Sampaikan kritik, saran, atau aduan Anda kepada - kami. -

- } actions={ - - + canCreate && ( + + + ) } /> @@ -149,6 +176,16 @@ export default function FeedbackIndex({ types={types} /> + { + if (!open) { + setViewing(null); + } + }} + feedback={viewing} + /> + onOpenChange(false)} + contentClassName="sm:max-w-4xl" > {({ errors }) => (
@@ -291,6 +329,7 @@ function EditForm({ action={editing ? update(editing.id) : ''} resetOnSuccess onSuccess={() => onOpenChange(false)} + contentClassName="sm:max-w-2xl" > {({ errors }) => editing && ( @@ -306,3 +345,87 @@ function EditForm({ ); } + +function ViewDetailDialog({ + open, + onOpenChange, + feedback, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + feedback: Feedback | null; +}) { + return ( + + + + Detail Kritik dan Saran + + + {feedback && ( +
+
+ + {FeedbackTypeLabels[feedback.type]} + + + {FeedbackStatusLabels[feedback.status]} + + + Dikirim{' '} + {format( + new Date(feedback.created_at), + 'd MMM yyyy, HH:mm', + )} + +
+ +
+ +

{feedback.subject}

+
+ +
+ +

+ {feedback.handler?.profile?.full_name ?? + 'Belum ditindaklanjuti'} +

+
+ +
+ +
+
+ + {feedback.admin_notes && ( +
+ +

+ {feedback.admin_notes} +

+
+ )} +
+ )} + +
+ ); +} diff --git a/resources/js/pages/admin/users/students/columns.tsx b/resources/js/pages/admin/users/students/columns.tsx index 467581e..0c79a59 100644 --- a/resources/js/pages/admin/users/students/columns.tsx +++ b/resources/js/pages/admin/users/students/columns.tsx @@ -3,7 +3,17 @@ import { Key, Pencil, Trash2 } from 'lucide-react'; import { ActiveStatusSwitch } from '@/components/active-status-switch'; import { GenderBadge } from '@/components/gender-badge'; import { RowActions } from '@/components/row-actions'; -import { StudentStatusBadge } from '@/components/student-status-badge'; +import { StatusBadge } from '@/components/status-badge'; + +const StudentStatusVariants: Record< + string, + 'default' | 'secondary' | 'destructive' | 'outline' +> = { + active: 'default', + on_leave: 'secondary', + graduated: 'outline', + dropped_out: 'destructive', +}; export type Student = { id: number; @@ -97,9 +107,10 @@ export function createStudentColumns( accessorKey: 'student.status', header: () => Status, cell: ({ row }) => ( - handleStatusChange(row.original, status) } diff --git a/resources/js/types/feedback.ts b/resources/js/types/feedback.ts index ab4c766..365c356 100644 --- a/resources/js/types/feedback.ts +++ b/resources/js/types/feedback.ts @@ -18,6 +18,11 @@ export const FeedbackStatusLabels: Record = { resolved: 'Selesai', }; +export type FeedbackHandler = { + id: number; + profile: { full_name: string } | null; +}; + export type Feedback = { id: number; type: FeedbackTypeValue; @@ -25,6 +30,8 @@ export type Feedback = { message: string; status: FeedbackStatusValue; admin_notes: string | null; + handled_by: number | null; + handler: FeedbackHandler | null; created_at: string; updated_at: string; }; diff --git a/routes/admin.php b/routes/admin.php index 234d53b..f2799fa 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -202,7 +202,12 @@ Route::resource('admin/feedback', FeedbackController::class) ->except(['create', 'edit', 'show']) - ->names('admin.feedback'); + ->names('admin.feedback') + ->middlewareFor(['index'], 'permission:view-feedback') + ->middlewareFor(['store'], 'permission:create-feedback') + ->middlewareFor(['update'], 'permission:update-feedback') + ->middlewareFor(['destroy'], 'permission:delete-feedback'); + Route::patch('admin/feedback/{feedback}/status', [FeedbackController::class, 'updateStatus'])->name('admin.feedback.update_status')->middleware('permission:update-feedback-status'); Route::prefix('admin/users')->name('admin.users.')->group(function () { Route::resource('lecturers', LecturerController::class)