feat: implement feedback management with status updates and permissions
This commit is contained in:
parent
ef3988f734
commit
ddbbf1cc7f
@ -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);
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Admin;
|
||||
namespace App\Http\Requests\Admin\Feedback;
|
||||
|
||||
use App\Enums\FeedbackType;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
@ -10,7 +10,7 @@ class FeedbackRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
return $this->user()->can($this->isMethod('post') ? 'create-feedback' : 'update-feedback');
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Admin\Feedback;
|
||||
|
||||
use App\Enums\FeedbackStatus;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class UpdateFeedbackStatusRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user()->can('update-feedback-status');
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'status' => ['required', Rule::enum(FeedbackStatus::class)],
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -28,4 +28,9 @@ public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function handler(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'handled_by');
|
||||
}
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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<string, array<int, string>>
|
||||
*/
|
||||
@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
@ -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();
|
||||
});
|
||||
|
||||
@ -1,22 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('notifications', function (Blueprint $table) {
|
||||
$table->foreignId('created_by')->nullable()->after('user_id')->constrained('users')->nullOnDelete();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('notifications', function (Blueprint $table) {
|
||||
$table->dropConstrainedForeignId('created_by');
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -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,
|
||||
];
|
||||
|
||||
@ -20,12 +20,13 @@ type FormDialogProps = {
|
||||
submitDisabled?: boolean;
|
||||
submitLabel?: ReactNode;
|
||||
submittingLabel?: ReactNode;
|
||||
contentClassName?: string;
|
||||
children:
|
||||
| ReactNode
|
||||
| ((ctx: {
|
||||
errors: Record<string, string>;
|
||||
processing: boolean;
|
||||
}) => ReactNode);
|
||||
| ReactNode
|
||||
| ((ctx: {
|
||||
errors: Record<string, string>;
|
||||
processing: boolean;
|
||||
}) => ReactNode);
|
||||
};
|
||||
|
||||
export function FormDialog({
|
||||
@ -38,11 +39,12 @@ export function FormDialog({
|
||||
submitDisabled = false,
|
||||
submitLabel = 'Simpan',
|
||||
submittingLabel = 'Menyimpan...',
|
||||
contentClassName,
|
||||
children,
|
||||
}: FormDialogProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogContent className={contentClassName}>
|
||||
<Form
|
||||
action={action}
|
||||
resetOnSuccess={resetOnSuccess}
|
||||
|
||||
@ -9,34 +9,29 @@ import {
|
||||
|
||||
type StatusOption = { value: string; label: string };
|
||||
|
||||
type StudentStatusBadgeProps = {
|
||||
type StatusBadgeProps = {
|
||||
status: string | null | undefined;
|
||||
statuses: StatusOption[];
|
||||
onChange: (status: string) => 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 (
|
||||
<DropdownMenu>
|
||||
@ -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<Feedback>[] {
|
||||
const { handleEdit, handleDeleteClick } = params;
|
||||
const {
|
||||
handleView,
|
||||
handleEdit,
|
||||
handleDeleteClick,
|
||||
handleStatusChange,
|
||||
statuses,
|
||||
canUpdateStatus,
|
||||
canUpdate,
|
||||
canDelete,
|
||||
} = params;
|
||||
|
||||
return [
|
||||
{
|
||||
@ -80,12 +93,23 @@ export function createFeedbackColumns(
|
||||
},
|
||||
cell: ({ row }) => (
|
||||
<div className="flex justify-center">
|
||||
<Badge variant={statusBadgeVariant(row.original.status)}>
|
||||
{FeedbackStatusLabels[row.original.status]}
|
||||
</Badge>
|
||||
<StatusBadge
|
||||
status={row.original.status}
|
||||
statuses={statuses}
|
||||
variants={FeedbackStatusVariants}
|
||||
onChange={(status) =>
|
||||
handleStatusChange(row.original, status)
|
||||
}
|
||||
disabled={!canUpdateStatus}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'handler.profile.full_name',
|
||||
header: () => <span>Ditindaklanjuti Oleh</span>,
|
||||
cell: ({ row }) => row.original.handler?.profile?.full_name ?? '-',
|
||||
},
|
||||
{
|
||||
accessorKey: 'created_at',
|
||||
header: () => <span>Dikirim</span>,
|
||||
@ -102,9 +126,15 @@ export function createFeedbackColumns(
|
||||
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),
|
||||
},
|
||||
{
|
||||
@ -112,6 +142,7 @@ export function createFeedbackColumns(
|
||||
icon: (
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
),
|
||||
show: canDelete,
|
||||
onClick: () => handleDeleteClick(row.original),
|
||||
},
|
||||
]}
|
||||
|
||||
@ -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<Feedback | null>(null);
|
||||
const [deleting, setDeleting] = useState<Feedback | null>(null);
|
||||
const [viewing, setViewing] = 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 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({
|
||||
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
|
||||
<PageHeader
|
||||
title="Kritik dan Saran"
|
||||
description={
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Sampaikan kritik, saran, atau aduan Anda kepada
|
||||
kami.
|
||||
</p>
|
||||
}
|
||||
actions={
|
||||
<Button asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCreateOpen(true)}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Tambah
|
||||
</button>
|
||||
</Button>
|
||||
canCreate && (
|
||||
<Button asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCreateOpen(true)}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Tambah
|
||||
</button>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
@ -149,6 +176,16 @@ export default function FeedbackIndex({
|
||||
types={types}
|
||||
/>
|
||||
|
||||
<ViewDetailDialog
|
||||
open={viewing !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setViewing(null);
|
||||
}
|
||||
}}
|
||||
feedback={viewing}
|
||||
/>
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={feedbacks.data}
|
||||
@ -262,6 +299,7 @@ function CreateForm({
|
||||
action={store()}
|
||||
resetOnSuccess
|
||||
onSuccess={() => onOpenChange(false)}
|
||||
contentClassName="sm:max-w-4xl"
|
||||
>
|
||||
{({ errors }) => (
|
||||
<div className="grid gap-4">
|
||||
@ -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({
|
||||
</FormDialog>
|
||||
);
|
||||
}
|
||||
|
||||
function ViewDetailDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
feedback,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
feedback: Feedback | null;
|
||||
}) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="flex max-h-[85vh] flex-col overflow-hidden sm:max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Detail Kritik dan Saran</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{feedback && (
|
||||
<div className="grid gap-4 overflow-y-auto">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="outline">
|
||||
{FeedbackTypeLabels[feedback.type]}
|
||||
</Badge>
|
||||
<Badge
|
||||
variant={
|
||||
FeedbackStatusVariants[feedback.status]
|
||||
}
|
||||
>
|
||||
{FeedbackStatusLabels[feedback.status]}
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Dikirim{' '}
|
||||
{format(
|
||||
new Date(feedback.created_at),
|
||||
'd MMM yyyy, HH:mm',
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1">
|
||||
<Label className="text-muted-foreground">
|
||||
Subjek
|
||||
</Label>
|
||||
<p className="font-medium">{feedback.subject}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1">
|
||||
<Label className="text-muted-foreground">
|
||||
Ditindaklanjuti Oleh
|
||||
</Label>
|
||||
<p>
|
||||
{feedback.handler?.profile?.full_name ??
|
||||
'Belum ditindaklanjuti'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1">
|
||||
<Label className="text-muted-foreground">
|
||||
Pesan
|
||||
</Label>
|
||||
<div
|
||||
className="rounded-md border p-3 text-sm [&_a]:text-primary [&_a]:underline [&_blockquote]:border-l-2 [&_blockquote]:pl-3 [&_blockquote]:italic [&_h2]:text-base [&_h2]:font-semibold [&_h3]:font-semibold [&_ol]:list-decimal [&_ol]:pl-5 [&_p]:mb-2 last:[&_p]:mb-0 [&_ul]:list-disc [&_ul]:pl-5"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: feedback.message,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{feedback.admin_notes && (
|
||||
<div className="grid gap-1">
|
||||
<Label className="text-muted-foreground">
|
||||
Catatan Admin
|
||||
</Label>
|
||||
<p className="rounded-md border bg-muted/50 p-3 text-sm whitespace-pre-line">
|
||||
{feedback.admin_notes}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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: () => <span>Status</span>,
|
||||
cell: ({ row }) => (
|
||||
<StudentStatusBadge
|
||||
<StatusBadge
|
||||
status={row.original.student?.status}
|
||||
statuses={statuses}
|
||||
variants={StudentStatusVariants}
|
||||
onChange={(status) =>
|
||||
handleStatusChange(row.original, status)
|
||||
}
|
||||
|
||||
@ -18,6 +18,11 @@ export const FeedbackStatusLabels: Record<FeedbackStatusValue, string> = {
|
||||
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;
|
||||
};
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user