feat: implement feedback system with CRUD functionality #32

Merged
pangestu merged 1 commits from feat/implement-feedback-system into dev 2026-08-25 15:26:34 +08:00
20 changed files with 6339 additions and 253 deletions

View File

@ -0,0 +1,19 @@
<?php
namespace App\Enums;
enum FeedbackStatus: string
{
case Submitted = 'submitted';
case InReview = 'in_review';
case Resolved = 'resolved';
public function label(): string
{
return match ($this) {
self::Submitted => 'Terkirim',
self::InReview => 'Ditinjau',
self::Resolved => 'Selesai',
};
}
}

View File

@ -0,0 +1,19 @@
<?php
namespace App\Enums;
enum FeedbackType: string
{
case Kritik = 'kritik';
case Saran = 'saran';
case Aduan = 'aduan';
public function label(): string
{
return match ($this) {
self::Kritik => 'Kritik',
self::Saran => 'Saran',
self::Aduan => 'Aduan',
};
}
}

View File

@ -0,0 +1,58 @@
<?php
namespace App\Http\Controllers;
use App\Enums\FeedbackType;
use App\Http\Requests\FeedbackRequest;
use App\Http\Requests\PaginatedRequest;
use App\Models\Feedback;
use App\Services\FeedbackService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class FeedbackController extends Controller
{
public function __construct(private readonly FeedbackService $service) {}
public function index(PaginatedRequest $request): Response
{
return Inertia::render('feedback/index', [
'feedbacks' => $this->service->paginated($request->user(), ...$request->validatedWithDefaults()),
'types' => array_map(fn (FeedbackType $type) => [
'value' => $type->value,
'label' => $type->label(),
], FeedbackType::cases()),
]);
}
public function store(FeedbackRequest $request): RedirectResponse
{
$this->service->create($request->user(), $request->validated());
Inertia::flash('toast', ['type' => 'success', 'message' => 'Masukan berhasil dikirim.']);
return to_route('feedback.index');
}
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.']);
return to_route('feedback.index');
}
public function destroy(Request $request, Feedback $feedback): RedirectResponse
{
abort_unless($feedback->user_id === $request->user()->id, 403);
$this->service->delete($feedback);
return Inertia::flash('toast', ['type' => 'success', 'message' => 'Masukan berhasil dihapus.'])->back();
}
}

View File

@ -0,0 +1,24 @@
<?php
namespace App\Http\Requests;
use App\Enums\FeedbackType;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class FeedbackRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'type' => ['required', 'string', Rule::in(array_column(FeedbackType::cases(), 'value'))],
'subject' => ['required', 'string', 'max:150'],
'message' => ['required', 'string'],
];
}
}

31
app/Models/Feedback.php Normal file
View File

