Compare commits

...

4 Commits

21 changed files with 368 additions and 122 deletions

View File

@ -5,7 +5,8 @@
use App\Enums\FeedbackStatus; use App\Enums\FeedbackStatus;
use App\Enums\FeedbackType; use App\Enums\FeedbackType;
use App\Http\Controllers\Controller; 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\Http\Requests\PaginatedRequest;
use App\Models\Feedback; use App\Models\Feedback;
use App\Services\Admin\FeedbackService; use App\Services\Admin\FeedbackService;
@ -55,6 +56,13 @@ public function update(FeedbackRequest $request, Feedback $feedback): RedirectRe
return to_route('admin.feedback.index'); 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 public function destroy(Request $request, Feedback $feedback): RedirectResponse
{ {
abort_unless($feedback->user_id === $request->user()->id, 403); abort_unless($feedback->user_id === $request->user()->id, 403);

View File

@ -2,6 +2,7 @@
namespace App\Http\Middleware; namespace App\Http\Middleware;
use App\Services\Admin\FeedbackService;
use App\Services\NotificationService; use App\Services\NotificationService;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Inertia\Inertia; use Inertia\Inertia;
@ -48,6 +49,9 @@ public function share(Request $request): array
'unreadNotificationsCount' => fn () => $request->user() 'unreadNotificationsCount' => fn () => $request->user()
? app(NotificationService::class)->unreadCount($request->user()) ? app(NotificationService::class)->unreadCount($request->user())
: 0, : 0,
'pendingFeedbackCount' => fn () => $request->user()
? app(FeedbackService::class)->pendingCount($request->user())
: 0,
'notifications' => Inertia::merge(fn () => $request->user() 'notifications' => Inertia::merge(fn () => $request->user()
? app(NotificationService::class)->paginated($request->user()) ? app(NotificationService::class)->paginated($request->user())
: null)->append('data', 'id'), : null)->append('data', 'id'),

View File

@ -1,6 +1,6 @@
<?php <?php
namespace App\Http\Requests\Admin; namespace App\Http\Requests\Admin\Feedback;
use App\Enums\FeedbackType; use App\Enums\FeedbackType;
use Illuminate\Foundation\Http\FormRequest; use Illuminate\Foundation\Http\FormRequest;
@ -10,7 +10,7 @@ class FeedbackRequest extends FormRequest
{ {
public function authorize(): bool public function authorize(): bool
{ {
return true; return $this->user()->can($this->isMethod('post') ? 'create-feedback' : 'update-feedback');
} }
public function rules(): array public function rules(): array

View File

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

View File

@ -28,4 +28,9 @@ public function user(): BelongsTo
{ {
return $this->belongsTo(User::class); return $this->belongsTo(User::class);
} }
public function handler(): BelongsTo
{
return $this->belongsTo(User::class, 'handled_by');
}
} }

View File

@ -2,6 +2,7 @@
namespace App\Services\Admin; namespace App\Services\Admin;
use App\Enums\FeedbackStatus;
use App\Models\Feedback; use App\Models\Feedback;
use App\Models\User; use App\Models\User;
use App\Support\TextAlignAttributeSanitizer; use App\Support\TextAlignAttributeSanitizer;
@ -44,13 +45,22 @@ public function paginated(User $user, int $perPage = 25, string $search = '', ?s
{ {
return Feedback::query() return Feedback::query()
->where('user_id', $user->id) ->where('user_id', $user->id)
->when($search, fn ($q) => $q->where('subject', 'like', "%{$search}%")) ->with('handler.profile')
->when($type, fn ($q) => $q->where('type', $type)) ->when($search, fn($q) => $q->where('subject', 'like', "%{$search}%"))
->when($status, fn ($q) => $q->where('status', $status)) ->when($type, fn($q) => $q->where('type', $type))
->when($status, fn($q) => $q->where('status', $status))
->latest() ->latest()
->paginate($perPage); ->paginate($perPage);
} }
public function pendingCount(User $user): int
{
return Feedback::query()
->where('user_id', $user->id)
->where('status', '!=', FeedbackStatus::Resolved)
->count();
}
public function create(User $user, array $data): Feedback public function create(User $user, array $data): Feedback
{ {
return Feedback::create([ return Feedback::create([
@ -71,6 +81,16 @@ public function update(Feedback $feedback, array $data): Feedback
return $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 public function delete(Feedback $feedback): bool
{ {
return $feedback->delete(); return $feedback->delete();

View File

@ -48,6 +48,10 @@ class PermissionCatalog
'view-roles', 'update-roles', 'view-roles', 'update-roles',
]; ];
public const FEEDBACK = [
'view-feedback', 'create-feedback', 'update-feedback', 'delete-feedback', 'update-feedback-status',
];
/** /**
* @return array<string, array<int, string>> * @return array<string, array<int, string>>
*/ */
@ -61,6 +65,7 @@ public static function groups(): array
'Keuangan' => self::FINANCES, 'Keuangan' => self::FINANCES,
'Layanan' => self::SERVICES, 'Layanan' => self::SERVICES,
'Pengguna' => self::USERS, 'Pengguna' => self::USERS,
'Kritik & Saran' => self::FEEDBACK,
'Pengembang' => self::DEVELOPER, 'Pengembang' => self::DEVELOPER,
]; ];
} }

View File

@ -17,6 +17,7 @@ public function up(): void
$table->string('subject', 150); $table->string('subject', 150);
$table->text('message'); $table->text('message');
$table->enum('status', FeedbackStatus::values())->default(FeedbackStatus::Submitted->value); $table->enum('status', FeedbackStatus::values())->default(FeedbackStatus::Submitted->value);
$table->foreignId('handled_by')->nullable()->constrained('users')->nullOnDelete();
$table->text('admin_notes')->nullable(); $table->text('admin_notes')->nullable();
$table->timestamps(); $table->timestamps();
}); });

View File

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

View File

@ -21,6 +21,10 @@ public function run(): void
$services = PermissionCatalog::SERVICES; $services = PermissionCatalog::SERVICES;
$users = PermissionCatalog::USERS; $users = PermissionCatalog::USERS;
$feedbackSelfService = [
'view-feedback', 'create-feedback', 'update-feedback', 'delete-feedback',
];
$permissionNames = PermissionCatalog::all(); $permissionNames = PermissionCatalog::all();
foreach ($permissionNames as $name) { foreach ($permissionNames as $name) {
@ -30,8 +34,8 @@ public function run(): void
app()[PermissionRegistrar::class]->forgetCachedPermissions(); app()[PermissionRegistrar::class]->forgetCachedPermissions();
$roles = [ $roles = [
'mahasiswa' => ['view-dashboard'], 'mahasiswa' => ['view-dashboard', ...$feedbackSelfService],
'dosen' => ['view-dashboard'], 'dosen' => ['view-dashboard', ...$feedbackSelfService],
'staff-admin' => [ 'staff-admin' => [
'view-dashboard', 'view-dashboard',
...$master, ...$master,
@ -39,10 +43,13 @@ public function run(): void
...$manage, ...$manage,
...$services, ...$services,
...$users, ...$users,
...$feedbackSelfService,
'update-feedback-status',
], ],
'staff-keuangan' => [ 'staff-keuangan' => [
'view-dashboard', 'view-dashboard',
...$finances, ...$finances,
...$feedbackSelfService,
], ],
'kaprodi' => [ 'kaprodi' => [
'view-dashboard', 'view-dashboard',
@ -51,6 +58,7 @@ public function run(): void
'view-course-classes', 'view-course-classes',
'view-course-registrations', 'view-course-registrations',
'view-students', 'view-students',
...$feedbackSelfService,
], ],
'developer' => $permissionNames, 'developer' => $permissionNames,
]; ];

View File

@ -224,21 +224,27 @@ function buildNavMain({
]; ];
} }
const navSecondary: { title: string; url: string; icon: Icon }[] = [ function buildNavSecondary(
{ pendingFeedbackCount: number,
title: 'Kritik dan Saran', ): { title: string; url: string; icon: Icon; badge?: number }[] {
url: feedbackRoute.url(), return [
icon: IconMessageDots, {
}, title: 'Kritik dan Saran',
{ url: feedbackRoute.url(),
title: 'Bantuan', icon: IconMessageDots,
url: '#', badge: pendingFeedbackCount,
icon: IconHelp, },
}, {
]; title: 'Bantuan',
url: '#',
icon: IconHelp,
},
];
}
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) { export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
const { name, auth } = usePage<{ auth: Auth }>().props; const { name, auth, pendingFeedbackCount } = usePage<{ auth: Auth }>()
.props;
const roleNames = auth?.user?.roles?.map((role) => role.name) ?? []; const roleNames = auth?.user?.roles?.map((role) => role.name) ?? [];
const isStaff = roleNames.some((role) => STAFF_ROLES.includes(role)); const isStaff = roleNames.some((role) => STAFF_ROLES.includes(role));
const isMahasiswa = !isStaff && roleNames.includes('mahasiswa'); const isMahasiswa = !isStaff && roleNames.includes('mahasiswa');
@ -273,7 +279,10 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
</SidebarHeader> </SidebarHeader>
<SidebarContent> <SidebarContent>
<NavMain items={navMain} /> <NavMain items={navMain} />
<NavSecondary items={navSecondary} className="mt-auto" /> <NavSecondary
items={buildNavSecondary(pendingFeedbackCount ?? 0)}
className="mt-auto"
/>
</SidebarContent> </SidebarContent>
</Sidebar> </Sidebar>
); );

