diff --git a/app/Http/Controllers/Admin/FeedbackController.php b/app/Http/Controllers/Admin/FeedbackController.php index 26c785d..c244692 100644 --- a/app/Http/Controllers/Admin/FeedbackController.php +++ b/app/Http/Controllers/Admin/FeedbackController.php @@ -6,6 +6,7 @@ use App\Enums\FeedbackType; use App\Http\Controllers\Controller; use App\Http\Requests\Admin\Feedback\FeedbackRequest; +use App\Http\Requests\Admin\Feedback\ReplyFeedbackRequest; use App\Http\Requests\Admin\Feedback\UpdateFeedbackStatusRequest; use App\Http\Requests\PaginatedRequest; use App\Models\Feedback; @@ -55,6 +56,7 @@ public function store(FeedbackRequest $request): RedirectResponse public function edit(Request $request, Feedback $feedback): Response { abort_unless($feedback->user_id === $request->user()->id, 403); + abort_unless($feedback->status === FeedbackStatus::Submitted, 403); return Inertia::render('admin/feedback/edit', [ 'feedback' => $feedback, @@ -64,8 +66,6 @@ public function edit(Request $request, Feedback $feedback): Response public function update(FeedbackRequest $request, Feedback $feedback): RedirectResponse { - abort_unless($feedback->user_id === $request->user()->id, 403); - $this->service->update($feedback, $request->validated()); Inertia::flash('toast', ['type' => 'success', 'message' => 'Masukan berhasil diperbarui.']); @@ -76,6 +76,7 @@ public function update(FeedbackRequest $request, Feedback $feedback): RedirectRe public function destroy(Request $request, Feedback $feedback): RedirectResponse { abort_unless($feedback->user_id === $request->user()->id, 403); + abort_unless($feedback->status === FeedbackStatus::Submitted, 403); $this->service->delete($feedback); @@ -88,4 +89,11 @@ public function updateStatus(UpdateFeedbackStatusRequest $request, Feedback $fee return Inertia::flash('toast', ['type' => 'success', 'message' => 'Status masukan berhasil diperbarui.'])->back(); } + + public function reply(ReplyFeedbackRequest $request, Feedback $feedback): RedirectResponse + { + $this->service->reply($feedback, $request->validated('reply')); + + return Inertia::flash('toast', ['type' => 'success', 'message' => 'Balasan berhasil dikirim.'])->back(); + } } diff --git a/app/Http/Controllers/Admin/Services/LetterRequestController.php b/app/Http/Controllers/Admin/Services/LetterRequestController.php index ef06550..fab579d 100644 --- a/app/Http/Controllers/Admin/Services/LetterRequestController.php +++ b/app/Http/Controllers/Admin/Services/LetterRequestController.php @@ -54,7 +54,7 @@ public function update(LetterRequestRequest $request, LetterRequest $letterReque public function destroy(LetterRequest $letterRequest): RedirectResponse { - abort_if($letterRequest->status !== LetterStatus::Submitted->value, 403); + abort_if($letterRequest->status !== LetterStatus::Submitted, 403); $this->service->delete($letterRequest); diff --git a/app/Http/Requests/Admin/Feedback/FeedbackRequest.php b/app/Http/Requests/Admin/Feedback/FeedbackRequest.php index 0a32d11..aa899e8 100644 --- a/app/Http/Requests/Admin/Feedback/FeedbackRequest.php +++ b/app/Http/Requests/Admin/Feedback/FeedbackRequest.php @@ -2,6 +2,7 @@ namespace App\Http\Requests\Admin\Feedback; +use App\Enums\FeedbackStatus; use App\Enums\FeedbackType; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; @@ -10,7 +11,15 @@ class FeedbackRequest extends FormRequest { public function authorize(): bool { - return $this->user()->can($this->isMethod('post') ? 'create-feedback' : 'update-feedback'); + if ($this->isMethod('post')) { + return $this->user()->can('create-feedback'); + } + + $feedback = $this->route('feedback'); + + return $this->user()->can('update-feedback') + && $feedback->user_id === $this->user()->id + && $feedback->status === FeedbackStatus::Submitted; } public function rules(): array diff --git a/app/Http/Requests/Admin/Feedback/ReplyFeedbackRequest.php b/app/Http/Requests/Admin/Feedback/ReplyFeedbackRequest.php new file mode 100644 index 0000000..750597b --- /dev/null +++ b/app/Http/Requests/Admin/Feedback/ReplyFeedbackRequest.php @@ -0,0 +1,20 @@ +user()->can('reply-feedback'); + } + + public function rules(): array + { + return [ + 'reply' => ['required', 'string'], + ]; + } +} diff --git a/app/Http/Requests/Admin/Services/LetterRequest/LetterRequestRequest.php b/app/Http/Requests/Admin/Services/LetterRequest/LetterRequestRequest.php index b1a9456..dbc1dc1 100644 --- a/app/Http/Requests/Admin/Services/LetterRequest/LetterRequestRequest.php +++ b/app/Http/Requests/Admin/Services/LetterRequest/LetterRequestRequest.php @@ -17,7 +17,7 @@ public function authorize(): bool return $this->user()->can('update-letter-requests') && $letterRequest->user_id === $this->user()->id - && $letterRequest->status === LetterStatus::Submitted->value; + && $letterRequest->status === LetterStatus::Submitted; } public function rules(): array diff --git a/app/Models/Feedback.php b/app/Models/Feedback.php index 32c555c..114a10f 100644 --- a/app/Models/Feedback.php +++ b/app/Models/Feedback.php @@ -21,6 +21,7 @@ protected function casts(): array return [ 'type' => FeedbackType::class, 'status' => FeedbackStatus::class, + 'replied_at' => 'datetime', ]; } diff --git a/app/Services/Admin/FeedbackService.php b/app/Services/Admin/FeedbackService.php index a5e4215..755537f 100644 --- a/app/Services/Admin/FeedbackService.php +++ b/app/Services/Admin/FeedbackService.php @@ -43,16 +43,19 @@ public function __construct() public function pendingCount(User $user): int { + if (! $user->can('update-feedback-status')) { + return 0; + } + return Feedback::query() - ->where('user_id', $user->id) - ->where('status', '!=', FeedbackStatus::Resolved) + ->where('status', FeedbackStatus::Submitted) ->count(); } public function paginated(User $user, int $perPage = 25, string $search = '', ?string $type = null, ?string $status = null): LengthAwarePaginator { return Feedback::query() - ->where('user_id', $user->id) + ->when(! $user->can('update-feedback-status'), fn ($q) => $q->where('user_id', $user->id)) ->with(['user.profile', 'handler.profile']) ->when($search, fn ($q) => $q->where('subject', 'like', "%{$search}%")) ->when($type, fn ($q) => $q->where('type', $type)) @@ -73,10 +76,11 @@ public function create(User $user, array $data): Feedback public function update(Feedback $feedback, array $data): Feedback { - $feedback->type = $data['type']; - $feedback->subject = $data['subject']; - $feedback->message = $this->sanitizer->sanitize($data['message']); - $feedback->update(); + $feedback->update([ + 'type' => $data['type'], + 'subject' => $data['subject'], + 'message' => $this->sanitizer->sanitize($data['message']), + ]); return $feedback; } @@ -91,6 +95,18 @@ public function updateStatus(Feedback $feedback, string $status): Feedback return $feedback; } + public function reply(Feedback $feedback, string $reply): Feedback + { + $feedback->update([ + 'reply' => $this->sanitizer->sanitize($reply), + 'replied_at' => now(), + 'handled_by' => auth()->id(), + 'status' => FeedbackStatus::Resolved->value, + ]); + + return $feedback; + } + public function delete(Feedback $feedback): bool { return $feedback->delete(); diff --git a/app/Support/PermissionCatalog.php b/app/Support/PermissionCatalog.php index 536f105..922f9e3 100644 --- a/app/Support/PermissionCatalog.php +++ b/app/Support/PermissionCatalog.php @@ -50,7 +50,7 @@ class PermissionCatalog ]; public const FEEDBACK = [ - 'view-feedback', 'create-feedback', 'update-feedback', 'delete-feedback', 'update-feedback-status', + 'view-feedback', 'create-feedback', 'update-feedback', 'delete-feedback', 'update-feedback-status', 'reply-feedback', ]; /** 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 b756bca..817f0e4 100644 --- a/database/migrations/2026_08_25_000006_create_feedbacks_table.php +++ b/database/migrations/2026_08_25_000006_create_feedbacks_table.php @@ -18,7 +18,7 @@ public function up(): void $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->text('reply')->nullable(); $table->timestamps(); }); } diff --git a/database/seeders/RolePermissionSeeder.php b/database/seeders/RolePermissionSeeder.php index aec69ad..cf869a1 100644 --- a/database/seeders/RolePermissionSeeder.php +++ b/database/seeders/RolePermissionSeeder.php @@ -91,8 +91,9 @@ public function run(): void ...$manage, ...array_diff($services, ['create-letter-requests', 'update-letter-requests']), ...$users, - ...$feedbackSelfService, + 'view-feedback', 'update-feedback-status', + 'reply-feedback', ], 'staff-keuangan' => [ 'view-dashboard', diff --git a/resources/js/pages/admin/feedback/columns.tsx b/resources/js/pages/admin/feedback/columns.tsx index 1e8408b..5009ba4 100644 --- a/resources/js/pages/admin/feedback/columns.tsx +++ b/resources/js/pages/admin/feedback/columns.tsx @@ -1,6 +1,6 @@ import type { ColumnDef } from '@tanstack/react-table'; import { format } from 'date-fns'; -import { Eye, Pencil, Trash2 } from 'lucide-react'; +import { Eye, MessageSquareReply, Pencil, Trash2 } from 'lucide-react'; import { RowActions } from '@/components/row-actions'; import { StatusBadge } from '@/components/status-badge'; import { Badge } from '@/components/ui/badge'; @@ -16,10 +16,12 @@ type CreateColumnsParams = { handleEdit: (feedback: Feedback) => void; handleDeleteClick: (feedback: Feedback) => void; handleStatusChange: (feedback: Feedback, status: string) => void; + handleReply: (feedback: Feedback) => void; statuses: StatusOption[]; canUpdateStatus: boolean; canUpdate: boolean; canDelete: boolean; + canReply: boolean; }; export const FeedbackStatusVariants: Record< @@ -39,10 +41,12 @@ export function createFeedbackColumns( handleEdit, handleDeleteClick, handleStatusChange, + handleReply, statuses, canUpdateStatus, canUpdate, canDelete, + canReply, } = params; return [ @@ -109,31 +113,45 @@ export function createFeedbackColumns( className: 'w-[100px] text-center', headerClassName: 'w-[100px] text-center', }, - cell: ({ row }) => ( - , - onClick: () => handleView(row.original), - }, - { - label: 'Edit', - icon: , - show: canUpdate, - onClick: () => handleEdit(row.original), - }, - { - label: 'Hapus', - icon: ( - - ), - show: canDelete, - onClick: () => handleDeleteClick(row.original), - }, - ]} - /> - ), + cell: ({ row }) => { + const isLocked = row.original.status !== 'submitted'; + + return ( + , + onClick: () => handleView(row.original), + }, + { + label: 'Balas', + icon: ( + + ), + show: + canReply && + row.original.status === 'in_review', + onClick: () => handleReply(row.original), + }, + { + label: 'Edit', + icon: , + show: canUpdate && !isLocked, + onClick: () => handleEdit(row.original), + }, + { + label: 'Hapus', + icon: ( + + ), + show: canDelete && !isLocked, + onClick: () => handleDeleteClick(row.original), + }, + ]} + /> + ); + }, }, ]; } diff --git a/resources/js/pages/admin/feedback/index.tsx b/resources/js/pages/admin/feedback/index.tsx index 5349f28..8edfaae 100644 --- a/resources/js/pages/admin/feedback/index.tsx +++ b/resources/js/pages/admin/feedback/index.tsx @@ -3,7 +3,10 @@ import { DataTable } from '@/components/data-table'; import { DeleteConfirmDialog } from '@/components/delete-confirm-dialog'; import type { FilterField } from '@/components/filter-dialog'; 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 TiptapEditor from '@/components/rich-text-editor'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; @@ -23,6 +26,7 @@ import { destroy, edit, index as feedbackIndex, + reply as replyRoute, update_status, } from '@/routes/admin/feedback'; import type { Feedback } from '@/types/feedback'; @@ -59,11 +63,13 @@ export default function FeedbackIndex({ }: Props) { const [deleting, setDeleting] = useState(null); const [viewing, setViewing] = useState(null); + const [replying, setReplying] = 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 canReply = hasPermission('reply-feedback'); const filterFields: FilterField[] = [ { @@ -120,10 +126,12 @@ export default function FeedbackIndex({ handleEdit: (feedback) => router.get(edit.url(feedback.id)), handleDeleteClick: (feedback) => setDeleting(feedback), handleStatusChange, + handleReply: (feedback) => setReplying(feedback), statuses, canUpdateStatus, canUpdate, canDelete, + canReply, }); return ( @@ -155,6 +163,17 @@ export default function FeedbackIndex({ feedback={viewing} /> + { + if (!open) { + setReplying(null); + } + }} + feedback={replying} + /> + {canUpdateStatus && ( @@ -202,6 +221,57 @@ export default function FeedbackIndex({ ); } +function ReplyForm({ + open, + onOpenChange, + feedback, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + feedback: Feedback | null; +}) { + const [reply, setReply] = useState(feedback?.reply ?? ''); + + return ( + onOpenChange(false)} + > + {({ errors }) => + feedback && ( +
+
+ +

{feedback.subject}

+
+ +
+ + + +
+
+ ) + } +
+ ); +} + function ViewDetailDialog({ open, onOpenChange, @@ -279,14 +349,22 @@ function ViewDetailDialog({ /> - {feedback.admin_notes && ( + {feedback.reply && (
-

- {feedback.admin_notes} -

+
)}
diff --git a/resources/js/types/feedback.ts b/resources/js/types/feedback.ts index 3db5ec9..73c4d96 100644 --- a/resources/js/types/feedback.ts +++ b/resources/js/types/feedback.ts @@ -36,7 +36,8 @@ export type Feedback = { subject: string; message: string; status: FeedbackStatusValue; - admin_notes: string | null; + reply: string | null; + replied_at: string | null; handled_by: number | null; handler: FeedbackHandler | null; created_at: string; diff --git a/routes/admin.php b/routes/admin.php index dfaf72f..fa65333 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -194,6 +194,7 @@ ->middlewareFor(['edit', '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::patch('admin/feedback/{feedback}/reply', [FeedbackController::class, 'reply'])->name('admin.feedback.reply')->middleware('permission:reply-feedback'); Route::prefix('admin/users')->name('admin.users.')->group(function () { Route::resource('lecturers', LecturerController::class)