@ -0,0 +1,31 @@
<?php
namespace App\Models;
use App\Enums\FeedbackStatus;
use App\Enums\FeedbackType;
use Illuminate\Database\Eloquent\Attributes\Guarded;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Guarded(['id'])]
class Feedback extends Model
{
use HasFactory;
protected $table = 'feedbacks';
protected function casts(): array
{
return [
'type' => FeedbackType::class,
'status' => FeedbackStatus::class,
];
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

View File

@ -55,4 +55,9 @@ public function notifications(): HasMany
{
return $this->hasMany(Notification::class);
}
public function feedbacks(): HasMany
{
return $this->hasMany(Feedback::class);
}
}

View File

@ -0,0 +1,76 @@
<?php
namespace App\Services;
use App\Models\Feedback;
use App\Models\User;
use App\Support\TextAlignAttributeSanitizer;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Symfony\Component\HtmlSanitizer\HtmlSanitizer;
use Symfony\Component\HtmlSanitizer\HtmlSanitizerConfig;
class FeedbackService
{
private readonly HtmlSanitizer $sanitizer;
public function __construct()
{
$config = (new HtmlSanitizerConfig)
->allowElement('p', ['style'])
->allowElement('h2', ['style'])
->allowElement('h3', ['style'])
->allowElement('strong')
->allowElement('b')
->allowElement('em')
->allowElement('i')
->allowElement('u')
->allowElement('s')
->allowElement('strike')
->allowElement('ul')
->allowElement('ol')
->allowElement('li')
->allowElement('blockquote')
->allowElement('br')
->allowElement('a', ['href'])
->allowElement('img', ['src', 'alt'])
->allowLinkSchemes(['http', 'https', 'mailto'])
->allowMediaSchemes(['https'])
->withAttributeSanitizer(new TextAlignAttributeSanitizer);
$this->sanitizer = new HtmlSanitizer($config);
}
public function paginated(User $user, int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', array $filters = []): LengthAwarePaginator
{
return Feedback::query()
->where('user_id', $user->id)
->when($search, fn ($q) => $q->where('subject', 'like', "%{$search}%"))
->orderBy($sort, $direction)
->paginate($perPage);
}
public function create(User $user, array $data): Feedback
{
return Feedback::create([
'user_id' => $user->id,
'type' => $data['type'],
'subject' => $data['subject'],
'message' => $this->sanitizer->sanitize($data['message']),
]);
}
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();
return $feedback;
}
public function delete(Feedback $feedback): bool
{
return $feedback->delete();
}
}

View File

@ -0,0 +1,32 @@
<?php
namespace App\Support;
use Symfony\Component\HtmlSanitizer\HtmlSanitizerConfig;
use Symfony\Component\HtmlSanitizer\Visitor\AttributeSanitizer\AttributeSanitizerInterface;
/**
* Allows only `style="text-align: left|center|right|justify"` (as emitted by
* Tiptap's TextAlign extension) through the sanitizer's `style` attribute.
*/
class TextAlignAttributeSanitizer implements AttributeSanitizerInterface
{
public function getSupportedElements(): ?array
{
return ['p', 'h2', 'h3'];
}
public function getSupportedAttributes(): ?array
{
return ['style'];
}
public function sanitizeAttribute(string $element, string $attribute, string $value, HtmlSanitizerConfig $config): ?string
{
if (preg_match('/^text-align:\s*(left|center|right|justify);?$/i', trim($value)) === 1) {
return $value;
}
return null;
}
}

View File

@ -17,7 +17,8 @@
"laravel/tinker": "^3.0",
"laravel/wayfinder": "^0.1.14",
"spatie/laravel-medialibrary": "^11.23",
"spatie/laravel-permission": "^8.3"
"spatie/laravel-permission": "^8.3",
"symfony/html-sanitizer": "^8.1"
},
"require-dev": {
"fakerphp/faker": "^1.24",

74
composer.lock generated
View File

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "05388ded27fad46309907544186b1146",
"content-hash": "3fabe2a835a47ecc56faf22b029cda13",
"packages": [
{
"name": "bacon/bacon-qr-code",
@ -5539,6 +5539,78 @@
],
"time": "2026-06-27T09:05:56+00:00"
},
{
"name": "symfony/html-sanitizer",
"version": "v8.1.1",
"source": {
"type": "git",
"url": "https://github.com/symfony/html-sanitizer.git",
"reference": "09e1f2f9a3c8dcdca072587dc71999c1921c07cb"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/html-sanitizer/zipball/09e1f2f9a3c8dcdca072587dc71999c1921c07cb",
"reference": "09e1f2f9a3c8dcdca072587dc71999c1921c07cb",
"shasum": ""
},
"require": {
"ext-dom": "*",
"league/uri": "^6.5|^7.0",
"php": ">=8.4.1"
},
"type": "library",
"autoload": {
"psr-4": {
"Symfony\\Component\\HtmlSanitizer\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Titouan Galopin",
"email": "galopintitouan@gmail.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Provides an object-oriented API to sanitize untrusted HTML input for safe insertion into a document's DOM.",
"homepage": "https://symfony.com",
"keywords": [
"Purifier",
"html",
"sanitizer"
],
"support": {
"source": "https://github.com/symfony/html-sanitizer/tree/v8.1.1"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-06-06T11:11:44+00:00"
},
{
"name": "symfony/http-foundation",
"version": "v8.1.1",

View File

@ -0,0 +1,29 @@
<?php
use App\Enums\FeedbackStatus;
use App\Enums\FeedbackType;
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::create('feedbacks', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->enum('type', array_column(FeedbackType::cases(), 'value'));
$table->string('subject', 150);
$table->text('message');
$table->enum('status', array_column(FeedbackStatus::cases(), 'value'))->default(FeedbackStatus::Submitted->value);
$table->text('admin_notes')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('feedbacks');
}
};

4940
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -54,6 +54,14 @@
"@tabler/icons-react": "^3.46.0",
"@tailwindcss/vite": "^4.1.11",
"@tanstack/react-table": "^8.21.3",
"@tiptap/extension-image": "^3.30.3",
"@tiptap/extension-link": "^3.30.3",
"@tiptap/extension-placeholder": "^3.30.3",
"@tiptap/extension-text-align": "^3.30.3",
"@tiptap/extension-underline": "^3.30.3",
"@tiptap/pm": "^3.30.3",
"@tiptap/react": "^3.30.3",
"@tiptap/starter-kit": "^3.30.3",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"@vitejs/plugin-react": "^5.2.0",

View File

@ -1,9 +1,9 @@
@import 'tailwindcss';
@import 'tw-animate-css';
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@import "@fontsource-variable/inter";
@import 'tw-animate-css';
@import 'shadcn/tailwind.css';
@import '@fontsource-variable/inter';
@source '../views';
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
@ -65,137 +65,74 @@ @theme {
}
:root {
--background:
oklch(1 0 0);
--foreground:
oklch(0.145 0 0);
--card:
oklch(1 0 0);
--card-foreground:
oklch(0.145 0 0);
--popover:
oklch(1 0 0);
--popover-foreground:
oklch(0.145 0 0);
--primary:
oklch(0.852 0.199 91.936);
--primary-foreground:
oklch(0.421 0.095 57.708);
--secondary:
oklch(0.967 0.001 286.375);
--secondary-foreground:
oklch(0.21 0.006 285.885);
--muted:
oklch(0.97 0 0);
--muted-foreground:
oklch(0.556 0 0);
--accent:
oklch(0.97 0 0);
--accent-foreground:
oklch(0.205 0 0);
--destructive:
oklch(0.577 0.245 27.325);
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.852 0.199 91.936);
--primary-foreground: oklch(0.421 0.095 57.708);
--secondary: oklch(0.967 0.001 286.375);
--secondary-foreground: oklch(0.21 0.006 285.885);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(0.577 0.245 27.325);
--border:
oklch(0.922 0 0);
--input:
oklch(0.922 0 0);
--ring:
oklch(0.708 0 0);
--chart-1:
oklch(0.905 0.182 98.111);
--chart-2:
oklch(0.795 0.184 86.047);
--chart-3:
oklch(0.681 0.162 75.834);
--chart-4:
oklch(0.554 0.135 66.442);
--chart-5:
oklch(0.476 0.114 61.907);
--radius:
0.625rem;
--sidebar:
oklch(0.985 0 0);
--sidebar-foreground:
oklch(0.145 0 0);
--sidebar-primary:
oklch(0.681 0.162 75.834);
--sidebar-primary-foreground:
oklch(0.987 0.026 102.212);
--sidebar-accent:
oklch(0.97 0 0);
--sidebar-accent-foreground:
oklch(0.205 0 0);
--sidebar-border:
oklch(0.922 0 0);
--sidebar-ring:
oklch(0.708 0 0);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.905 0.182 98.111);
--chart-2: oklch(0.795 0.184 86.047);
--chart-3: oklch(0.681 0.162 75.834);
--chart-4: oklch(0.554 0.135 66.442);
--chart-5: oklch(0.476 0.114 61.907);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.681 0.162 75.834);
--sidebar-primary-foreground: oklch(0.987 0.026 102.212);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background:
oklch(0.145 0 0);
--foreground:
oklch(0.985 0 0);
--card:
oklch(0.205 0 0);
--card-foreground:
oklch(0.985 0 0);
--popover:
oklch(0.205 0 0);
--popover-foreground:
oklch(0.985 0 0);
--primary:
oklch(0.795 0.184 86.047);
--primary-foreground:
oklch(0.421 0.095 57.708);
--secondary:
oklch(0.274 0.006 286.033);
--secondary-foreground:
oklch(0.985 0 0);
--muted:
oklch(0.269 0 0);
--muted-foreground:
oklch(0.708 0 0);
--accent:
oklch(0.269 0 0);
--accent-foreground:
oklch(0.985 0 0);
--destructive:
oklch(0.704 0.191 22.216);
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.795 0.184 86.047);
--primary-foreground: oklch(0.421 0.095 57.708);
--secondary: oklch(0.274 0.006 286.033);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--destructive-foreground: oklch(0.637 0.237 25.331);
--border:
oklch(1 0 0 / 10%);
--input:
oklch(1 0 0 / 15%);
--ring:
oklch(0.556 0 0);
--chart-1:
oklch(0.905 0.182 98.111);
--chart-2:
oklch(0.795 0.184 86.047);
--chart-3:
oklch(0.681 0.162 75.834);
--chart-4:
oklch(0.554 0.135 66.442);
--chart-5:
oklch(0.476 0.114 61.907);
--sidebar:
oklch(0.205 0 0);
--sidebar-foreground:
oklch(0.985 0 0);
--sidebar-primary:
oklch(0.795 0.184 86.047);
--sidebar-primary-foreground:
oklch(0.987 0.026 102.212);
--sidebar-accent:
oklch(0.269 0 0);
--sidebar-accent-foreground:
oklch(0.985 0 0);
--sidebar-border:
oklch(1 0 0 / 10%);
--sidebar-ring:
oklch(0.556 0 0);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.905 0.182 98.111);
--chart-2: oklch(0.795 0.184 86.047);
--chart-3: oklch(0.681 0.162 75.834);
--chart-4: oklch(0.554 0.135 66.442);
--chart-5: oklch(0.476 0.114 61.907);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.795 0.184 86.047);
--sidebar-primary-foreground: oklch(0.987 0.026 102.212);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
@ -248,84 +185,52 @@ @layer base {
}
@theme inline {
--font-heading:
var(--font-sans);
--font-sans:
'Inter Variable', sans-serif;
--color-sidebar-ring:
var(--sidebar-ring);
--color-sidebar-border:
var(--sidebar-border);
--color-sidebar-accent-foreground:
var(--sidebar-accent-foreground);
--color-sidebar-accent:
var(--sidebar-accent);
--color-sidebar-primary-foreground:
var(--sidebar-primary-foreground);
--color-sidebar-primary:
var(--sidebar-primary);
--color-sidebar-foreground:
var(--sidebar-foreground);
--color-sidebar:
var(--sidebar);
--color-chart-5:
var(--chart-5);
--color-chart-4:
var(--chart-4);
--color-chart-3:
var(--chart-3);
--color-chart-2:
var(--chart-2);
--color-chart-1:
var(--chart-1);
--color-ring:
var(--ring);
--color-input:
var(--input);
--color-border:
var(--border);
--color-destructive:
var(--destructive);
--color-accent-foreground:
var(--accent-foreground);
--color-accent:
var(--accent);
--color-muted-foreground:
var(--muted-foreground);
--color-muted:
var(--muted);
--color-secondary-foreground:
var(--secondary-foreground);
--color-secondary:
var(--secondary);
--color-primary-foreground:
var(--primary-foreground);
--color-primary:
var(--primary);
--color-popover-foreground:
var(--popover-foreground);
--color-popover:
var(--popover);
--color-card-foreground:
var(--card-foreground);
--color-card:
var(--card);
--color-foreground:
var(--foreground);
--color-background:
var(--background);
--radius-sm:
calc(var(--radius) * 0.6);
--radius-md:
calc(var(--radius) * 0.8);
--radius-lg:
var(--radius);
--radius-xl:
calc(var(--radius) * 1.4);
--radius-2xl:
calc(var(--radius) * 1.8);
--radius-3xl:
calc(var(--radius) * 2.2);
--radius-4xl:
calc(var(--radius) * 2.6);
}
--font-heading: var(--font-sans);
--font-sans: 'Inter Variable', sans-serif;
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--color-foreground: var(--foreground);
--color-background: var(--background);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
}
.ProseMirror p.is-editor-empty:first-child::before {
content: attr(data-placeholder);
float: left;
height: 0;
color: var(--muted-foreground);
pointer-events: none;
}