View File

@ -20,12 +20,13 @@ type FormDialogProps = {
submitDisabled?: boolean; submitDisabled?: boolean;
submitLabel?: ReactNode; submitLabel?: ReactNode;
submittingLabel?: ReactNode; submittingLabel?: ReactNode;
contentClassName?: string;
children: children:
| ReactNode | ReactNode
| ((ctx: { | ((ctx: {
errors: Record<string, string>; errors: Record<string, string>;
processing: boolean; processing: boolean;
}) => ReactNode); }) => ReactNode);
}; };
export function FormDialog({ export function FormDialog({
@ -38,11 +39,12 @@ export function FormDialog({
submitDisabled = false, submitDisabled = false,
submitLabel = 'Simpan', submitLabel = 'Simpan',
submittingLabel = 'Menyimpan...', submittingLabel = 'Menyimpan...',
contentClassName,
children, children,
}: FormDialogProps) { }: FormDialogProps) {
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent> <DialogContent className={contentClassName}>
<Form <Form
action={action} action={action}
resetOnSuccess={resetOnSuccess} resetOnSuccess={resetOnSuccess}

View File

@ -8,6 +8,7 @@ import {
SidebarGroup, SidebarGroup,
SidebarGroupContent, SidebarGroupContent,
SidebarMenu, SidebarMenu,
SidebarMenuBadge,
SidebarMenuButton, SidebarMenuButton,
SidebarMenuItem, SidebarMenuItem,
} from '@/components/ui/sidebar'; } from '@/components/ui/sidebar';
@ -20,6 +21,7 @@ export function NavSecondary({
title: string; title: string;
url: string; url: string;
icon: Icon; icon: Icon;
badge?: number;
}[]; }[];
} & React.ComponentPropsWithoutRef<typeof SidebarGroup>) { } & React.ComponentPropsWithoutRef<typeof SidebarGroup>) {
return ( return (
@ -34,6 +36,11 @@ export function NavSecondary({
<span>{item.title}</span> <span>{item.title}</span>
</Link> </Link>
</SidebarMenuButton> </SidebarMenuButton>
{!!item.badge && (
<SidebarMenuBadge>
{item.badge}
</SidebarMenuBadge>
)}
</SidebarMenuItem> </SidebarMenuItem>
))} ))}
</SidebarMenu> </SidebarMenu>

