diff --git a/app/Http/Controllers/Admin/FeedbackController.php b/app/Http/Controllers/Admin/FeedbackController.php new file mode 100644 index 0000000..52a2ace --- /dev/null +++ b/app/Http/Controllers/Admin/FeedbackController.php @@ -0,0 +1,113 @@ +input('board'); + $status = $request->input('status'); + $sort = $request->input('sort') ?? 'voted'; + + $boards = Board::select('id', 'name', 'posts', 'slug')->get(); + $statuses = Status::select('id', 'name', 'color')->get(); + $query = Post::with('user', 'board', 'status'); + + if ($board && $board !== 'all') { + $query->where('board_id', $board); + } + + if ($status && $status !== 'all') { + $query->where('status_id', $status); + } + + if ($sort) { + if ($sort === 'oldest') { + $query->orderBy('created_at', 'asc'); + } elseif ($sort === 'voted') { + $query->orderBy('vote', 'desc'); + } elseif ($sort === 'commented') { + $query->orderBy('comments', 'desc'); + } elseif ($sort === 'latest') { + $query->orderBy('created_at', 'desc'); + } + } + + $posts = $query->paginate(30)->withQueryString(); + + return inertia('Admin/Feedbacks/Index', [ + 'posts' => $posts, + 'boards' => $boards, + 'statuses' => $statuses, + ]); + } + + public function show(Post $post) + { + $post->load('creator', 'board', 'status'); + + $boards = Board::select('id', 'name', 'posts', 'slug')->get(); + $statuses = Status::select('id', 'name', 'color')->get(); + + return inertia('Admin/Feedbacks/Show', [ + 'post' => $post, + 'boards' => $boards, + 'statuses' => $statuses, + ]); + } + + public function update(Request $request, Post $post) + { + $request->validate([ + 'board_id' => 'required|exists:boards,id', + 'status_id' => 'required|exists:statuses,id', + 'comment' => 'nullable|string', + 'notify' => 'nullable|boolean' + ]); + + if ($post->status_id !== $request->input('status_id')) { + Comment::create([ + 'post_id' => $post->id, + 'user_id' => auth()->user()->id, + 'body' => $request->input('comment') ?? '', + 'status_id' => $request->input('status_id'), + ]); + + if ($request->input('notify') === true) { + $this->notify($post); + } + } + + $post->update([ + 'board_id' => $request->input('board_id'), + 'status_id' => $request->input('status_id'), + ]); + + return redirect()->back()->with('success', 'Feedback updated successfully.'); + } + + private function notify($post) + { + // get all voters + $voters = $post->votes()->with('user') + ->where('user_id', '!=', auth()->user()->id) + ->get(); + + if (!$voters->count()) { + return; + } + + $voters->each(function ($voter) use ($post) { + $voter->user->notify(new FeedbackStatusChanged($post)); + }); + } +} diff --git a/app/Models/Comment.php b/app/Models/Comment.php index 01bec89..15e032b 100644 --- a/app/Models/Comment.php +++ b/app/Models/Comment.php @@ -9,7 +9,7 @@ class Comment extends Model { use HasFactory; - protected $fillable = ['post_id', 'parent_id', 'user_id', 'body']; + protected $fillable = ['post_id', 'parent_id', 'status_id', 'user_id', 'body']; protected static function boot() { diff --git a/app/Notifications/FeedbackStatusChanged.php b/app/Notifications/FeedbackStatusChanged.php new file mode 100644 index 0000000..85055c7 --- /dev/null +++ b/app/Notifications/FeedbackStatusChanged.php @@ -0,0 +1,57 @@ + + */ + public function via(object $notifiable): array + { + return ['mail']; + } + + /** + * Get the mail representation of the notification. + */ + public function toMail(object $notifiable): MailMessage + { + return (new MailMessage) + ->subject('Feedback Status Changed') + ->greeting('Hello, ' . $notifiable->name . '!') + ->line('The status of your feedback has been changed to ' . $this->post->status->name . '.') + ->action('View Feedback', route('post.show', [$this->post->board, $this->post])) + ->line('Thank you for using our application!'); + } + + /** + * Get the array representation of the notification. + * + * @return array + */ + public function toArray(object $notifiable): array + { + return [ + // + ]; + } +} diff --git a/resources/js/Components/Pagination.tsx b/resources/js/Components/Pagination.tsx new file mode 100644 index 0000000..548ad4d --- /dev/null +++ b/resources/js/Components/Pagination.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import { Link } from '@inertiajs/react'; +import classnames from 'classnames'; +import { PaginatedLink } from '@/types'; + +type Props = { + links: PaginatedLink[]; +}; + +const Pagination = ({ links }: Props) => { + return ( +
+ {links.map((link, index) => { + const linkClasses = classnames( + 'inline-flex px-4 py-2 text-sm font-semibold focus:outline-offset-0', + link.active + ? 'z-10 bg-indigo-600 text-white focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600' + : 'hover:bg-gray-50 ring-1 ring-inset ring-gray-300 text-gray-900', + { + 'text-gray-400 border-gray-300': !link.url, + } + ); + + return ( + { + if (!link.url) e.preventDefault(); + }} + preserveScroll + /> + ); + })} +
+ ); +}; + +export default Pagination; diff --git a/resources/js/Layouts/AuthenticatedLayout.tsx b/resources/js/Layouts/AuthenticatedLayout.tsx index 8e20573..4840c04 100644 --- a/resources/js/Layouts/AuthenticatedLayout.tsx +++ b/resources/js/Layouts/AuthenticatedLayout.tsx @@ -41,6 +41,13 @@ export default function Authenticated({ Dashboard + + Feedback + + {error && ( -
+
)} {success && ( -
+
)} diff --git a/resources/js/Pages/Admin/Dashboard.tsx b/resources/js/Pages/Admin/Dashboard.tsx index fe1f037..6b75c26 100644 --- a/resources/js/Pages/Admin/Dashboard.tsx +++ b/resources/js/Pages/Admin/Dashboard.tsx @@ -4,13 +4,11 @@ import { PageProps } from '@/types'; const Dashboard = ({ auth }: PageProps) => { return ( -
+
-
-
- You're logged in! -
+
+ You're logged in!
); diff --git a/resources/js/Pages/Admin/Feedbacks/Index.tsx b/resources/js/Pages/Admin/Feedbacks/Index.tsx new file mode 100644 index 0000000..ec4a549 --- /dev/null +++ b/resources/js/Pages/Admin/Feedbacks/Index.tsx @@ -0,0 +1,162 @@ +import React, { useState, useEffect } from 'react'; +import { Head, Link, router } from '@inertiajs/react'; +import { Button, SelectInput } from '@wedevs/tail-react'; +import { + MagnifyingGlassIcon, + ChevronUpIcon, + ChatBubbleLeftIcon, +} from '@heroicons/react/24/outline'; + +import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout'; +import { BoardType, PaginatedResponse, PostType, StatusType } from '@/types'; +import Pagination from '@/Components/Pagination'; + +type Props = { + posts: PaginatedResponse; + boards: BoardType[]; + statuses: StatusType[]; +}; + +const Feedbacks = ({ posts, boards, statuses }: Props) => { + const urlParams = new URLSearchParams(window.location.search); + + const [selectedStatus, setSelectedStatus] = useState( + urlParams.get('status') || 'all' + ); + const [selectedBoard, setSelectedBoard] = useState( + urlParams.get('board') || 'all' + ); + const [sortKey, setSortKey] = useState(urlParams.get('sort') || 'voted'); + + const sortOptions = [ + { value: 'Latest', key: 'latest' }, + { value: 'Oldest', key: 'oldest' }, + { value: 'Top Voted', key: 'voted' }, + { value: 'Most Commented', key: 'commented' }, + ]; + + const statusOptions = [ + { value: 'All', key: 'all' }, + ...statuses.map((status) => ({ + value: status.name, + key: status.id.toString(), + })), + ]; + + const boardOptions = [ + { value: 'All', key: 'all' }, + ...boards.map((board) => ({ + value: board.name, + key: board.id.toString(), + })), + ]; + + const filterRequest = () => { + router.replace( + route('admin.feedbacks.index', { + status: selectedStatus, + board: selectedBoard, + sort: sortKey, + }) + ); + }; + + return ( +
+ + +
+
+
+ setSortKey(option.key)} + /> + + setSelectedStatus(option.key)} + /> + setSelectedBoard(option.key)} + /> + + +
+ +
+ {posts.data.map((post) => ( +
+ +
{post.title}
+
+
+ + {post.vote} +
+ +
+ + {post.comments} +
+ +
{post.board?.name}
+ + {post.status_id && ( +
+ {post.status?.name} +
+ )} +
+ +
+ ))} + + {posts.last_page > 1 && ( +
+ +
+ )} +
+
+
+
+ ); +}; + +Feedbacks.layout = (page: React.ReactNode) => ( + + Feedback + + } + > +); + +export default Feedbacks; diff --git a/resources/js/Pages/Admin/Feedbacks/Show.tsx b/resources/js/Pages/Admin/Feedbacks/Show.tsx new file mode 100644 index 0000000..5bd14ba --- /dev/null +++ b/resources/js/Pages/Admin/Feedbacks/Show.tsx @@ -0,0 +1,178 @@ +import React, { useState } from 'react'; +import { Head, Link, router, useForm } from '@inertiajs/react'; +import { Button, Checkbox, SelectInput, Textarea } from '@wedevs/tail-react'; +import { + ArrowTopRightOnSquareIcon, + ChevronUpIcon, + ChatBubbleLeftIcon, + ChevronLeftIcon, +} from '@heroicons/react/24/outline'; + +import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout'; +import { formatDate } from '@/utils'; +import { PostType, StatusType, BoardType } from '@/types'; +import Comments from '@/Components/Comments'; + +type Props = { + post: PostType; + statuses: StatusType[]; + boards: BoardType[]; +}; + +const FeedbackShow = ({ post, statuses, boards }: Props) => { + const [localPost, setLocalPost] = useState(post); + const form = useForm({ + status_id: post.status_id, + board_id: post.board_id, + comment: '', + notify: true, + }); + + const statusOptions = [ + { value: '- Select Status -', key: '' }, + ...statuses.map((status) => ({ + value: status.name, + key: status.id.toString(), + })), + ]; + + const boardOptions = boards.map((board) => ({ + value: board.name, + key: board.id.toString(), + })); + + const onSubmit = (e: React.FormEvent) => { + e.preventDefault(); + + form.post(route('admin.feedbacks.update', [post]), { + onSuccess: (resp) => { + console.log(resp); + form.reset(); + }, + }); + }; + + return ( +
+ + +
+
+ + + Back + + +
+ {localPost.title} +
+ +
+
+ +
+
+
+ {localPost.creator?.name} +
+
{localPost.body}
+ +
+
+ {formatDate(localPost.created_at)} +
+ +
+ + {localPost.vote} +
+ +
+ + {localPost.comments} +
+
+
+
+ + +
+ +
+ + +
+ form.setData('board_id', option.key)} + /> + + form.setData('status_id', option.key)} + /> + + {form.data.status_id !== localPost.status_id && ( + <> +