View File

@ -54,6 +54,7 @@ import { index as departmentsRoute } from '@/routes/admin/master/departments';
import { index as administratorsRoute } from '@/routes/admin/users/administrators';
import { index as lecturersRoute } from '@/routes/admin/users/lecturers';
import { index as studentsRoute } from '@/routes/admin/users/students';
import { index as feedbackRoute } from '@/routes/feedback';
const data: {
navMain: (NavGroup | NavItem)[];
@ -184,7 +185,7 @@ const data: {
navSecondary: [
{
title: 'Kritik dan Saran',
url: '#',
url: feedbackRoute.url(),
icon: IconMessageDots,
},
{

View File

@ -0,0 +1,515 @@
import Image from '@tiptap/extension-image';
import Link from '@tiptap/extension-link';
import Placeholder from '@tiptap/extension-placeholder';
import TextAlign from '@tiptap/extension-text-align';
import Underline from '@tiptap/extension-underline';
import { EditorContent, useEditor } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import {
AlignCenter,
AlignLeft,
AlignRight,
Bold,
Heading2,
Heading3,
ImagePlus,
Italic,
Link as LinkIcon,
List,
ListOrdered,
Loader2,
Redo2,
Underline as UnderlineIcon,
Undo2,
X,
} from 'lucide-react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { cn } from '@/lib/utils';
type MediaItem = {
id: number;
name: string;
file_name: string;
mime_type: string;
size: number;
url: string;
};
type TiptapEditorProps = {
value?: string;
onChange?: (value: string) => void;
name?: string;
placeholder?: string;
modelType?: string;
modelId?: number | string;
collection?: string;
error?: string;
};
function ToolbarButton({
onClick,
isActive = false,
disabled = false,
children,
title,
}: {
onClick: () => void;
isActive?: boolean;
disabled?: boolean;
children: React.ReactNode;
title?: string;
}) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
title={title}
className={cn(
'inline-flex h-8 w-8 items-center justify-center rounded-md text-sm font-medium transition-colors',
'hover:bg-muted hover:text-muted-foreground',
'disabled:pointer-events-none disabled:opacity-50',
isActive && 'bg-accent text-accent-foreground',
)}
>
{children}
</button>
);
}
function ToolbarDivider() {
return <div className="h-6 w-px bg-border" />;
}
export default function TiptapEditor({
value = '',
onChange,
name = 'content',
placeholder = 'Tulis konten di sini...',
modelType = 'news',
modelId = 0,
collection = 'content',
error,
}: TiptapEditorProps) {
const fileInputRef = useRef<HTMLInputElement>(null);
const [uploading, setUploading] = useState(false);
const [showLinkInput, setShowLinkInput] = useState(false);
const [linkUrl, setLinkUrl] = useState('');
const [draftId, setDraftId] = useState<number | null>(null);
const editor = useEditor({
extensions: [
StarterKit.configure({
heading: {
levels: [2, 3],
},
}),
Image.configure({
inline: false,
allowBase64: true,
}),
Placeholder.configure({
placeholder,
}),
Underline,
TextAlign.configure({
types: ['heading', 'paragraph'],
}),
Link.configure({
openOnClick: false,
HTMLAttributes: {
class: 'text-primary underline cursor-pointer',
},
}),
],
content: value,
onUpdate: ({ editor: e }) => {
onChange?.(e.getHTML());
},
editorProps: {
attributes: {
class: cn(
'prose prose-sm sm:prose-base max-w-none',
'min-h-[300px] w-full rounded-b-md px-3 py-2',
'focus:outline-none',
'[&_h2]:mt-4 [&_h2]:mb-2 [&_h2]:text-xl [&_h2]:font-semibold',
'[&_h3]:mt-3 [&_h3]:mb-2 [&_h3]:text-lg [&_h3]:font-semibold',
'[&_p]:mb-2',
'[&_ul]:mb-2 [&_ul]:list-disc [&_ul]:pl-6',
'[&_ol]:mb-2 [&_ol]:list-decimal [&_ol]:pl-6',
'[&_li]:mb-1',
'[&_img]:my-4 [&_img]:h-auto [&_img]:max-w-full [&_img]:rounded-md',
'[&_a]:text-primary [&_a]:underline',
'[&_blockquote]:border-l-4 [&_blockquote]:border-border [&_blockquote]:pl-4 [&_blockquote]:text-muted-foreground [&_blockquote]:italic',
'placeholder:text-muted-foreground',
),
},
},
});
useEffect(() => {
if (editor && value !== editor.getHTML()) {
editor.commands.setContent(value);
}
}, [value, editor]);
const getCsrfToken = () =>
document
.querySelector('meta[name="csrf-token"]')
?.getAttribute('content') ?? '';
const handleImageUpload = useCallback(
async (file: File) => {
if (!editor) {
return;
}
setUploading(true);
try {
const token = getCsrfToken();
const headers = {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': token,
Accept: 'application/json',
};
let currentModelId =
draftId ?? (modelId ? Number(modelId) : null);
if (!currentModelId) {
const initRes = await fetch(`/media/${modelType}`, {
method: 'POST',
headers,
});
if (!initRes.ok) {
throw new Error('Gagal membuat draft.');
}
const { id } = await initRes.json();
currentModelId = id;
setDraftId(id);
}
const presignedRes = await fetch(
`/media/${modelType}/${currentModelId}/presigned-url`,
{
method: 'POST',
headers,
body: JSON.stringify({
file_name: file.name,
mime_type: file.type,
size: file.size,
collection,
}),
},
);
if (!presignedRes.ok) {
throw new Error('Gagal mendapatkan URL unggahan.');
}
const {
id,
presigned_url,
headers: putHeaders,
} = await presignedRes.json();
await fetch(presigned_url, {
method: 'PUT',
headers: putHeaders,
body: file,
});
const completeRes = await fetch(`/media/item/${id}/complete`, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': token,
Accept: 'application/json',
},
});
if (!completeRes.ok) {
throw new Error('Gagal menyelesaikan unggahan.');
}
const media: MediaItem = await completeRes.json();
editor
.chain()
.focus()
.setImage({ src: media.url, alt: file.name })
.run();
} catch (err) {
console.error('Image upload error:', err);
} finally {
setUploading(false);
}
},
[editor, modelType, modelId, collection, draftId],
);
const handleFileSelect = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
handleImageUpload(file);
}
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
},
[handleImageUpload],
);
const handleSetLink = useCallback(() => {
if (!editor) {
return;
}
if (linkUrl) {
editor
.chain()
.focus()
.extendMarkRange('link')
.setLink({ href: linkUrl })
.run();
} else {
editor.chain().focus().extendMarkRange('link').unsetLink().run();
}
setShowLinkInput(false);
setLinkUrl('');
}, [editor, linkUrl]);
if (!editor) {
return null;
}
return (
<div className="grid gap-2">
<div className="rounded-md border border-input">
<div className="flex flex-wrap items-center gap-0.5 border-b border-border p-1">
<ToolbarButton
onClick={() =>
editor.chain().focus().toggleBold().run()
}
isActive={editor.isActive('bold')}
title="Bold"
>
<Bold className="h-4 w-4" />
</ToolbarButton>
<ToolbarButton
onClick={() =>
editor.chain().focus().toggleItalic().run()
}
isActive={editor.isActive('italic')}
title="Italic"
>
<Italic className="h-4 w-4" />
</ToolbarButton>
<ToolbarButton
onClick={() =>
editor.chain().focus().toggleUnderline().run()
}
isActive={editor.isActive('underline')}
title="Underline"
>
<UnderlineIcon className="h-4 w-4" />
</ToolbarButton>
<ToolbarDivider />
<ToolbarButton
onClick={() =>
editor
.chain()
.focus()
.toggleHeading({ level: 2 })
.run()
}
isActive={editor.isActive('heading', { level: 2 })}
title="Heading 2"
>
<Heading2 className="h-4 w-4" />
</ToolbarButton>
<ToolbarButton
onClick={() =>
editor
.chain()
.focus()
.toggleHeading({ level: 3 })
.run()
}
isActive={editor.isActive('heading', { level: 3 })}
title="Heading 3"
>
<Heading3 className="h-4 w-4" />
</ToolbarButton>
<ToolbarDivider />
<ToolbarButton
onClick={() =>
editor.chain().focus().toggleBulletList().run()
}
isActive={editor.isActive('bulletList')}
title="Bullet List"
>
<List className="h-4 w-4" />
</ToolbarButton>
<ToolbarButton
onClick={() =>
editor.chain().focus().toggleOrderedList().run()
}
isActive={editor.isActive('orderedList')}
title="Ordered List"
>
<ListOrdered className="h-4 w-4" />
</ToolbarButton>
<ToolbarDivider />
<ToolbarButton
onClick={() =>
editor.chain().focus().setTextAlign('left').run()
}
isActive={editor.isActive({ textAlign: 'left' })}
title="Align Left"
>
<AlignLeft className="h-4 w-4" />
</ToolbarButton>
<ToolbarButton
onClick={() =>
editor.chain().focus().setTextAlign('center').run()
}
isActive={editor.isActive({ textAlign: 'center' })}
title="Align Center"
>
<AlignCenter className="h-4 w-4" />
</ToolbarButton>
<ToolbarButton
onClick={() =>
editor.chain().focus().setTextAlign('right').run()
}
isActive={editor.isActive({ textAlign: 'right' })}
title="Align Right"
>
<AlignRight className="h-4 w-4" />
</ToolbarButton>
<ToolbarDivider />
<ToolbarButton
onClick={() => {
if (editor.isActive('link')) {
editor.chain().focus().unsetLink().run();
} else {
setShowLinkInput(true);
}
}}
isActive={editor.isActive('link')}
title="Insert Link"
>
<LinkIcon className="h-4 w-4" />
</ToolbarButton>
<ToolbarButton
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
title="Insert Image"
>
{uploading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<ImagePlus className="h-4 w-4" />
)}
</ToolbarButton>
<div className="ml-auto flex items-center gap-0.5">
<ToolbarButton
onClick={() => editor.chain().focus().undo().run()}
disabled={!editor.can().undo()}
title="Undo"
>
<Undo2 className="h-4 w-4" />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().redo().run()}
disabled={!editor.can().redo()}
title="Redo"
>
<Redo2 className="h-4 w-4" />
</ToolbarButton>
</div>
</div>
{showLinkInput && (
<div className="flex items-center gap-2 border-b border-border px-3 py-2">
<LinkIcon className="h-4 w-4 shrink-0 text-muted-foreground" />
<input
type="url"
value={linkUrl}
onChange={(e) => setLinkUrl(e.target.value)}
placeholder="https://example.com"
className="flex-1 rounded-md border border-input bg-transparent px-2 py-1 text-sm outline-none focus:border-ring focus:ring-1 focus:ring-ring"
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleSetLink();
}
if (e.key === 'Escape') {
setShowLinkInput(false);
setLinkUrl('');
}
}}
autoFocus
/>
<button
type="button"
onClick={handleSetLink}
className="rounded-md bg-primary px-2 py-1 text-xs text-primary-foreground hover:bg-primary/90"
>
Set
</button>
<button
type="button"
onClick={() => {
setShowLinkInput(false);
setLinkUrl('');
}}
className="rounded-md px-2 py-1 text-xs text-muted-foreground hover:bg-muted"
>
<X className="h-3 w-3" />
</button>
</div>
)}
<EditorContent editor={editor} />
</div>
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleFileSelect}
className="hidden"
/>
<input type="hidden" name={name} value={editor.getHTML()} />
{error && <p className="text-sm text-destructive">{error}</p>}
</div>
);
}