View File

@ -9,34 +9,29 @@ import {
type StatusOption = { value: string; label: string }; type StatusOption = { value: string; label: string };
type StudentStatusBadgeProps = { type StatusBadgeProps = {
status: string | null | undefined; status: string | null | undefined;
statuses: StatusOption[]; statuses: StatusOption[];
onChange: (status: string) => void; onChange: (status: string) => void;
disabled?: boolean; disabled?: boolean;
variants?: Record<
string,
'default' | 'secondary' | 'destructive' | 'outline'
>;
}; };
const StudentStatusVariants: Record< export function StatusBadge({
string,
'default' | 'secondary' | 'destructive' | 'outline'
> = {
active: 'default',
on_leave: 'secondary',
graduated: 'outline',
dropped_out: 'destructive',
};
export function StudentStatusBadge({
status, status,
statuses, statuses,
onChange, onChange,
disabled, disabled,
}: StudentStatusBadgeProps) { variants,
}: StatusBadgeProps) {
const label = const label =
statuses.find((option) => option.value === status)?.label ?? statuses.find((option) => option.value === status)?.label ??
status ?? status ??
'-'; '-';
const variant = (status && StudentStatusVariants[status]) || 'outline'; const variant = (status && variants?.[status]) || 'outline';
return ( return (
<DropdownMenu> <DropdownMenu>

View File

@ -1,6 +1,3 @@
import { Head, router } from '@inertiajs/react';
import { Clock, MapPin, Pencil, Plus, Trash2, Video } from 'lucide-react';
import { useState } from 'react';
import { DeleteConfirmDialog } from '@/components/delete-confirm-dialog'; import { DeleteConfirmDialog } from '@/components/delete-confirm-dialog';
import { FormDialog } from '@/components/form-dialog'; import { FormDialog } from '@/components/form-dialog';
import InputError from '@/components/input-error'; import InputError from '@/components/input-error';
@ -27,6 +24,9 @@ import {
} from '@/routes/admin/academic-classes/schedules'; } from '@/routes/admin/academic-classes/schedules';
import type { Schedule } from '@/types/schedule'; import type { Schedule } from '@/types/schedule';
import { DayOfWeekLabels, DaysOfWeek } from '@/types/schedule'; import { DayOfWeekLabels, DaysOfWeek } from '@/types/schedule';
import { Head, router } from '@inertiajs/react';
import { Clock, MapPin, Pencil, Plus, Trash2, Video } from 'lucide-react';
import { useState } from 'react';
type CourseClassOption = { type CourseClassOption = {
id: number; id: number;
@ -108,6 +108,10 @@ export default function ScheduleIndex({
} }
/> />
<p className="hidden text-xs text-muted-foreground md:block">
Geser ke samping untuk melihat hari lainnya.
</p>
<CreateForm <CreateForm
open={createOpen} open={createOpen}
onOpenChange={setCreateOpen} onOpenChange={setCreateOpen}
@ -131,14 +135,14 @@ export default function ScheduleIndex({
Belum ada data jadwal. Belum ada data jadwal.
</p> </p>
) : ( ) : (
<div className="flex flex-1 gap-4 overflow-x-auto pb-2"> <div className="flex flex-1 flex-col gap-4 pb-2 md:flex-row md:overflow-x-auto">
{columns.map((day) => { {columns.map((day) => {
const items = grouped.get(day) ?? []; const items = grouped.get(day) ?? [];
return ( return (
<div <div
key={day} key={day}
className="flex w-72 shrink-0 flex-col gap-3" className="flex w-full shrink-0 flex-col gap-3 border-b pb-6 last:border-b-0 last:pb-0 md:w-72 md:border-b-0 md:pb-0"
> >
<div className="flex items-center justify-between rounded-md bg-muted px-3 py-2"> <div className="flex items-center justify-between rounded-md bg-muted px-3 py-2">
<span className="text-sm font-semibold"> <span className="text-sm font-semibold">
@ -163,8 +167,8 @@ export default function ScheduleIndex({
className={cn( className={cn(
'gap-3 py-4', 'gap-3 py-4',
highlight === highlight ===
schedule.id && schedule.id &&
'ring-2 ring-primary', 'ring-2 ring-primary',
)} )}
> >
<CardHeader className="flex flex-row items-start justify-between gap-2 px-4"> <CardHeader className="flex flex-row items-start justify-between gap-2 px-4">
@ -209,17 +213,17 @@ export default function ScheduleIndex({
<CardContent className="flex flex-col gap-1.5 px-4 text-xs text-muted-foreground"> <CardContent className="flex flex-col gap-1.5 px-4 text-xs text-muted-foreground">
{(schedule.start_time || {(schedule.start_time ||
schedule.end_time) && ( schedule.end_time) && (
<span className="inline-flex items-center gap-1.5"> <span className="inline-flex items-center gap-1.5">
<Clock className="h-3.5 w-3.5 shrink-0" /> <Clock className="h-3.5 w-3.5 shrink-0" />
{toTimeInput( {toTimeInput(
schedule.start_time, schedule.start_time,
) || '-'}{' '} ) || '-'}{' '}
-{' '} -{' '}
{toTimeInput( {toTimeInput(
schedule.end_time, schedule.end_time,
) || '-'} ) || '-'}
</span> </span>
)} )}
{schedule.room && ( {schedule.room && (
<span className="inline-flex items-center gap-1.5"> <span className="inline-flex items-center gap-1.5">
<MapPin className="h-3.5 w-3.5 shrink-0" /> <MapPin className="h-3.5 w-3.5 shrink-0" />

View File

@ -1,16 +1,34 @@
import type { ColumnDef } from '@tanstack/react-table'; import type { ColumnDef } from '@tanstack/react-table';
import { format } from 'date-fns'; 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 { RowActions } from '@/components/row-actions';
import { StatusBadge } from '@/components/status-badge';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import type { Feedback, FeedbackStatusValue } from '@/types/feedback'; import type { Feedback } from '@/types/feedback';
import { FeedbackStatusLabels, FeedbackTypeLabels } from '@/types/feedback'; import { FeedbackTypeLabels } from '@/types/feedback';
export type { Feedback } from '@/types/feedback'; export type { Feedback } from '@/types/feedback';
type StatusOption = { value: string; label: string };
type CreateColumnsParams = { type CreateColumnsParams = {
handleView: (feedback: Feedback) => void;
handleEdit: (feedback: Feedback) => void; handleEdit: (feedback: Feedback) => void;
handleDeleteClick: (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 { function stripHtml(html: string): string {
@ -20,24 +38,19 @@ function stripHtml(html: string): string {
.trim(); .trim();
} }
function statusBadgeVariant(
status: FeedbackStatusValue,
): 'outline' | 'secondary' | 'default' {
if (status === 'resolved') {
return 'default';
}
if (status === 'in_review') {
return 'secondary';
}
return 'outline';
}
export function createFeedbackColumns( export function createFeedbackColumns(
params: CreateColumnsParams, params: CreateColumnsParams,
): ColumnDef<Feedback>[] { ): ColumnDef<Feedback>[] {
const { handleEdit, handleDeleteClick } = params; const {
handleView,
handleEdit,
handleDeleteClick,
handleStatusChange,
statuses,
canUpdateStatus,
canUpdate,
canDelete,
} = params;
return [ return [
{ {
@ -80,12 +93,23 @@ export function createFeedbackColumns(
}, },
cell: ({ row }) => ( cell: ({ row }) => (
<div className="flex justify-center"> <div className="flex justify-center">
<Badge variant={statusBadgeVariant(row.original.status)}> <StatusBadge
{FeedbackStatusLabels[row.original.status]} status={row.original.status}
</Badge> statuses={statuses}
variants={FeedbackStatusVariants}
onChange={(status) =>
handleStatusChange(row.original, status)
}
disabled={!canUpdateStatus}
/>
</div> </div>
), ),
}, },
{
accessorKey: 'handler.profile.full_name',
header: () => <span>Ditindaklanjuti Oleh</span>,
cell: ({ row }) => row.original.handler?.profile?.full_name ?? '-',
},
{ {
accessorKey: 'created_at', accessorKey: 'created_at',
header: () => <span>Dikirim</span>, header: () => <span>Dikirim</span>,
@ -102,9 +126,15 @@ export function createFeedbackColumns(
cell: ({ row }) => ( cell: ({ row }) => (
<RowActions <RowActions
actions={[ actions={[
{
label: 'Lihat Detail',
icon: <Eye className="h-4 w-4" />,
onClick: () => handleView(row.original),
},
{ {
label: 'Edit', label: 'Edit',
icon: <Pencil className="h-4 w-4" />, icon: <Pencil className="h-4 w-4" />,
show: canUpdate,
onClick: () => handleEdit(row.original), onClick: () => handleEdit(row.original),
}, },
{ {
@ -112,6 +142,7 @@ export function createFeedbackColumns(
icon: ( icon: (
<Trash2 className="h-4 w-4 text-destructive" /> <Trash2 className="h-4 w-4 text-destructive" />
), ),
show: canDelete,
onClick: () => handleDeleteClick(row.original), onClick: () => handleDeleteClick(row.original),
}, },
]} ]}

View File

@ -1,4 +1,5 @@
import { Head, router } from '@inertiajs/react'; import { Head, router } from '@inertiajs/react';
import { format } from 'date-fns';
import { Plus } from 'lucide-react'; import { Plus } from 'lucide-react';
import { useState } from 'react'; import { useState } from 'react';
import type { PaginationState } from '@/components/data-table'; import type { PaginationState } from '@/components/data-table';
@ -10,7 +11,14 @@ import { FormDialog } from '@/components/form-dialog';
import InputError from '@/components/input-error'; 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 TiptapEditor from '@/components/rich-text-editor';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { import {
@ -20,15 +28,18 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@/components/ui/select'; } from '@/components/ui/select';
import { usePermissions } from '@/hooks/use-permissions';
import { useServerTable } from '@/hooks/use-server-table'; import { useServerTable } from '@/hooks/use-server-table';
import { import {
index as feedbackIndex, index as feedbackIndex,
destroy, destroy,
store, store,
update, update,
update_status,
} from '@/routes/admin/feedback'; } from '@/routes/admin/feedback';
import type { Feedback } from '@/types/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 }; type FeedbackTypeOption = { value: string; label: string };
@ -57,6 +68,12 @@ export default function FeedbackIndex({
const [createOpen, setCreateOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false);
const [editing, setEditing] = useState<Feedback | null>(null); 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 { 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[] = [ 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({ const columns = createFeedbackColumns({
handleView: (feedback) => setViewing(feedback),
handleEdit: (feedback) => setEditing(feedback), handleEdit: (feedback) => setEditing(feedback),
handleDeleteClick: (feedback) => setDeleting(feedback), handleDeleteClick: (feedback) => setDeleting(feedback),
handleStatusChange,
statuses,
canUpdateStatus,
canUpdate,
canDelete,
}); });
return ( 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"> <div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
<PageHeader <PageHeader
title="Kritik dan Saran" title="Kritik dan Saran"
description={
<p className="mt-1 text-sm text-muted-foreground">
Sampaikan kritik, saran, atau aduan Anda kepada
kami.
</p>
}
actions={ actions={
<Button asChild> canCreate && (
<button <Button asChild>
type="button" <button
onClick={() => setCreateOpen(true)} type="button"
> onClick={() => setCreateOpen(true)}
<Plus className="h-4 w-4" /> >
Tambah <Plus className="h-4 w-4" />
</button> Tambah
</Button> </button>
</Button>
)
} }
/> />
@ -149,6 +176,16 @@ export default function FeedbackIndex({
types={types} types={types}
/> />
<ViewDetailDialog
open={viewing !== null}
onOpenChange={(open) => {
if (!open) {
setViewing(null);
}
}}
feedback={viewing}
/>
<DataTable <DataTable
columns={columns} columns={columns}
data={feedbacks.data} data={feedbacks.data}
@ -262,6 +299,7 @@ function CreateForm({
action={store()} action={store()}
resetOnSuccess resetOnSuccess
onSuccess={() => onOpenChange(false)} onSuccess={() => onOpenChange(false)}
contentClassName="sm:max-w-4xl"
> >
{({ errors }) => ( {({ errors }) => (
<div className="grid gap-4"> <div className="grid gap-4">
@ -291,6 +329,7 @@ function EditForm({
action={editing ? update(editing.id) : ''} action={editing ? update(editing.id) : ''}
resetOnSuccess resetOnSuccess
onSuccess={() => onOpenChange(false)} onSuccess={() => onOpenChange(false)}
contentClassName="sm:max-w-2xl"
> >
{({ errors }) => {({ errors }) =>
editing && ( editing && (
@ -306,3 +345,87 @@ function EditForm({
</FormDialog> </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>
);
}

View File

@ -3,7 +3,17 @@ import { Key, Pencil, Trash2 } from 'lucide-react';
import { ActiveStatusSwitch } from '@/components/active-status-switch'; import { ActiveStatusSwitch } from '@/components/active-status-switch';
import { GenderBadge } from '@/components/gender-badge'; import { GenderBadge } from '@/components/gender-badge';
import { RowActions } from '@/components/row-actions'; 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 = { export type Student = {
id: number; id: number;
@ -97,9 +107,10 @@ export function createStudentColumns(
accessorKey: 'student.status', accessorKey: 'student.status',
header: () => <span>Status</span>, header: () => <span>Status</span>,
cell: ({ row }) => ( cell: ({ row }) => (
<StudentStatusBadge <StatusBadge
status={row.original.student?.status} status={row.original.student?.status}
statuses={statuses} statuses={statuses}
variants={StudentStatusVariants}
onChange={(status) => onChange={(status) =>
handleStatusChange(row.original, status) handleStatusChange(row.original, status)
} }

View File

@ -18,6 +18,11 @@ export const FeedbackStatusLabels: Record<FeedbackStatusValue, string> = {
resolved: 'Selesai', resolved: 'Selesai',
}; };
export type FeedbackHandler = {
id: number;
profile: { full_name: string } | null;
};
export type Feedback = { export type Feedback = {
id: number; id: number;
type: FeedbackTypeValue; type: FeedbackTypeValue;
@ -25,6 +30,8 @@ export type Feedback = {
message: string; message: string;
status: FeedbackStatusValue; status: FeedbackStatusValue;
admin_notes: string | null; admin_notes: string | null;
handled_by: number | null;
handler: FeedbackHandler | null;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
}; };

View File

@ -14,6 +14,7 @@ declare module '@inertiajs/core' {
auth: Auth; auth: Auth;
sidebarOpen: boolean; sidebarOpen: boolean;
unreadNotificationsCount: number; unreadNotificationsCount: number;
pendingFeedbackCount: number;
[key: string]: unknown; [key: string]: unknown;
}; };
} }

View File

@ -202,7 +202,12 @@
Route::resource('admin/feedback', FeedbackController::class) Route::resource('admin/feedback', FeedbackController::class)
->except(['create', 'edit', 'show']) ->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::prefix('admin/users')->name('admin.users.')->group(function () {
Route::resource('lecturers', LecturerController::class) Route::resource('lecturers', LecturerController::class)