feat: add create and edit feedback functionality with corresponding UI components

This commit is contained in:
Yoga Pangestu 2026-08-31 16:09:54 +07:00
parent b4ffdcb9dd
commit 8be0d57e16
5 changed files with 294 additions and 171 deletions

View File

@ -36,6 +36,13 @@ public function index(PaginatedRequest $request): Response
]); ]);
} }
public function create(): Response
{
return Inertia::render('admin/feedback/create', [
'types' => FeedbackType::options(),
]);
}
public function store(FeedbackRequest $request): RedirectResponse public function store(FeedbackRequest $request): RedirectResponse
{ {
$this->service->create($request->user(), $request->validated()); $this->service->create($request->user(), $request->validated());
@ -45,6 +52,16 @@ public function store(FeedbackRequest $request): RedirectResponse
return to_route('admin.feedback.index'); return to_route('admin.feedback.index');
} }
public function edit(Request $request, Feedback $feedback): Response
{
abort_unless($feedback->user_id === $request->user()->id, 403);
return Inertia::render('admin/feedback/edit', [
'feedback' => $feedback,
'types' => FeedbackType::options(),
]);
}
public function update(FeedbackRequest $request, Feedback $feedback): RedirectResponse public function update(FeedbackRequest $request, Feedback $feedback): RedirectResponse
{ {
abort_unless($feedback->user_id === $request->user()->id, 403); abort_unless($feedback->user_id === $request->user()->id, 403);

View File

@ -0,0 +1,126 @@
import { Form, Head, Link } from '@inertiajs/react';
import { ArrowLeft, Save } from 'lucide-react';
import { useState } from 'react';
import InputError from '@/components/input-error';
import { PageHeader } from '@/components/page-header';
import TiptapEditor from '@/components/rich-text-editor';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { index, store } from '@/routes/admin/feedback';
type FeedbackTypeOption = { value: string; label: string };
type Props = {
types: FeedbackTypeOption[];
};
export default function FeedbackCreate({ types }: Props) {
const [message, setMessage] = useState('');
return (
<>
<Head title="Tambah Kritik dan Saran" />
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
<PageHeader
title="Tambah Kritik dan Saran"
actions={
<Button variant="outline" asChild>
<Link href={index.url()}>
<ArrowLeft className="h-4 w-4" />
Kembali
</Link>
</Button>
}
/>
<Form action={store()} className="space-y-6">
{({ errors, processing }) => (
<>
<Card>
<CardHeader>
<CardTitle>Detail Masukan</CardTitle>
</CardHeader>
<CardContent className="grid gap-4">
<div className="grid gap-2">
<Label>
Jenis{' '}
<span className="text-destructive">
*
</span>
</Label>
<input type="hidden" name="type" />
<Select
name="type"
defaultValue="kritik"
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Pilih jenis" />
</SelectTrigger>
<SelectContent>
{types.map((type) => (
<SelectItem
key={type.value}
value={type.value}
>
{type.label}
</SelectItem>
))}
</SelectContent>
</Select>
<InputError message={errors.type} />
</div>
<div className="grid gap-2">
<Label htmlFor="subject">
Subjek{' '}
<span className="text-destructive">
*
</span>
</Label>
<Input
id="subject"
name="subject"
placeholder="Ringkasan singkat"
/>
<InputError message={errors.subject} />
</div>
<div className="grid gap-2">
<Label>
Pesan{' '}
<span className="text-destructive">
*
</span>
</Label>
<TiptapEditor
name="message"
value={message}
onChange={setMessage}
placeholder="Jelaskan kritik, saran, atau aduan Anda secara rinci"
error={errors.message}
/>
</div>
</CardContent>
</Card>
<div className="flex justify-end">
<Button type="submit" disabled={processing}>
<Save className="h-4 w-4" />
Simpan
</Button>
</div>
</>
)}
</Form>
</div>
</>
);
}

View File

@ -0,0 +1,128 @@
import { Form, Head, Link } from '@inertiajs/react';
import { ArrowLeft, Save } from 'lucide-react';
import { useState } from 'react';
import InputError from '@/components/input-error';
import { PageHeader } from '@/components/page-header';
import TiptapEditor from '@/components/rich-text-editor';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { index, update } from '@/routes/admin/feedback';
import type { Feedback } from '@/types/feedback';
type FeedbackTypeOption = { value: string; label: string };
type Props = {
feedback: Feedback;
types: FeedbackTypeOption[];
};
export default function FeedbackEdit({ feedback, types }: Props) {
const [message, setMessage] = useState(feedback.message);
return (
<>
<Head title="Edit Kritik dan Saran" />
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
<PageHeader
title="Edit Kritik dan Saran"
actions={
<Button variant="outline" asChild>
<Link href={index.url()}>
<ArrowLeft className="h-4 w-4" />
Kembali
</Link>
</Button>
}
/>
<Form action={update(feedback.id)} className="space-y-6">
{({ errors, processing }) => (
<>
<Card>
<CardHeader>
<CardTitle>Detail Masukan</CardTitle>
</CardHeader>
<CardContent className="grid gap-4">
<div className="grid gap-2">
<Label>
Jenis{' '}
<span className="text-destructive">
*
</span>
</Label>
<Select
name="type"
defaultValue={feedback.type}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Pilih jenis" />
</SelectTrigger>
<SelectContent>
{types.map((type) => (
<SelectItem
key={type.value}
value={type.value}
>
{type.label}
</SelectItem>
))}
</SelectContent>
</Select>
<InputError message={errors.type} />
</div>
<div className="grid gap-2">
<Label htmlFor="subject">
Subjek{' '}
<span className="text-destructive">
*
</span>
</Label>
<Input
id="subject"
name="subject"
placeholder="Ringkasan singkat"
defaultValue={feedback.subject}
/>
<InputError message={errors.subject} />
</div>
<div className="grid gap-2">
<Label>
Pesan{' '}
<span className="text-destructive">
*
</span>
</Label>
<TiptapEditor
name="message"
value={message}
onChange={setMessage}
placeholder="Jelaskan kritik, saran, atau aduan Anda secara rinci"
error={errors.message}
/>
</div>
</CardContent>
</Card>
<div className="flex justify-end">
<Button type="submit" disabled={processing}>
<Save className="h-4 w-4" />
Simpan
</Button>
</div>
</>
)}
</Form>
</div>
</>
);
}

View File

@ -1,16 +1,10 @@
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'; import type { PaginationState } from '@/components/data-table';
import { DataTable } from '@/components/data-table'; import { DataTable } from '@/components/data-table';
import { DeleteConfirmDialog } from '@/components/delete-confirm-dialog'; import { DeleteConfirmDialog } from '@/components/delete-confirm-dialog';
import type { FilterField } from '@/components/filter-dialog'; import type { FilterField } from '@/components/filter-dialog';
import { FilterDialog } 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 { 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 { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import {
@ -19,28 +13,24 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { usePermissions } from '@/hooks/use-permissions'; import { usePermissions } from '@/hooks/use-permissions';
import { useServerTable } from '@/hooks/use-server-table'; import { useServerTable } from '@/hooks/use-server-table';
import { richTextContentClass } from '@/lib/rich-text-content'; import { richTextContentClass } from '@/lib/rich-text-content';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { import {
index as feedbackIndex, create,
destroy, destroy,
store, edit,
update, index as feedbackIndex,
update_status, update_status,
} from '@/routes/admin/feedback'; } from '@/routes/admin/feedback';
import type { Feedback } from '@/types/feedback'; import type { Feedback } from '@/types/feedback';
import { FeedbackStatusLabels, FeedbackTypeLabels } from '@/types/feedback'; import { FeedbackStatusLabels, FeedbackTypeLabels } from '@/types/feedback';
import { Head, Link, router } from '@inertiajs/react';
import { format } from 'date-fns';
import { Info, Plus } from 'lucide-react';
import { useState } from 'react';
import { createFeedbackColumns, FeedbackStatusVariants } from './columns'; import { createFeedbackColumns, FeedbackStatusVariants } from './columns';
type FeedbackTypeOption = { value: string; label: string }; type FeedbackTypeOption = { value: string; label: string };
@ -67,8 +57,6 @@ export default function FeedbackIndex({
statuses, statuses,
filters, filters,
}: Props) { }: Props) {
const [createOpen, setCreateOpen] = useState(false);
const [editing, setEditing] = useState<Feedback | null>(null);
const [deleting, setDeleting] = useState<Feedback | null>(null); const [deleting, setDeleting] = useState<Feedback | null>(null);
const [viewing, setViewing] = useState<Feedback | null>(null); const [viewing, setViewing] = useState<Feedback | null>(null);
const { hasPermission } = usePermissions(); const { hasPermission } = usePermissions();
@ -129,7 +117,7 @@ export default function FeedbackIndex({
const columns = createFeedbackColumns({ const columns = createFeedbackColumns({
handleView: (feedback) => setViewing(feedback), handleView: (feedback) => setViewing(feedback),
handleEdit: (feedback) => setEditing(feedback), handleEdit: (feedback) => router.get(edit.url(feedback.id)),
handleDeleteClick: (feedback) => setDeleting(feedback), handleDeleteClick: (feedback) => setDeleting(feedback),
handleStatusChange, handleStatusChange,
statuses, statuses,
@ -148,36 +136,15 @@ export default function FeedbackIndex({
actions={ actions={
canCreate && ( canCreate && (
<Button asChild> <Button asChild>
<button <Link href={create.url()}>
type="button"
onClick={() => setCreateOpen(true)}
>
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
Tambah Tambah
</button> </Link>
</Button> </Button>
) )
} }
/> />
<CreateForm
open={createOpen}
onOpenChange={setCreateOpen}
types={types}
/>
<EditForm
key={editing?.id}
open={editing !== null}
onOpenChange={(open) => {
if (!open) {
setEditing(null);
}
}}
editing={editing}
types={types}
/>
<ViewDetailDialog <ViewDetailDialog
open={viewing !== null} open={viewing !== null}
onOpenChange={(open) => { onOpenChange={(open) => {
@ -188,6 +155,15 @@ export default function FeedbackIndex({
feedback={viewing} feedback={viewing}
/> />
<Alert>
<Info />
<AlertTitle>Ubah status Kritik dan Saran</AlertTitle>
<AlertDescription>
Klik badge Status pada tabel untuk mengubah status Kritik dan Saran
secara langsung.
</AlertDescription>
</Alert>
<DataTable <DataTable
columns={columns} columns={columns}
data={feedbacks.data} data={feedbacks.data}
@ -224,130 +200,6 @@ export default function FeedbackIndex({
); );
} }
function FeedbackFields({
errors,
editing,
types,
}: {
errors: Record<string, string>;
editing?: Feedback;
types: FeedbackTypeOption[];
}) {
const [message, setMessage] = useState(editing?.message ?? '');
return (
<>
<div className="grid gap-2">
<Label>
Jenis <span className="text-destructive">*</span>
</Label>
{!editing && <input type="hidden" name="type" />}
<Select name="type" defaultValue={editing?.type ?? 'kritik'}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Pilih jenis" />
</SelectTrigger>
<SelectContent>
{types.map((type) => (
<SelectItem key={type.value} value={type.value}>
{type.label}
</SelectItem>
))}
</SelectContent>
</Select>
<InputError message={errors.type} />
</div>
<div className="grid gap-2">
<Label htmlFor={editing ? 'edit-subject' : 'subject'}>
Subjek <span className="text-destructive">*</span>
</Label>
<Input
id={editing ? 'edit-subject' : 'subject'}
name="subject"
placeholder="Ringkasan singkat"
defaultValue={editing?.subject ?? ''}
/>
<InputError message={errors.subject} />
</div>
<div className="grid gap-2">
<Label>
Pesan <span className="text-destructive">*</span>
</Label>
<TiptapEditor
name="message"
value={message}
onChange={setMessage}
placeholder="Jelaskan kritik, saran, atau aduan Anda secara rinci"
error={errors.message}
/>
</div>
</>
);
}
function CreateForm({
open,
onOpenChange,
types,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
types: FeedbackTypeOption[];
}) {
return (
<FormDialog
open={open}
onOpenChange={onOpenChange}
title="Tambah Kritik dan Saran"
action={store()}
resetOnSuccess
onSuccess={() => onOpenChange(false)}
contentClassName="sm:max-w-4xl"
>
{({ errors }) => (
<div className="grid gap-4">
<FeedbackFields errors={errors} types={types} />
</div>
)}
</FormDialog>
);
}
function EditForm({
open,
onOpenChange,
editing,
types,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
editing: Feedback | null;
types: FeedbackTypeOption[];
}) {
return (
<FormDialog
open={open}
onOpenChange={onOpenChange}
title="Edit Kritik dan Saran"
action={editing ? update(editing.id) : ''}
resetOnSuccess
onSuccess={() => onOpenChange(false)}
contentClassName="sm:max-w-2xl"
>
{({ errors }) =>
editing && (
<div className="grid gap-4">
<FeedbackFields
errors={errors}
editing={editing}
types={types}
/>
</div>
)
}
</FormDialog>
);
}
function ViewDetailDialog({ function ViewDetailDialog({
open, open,
onOpenChange, onOpenChange,

View File

@ -201,11 +201,11 @@
}); });
Route::resource('admin/feedback', FeedbackController::class) Route::resource('admin/feedback', FeedbackController::class)
->except(['create', 'edit', 'show']) ->except(['show'])
->names('admin.feedback') ->names('admin.feedback')
->middlewareFor(['index'], 'permission:view-feedback') ->middlewareFor(['index'], 'permission:view-feedback')
->middlewareFor(['store'], 'permission:create-feedback') ->middlewareFor(['create', 'store'], 'permission:create-feedback')
->middlewareFor(['update'], 'permission:update-feedback') ->middlewareFor(['edit', 'update'], 'permission:update-feedback')
->middlewareFor(['destroy'], 'permission:delete-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}/status', [FeedbackController::class, 'updateStatus'])->name('admin.feedback.update_status')->middleware('permission:update-feedback-status');