From 9d5b28bd54c0e63dd440c58cb36536387e0a45b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andrei=20Ioni=C8=9B=C4=83?= Date: Fri, 18 Aug 2023 20:17:23 +0300 Subject: [PATCH] feat: tickets (#267) --- .prettierrc | 16 + app/Events/TicketCreated.php | 27 ++ app/Events/TicketReplyReceived.php | 27 ++ app/Events/TicketUpdated.php | 27 ++ app/Filament/Form/Components/Value.php | 155 +++++++++ app/Filament/Resources/TicketResource.php | 68 ++-- .../Actions/TicketReplyAction.php | 50 +++ .../Actions/ToggleStatusAction.php | 78 +++++ .../TicketResource/Pages/EditTicket.php | 21 -- .../TicketResource/Pages/ListTickets.php | 21 +- .../TicketResource/Pages/ViewTicket.php | 27 ++ .../MessagesRelationManager.php | 78 +++++ .../Widgets/ClosedTicketsWidget.php | 80 +++++ .../Widgets/OpenTicketsWidget.php | 80 +++++ app/Http/Controllers/TicketController.php | 98 ++++++ app/Http/Resources/TicketMessageResource.php | 28 ++ app/Http/Resources/TicketResource.php | 26 ++ .../SendTicketCreatedNotification.php | 34 ++ .../SendTicketReplyReceivedNotification.php | 34 ++ .../SendTicketStatusChangedNotification.php | 40 +++ app/Models/Organization.php | 8 +- app/Models/Ticket.php | 93 ++++++ app/Models/TicketMessage.php | 38 +++ .../Admin/TicketCreatedNotification.php | 54 +++ .../Admin/TicketReceivedReplyNotification.php | 54 +++ .../Admin/TicketStatusChangedNotification.php | 79 +++++ .../Ngo/TicketCreatedNotification.php | 53 +++ .../Ngo/TicketReceivedReplyNotification.php | 56 ++++ .../Ngo/TicketStatusChangedNotification.php | 78 +++++ app/Policies/TicketPolicy.php | 51 +++ app/Providers/AppServiceProvider.php | 12 +- app/Providers/EventServiceProvider.php | 2 +- app/Traits/HasRole.php | 18 +- database/factories/TicketFactory.php | 56 ++++ database/factories/TicketMessageFactory.php | 29 ++ ...2023_08_17_135831_create_tickets_table.php | 52 +++ database/seeders/DatabaseSeeder.php | 23 +- lang/ro/ticket.php | 60 ++++ .../modals/ToggleTicketStatusModal.vue | 124 +++++++ .../js/Components/templates/Dashboard.vue | 313 ++++++++++-------- .../Pages/AdminOng/Tichets/ClosedTickets.vue | 133 -------- .../js/Pages/AdminOng/Tichets/OpenTickets.vue | 210 ------------ .../js/Pages/AdminOng/Tichets/Ticket.vue | 177 ---------- resources/js/Pages/AdminOng/Tickets/List.vue | 201 +++++++++++ resources/js/Pages/AdminOng/Tickets/Show.vue | 168 ++++++++++ resources/js/app.js | 2 +- resources/js/locales/ro.js | 21 +- resources/js/ssr.js | 2 +- .../views/components/forms/value.blade.php | 16 + .../views/filament/resources/page.blade.php | 3 + routes/tickets.php | 23 +- 51 files changed, 2468 insertions(+), 756 deletions(-) create mode 100644 .prettierrc create mode 100644 app/Events/TicketCreated.php create mode 100644 app/Events/TicketReplyReceived.php create mode 100644 app/Events/TicketUpdated.php create mode 100644 app/Filament/Form/Components/Value.php create mode 100644 app/Filament/Resources/TicketResource/Actions/TicketReplyAction.php create mode 100644 app/Filament/Resources/TicketResource/Actions/ToggleStatusAction.php delete mode 100644 app/Filament/Resources/TicketResource/Pages/EditTicket.php create mode 100644 app/Filament/Resources/TicketResource/Pages/ViewTicket.php create mode 100644 app/Filament/Resources/TicketResource/RelationManagers/MessagesRelationManager.php create mode 100644 app/Filament/Resources/TicketResource/Widgets/ClosedTicketsWidget.php create mode 100644 app/Filament/Resources/TicketResource/Widgets/OpenTicketsWidget.php create mode 100644 app/Http/Controllers/TicketController.php create mode 100644 app/Http/Resources/TicketMessageResource.php create mode 100644 app/Http/Resources/TicketResource.php create mode 100644 app/Listeners/SendTicketCreatedNotification.php create mode 100644 app/Listeners/SendTicketReplyReceivedNotification.php create mode 100644 app/Listeners/SendTicketStatusChangedNotification.php create mode 100644 app/Models/Ticket.php create mode 100644 app/Models/TicketMessage.php create mode 100644 app/Notifications/Admin/TicketCreatedNotification.php create mode 100644 app/Notifications/Admin/TicketReceivedReplyNotification.php create mode 100644 app/Notifications/Admin/TicketStatusChangedNotification.php create mode 100644 app/Notifications/Ngo/TicketCreatedNotification.php create mode 100644 app/Notifications/Ngo/TicketReceivedReplyNotification.php create mode 100644 app/Notifications/Ngo/TicketStatusChangedNotification.php create mode 100644 app/Policies/TicketPolicy.php create mode 100644 database/factories/TicketFactory.php create mode 100644 database/factories/TicketMessageFactory.php create mode 100644 database/migrations/2023_08_17_135831_create_tickets_table.php create mode 100644 lang/ro/ticket.php create mode 100644 resources/js/Components/modals/ToggleTicketStatusModal.vue delete mode 100644 resources/js/Pages/AdminOng/Tichets/ClosedTickets.vue delete mode 100644 resources/js/Pages/AdminOng/Tichets/OpenTickets.vue delete mode 100644 resources/js/Pages/AdminOng/Tichets/Ticket.vue create mode 100644 resources/js/Pages/AdminOng/Tickets/List.vue create mode 100644 resources/js/Pages/AdminOng/Tickets/Show.vue create mode 100644 resources/views/components/forms/value.blade.php create mode 100644 resources/views/filament/resources/page.blade.php diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 00000000..7e43c0e4 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,16 @@ +{ + "trailingComma": "es5", + "printWidth": 120, + "tabWidth": 4, + "useTabs": false, + "semi": true, + "singleQuote": true, + "overrides": [ + { + "files": ["*.yml", "*.yaml"], + "options": { + "tabWidth": 2 + } + } + ] +} diff --git a/app/Events/TicketCreated.php b/app/Events/TicketCreated.php new file mode 100644 index 00000000..5eed8bd4 --- /dev/null +++ b/app/Events/TicketCreated.php @@ -0,0 +1,27 @@ +ticket = $ticket; + } +} diff --git a/app/Events/TicketReplyReceived.php b/app/Events/TicketReplyReceived.php new file mode 100644 index 00000000..cb12ee6a --- /dev/null +++ b/app/Events/TicketReplyReceived.php @@ -0,0 +1,27 @@ +message = $message; + } +} diff --git a/app/Events/TicketUpdated.php b/app/Events/TicketUpdated.php new file mode 100644 index 00000000..27ebec1b --- /dev/null +++ b/app/Events/TicketUpdated.php @@ -0,0 +1,27 @@ +ticket = $ticket; + } +} diff --git a/app/Filament/Form/Components/Value.php b/app/Filament/Form/Components/Value.php new file mode 100644 index 00000000..b40d8f34 --- /dev/null +++ b/app/Filament/Form/Components/Value.php @@ -0,0 +1,155 @@ +name($name); + $this->statePath($name); + } + + public static function make(string $name): static + { + $static = app(static::class, ['name' => $name]); + $static->configure(); + + return $static; + } + + public function empty(): static + { + $this->empty = true; + + return $this; + } + + public function boolean(string | Htmlable | null $trueLabel = null, string | Htmlable | null $falseLabel = null): static + { + $this->boolean = [ + 1 => $trueLabel ?? __('forms::components.select.boolean.true'), + 0 => $falseLabel ?? __('forms::components.select.boolean.false'), + ]; + + return $this; + } + + public function fallback(string | Htmlable | Closure | null $fallback): static + { + $this->fallback = $fallback; + + return $this; + } + + public function getFallback(): string | Htmlable | null + { + return $this->evaluate($this->fallback); + } + + public function content(string | Htmlable | Closure | null $content): static + { + $this->content = $content; + + return $this; + } + + public function getContent(): string | Htmlable | null + { + if ($this->empty) { + return null; + } + + $content = $this->evaluate($this->content) ?? data_get($this->getRecord(), $this->getName()); + + if (! empty($this->boolean)) { + $content = $this->boolean[\intval(\boolval($content))]; + } + + $content = match (true) { + $content instanceof BackedEnum => $this->getEnumLabel($content), + $content instanceof Carbon => $this->getFormattedDate($content), + $content instanceof Collection => $this->getFormattedCollection($content), + default => $content, + }; + + if (! $content instanceof HtmlString) { + $content = Str::of($content) + ->trim() + ->toHtmlString(); + } + + if (! $content->isEmpty()) { + return $content; + } + + if (null !== $fallback = $this->getFallback()) { + return new HtmlString('
' . $fallback . '
'); + } + + return new HtmlString(''); + } + + public function withTime(bool $condition = true): static + { + $this->withTime = $condition; + + return $this; + } + + protected function getFormattedCollection(Collection $collection, string $glue = ', '): string + { + return $collection + ->map(fn ($item) => match (true) { + $item instanceof Htmlable => $item->toHtml(), + $item instanceof Stringable => $item->toString(), + default => $item + }) + ->join($glue); + } + + protected function getFormattedDate(Carbon $date): string + { + return $this->withTime + ? $date->toFormattedDateTime() + : $date->toFormattedDate(); + } + + protected function getEnumLabel(BackedEnum $content): ?string + { + if (! \in_array(\App\Concerns\Enums\Arrayable::class, class_uses_recursive($content))) { + return null; + } + + return $content?->label(); + } +} diff --git a/app/Filament/Resources/TicketResource.php b/app/Filament/Resources/TicketResource.php index f5503a18..65b4fe12 100644 --- a/app/Filament/Resources/TicketResource.php +++ b/app/Filament/Resources/TicketResource.php @@ -4,12 +4,15 @@ namespace App\Filament\Resources; +use App\Filament\Forms\Components\Value; use App\Filament\Resources\TicketResource\Pages; +use App\Filament\Resources\TicketResource\RelationManagers\MessagesRelationManager; +use App\Filament\Resources\TicketResource\Widgets\ClosedTicketsWidget; +use App\Filament\Resources\TicketResource\Widgets\OpenTicketsWidget; use App\Models\Ticket; use Filament\Resources\Form; use Filament\Resources\Resource; -use Filament\Resources\Table; -use Filament\Tables; +use Illuminate\Support\HtmlString; class TicketResource extends Resource { @@ -19,37 +22,60 @@ class TicketResource extends Resource protected static ?int $navigationSort = 6; - protected static ?string $navigationIcon = 'heroicon-o-collection'; + protected static ?string $navigationIcon = 'heroicon-o-mail'; + + protected static ?string $recordTitleAttribute = 'subject'; public static function form(Form $form): Form { return $form ->schema([ - // + Value::make('created_at') + ->label(__('ticket.date')) + ->withTime() + ->inlineLabel() + ->columnSpanFull(), + + Value::make('closed_at') + ->label(__('ticket.status')) + ->boolean( + trueLabel: new HtmlString('' . __('ticket.status.closed') . ''), + falseLabel: new HtmlString('' . __('ticket.status.open') . ''), + ) + ->inlineLabel() + ->columnSpanFull(), + + Value::make('closed_at') + ->visible(fn (Ticket $record) => ! $record->isOpen()) + ->label(__('ticket.closed_at')) + ->withTime() + ->inlineLabel() + ->columnSpanFull(), + + Value::make('subject') + ->label(__('ticket.subject')) + ->inlineLabel() + ->columnSpanFull(), + + Value::make('content') + ->label(__('ticket.message')) + ->inlineLabel() + ->columnSpanFull(), ]); } - public static function table(Table $table): Table + public static function getRelations(): array { - return $table - ->columns([ - // - ]) - ->filters([ - // - ]) - ->actions([ - Tables\Actions\EditAction::make(), - ]) - ->bulkActions([ - Tables\Actions\DeleteBulkAction::make(), - ]); + return [ + MessagesRelationManager::class, + ]; } - public static function getRelations(): array + public static function getWidgets(): array { return [ - // + OpenTicketsWidget::class, + ClosedTicketsWidget::class, ]; } @@ -58,7 +84,7 @@ public static function getPages(): array return [ 'index' => Pages\ListTickets::route('/'), 'create' => Pages\CreateTicket::route('/create'), - 'edit' => Pages\EditTicket::route('/{record}/edit'), + 'view' => Pages\ViewTicket::route('/{record}'), ]; } } diff --git a/app/Filament/Resources/TicketResource/Actions/TicketReplyAction.php b/app/Filament/Resources/TicketResource/Actions/TicketReplyAction.php new file mode 100644 index 00000000..3ffbc9dd --- /dev/null +++ b/app/Filament/Resources/TicketResource/Actions/TicketReplyAction.php @@ -0,0 +1,50 @@ +label(__('ticket.action.reply')); + $this->modalHeading(__('ticket.action.reply')); + $this->visible( + fn (MessagesRelationManager $livewire) => $livewire->ownerRecord->isOpen() + ); + + $this->modalButton(__('ticket.action.reply')); + + $this->disableCreateAnother(); + + $this->using(function (array $data): Model { + $data['user_id'] = auth()->user()->id; + + $relationship = $this->getRelationship(); + + if (! $relationship) { + return $this->getModel()::create($data); + } + + if ($relationship instanceof BelongsToMany) { + $pivotColumns = $relationship->getPivotColumns(); + + return $relationship->create( + Arr::except($data, $pivotColumns), + Arr::only($data, $pivotColumns), + ); + } + + return $relationship->create($data); + }); + } +} diff --git a/app/Filament/Resources/TicketResource/Actions/ToggleStatusAction.php b/app/Filament/Resources/TicketResource/Actions/ToggleStatusAction.php new file mode 100644 index 00000000..d9ef569a --- /dev/null +++ b/app/Filament/Resources/TicketResource/Actions/ToggleStatusAction.php @@ -0,0 +1,78 @@ +label( + fn (Ticket $record) => $record->isOpen() + ? __('ticket.action.close') + : __('ticket.action.reopen') + ); + + // $this->icon( + // fn (Ticket $record) => $record->isOpen() + // ? 'heroicon-s-check-circle' + // : null + // ); + + $this->color( + fn (Ticket $record) => $record->isOpen() + ? 'primary' + : 'warning' + ); + + $this->modalHeading( + fn (Ticket $record) => $record->isOpen() + ? __('ticket.action_close_confirm.title') + : __('ticket.action_reopen_confirm.title') + ); + $this->modalSubheading( + fn (Ticket $record) => $record->isOpen() + ? __('ticket.action_close_confirm.text') + : __('ticket.action_reopen_confirm.text') + ); + $this->modalButton( + fn (Ticket $record) => $record->isOpen() + ? __('ticket.action_close_confirm.action') + : __('ticket.action_reopen_confirm.action') + ); + $this->modalWidth('md'); + $this->centerModal(false); + + $this->action(function (Ticket $record) { + if ($record->isOpen()) { + $record->close(); + } else { + $record->open(); + } + + $this->success(); + }); + + $this->successNotificationTitle( + fn (Ticket $record) => $record->isOpen() + ? __('ticket.action_reopen_confirm.success') + : __('ticket.action_close_confirm.success') + ); + + $this->successRedirectUrl( + fn (Ticket $record) => TicketResource::getUrl('view', $record) + ); + } +} diff --git a/app/Filament/Resources/TicketResource/Pages/EditTicket.php b/app/Filament/Resources/TicketResource/Pages/EditTicket.php deleted file mode 100644 index 841bafaf..00000000 --- a/app/Filament/Resources/TicketResource/Pages/EditTicket.php +++ /dev/null @@ -1,21 +0,0 @@ -record($this->getRecord()), + ]; + } + + // public function getTitle(): string + // { + // return $this->getRecord()->title; + // } +} diff --git a/app/Filament/Resources/TicketResource/RelationManagers/MessagesRelationManager.php b/app/Filament/Resources/TicketResource/RelationManagers/MessagesRelationManager.php new file mode 100644 index 00000000..23c78013 --- /dev/null +++ b/app/Filament/Resources/TicketResource/RelationManagers/MessagesRelationManager.php @@ -0,0 +1,78 @@ +columns(1) + ->schema([ + Textarea::make('content') + ->label(__('ticket.message')) + ->required(), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + Split::make([ + Stack::make([ + TextColumn::make('user.name') + ->translateLabel() + ->weight('bold') + ->color(function (Component $livewire, TicketMessage $record) { + if ($record->user->isBbAdmin()) { + return 'warning'; + } + + return 'secondary'; + }) + ->grow(false), + + TextColumn::make('created_at') + ->translateLabel() + ->since() + ->color('secondary') + ->size('sm'), + ]), + + TextColumn::make('content') + ->formatStateUsing(fn ($state) => new HtmlString(nl2br($state))) + ->wrap(), + ]), + ]) + ->filters([ + // + ]) + ->headerActions([ + TicketReplyAction::make(), + ]) + ->actions([ + // + ]) + ->bulkActions([ + // + ]) + ->defaultSort('created_at', 'desc'); + } +} diff --git a/app/Filament/Resources/TicketResource/Widgets/ClosedTicketsWidget.php b/app/Filament/Resources/TicketResource/Widgets/ClosedTicketsWidget.php new file mode 100644 index 00000000..6dbc02c4 --- /dev/null +++ b/app/Filament/Resources/TicketResource/Widgets/ClosedTicketsWidget.php @@ -0,0 +1,80 @@ +whereClosed() + ->with([ + 'organization' => function (Relation $query) { + $query->withoutEagerLoads(); + }, + ]); + } + + protected function getTableQueryStringIdentifier(): ?string + { + return 'closed'; + } + + protected function getDefaultTableSortColumn(): ?string + { + return 'closed_at'; + } + + protected function getDefaultTableSortDirection(): ?string + { + return 'desc'; + } + + protected function getTableColumns(): array + { + return [ + TextColumn::make('subject') + ->label(__('ticket.subject')), + TextColumn::make('organization.name') + ->label(__('ticket.organization')), + TextColumn::make('closed_at') + ->label(__('ticket.closed_at')), + ]; + } + + protected function getTableActions(): array + { + return [ + ViewAction::make() + ->url($this->getTableRecordUrlUsing()), + ]; + } + + protected function getTableRecordUrlUsing(): Closure + { + return fn (Ticket $record) => TicketResource::getUrl('view', $record); + } + + protected function paginateTableQuery(Builder $query): Paginator + { + return $query->simplePaginate( + $this->getTableRecordsPerPage() == -1 ? $query->count() : $this->getTableRecordsPerPage(), + ['*'], + $this->getTablePaginationPageName() + ); + } +} diff --git a/app/Filament/Resources/TicketResource/Widgets/OpenTicketsWidget.php b/app/Filament/Resources/TicketResource/Widgets/OpenTicketsWidget.php new file mode 100644 index 00000000..5e24d750 --- /dev/null +++ b/app/Filament/Resources/TicketResource/Widgets/OpenTicketsWidget.php @@ -0,0 +1,80 @@ +whereOpen() + ->with([ + 'organization' => function (Relation $query) { + $query->withoutEagerLoads(); + }, + ]); + } + + protected function getTableQueryStringIdentifier(): ?string + { + return 'open'; + } + + protected function getDefaultTableSortColumn(): ?string + { + return 'created_at'; + } + + protected function getDefaultTableSortDirection(): ?string + { + return 'desc'; + } + + protected function getTableColumns(): array + { + return [ + TextColumn::make('subject') + ->label(__('ticket.subject')), + TextColumn::make('organization.name') + ->label(__('ticket.organization')), + TextColumn::make('created_at') + ->label(__('ticket.date')), + ]; + } + + protected function getTableActions(): array + { + return [ + ViewAction::make() + ->url($this->getTableRecordUrlUsing()), + ]; + } + + protected function getTableRecordUrlUsing(): Closure + { + return fn (Ticket $record) => TicketResource::getUrl('view', $record); + } + + protected function paginateTableQuery(Builder $query): Paginator + { + return $query->simplePaginate( + $this->getTableRecordsPerPage() == -1 ? $query->count() : $this->getTableRecordsPerPage(), + ['*'], + $this->getTablePaginationPageName() + ); + } +} diff --git a/app/Http/Controllers/TicketController.php b/app/Http/Controllers/TicketController.php new file mode 100644 index 00000000..5b4d373f --- /dev/null +++ b/app/Http/Controllers/TicketController.php @@ -0,0 +1,98 @@ + $status, + 'tickets' => TicketResource::collection( + Ticket::query() + ->where('organization_id', auth()->user()->organization_id) + ->when( + $status === 'open', + fn ($query) => $query->whereOpen(), + fn ($query) => $query->whereClosed() + ) + ->orderBy('closed_at', 'desc') + ->orderBy('created_at', 'desc') + ->paginate() + ), + ]); + } + + public function store(Request $request): RedirectResponse + { + $this->authorize('create', Ticket::class); + + $attributes = $request->validate([ + 'subject' => ['required', 'string', 'max:200'], + 'content' => ['required', 'string'], + ]); + + $ticket = Ticket::create([ + 'subject' => strip_tags($attributes['subject']), + 'content' => strip_tags($attributes['content']), + 'user_id' => auth()->user()->id, + 'organization_id' => auth()->user()->organization_id, + ]); + + return redirect()->route('admin.ong.tickets.view', $ticket) + ->with('success_message', __('ticket.action_open.success')); + } + + public function reply(Ticket $ticket, Request $request): RedirectResponse + { + $this->authorize('reply', $ticket); + + $attributes = $request->validate([ + 'content' => ['required', 'string'], + ]); + + $ticket->messages()->create([ + 'user_id' => auth()->user()->id, + 'content' => strip_tags($attributes['content']), + ]); + + return redirect()->route('admin.ong.tickets.view', $ticket) + ->with('success_message', __('ticket.action_reply.success')); + } + + public function show(Ticket $ticket) + { + $this->authorize('view', $ticket); + + $ticket->loadMissing('messages.user'); + + return Inertia::render('AdminOng/Tickets/Show', [ + 'ticket' => new TicketResource($ticket), + ]); + } + + public function status(Ticket $ticket, Request $request): RedirectResponse + { + $this->authorize('update', $ticket); + + if ($ticket->isOpen()) { + $ticket->close(); + $message = __('ticket.action_close_confirm.success'); + } else { + $ticket->open(); + $message = __('ticket.action_reopen_confirm.success'); + } + + return redirect()->route('admin.ong.tickets.view', $ticket) + ->with('success_message', $message); + } +} diff --git a/app/Http/Resources/TicketMessageResource.php b/app/Http/Resources/TicketMessageResource.php new file mode 100644 index 00000000..4d03e64f --- /dev/null +++ b/app/Http/Resources/TicketMessageResource.php @@ -0,0 +1,28 @@ + $this->id, + 'content' => $this->content, + 'created_at' => $this->created_at->toDateTimeString(), + 'created_at_relative' => $this->created_at->diffForHumans(), + 'user' => [ + 'id' => $this->user->id, + 'name' => $this->user->name, + 'is_bb_admin' => $this->user->isBbAdmin(), + ], + ]; + } +} diff --git a/app/Http/Resources/TicketResource.php b/app/Http/Resources/TicketResource.php new file mode 100644 index 00000000..43c8c52b --- /dev/null +++ b/app/Http/Resources/TicketResource.php @@ -0,0 +1,26 @@ + $this->id, + 'subject' => $this->subject, + 'content' => $this->content, + 'is_open' => $this->isOpen(), + 'created_at' => $this->created_at->toFormattedDateTime(), + 'closed_at' => $this->closed_at?->toFormattedDateTime(), + 'messages' => TicketMessageResource::collection($this->whenLoaded('messages')), + ]; + } +} diff --git a/app/Listeners/SendTicketCreatedNotification.php b/app/Listeners/SendTicketCreatedNotification.php new file mode 100644 index 00000000..dbed5346 --- /dev/null +++ b/app/Listeners/SendTicketCreatedNotification.php @@ -0,0 +1,34 @@ +onlyBBAdmins() + ->get(), + new Admin\TicketCreatedNotification($event->ticket) + ); + + Notification::send( + User::query() + ->onlyNGOAdmins($event->ticket->organization) + ->get(), + new Ngo\TicketCreatedNotification($event->ticket) + ); + } +} diff --git a/app/Listeners/SendTicketReplyReceivedNotification.php b/app/Listeners/SendTicketReplyReceivedNotification.php new file mode 100644 index 00000000..ec5abb0b --- /dev/null +++ b/app/Listeners/SendTicketReplyReceivedNotification.php @@ -0,0 +1,34 @@ +onlyBBAdmins() + ->get(), + new Admin\TicketReceivedReplyNotification($event->message) + ); + + Notification::send( + User::query() + ->onlyNGOAdmins($event->message->ticket->organization) + ->get(), + new Ngo\TicketReceivedReplyNotification($event->message) + ); + } +} diff --git a/app/Listeners/SendTicketStatusChangedNotification.php b/app/Listeners/SendTicketStatusChangedNotification.php new file mode 100644 index 00000000..8a94a6d5 --- /dev/null +++ b/app/Listeners/SendTicketStatusChangedNotification.php @@ -0,0 +1,40 @@ +ticket->getOriginal('closed_at') === $event->ticket->closed_at) { + return; + } + + $ticketIsOpen = $event->ticket->closed_at === null; + + Notification::send( + User::query() + ->onlyBBAdmins() + ->get(), + new Admin\TicketStatusChangedNotification($event->ticket, $ticketIsOpen) + ); + + Notification::send( + User::query() + ->onlyNGOAdmins($event->ticket->organization) + ->get(), + new Ngo\TicketStatusChangedNotification($event->ticket, $ticketIsOpen) + ); + } +} diff --git a/app/Models/Organization.php b/app/Models/Organization.php index fc2bf687..fdd6210b 100644 --- a/app/Models/Organization.php +++ b/app/Models/Organization.php @@ -75,9 +75,6 @@ public function registerMediaConversions(Media $media = null): void ->nonQueued(); } - /** - * Get the users or the organization. - */ public function users(): HasMany { return $this->hasMany(User::class); @@ -93,6 +90,11 @@ public function activityDomains(): BelongsToMany return $this->belongsToMany(ActivityDomain::class); } + public function tickets(): HasMany + { + return $this->hasMany(Ticket::class); + } + /** * Scope a query to include the searched text. */ diff --git a/app/Models/Ticket.php b/app/Models/Ticket.php new file mode 100644 index 00000000..cd29ad97 --- /dev/null +++ b/app/Models/Ticket.php @@ -0,0 +1,93 @@ + 'datetime', + ]; + + protected $dispatchesEvents = [ + 'created' => TicketCreated::class, + 'updated' => TicketUpdated::class, + ]; + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function organization(): BelongsTo + { + return $this->belongsTo(Organization::class); + } + + public function messages(): HasMany + { + return $this->hasMany(TicketMessage::class); + } + + public function scopeWhereOpen(Builder $query): Builder + { + return $query->whereNull('closed_at'); + } + + public function scopeWhereClosed(Builder $query): Builder + { + return $query->whereNotNull('closed_at'); + } + + public function isOpen(): bool + { + return \is_null($this->closed_at); + } + + public function open(): void + { + self::withoutEvents(function () { + $this->messages()->create([ + 'user_id' => auth()->user()->id, + 'content' => __('ticket.action_reopen_confirm.success'), + ]); + }); + + $this->update([ + 'closed_at' => null, + ]); + } + + public function close(): void + { + self::withoutEvents(function () { + $this->messages()->create([ + 'user_id' => auth()->user()->id, + 'content' => __('ticket.action_close_confirm.success'), + ]); + }); + + $this->update([ + 'closed_at' => $this->freshTimestamp(), + ]); + } +} diff --git a/app/Models/TicketMessage.php b/app/Models/TicketMessage.php new file mode 100644 index 00000000..7476ef7e --- /dev/null +++ b/app/Models/TicketMessage.php @@ -0,0 +1,38 @@ + TicketReplyReceived::class, + ]; + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function ticket(): BelongsTo + { + return $this->belongsTo(Ticket::class); + } +} diff --git a/app/Notifications/Admin/TicketCreatedNotification.php b/app/Notifications/Admin/TicketCreatedNotification.php new file mode 100644 index 00000000..32332ab7 --- /dev/null +++ b/app/Notifications/Admin/TicketCreatedNotification.php @@ -0,0 +1,54 @@ +ticket = $ticket; + } + + /** + * Get the notification's delivery channels. + * + * @return array + */ + 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(__('ticket.mail.created.subject', [ + 'id' => $this->ticket->id, + ])) + ->line(__('ticket.mail.created.subject', [ + 'id' => $this->ticket->id, + ])) + ->action( + __('ticket.action.view'), + TicketResource::getUrl('view', $this->ticket) + ); + } +} diff --git a/app/Notifications/Admin/TicketReceivedReplyNotification.php b/app/Notifications/Admin/TicketReceivedReplyNotification.php new file mode 100644 index 00000000..e44f81e2 --- /dev/null +++ b/app/Notifications/Admin/TicketReceivedReplyNotification.php @@ -0,0 +1,54 @@ +message = $message; + } + + /** + * Get the notification's delivery channels. + * + * @return array + */ + 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(__('ticket.mail.replied.subject', [ + 'id' => $this->message->ticket->id, + ])) + ->line(__('ticket.mail.replied.subject', [ + 'id' => $this->message->ticket->id, + ])) + ->action( + __('ticket.action.view'), + TicketResource::getUrl('view', $this->message->ticket) + ); + } +} diff --git a/app/Notifications/Admin/TicketStatusChangedNotification.php b/app/Notifications/Admin/TicketStatusChangedNotification.php new file mode 100644 index 00000000..44ce8b7f --- /dev/null +++ b/app/Notifications/Admin/TicketStatusChangedNotification.php @@ -0,0 +1,79 @@ +ticket = $ticket; + $this->isOpen = $isOpen; + } + + /** + * Get the notification's delivery channels. + * + * @return array + */ + public function via(object $notifiable): array + { + return ['mail']; + } + + /** + * Get the mail representation of the notification. + */ + public function toMail(object $notifiable): MailMessage + { + return $this->isOpen + ? $this->buildReopenedMail($notifiable) + : $this->buildClosedMail($notifiable); + } + + protected function buildClosedMail(object $notifiable): MailMessage + { + return (new MailMessage) + ->subject(__('ticket.mail.closed.subject', [ + 'id' => $this->ticket->id, + ])) + ->line(__('ticket.mail.closed.subject', [ + 'id' => $this->ticket->id, + ])) + ->action( + __('ticket.mail.closed.action'), + TicketResource::getUrl('view', $this->ticket) + ); + } + + protected function buildReopenedMail(object $notifiable): MailMessage + { + return (new MailMessage) + ->subject(__('ticket.mail.reopened.subject', [ + 'id' => $this->ticket->id, + ])) + ->line(__('ticket.mail.reopened.subject', [ + 'id' => $this->ticket->id, + ])) + ->action( + __('ticket.mail.reopened.action'), + TicketResource::getUrl('view', $this->ticket) + ); + } +} diff --git a/app/Notifications/Ngo/TicketCreatedNotification.php b/app/Notifications/Ngo/TicketCreatedNotification.php new file mode 100644 index 00000000..1f6219a6 --- /dev/null +++ b/app/Notifications/Ngo/TicketCreatedNotification.php @@ -0,0 +1,53 @@ +ticket = $ticket; + } + + /** + * Get the notification's delivery channels. + * + * @return array + */ + 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(__('ticket.mail.created.subject', [ + 'id' => $this->ticket->id, + ])) + ->line(__('ticket.mail.created.subject', [ + 'id' => $this->ticket->id, + ])) + ->action( + __('ticket.action.view'), + route('admin.ong.tickets.view', $this->ticket) + ); + } +} diff --git a/app/Notifications/Ngo/TicketReceivedReplyNotification.php b/app/Notifications/Ngo/TicketReceivedReplyNotification.php new file mode 100644 index 00000000..04aee33a --- /dev/null +++ b/app/Notifications/Ngo/TicketReceivedReplyNotification.php @@ -0,0 +1,56 @@ +message = $message; + } + + /** + * Get the notification's delivery channels. + * + * @return array + */ + 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(__('ticket.mail.replied.subject', [ + 'id' => $this->message->ticket->id, + ])) + ->line(__('ticket.mail.replied.subject', [ + 'id' => $this->message->ticket->id, + ])) + ->action( + __('ticket.action.view'), + route('admin.ong.tickets.view', [ + $this->message->ticket, + "#reply-{$this->message->id}", + ]) + ); + } +} diff --git a/app/Notifications/Ngo/TicketStatusChangedNotification.php b/app/Notifications/Ngo/TicketStatusChangedNotification.php new file mode 100644 index 00000000..5432abd7 --- /dev/null +++ b/app/Notifications/Ngo/TicketStatusChangedNotification.php @@ -0,0 +1,78 @@ +ticket = $ticket; + $this->isOpen = $isOpen; + } + + /** + * Get the notification's delivery channels. + * + * @return array + */ + public function via(object $notifiable): array + { + return ['mail']; + } + + /** + * Get the mail representation of the notification. + */ + public function toMail(object $notifiable): MailMessage + { + return $this->isOpen + ? $this->buildReopenedMail($notifiable) + : $this->buildClosedMail($notifiable); + } + + protected function buildClosedMail(object $notifiable): MailMessage + { + return (new MailMessage) + ->subject(__('ticket.mail.closed.subject', [ + 'id' => $this->ticket->id, + ])) + ->line(__('ticket.mail.closed.subject', [ + 'id' => $this->ticket->id, + ])) + ->action( + __('ticket.action.view'), + route('admin.ong.tickets.view', $this->ticket) + ); + } + + protected function buildReopenedMail(object $notifiable): MailMessage + { + return (new MailMessage) + ->subject(__('ticket.mail.reopened.subject', [ + 'id' => $this->ticket->id, + ])) + ->line(__('ticket.mail.reopened.subject', [ + 'id' => $this->ticket->id, + ])) + ->action( + __('ticket.action.view'), + route('admin.ong.tickets.view', $this->ticket) + ); + } +} diff --git a/app/Policies/TicketPolicy.php b/app/Policies/TicketPolicy.php new file mode 100644 index 00000000..53c59ac3 --- /dev/null +++ b/app/Policies/TicketPolicy.php @@ -0,0 +1,51 @@ +isBbAdmin() || $user->organization->is($ticket->organization); + } + + public function create(User $user): bool + { + return $user->isNgoAdmin(); + } + + public function update(User $user, Ticket $ticket): bool + { + return $user->isBbAdmin() || ($user->isNgoAdmin() && $user->organization->is($ticket->organization)); + } + + public function reply(User $user, Ticket $ticket): bool + { + return $this->update($user, $ticket) && $ticket->isOpen(); + } + + public function delete(User $user, Ticket $ticket): bool + { + return false; + } + + public function restore(User $user, Ticket $ticket): bool + { + return false; + } + + public function forceDelete(User $user, Ticket $ticket): bool + { + return false; + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 57b8042a..3340b2f3 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -5,6 +5,7 @@ namespace App\Providers; use App\Models\Championship; +use Carbon\Carbon; use Filament\Facades\Filament; use Filament\Navigation\NavigationItem; use Illuminate\Database\Eloquent\Model; @@ -18,7 +19,7 @@ class AppServiceProvider extends ServiceProvider */ public function register(): void { - // + $this->registerCarbonMacros(); } /** @@ -94,4 +95,13 @@ public function boot(): void ]); }); } + + protected function registerCarbonMacros(): void + { + Carbon::macro('toFormattedDate', fn () => $this->translatedFormat(config('forms.components.date_time_picker.display_formats.date'))); + Carbon::macro('toFormattedDateTime', fn () => $this->translatedFormat(config('forms.components.date_time_picker.display_formats.date_time'))); + Carbon::macro('toFormattedDateTimeWithSeconds', fn () => $this->translatedFormat(config('forms.components.date_time_picker.display_formats.date_time_with_seconds'))); + Carbon::macro('toFormattedTime', fn () => $this->translatedFormat(config('forms.components.date_time_picker.display_formats.time'))); + Carbon::macro('toFormattedTimeWithSeconds', fn () => $this->translatedFormat(config('forms.components.date_time_picker.display_formats.time_with_seconds'))); + } } diff --git a/app/Providers/EventServiceProvider.php b/app/Providers/EventServiceProvider.php index ee09f108..7cbef06c 100644 --- a/app/Providers/EventServiceProvider.php +++ b/app/Providers/EventServiceProvider.php @@ -35,6 +35,6 @@ public function boot(): void */ public function shouldDiscoverEvents(): bool { - return false; + return true; } } diff --git a/app/Traits/HasRole.php b/app/Traits/HasRole.php index be51b24f..2abac8be 100644 --- a/app/Traits/HasRole.php +++ b/app/Traits/HasRole.php @@ -5,6 +5,7 @@ namespace App\Traits; use App\Enums\UserRole; +use App\Models\Organization; use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Collection; @@ -40,8 +41,21 @@ public function isBbAdmin(): bool return $this->role === UserRole::bb_admin; } - public function scopeRole(Builder $query, array|string|Collection|UserRole $roles): void + public function scopeRole(Builder $query, array|string|Collection|UserRole $roles): Builder { - $query->whereIn('role', collect($roles)); + return $query->whereIn('role', collect($roles)); + } + + public function scopeOnlyBBAdmins(Builder $query): Builder + { + return $query->role(UserRole::bb_admin); + } + + public function scopeOnlyNGOAdmins(Builder $query, ?Organization $organization = null): Builder + { + return $query->role(UserRole::ngo_admin) + ->when($organization !== null, function (Builder $query) use ($organization) { + return $query->where('organization_id', $organization->id); + }); } } diff --git a/database/factories/TicketFactory.php b/database/factories/TicketFactory.php new file mode 100644 index 00000000..94372041 --- /dev/null +++ b/database/factories/TicketFactory.php @@ -0,0 +1,56 @@ + + */ +class TicketFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'user_id' => User::factory(), + 'organization_id' => Organization::factory(), + 'subject' => fake()->sentence(), + 'content' => fake()->paragraph(), + 'closed_at' => fake()->boolean() ? now() : null, + ]; + } + + public function configure(): static + { + return $this->afterCreating(function (Ticket $ticket) { + $ngoAdmin = $ticket->organization->getAdministrator(); + $bbAdmin = User::query() + ->role(UserRole::bb_admin) + ->first(); + + TicketMessage::factory() + ->for($ticket) + ->recycle($ngoAdmin) + ->count(fake()->randomDigitNotNull()) + ->create(); + + TicketMessage::factory() + ->for($ticket) + ->recycle($bbAdmin) + ->count(fake()->randomDigitNotNull()) + ->create(); + }); + } +} diff --git a/database/factories/TicketMessageFactory.php b/database/factories/TicketMessageFactory.php new file mode 100644 index 00000000..db608eec --- /dev/null +++ b/database/factories/TicketMessageFactory.php @@ -0,0 +1,29 @@ + + */ +class TicketMessageFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'user_id' => User::factory(), + 'ticket_id' => Ticket::factory(), + 'content' => fake()->paragraph(), + ]; + } +} diff --git a/database/migrations/2023_08_17_135831_create_tickets_table.php b/database/migrations/2023_08_17_135831_create_tickets_table.php new file mode 100644 index 00000000..5a4bd13e --- /dev/null +++ b/database/migrations/2023_08_17_135831_create_tickets_table.php @@ -0,0 +1,52 @@ +id(); + $table->timestamps(); + $table->timestamp('closed_at')->nullable(); + + $table->foreignIdFor(Organization::class) + ->nullable() + ->constrained() + ->cascadeOnDelete(); + + $table->foreignIdFor(User::class) + ->constrained() + ->cascadeOnDelete(); + + $table->string('subject'); + $table->text('content'); + }); + + Schema::create('ticket_messages', function (Blueprint $table) { + $table->id(); + $table->timestamps(); + + $table->foreignIdFor(User::class) + ->constrained() + ->cascadeOnDelete(); + + $table->foreignIdFor(Ticket::class) + ->constrained() + ->cascadeOnDelete(); + + $table->text('content'); + }); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 0198119a..b4c7cb14 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -12,6 +12,7 @@ use App\Models\Organization; use App\Models\Project; use App\Models\ProjectCategory; +use App\Models\Ticket; use App\Models\User; use Illuminate\Database\Eloquent\Factories\Sequence; use Illuminate\Database\Seeder; @@ -52,12 +53,14 @@ public function run(): void ActivityDomain::insert($collection->toArray()); }); + $this->seedProjectCategories(); + Championship::factory() ->count(3) ->create(); Organization::factory() - ->count(50) + ->count(10) ->has( User::factory() ->ngoAdmin() @@ -72,6 +75,9 @@ public function run(): void ->count(10) ->hasVolunteers(10) ) + ->has( + Ticket::factory() + ) ->create(); Badge::factory() @@ -87,19 +93,6 @@ public function run(): void } } - /** - * @return void - */ - private function seedActivityDomains(): void - { - $activityDomains = ActivityDomainEnum::values(); - $tmpActivityDomains = []; - foreach ($activityDomains as $domain) { - $tmpActivityDomains[] = ['name' => $domain, 'slug' => \Str::slug($domain)]; - } - ActivityDomain::insert($tmpActivityDomains); - } - private function seedProjectCategories() { $projectCategories = [ @@ -114,7 +107,7 @@ private function seedProjectCategories() 'Sport', ]; $projectCategories = collect($projectCategories)->transform(function ($category) { - return ['name' => $category, 'slug' => \Str::slug($category)]; + return ['name' => $category, 'slug' => Str::slug($category)]; }); ProjectCategory::insert($projectCategories->toArray()); } diff --git a/lang/ro/ticket.php b/lang/ro/ticket.php new file mode 100644 index 00000000..8976eba6 --- /dev/null +++ b/lang/ro/ticket.php @@ -0,0 +1,60 @@ + 'Închis la data', + 'date' => 'Dată', + 'message' => 'Mesaj', + 'organization' => 'Organizație', + 'subject' => 'Subiect', + 'status' => 'Status', + 'status.open' => 'Deschis', + 'status.closed' => 'Închis', + + 'action' => [ + 'reply' => 'Răspunde', + 'close' => 'Închide', + 'reopen' => 'Redeschide', + 'view' => 'Vezi ticketul', + ], + + 'action_close_confirm' => [ + 'title' => 'Închide ticket', + 'text' => 'Ești sigur că dorești să efectuezi operaţia?', + 'action' => 'Închide', + 'success' => 'Ticket-ul a fost închis.', + ], + + 'action_reopen_confirm' => [ + 'title' => 'Redeschide ticket', + 'text' => 'Ești sigur că dorești să efectuezi operaţia?', + 'action' => 'Redeschide', + 'success' => 'Ticket-ul a fost redeschis.', + ], + + 'action_open' => [ + 'success' => 'Ticket-ul a fost deschis.', + ], + + 'action_reply' => [ + 'success' => 'Mesajul a fost trimis.', + ], + + 'mail' => [ + 'created' => [ + 'subject' => 'Ticketul #:id a fost creat.', + ], + 'replied' => [ + 'subject' => 'Ticketul #:id a primit un răspuns nou.', + ], + 'closed' => [ + 'subject' => 'Ticketul #:id a fost închis.', + ], + 'reopened' => [ + 'subject' => 'Ticketul #:id a fost redeschis.', + ], + ], + +]; diff --git a/resources/js/Components/modals/ToggleTicketStatusModal.vue b/resources/js/Components/modals/ToggleTicketStatusModal.vue new file mode 100644 index 00000000..8615739c --- /dev/null +++ b/resources/js/Components/modals/ToggleTicketStatusModal.vue @@ -0,0 +1,124 @@ + + + diff --git a/resources/js/Components/templates/Dashboard.vue b/resources/js/Components/templates/Dashboard.vue index f201d823..63fcbfaf 100644 --- a/resources/js/Components/templates/Dashboard.vue +++ b/resources/js/Components/templates/Dashboard.vue @@ -1,17 +1,31 @@ diff --git a/resources/js/Pages/AdminOng/Tichets/ClosedTickets.vue b/resources/js/Pages/AdminOng/Tichets/ClosedTickets.vue deleted file mode 100644 index 29e1d5d0..00000000 --- a/resources/js/Pages/AdminOng/Tichets/ClosedTickets.vue +++ /dev/null @@ -1,133 +0,0 @@ - - - diff --git a/resources/js/Pages/AdminOng/Tichets/OpenTickets.vue b/resources/js/Pages/AdminOng/Tichets/OpenTickets.vue deleted file mode 100644 index dbe7a9fb..00000000 --- a/resources/js/Pages/AdminOng/Tichets/OpenTickets.vue +++ /dev/null @@ -1,210 +0,0 @@ -