View File

@ -0,0 +1,122 @@
import type { ColumnDef } from '@tanstack/react-table';
import { format } from 'date-fns';
import { Pencil, Trash2 } from 'lucide-react';
import { RowActions } from '@/components/row-actions';
import { Badge } from '@/components/ui/badge';
import type { Feedback, FeedbackStatusValue } from '@/types/feedback';
import { FeedbackStatusLabels, FeedbackTypeLabels } from '@/types/feedback';
export type { Feedback } from '@/types/feedback';
type CreateColumnsParams = {
handleEdit: (feedback: Feedback) => void;
handleDeleteClick: (feedback: Feedback) => void;
};
function stripHtml(html: string): string {
return html
.replace(/<[^>]+>/g, ' ')
.replace(/\s+/g, ' ')
.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;
return [
{
accessorKey: 'subject',
header: () => <span>Subjek</span>,
cell: ({ row }) => {
const feedback = row.original;
return (
<div>
<p className="font-medium">{feedback.subject}</p>
<p className="line-clamp-1 text-xs text-muted-foreground">
{stripHtml(feedback.message)}
</p>
</div>
);
},
},
{
accessorKey: 'type',
header: () => <span className="block text-center">Jenis</span>,
meta: {
className: 'w-[110px] text-center',
headerClassName: 'w-[110px] text-center',
},
cell: ({ row }) => (
<div className="flex justify-center">
<Badge variant="outline">
{FeedbackTypeLabels[row.original.type]}
</Badge>
</div>
),
},
{
accessorKey: 'status',
header: () => <span className="block text-center">Status</span>,
meta: {
className: 'w-[130px] text-center',
headerClassName: 'w-[130px] text-center',
},
cell: ({ row }) => (
<div className="flex justify-center">
<Badge variant={statusBadgeVariant(row.original.status)}>
{FeedbackStatusLabels[row.original.status]}
</Badge>
</div>
),
},
{
accessorKey: 'created_at',
header: () => <span>Dikirim</span>,
cell: ({ row }) =>
format(new Date(row.original.created_at), 'd MMM yyyy, HH:mm'),
},
{
id: 'actions',
header: () => <span className="block text-center">Aksi</span>,
meta: {
className: 'w-[100px] text-center',
headerClassName: 'w-[100px] text-center',
},
cell: ({ row }) => (
<RowActions
actions={[
{
label: 'Edit',
icon: <Pencil className="h-4 w-4" />,
onClick: () => handleEdit(row.original),
},
{
label: 'Hapus',
icon: (
<Trash2 className="h-4 w-4 text-destructive" />
),
onClick: () => handleDeleteClick(row.original),
},
]}
/>
),
},
];
}

