feat: implement reply functionality for feedback; add ReplyFeedbackRequest and update FeedbackService; enhance UI for reply actions and display
This commit is contained in:
parent
900c27041a
commit
32c04b91dd
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
20
app/Http/Requests/Admin/Feedback/ReplyFeedbackRequest.php
Normal file
20
app/Http/Requests/Admin/Feedback/ReplyFeedbackRequest.php
Normal 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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -21,6 +21,7 @@ protected function casts(): array
|
||||
return [
|
||||
'type' => FeedbackType::class,
|
||||
'status' => FeedbackStatus::class,
|
||||
'replied_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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',
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@ -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();
|
||||
});
|
||||
}
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user