feat: implement reply functionality for feedback; add ReplyFeedbackRequest and update FeedbackService; enhance UI for reply actions and display

This commit is contained in:
Yoga Pangestu 2026-09-03 16:24:04 +07:00
parent 900c27041a
commit 32c04b91dd
14 changed files with 200 additions and 47 deletions

View File

@ -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();
}
}

View File

@ -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);

View File

@ -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

View File

@ -0,0 +1,20 @@
<?php
namespace App\Http\Requests\Admin\Feedback;
use Illuminate\Foundation\Http\FormRequest;
class ReplyFeedbackRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()->can('reply-feedback');
}
public function rules(): array
{
return [
'reply' => ['required', 'string'],
];
}
}

View File

@ -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

View File

@ -21,6 +21,7 @@ protected function casts(): array
return [
'type' => FeedbackType::class,
'status' => FeedbackStatus::class,
'replied_at' => 'datetime',
];
}

View File

@ -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();

View File

@ -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',
];
/**

View File

@ -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();
});
}

View File

@ -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',

View File

@ -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 }) => (
<RowActions
actions={[
{
label: 'Lihat Detail',
icon: <Eye className="h-4 w-4" />,
onClick: () => handleView(row.original),
},
{
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: 'Lihat Detail',
icon: <Eye className="h-4 w-4" />,
onClick: () => handleView(row.original),
},
{
label: 'Balas',
icon: (
<MessageSquareReply className="h-4 w-4" />
),
show:
canReply &&
row.original.status === 'in_review',
onClick: () => handleReply(row.original),
},
{
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

@ -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<Feedback | null>(null);
const [viewing, setViewing] = useState<Feedback | null>(null);
const [replying, setReplying] = useState<Feedback | null>(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}
/>
<ReplyForm
key={replying?.id}
open={replying !== null}
onOpenChange={(open) => {
if (!open) {
setReplying(null);
}
}}
feedback={replying}
/>
{canUpdateStatus && (
<Alert>
<Info />
@ -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 (
<FormDialog
open={open}
onOpenChange={onOpenChange}
title="Balas Kritik dan Saran"
action={feedback ? replyRoute(feedback.id) : ''}
resetOnSuccess
onSuccess={() => onOpenChange(false)}
>
{({ errors }) =>
feedback && (
<div className="grid gap-4">
<div className="grid gap-1">
<Label className="text-muted-foreground">
Subjek
</Label>
<p className="font-medium">{feedback.subject}</p>
</div>
<div className="grid gap-2">
<Label>
Balasan{' '}
<span className="text-destructive">*</span>
</Label>
<TiptapEditor
name="reply"
value={reply}
onChange={setReply}
placeholder="Tulis balasan untuk pengirim"
error={errors.reply}
/>
<InputError message={errors.reply} />
</div>
</div>
)
}
</FormDialog>
);
}
function ViewDetailDialog({
open,
onOpenChange,
@ -279,14 +349,22 @@ function ViewDetailDialog({
/>
</div>
{feedback.admin_notes && (
{feedback.reply && (
<div className="grid gap-1">
<Label className="text-muted-foreground">
Catatan Admin
Balasan
{feedback.replied_at &&
` · ${format(new Date(feedback.replied_at), 'd MMM yyyy, HH:mm')}`}
</Label>
<p className="rounded-md border bg-muted/50 p-3 text-sm whitespace-pre-line">
{feedback.admin_notes}
</p>
<div
className={cn(
'rounded-md border bg-muted/50 p-3 text-sm',
richTextContentClass,
)}
dangerouslySetInnerHTML={{
__html: feedback.reply,
}}
/>
</div>
)}
</div>

View File

@ -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;

View File

@ -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)