View File

@ -0,0 +1,276 @@
import { Head, router } from '@inertiajs/react';
import { Plus } from 'lucide-react';
import { useState } from 'react';
import type { PaginationState } from '@/components/data-table';
import { DataTable } from '@/components/data-table';
import { DeleteConfirmDialog } from '@/components/delete-confirm-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 { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { useServerTable } from '@/hooks/use-server-table';
import {
index as feedbackIndex,
destroy,
store,
update,
} from '@/routes/feedback';
import type { Feedback } from '@/types/feedback';
import { createFeedbackColumns } from './columns';
type FeedbackTypeOption = { value: string; label: string };
type Props = {
feedbacks: {
data: Feedback[];
current_page: number;
last_page: number;
per_page: number;
total: number;
};
types: FeedbackTypeOption[];
};
export default function FeedbackIndex({ feedbacks, types }: Props) {
const [createOpen, setCreateOpen] = useState(false);
const [editing, setEditing] = useState<Feedback | null>(null);
const [deleting, setDeleting] = useState<Feedback | null>(null);
const pagination: PaginationState = {
current_page: feedbacks.current_page,
last_page: feedbacks.last_page,
per_page: feedbacks.per_page,
total: feedbacks.total,
};
const {
search,
handlePageChange,
handlePerPageChange,
handleSearchChange,
} = useServerTable({
route: () => feedbackIndex.url(),
pagination,
});
function handleDelete() {
if (!deleting) {
return;
}
router.delete(destroy(deleting.id), {
onSuccess: () => setDeleting(null),
});
}
const columns = createFeedbackColumns({
handleEdit: (feedback) => setEditing(feedback),
handleDeleteClick: (feedback) => setDeleting(feedback),
});
return (
<>
<Head title="Kritik dan Saran" />
<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>
}
/>
<CreateForm
open={createOpen}
onOpenChange={setCreateOpen}
types={types}
/>
<EditForm
key={editing?.id}
open={editing !== null}
onOpenChange={(open) => {
if (!open) {
setEditing(null);
}
}}
editing={editing}
types={types}
/>
<DataTable
columns={columns}
data={feedbacks.data}
emptyText="Belum ada kritik atau saran yang dikirim."
pagination={pagination}
onPageChange={handlePageChange}
onPerPageChange={handlePerPageChange}
onSearchChange={handleSearchChange}
searchValue={search}
searchKey="subject"
searchPlaceholder="Cari subjek..."
/>
<DeleteConfirmDialog
target={deleting}
onOpenChange={(open) => {
if (!open) {
setDeleting(null);
}
}}
title="Hapus Masukan"
description={(feedback) =>
`Apakah Anda yakin ingin menghapus "${feedback.subject}"? Tindakan ini tidak dapat dibatalkan.`
}
onConfirm={handleDelete}
/>
</div>
</>
);
}
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)}
>
{({ 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)}
>
{({ errors }) =>
editing && (
<div className="grid gap-4">
<FeedbackFields
errors={errors}
editing={editing}
types={types}
/>
</div>
)
}
</FormDialog>
);
}

