Compare commits
2 Commits
daeeb3eb52
...
8be0d57e16
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8be0d57e16 | ||
|
|
b4ffdcb9dd |
@ -36,6 +36,13 @@ public function index(PaginatedRequest $request): Response
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function create(): Response
|
||||||
|
{
|
||||||
|
return Inertia::render('admin/feedback/create', [
|
||||||
|
'types' => FeedbackType::options(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
public function store(FeedbackRequest $request): RedirectResponse
|
public function store(FeedbackRequest $request): RedirectResponse
|
||||||
{
|
{
|
||||||
$this->service->create($request->user(), $request->validated());
|
$this->service->create($request->user(), $request->validated());
|
||||||
@ -45,6 +52,16 @@ public function store(FeedbackRequest $request): RedirectResponse
|
|||||||
return to_route('admin.feedback.index');
|
return to_route('admin.feedback.index');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function edit(Request $request, Feedback $feedback): Response
|
||||||
|
{
|
||||||
|
abort_unless($feedback->user_id === $request->user()->id, 403);
|
||||||
|
|
||||||
|
return Inertia::render('admin/feedback/edit', [
|
||||||
|
'feedback' => $feedback,
|
||||||
|
'types' => FeedbackType::options(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
public function update(FeedbackRequest $request, Feedback $feedback): RedirectResponse
|
public function update(FeedbackRequest $request, Feedback $feedback): RedirectResponse
|
||||||
{
|
{
|
||||||
abort_unless($feedback->user_id === $request->user()->id, 403);
|
abort_unless($feedback->user_id === $request->user()->id, 403);
|
||||||
|
|||||||
32
app/Http/Controllers/MediaUploadController.php
Normal file
32
app/Http/Controllers/MediaUploadController.php
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Http\Requests\MediaUploadRequest;
|
||||||
|
use App\Services\MediaUploadService;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
|
||||||
|
class MediaUploadController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly MediaUploadService $service,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function store(MediaUploadRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$media = $this->service->store(
|
||||||
|
$request->user(),
|
||||||
|
$request->file('file'),
|
||||||
|
$request->validated('collection') ?? 'content',
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'id' => $media->id,
|
||||||
|
'name' => $media->name,
|
||||||
|
'file_name' => $media->file_name,
|
||||||
|
'mime_type' => $media->mime_type,
|
||||||
|
'size' => $media->size,
|
||||||
|
'url' => $media->getUrl(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
21
app/Http/Requests/MediaUploadRequest.php
Normal file
21
app/Http/Requests/MediaUploadRequest.php
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class MediaUploadRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'file' => ['required', 'image', 'max:10240', 'mimes:jpg,jpeg,png,gif,webp'],
|
||||||
|
'collection' => ['nullable', 'string', 'max:50'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
20
app/Models/EditorUpload.php
Normal file
20
app/Models/EditorUpload.php
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Attributes\Guarded;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Spatie\MediaLibrary\HasMedia;
|
||||||
|
use Spatie\MediaLibrary\InteractsWithMedia;
|
||||||
|
|
||||||
|
#[Guarded(['id'])]
|
||||||
|
class EditorUpload extends Model implements HasMedia
|
||||||
|
{
|
||||||
|
use InteractsWithMedia;
|
||||||
|
|
||||||
|
public function uploader(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class, 'uploaded_by');
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -35,7 +35,7 @@ public function __construct()
|
|||||||
->allowElement('a', ['href'])
|
->allowElement('a', ['href'])
|
||||||
->allowElement('img', ['src', 'alt'])
|
->allowElement('img', ['src', 'alt'])
|
||||||
->allowLinkSchemes(['http', 'https', 'mailto'])
|
->allowLinkSchemes(['http', 'https', 'mailto'])
|
||||||
->allowMediaSchemes(['https'])
|
->allowMediaSchemes(['http', 'https'])
|
||||||
->withAttributeSanitizer(new TextAlignAttributeSanitizer);
|
->withAttributeSanitizer(new TextAlignAttributeSanitizer);
|
||||||
|
|
||||||
$this->sanitizer = new HtmlSanitizer($config);
|
$this->sanitizer = new HtmlSanitizer($config);
|
||||||
|
|||||||
18
app/Services/MediaUploadService.php
Normal file
18
app/Services/MediaUploadService.php
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Models\EditorUpload;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Http\UploadedFile;
|
||||||
|
use Spatie\MediaLibrary\MediaCollections\Models\Media;
|
||||||
|
|
||||||
|
class MediaUploadService
|
||||||
|
{
|
||||||
|
public function store(User $user, UploadedFile $file, string $collection = 'content'): Media
|
||||||
|
{
|
||||||
|
$upload = EditorUpload::create(['uploaded_by' => $user->id]);
|
||||||
|
|
||||||
|
return $upload->addMedia($file)->toMediaCollection($collection);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,22 @@
|
|||||||
|
<?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::create('editor_uploads', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('uploaded_by')->nullable()->constrained('users')->nullOnDelete();
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('editor_uploads');
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -24,7 +24,10 @@ import {
|
|||||||
X,
|
X,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { richTextContentClass } from '@/lib/rich-text-content';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { store as uploadMedia } from '@/routes/media/uploads';
|
||||||
|
|
||||||
type MediaItem = {
|
type MediaItem = {
|
||||||
id: number;
|
id: number;
|
||||||
@ -40,8 +43,6 @@ type TiptapEditorProps = {
|
|||||||
onChange?: (value: string) => void;
|
onChange?: (value: string) => void;
|
||||||
name?: string;
|
name?: string;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
modelType?: string;
|
|
||||||
modelId?: number | string;
|
|
||||||
collection?: string;
|
collection?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
};
|
};
|
||||||
@ -86,8 +87,6 @@ export default function TiptapEditor({
|
|||||||
onChange,
|
onChange,
|
||||||
name = 'content',
|
name = 'content',
|
||||||
placeholder = 'Tulis konten di sini...',
|
placeholder = 'Tulis konten di sini...',
|
||||||
modelType = 'news',
|
|
||||||
modelId = 0,
|
|
||||||
collection = 'content',
|
collection = 'content',
|
||||||
error,
|
error,
|
||||||
}: TiptapEditorProps) {
|
}: TiptapEditorProps) {
|
||||||
@ -95,7 +94,6 @@ export default function TiptapEditor({
|
|||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
const [showLinkInput, setShowLinkInput] = useState(false);
|
const [showLinkInput, setShowLinkInput] = useState(false);
|
||||||
const [linkUrl, setLinkUrl] = useState('');
|
const [linkUrl, setLinkUrl] = useState('');
|
||||||
const [draftId, setDraftId] = useState<number | null>(null);
|
|
||||||
|
|
||||||
const editor = useEditor({
|
const editor = useEditor({
|
||||||
extensions: [
|
extensions: [
|
||||||
@ -132,15 +130,7 @@ export default function TiptapEditor({
|
|||||||
'prose prose-sm sm:prose-base max-w-none',
|
'prose prose-sm sm:prose-base max-w-none',
|
||||||
'min-h-[300px] w-full rounded-b-md px-3 py-2',
|
'min-h-[300px] w-full rounded-b-md px-3 py-2',
|
||||||
'focus:outline-none',
|
'focus:outline-none',
|
||||||
'[&_h2]:mt-4 [&_h2]:mb-2 [&_h2]:text-xl [&_h2]:font-semibold',
|
richTextContentClass,
|
||||||
'[&_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',
|
'placeholder:text-muted-foreground',
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@ -167,74 +157,30 @@ export default function TiptapEditor({
|
|||||||
setUploading(true);
|
setUploading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const token = getCsrfToken();
|
const formData = new FormData();
|
||||||
const headers = {
|
formData.append('file', file);
|
||||||
'Content-Type': 'application/json',
|
formData.append('collection', collection);
|
||||||
'X-CSRF-TOKEN': token,
|
|
||||||
Accept: 'application/json',
|
|
||||||
};
|
|
||||||
|
|
||||||
let currentModelId =
|
const res = await fetch(uploadMedia.url(), {
|
||||||
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',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'X-CSRF-TOKEN': token,
|
'X-CSRF-TOKEN': getCsrfToken(),
|
||||||
Accept: 'application/json',
|
Accept: 'application/json',
|
||||||
},
|
},
|
||||||
|
body: formData,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!completeRes.ok) {
|
if (!res.ok) {
|
||||||
throw new Error('Gagal menyelesaikan unggahan.');
|
const body = await res.json().catch(() => null);
|
||||||
|
const message =
|
||||||
|
body?.errors?.file?.[0] ??
|
||||||
|
body?.message ??
|
||||||
|
'Gagal mengunggah gambar.';
|
||||||
|
|
||||||
|
throw new Error(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
const media: MediaItem = await completeRes.json();
|
const media: MediaItem = await res.json();
|
||||||
|
|
||||||
editor
|
editor
|
||||||
.chain()
|
.chain()
|
||||||
@ -242,12 +188,18 @@ export default function TiptapEditor({
|
|||||||
.setImage({ src: media.url, alt: file.name })
|
.setImage({ src: media.url, alt: file.name })
|
||||||
.run();
|
.run();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
const message =
|
||||||
|
err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: 'Gagal mengunggah gambar.';
|
||||||
|
|
||||||
|
toast.error(message);
|
||||||
console.error('Image upload error:', err);
|
console.error('Image upload error:', err);
|
||||||
} finally {
|
} finally {
|
||||||
setUploading(false);
|
setUploading(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[editor, modelType, modelId, collection, draftId],
|
[editor, collection],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleFileSelect = useCallback(
|
const handleFileSelect = useCallback(
|
||||||
|
|||||||
11
resources/js/lib/rich-text-content.ts
Normal file
11
resources/js/lib/rich-text-content.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
export const richTextContentClass = [
|
||||||
|
'[&_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 [&_p:last-child]:mb-0',
|
||||||
|
'[&_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',
|
||||||
|
].join(' ');
|
||||||
126
resources/js/pages/admin/feedback/create.tsx
Normal file
126
resources/js/pages/admin/feedback/create.tsx
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
import { Form, Head, Link } from '@inertiajs/react';
|
||||||
|
import { ArrowLeft, Save } from 'lucide-react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import InputError from '@/components/input-error';
|
||||||
|
import { PageHeader } from '@/components/page-header';
|
||||||
|
import TiptapEditor from '@/components/rich-text-editor';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import { index, store } from '@/routes/admin/feedback';
|
||||||
|
|
||||||
|
type FeedbackTypeOption = { value: string; label: string };
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
types: FeedbackTypeOption[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function FeedbackCreate({ types }: Props) {
|
||||||
|
const [message, setMessage] = useState('');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Head title="Tambah Kritik dan Saran" />
|
||||||
|
|
||||||
|
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
|
||||||
|
<PageHeader
|
||||||
|
title="Tambah Kritik dan Saran"
|
||||||
|
actions={
|
||||||
|
<Button variant="outline" asChild>
|
||||||
|
<Link href={index.url()}>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
Kembali
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Form action={store()} className="space-y-6">
|
||||||
|
{({ errors, processing }) => (
|
||||||
|
<>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Detail Masukan</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="grid gap-4">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label>
|
||||||
|
Jenis{' '}
|
||||||
|
<span className="text-destructive">
|
||||||
|
*
|
||||||
|
</span>
|
||||||
|
</Label>
|
||||||
|
<input type="hidden" name="type" />
|
||||||
|
<Select
|
||||||
|
name="type"
|
||||||
|
defaultValue="kritik"
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<SelectValue placeholder="Pilih jenis" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{types.map((type) => (
|
||||||
|
<SelectItem
|
||||||
|
key={type.value}
|
||||||
|
value={type.value}
|
||||||
|
>
|
||||||
|
{type.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<InputError message={errors.type} />
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="subject">
|
||||||
|
Subjek{' '}
|
||||||
|
<span className="text-destructive">
|
||||||
|
*
|
||||||
|
</span>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="subject"
|
||||||
|
name="subject"
|
||||||
|
placeholder="Ringkasan singkat"
|
||||||
|
/>
|
||||||
|
<InputError message={errors.subject} />
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label>
|
||||||
|
Pesan{' '}
|
||||||
|
<span className="text-destructive">
|
||||||
|
*
|
||||||
|
</span>
|
||||||
|
</Label>
|
||||||
|
<TiptapEditor
|
||||||
|
name="message"
|
||||||
|
value={message}
|
||||||
|
onChange={setMessage}
|
||||||
|
placeholder="Jelaskan kritik, saran, atau aduan Anda secara rinci"
|
||||||
|
error={errors.message}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button type="submit" disabled={processing}>
|
||||||
|
<Save className="h-4 w-4" />
|
||||||
|
Simpan
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
128
resources/js/pages/admin/feedback/edit.tsx
Normal file
128
resources/js/pages/admin/feedback/edit.tsx
Normal file
@ -0,0 +1,128 @@
|
|||||||
|
import { Form, Head, Link } from '@inertiajs/react';
|
||||||
|
import { ArrowLeft, Save } from 'lucide-react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import InputError from '@/components/input-error';
|
||||||
|
import { PageHeader } from '@/components/page-header';
|
||||||
|
import TiptapEditor from '@/components/rich-text-editor';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import { index, update } from '@/routes/admin/feedback';
|
||||||
|
import type { Feedback } from '@/types/feedback';
|
||||||
|
|
||||||
|
type FeedbackTypeOption = { value: string; label: string };
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
feedback: Feedback;
|
||||||
|
types: FeedbackTypeOption[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function FeedbackEdit({ feedback, types }: Props) {
|
||||||
|
const [message, setMessage] = useState(feedback.message);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Head title="Edit Kritik dan Saran" />
|
||||||
|
|
||||||
|
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
|
||||||
|
<PageHeader
|
||||||
|
title="Edit Kritik dan Saran"
|
||||||
|
actions={
|
||||||
|
<Button variant="outline" asChild>
|
||||||
|
<Link href={index.url()}>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
Kembali
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Form action={update(feedback.id)} className="space-y-6">
|
||||||
|
{({ errors, processing }) => (
|
||||||
|
<>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Detail Masukan</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="grid gap-4">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label>
|
||||||
|
Jenis{' '}
|
||||||
|
<span className="text-destructive">
|
||||||
|
*
|
||||||
|
</span>
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
name="type"
|
||||||
|
defaultValue={feedback.type}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<SelectValue placeholder="Pilih jenis" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{types.map((type) => (
|
||||||
|
<SelectItem
|
||||||
|
key={type.value}
|
||||||
|
value={type.value}
|
||||||
|
>
|
||||||
|
{type.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<InputError message={errors.type} />
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="subject">
|
||||||
|
Subjek{' '}
|
||||||
|
<span className="text-destructive">
|
||||||
|
*
|
||||||
|
</span>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="subject"
|
||||||
|
name="subject"
|
||||||
|
placeholder="Ringkasan singkat"
|
||||||
|
defaultValue={feedback.subject}
|
||||||
|
/>
|
||||||
|
<InputError message={errors.subject} />
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label>
|
||||||
|
Pesan{' '}
|
||||||
|
<span className="text-destructive">
|
||||||
|
*
|
||||||
|
</span>
|
||||||
|
</Label>
|
||||||
|
<TiptapEditor
|
||||||
|
name="message"
|
||||||
|
value={message}
|
||||||
|
onChange={setMessage}
|
||||||
|
placeholder="Jelaskan kritik, saran, atau aduan Anda secara rinci"
|
||||||
|
error={errors.message}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button type="submit" disabled={processing}>
|
||||||
|
<Save className="h-4 w-4" />
|
||||||
|
Simpan
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,16 +1,10 @@
|
|||||||
import { Head, router } from '@inertiajs/react';
|
|
||||||
import { format } from 'date-fns';
|
|
||||||
import { Plus } from 'lucide-react';
|
|
||||||
import { useState } from 'react';
|
|
||||||
import type { PaginationState } from '@/components/data-table';
|
import type { PaginationState } from '@/components/data-table';
|
||||||
import { DataTable } from '@/components/data-table';
|
import { DataTable } from '@/components/data-table';
|
||||||
import { DeleteConfirmDialog } from '@/components/delete-confirm-dialog';
|
import { DeleteConfirmDialog } from '@/components/delete-confirm-dialog';
|
||||||
import type { FilterField } from '@/components/filter-dialog';
|
import type { FilterField } from '@/components/filter-dialog';
|
||||||
import { FilterDialog } from '@/components/filter-dialog';
|
import { FilterDialog } from '@/components/filter-dialog';
|
||||||
import { FormDialog } from '@/components/form-dialog';
|
|
||||||
import InputError from '@/components/input-error';
|
|
||||||
import { PageHeader } from '@/components/page-header';
|
import { PageHeader } from '@/components/page-header';
|
||||||
import TiptapEditor from '@/components/rich-text-editor';
|
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
@ -19,26 +13,24 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
import { Input } from '@/components/ui/input';
|
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from '@/components/ui/select';
|
|
||||||
import { usePermissions } from '@/hooks/use-permissions';
|
import { usePermissions } from '@/hooks/use-permissions';
|
||||||
import { useServerTable } from '@/hooks/use-server-table';
|
import { useServerTable } from '@/hooks/use-server-table';
|
||||||
|
import { richTextContentClass } from '@/lib/rich-text-content';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
import {
|
import {
|
||||||
index as feedbackIndex,
|
create,
|
||||||
destroy,
|
destroy,
|
||||||
store,
|
edit,
|
||||||
update,
|
index as feedbackIndex,
|
||||||
update_status,
|
update_status,
|
||||||
} from '@/routes/admin/feedback';
|
} from '@/routes/admin/feedback';
|
||||||
import type { Feedback } from '@/types/feedback';
|
import type { Feedback } from '@/types/feedback';
|
||||||
import { FeedbackStatusLabels, FeedbackTypeLabels } from '@/types/feedback';
|
import { FeedbackStatusLabels, FeedbackTypeLabels } from '@/types/feedback';
|
||||||
|
import { Head, Link, router } from '@inertiajs/react';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
import { Info, Plus } from 'lucide-react';
|
||||||
|
import { useState } from 'react';
|
||||||
import { createFeedbackColumns, FeedbackStatusVariants } from './columns';
|
import { createFeedbackColumns, FeedbackStatusVariants } from './columns';
|
||||||
|
|
||||||
type FeedbackTypeOption = { value: string; label: string };
|
type FeedbackTypeOption = { value: string; label: string };
|
||||||
@ -65,8 +57,6 @@ export default function FeedbackIndex({
|
|||||||
statuses,
|
statuses,
|
||||||
filters,
|
filters,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
|
||||||
const [editing, setEditing] = useState<Feedback | null>(null);
|
|
||||||
const [deleting, setDeleting] = useState<Feedback | null>(null);
|
const [deleting, setDeleting] = useState<Feedback | null>(null);
|
||||||
const [viewing, setViewing] = useState<Feedback | null>(null);
|
const [viewing, setViewing] = useState<Feedback | null>(null);
|
||||||
const { hasPermission } = usePermissions();
|
const { hasPermission } = usePermissions();
|
||||||
@ -127,7 +117,7 @@ export default function FeedbackIndex({
|
|||||||
|
|
||||||
const columns = createFeedbackColumns({
|
const columns = createFeedbackColumns({
|
||||||
handleView: (feedback) => setViewing(feedback),
|
handleView: (feedback) => setViewing(feedback),
|
||||||
handleEdit: (feedback) => setEditing(feedback),
|
handleEdit: (feedback) => router.get(edit.url(feedback.id)),
|
||||||
handleDeleteClick: (feedback) => setDeleting(feedback),
|
handleDeleteClick: (feedback) => setDeleting(feedback),
|
||||||
handleStatusChange,
|
handleStatusChange,
|
||||||
statuses,
|
statuses,
|
||||||
@ -146,36 +136,15 @@ export default function FeedbackIndex({
|
|||||||
actions={
|
actions={
|
||||||
canCreate && (
|
canCreate && (
|
||||||
<Button asChild>
|
<Button asChild>
|
||||||
<button
|
<Link href={create.url()}>
|
||||||
type="button"
|
|
||||||
onClick={() => setCreateOpen(true)}
|
|
||||||
>
|
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
Tambah
|
Tambah
|
||||||
</button>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<CreateForm
|
|
||||||
open={createOpen}
|
|
||||||
onOpenChange={setCreateOpen}
|
|
||||||
types={types}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<EditForm
|
|
||||||
key={editing?.id}
|
|
||||||
open={editing !== null}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
if (!open) {
|
|
||||||
setEditing(null);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
editing={editing}
|
|
||||||
types={types}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ViewDetailDialog
|
<ViewDetailDialog
|
||||||
open={viewing !== null}
|
open={viewing !== null}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
@ -186,6 +155,15 @@ export default function FeedbackIndex({
|
|||||||
feedback={viewing}
|
feedback={viewing}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Alert>
|
||||||
|
<Info />
|
||||||
|
<AlertTitle>Ubah status Kritik dan Saran</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
Klik badge Status pada tabel untuk mengubah status Kritik dan Saran
|
||||||
|
secara langsung.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
<DataTable
|
<DataTable
|
||||||
columns={columns}
|
columns={columns}
|
||||||
data={feedbacks.data}
|
data={feedbacks.data}
|
||||||
@ -222,130 +200,6 @@ export default function FeedbackIndex({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function FeedbackFields({
|
|
||||||
errors,
|
|
||||||
editing,
|
|
||||||
types,
|
|
||||||
}: {
|
|
||||||
errors: Record<string, string>;
|
|
||||||
editing?: Feedback;
|
|
||||||
types: FeedbackTypeOption[];
|
|
||||||
}) {
|
|
||||||
const [message, setMessage] = useState(editing?.message ?? '');
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label>
|
|
||||||
Jenis <span className="text-destructive">*</span>
|
|
||||||
</Label>
|
|
||||||
{!editing && <input type="hidden" name="type" />}
|
|
||||||
<Select name="type" defaultValue={editing?.type ?? 'kritik'}>
|
|
||||||
<SelectTrigger className="w-full">
|
|
||||||
<SelectValue placeholder="Pilih jenis" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{types.map((type) => (
|
|
||||||
<SelectItem key={type.value} value={type.value}>
|
|
||||||
{type.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<InputError message={errors.type} />
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label htmlFor={editing ? 'edit-subject' : 'subject'}>
|
|
||||||
Subjek <span className="text-destructive">*</span>
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id={editing ? 'edit-subject' : 'subject'}
|
|
||||||
name="subject"
|
|
||||||
placeholder="Ringkasan singkat"
|
|
||||||
defaultValue={editing?.subject ?? ''}
|
|
||||||
/>
|
|
||||||
<InputError message={errors.subject} />
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label>
|
|
||||||
Pesan <span className="text-destructive">*</span>
|
|
||||||
</Label>
|
|
||||||
<TiptapEditor
|
|
||||||
name="message"
|
|
||||||
value={message}
|
|
||||||
onChange={setMessage}
|
|
||||||
placeholder="Jelaskan kritik, saran, atau aduan Anda secara rinci"
|
|
||||||
error={errors.message}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function CreateForm({
|
|
||||||
open,
|
|
||||||
onOpenChange,
|
|
||||||
types,
|
|
||||||
}: {
|
|
||||||
open: boolean;
|
|
||||||
onOpenChange: (open: boolean) => void;
|
|
||||||
types: FeedbackTypeOption[];
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<FormDialog
|
|
||||||
open={open}
|
|
||||||
onOpenChange={onOpenChange}
|
|
||||||
title="Tambah Kritik dan Saran"
|
|
||||||
action={store()}
|
|
||||||
resetOnSuccess
|
|
||||||
onSuccess={() => onOpenChange(false)}
|
|
||||||
contentClassName="sm:max-w-4xl"
|
|
||||||
>
|
|
||||||
{({ errors }) => (
|
|
||||||
<div className="grid gap-4">
|
|
||||||
<FeedbackFields errors={errors} types={types} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</FormDialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function EditForm({
|
|
||||||
open,
|
|
||||||
onOpenChange,
|
|
||||||
editing,
|
|
||||||
types,
|
|
||||||
}: {
|
|
||||||
open: boolean;
|
|
||||||
onOpenChange: (open: boolean) => void;
|
|
||||||
editing: Feedback | null;
|
|
||||||
types: FeedbackTypeOption[];
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<FormDialog
|
|
||||||
open={open}
|
|
||||||
onOpenChange={onOpenChange}
|
|
||||||
title="Edit Kritik dan Saran"
|
|
||||||
action={editing ? update(editing.id) : ''}
|
|
||||||
resetOnSuccess
|
|
||||||
onSuccess={() => onOpenChange(false)}
|
|
||||||
contentClassName="sm:max-w-2xl"
|
|
||||||
>
|
|
||||||
{({ errors }) =>
|
|
||||||
editing && (
|
|
||||||
<div className="grid gap-4">
|
|
||||||
<FeedbackFields
|
|
||||||
errors={errors}
|
|
||||||
editing={editing}
|
|
||||||
types={types}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
</FormDialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ViewDetailDialog({
|
function ViewDetailDialog({
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
@ -413,7 +267,10 @@ function ViewDetailDialog({
|
|||||||
Pesan
|
Pesan
|
||||||
</Label>
|
</Label>
|
||||||
<div
|
<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"
|
className={cn(
|
||||||
|
'rounded-md border p-3 text-sm',
|
||||||
|
richTextContentClass,
|
||||||
|
)}
|
||||||
dangerouslySetInnerHTML={{
|
dangerouslySetInnerHTML={{
|
||||||
__html: feedback.message,
|
__html: feedback.message,
|
||||||
}}
|
}}
|
||||||
|
|||||||
@ -201,11 +201,11 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
Route::resource('admin/feedback', FeedbackController::class)
|
Route::resource('admin/feedback', FeedbackController::class)
|
||||||
->except(['create', 'edit', 'show'])
|
->except(['show'])
|
||||||
->names('admin.feedback')
|
->names('admin.feedback')
|
||||||
->middlewareFor(['index'], 'permission:view-feedback')
|
->middlewareFor(['index'], 'permission:view-feedback')
|
||||||
->middlewareFor(['store'], 'permission:create-feedback')
|
->middlewareFor(['create', 'store'], 'permission:create-feedback')
|
||||||
->middlewareFor(['update'], 'permission:update-feedback')
|
->middlewareFor(['edit', 'update'], 'permission:update-feedback')
|
||||||
->middlewareFor(['destroy'], 'permission:delete-feedback');
|
->middlewareFor(['destroy'], 'permission:delete-feedback');
|
||||||
Route::patch('admin/feedback/{feedback}/status', [FeedbackController::class, 'updateStatus'])->name('admin.feedback.update_status')->middleware('permission:update-feedback-status');
|
Route::patch('admin/feedback/{feedback}/status', [FeedbackController::class, 'updateStatus'])->name('admin.feedback.update_status')->middleware('permission:update-feedback-status');
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use App\Http\Controllers\MediaUploadController;
|
||||||
use App\Http\Controllers\NotificationController;
|
use App\Http\Controllers\NotificationController;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
@ -14,6 +15,10 @@
|
|||||||
Route::patch('{notification}/read', [NotificationController::class, 'markRead'])->name('read');
|
Route::patch('{notification}/read', [NotificationController::class, 'markRead'])->name('read');
|
||||||
Route::delete('{notification}', [NotificationController::class, 'destroy'])->name('destroy');
|
Route::delete('{notification}', [NotificationController::class, 'destroy'])->name('destroy');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Route::prefix('media')->name('media.')->group(function () {
|
||||||
|
Route::post('uploads', [MediaUploadController::class, 'store'])->name('uploads.store');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
Route::get('.well-known/passkey-endpoints', function () {
|
Route::get('.well-known/passkey-endpoints', function () {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user