View File

@ -0,0 +1,30 @@
export const FeedbackTypes = ['kritik', 'saran', 'aduan'] as const;
export type FeedbackTypeValue = (typeof FeedbackTypes)[number];
export const FeedbackTypeLabels: Record<FeedbackTypeValue, string> = {
kritik: 'Kritik',
saran: 'Saran',
aduan: 'Aduan',
};
export const FeedbackStatuses = ['submitted', 'in_review', 'resolved'] as const;
export type FeedbackStatusValue = (typeof FeedbackStatuses)[number];
export const FeedbackStatusLabels: Record<FeedbackStatusValue, string> = {
submitted: 'Terkirim',
in_review: 'Ditinjau',
resolved: 'Selesai',
};
export type Feedback = {
id: number;
type: FeedbackTypeValue;
subject: string;
message: string;
status: FeedbackStatusValue;
admin_notes: string | null;
created_at: string;
updated_at: string;
};

View File

@ -1,5 +1,6 @@
<?php
use App\Http\Controllers\FeedbackController;
use App\Http\Controllers\NotificationController;
use Illuminate\Support\Facades\Route;
@ -14,6 +15,8 @@
Route::patch('{notification}/read', [NotificationController::class, 'markRead'])->name('read');
Route::delete('{notification}', [NotificationController::class, 'destroy'])->name('destroy');
});
Route::resource('feedback', FeedbackController::class)->except(['create', 'edit', 'show']);
});
require __DIR__.'/settings.php';