feat: AfterSales CRM - SOP compliant real-estate customer care system
- Departments + cross-department transfer workflow - Role-based closing (staff→resolved, manager→closed) with proof_of_resolution - Private file storage with collection system + temporary signed URLs - Dashboard: resolved tickets table, stats, handler performance bar chart - Spatie RBAC (admin/manager/staff) - Notifications: TicketTransferred, TicketClosed (database + mail) - Pest tests: 8 tests, 21 assertions - Docker production-ready (Dockerfile + compose + entrypoint)
This commit is contained in:
58
app/Filament/Resources/Customers/CustomerResource.php
Normal file
58
app/Filament/Resources/Customers/CustomerResource.php
Normal file
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\Customers;
|
||||
|
||||
use App\Filament\Resources\Customers\Pages\CreateCustomer;
|
||||
use App\Filament\Resources\Customers\Pages\EditCustomer;
|
||||
use App\Filament\Resources\Customers\Pages\ListCustomers;
|
||||
use App\Filament\Resources\Customers\RelationManagers\FeedbacksRelationManager;
|
||||
use App\Filament\Resources\Customers\Schemas\CustomerForm;
|
||||
use App\Filament\Resources\Customers\Tables\CustomersTable;
|
||||
use App\Models\Customer;
|
||||
use BackedEnum;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Support\Icons\Heroicon;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class CustomerResource extends Resource
|
||||
{
|
||||
protected static ?string $model = Customer::class;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedUserGroup;
|
||||
|
||||
protected static ?string $pluralLabel = 'Customers';
|
||||
|
||||
protected static ?int $navigationSort = 2;
|
||||
|
||||
public static function getNavigationGroup(): ?string
|
||||
{
|
||||
return 'Management';
|
||||
}
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return CustomerForm::configure($schema);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return CustomersTable::configure($table);
|
||||
}
|
||||
|
||||
public static function getRelations(): array
|
||||
{
|
||||
return [
|
||||
FeedbacksRelationManager::class,
|
||||
];
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => ListCustomers::route('/'),
|
||||
'create' => CreateCustomer::route('/create'),
|
||||
'edit' => EditCustomer::route('/{record}/edit'),
|
||||
];
|
||||
}
|
||||
}
|
||||
11
app/Filament/Resources/Customers/Pages/CreateCustomer.php
Normal file
11
app/Filament/Resources/Customers/Pages/CreateCustomer.php
Normal file
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\Customers\Pages;
|
||||
|
||||
use App\Filament\Resources\Customers\CustomerResource;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
|
||||
class CreateCustomer extends CreateRecord
|
||||
{
|
||||
protected static string $resource = CustomerResource::class;
|
||||
}
|
||||
19
app/Filament/Resources/Customers/Pages/EditCustomer.php
Normal file
19
app/Filament/Resources/Customers/Pages/EditCustomer.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\Customers\Pages;
|
||||
|
||||
use App\Filament\Resources\Customers\CustomerResource;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
|
||||
class EditCustomer extends EditRecord
|
||||
{
|
||||
protected static string $resource = CustomerResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
DeleteAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
19
app/Filament/Resources/Customers/Pages/ListCustomers.php
Normal file
19
app/Filament/Resources/Customers/Pages/ListCustomers.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\Customers\Pages;
|
||||
|
||||
use App\Filament\Resources\Customers\CustomerResource;
|
||||
use Filament\Actions\CreateAction;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListCustomers extends ListRecords
|
||||
{
|
||||
protected static string $resource = CustomerResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
CreateAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\Customers\RelationManagers;
|
||||
|
||||
use App\Filament\Resources\Feedback\FeedbackResource;
|
||||
use Filament\Resources\RelationManagers\RelationManager;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class FeedbacksRelationManager extends RelationManager
|
||||
{
|
||||
protected static string $relationship = 'feedbacks';
|
||||
|
||||
protected static ?string $title = 'Feedback History';
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->recordTitleAttribute('title')
|
||||
->columns([
|
||||
TextColumn::make('title')
|
||||
->searchable()
|
||||
->url(fn ($record) => FeedbackResource::getUrl('edit', ['record' => $record])),
|
||||
TextColumn::make('customerProduct.product.name')
|
||||
->label('Product')
|
||||
->placeholder('General'),
|
||||
TextColumn::make('status')
|
||||
->badge()
|
||||
->color(fn (string $state): string => match ($state) {
|
||||
'pending' => 'warning',
|
||||
'processing' => 'info',
|
||||
'resolved' => 'success',
|
||||
'closed' => 'gray',
|
||||
default => 'gray',
|
||||
}),
|
||||
TextColumn::make('tags.name')
|
||||
->badge(),
|
||||
TextColumn::make('feedbackChannel.name')
|
||||
->label('Channel'),
|
||||
TextColumn::make('interactions_count')
|
||||
->counts('interactions')
|
||||
->label('Interactions'),
|
||||
TextColumn::make('created_at')
|
||||
->dateTime('d/m/Y')
|
||||
->sortable(),
|
||||
])
|
||||
->filters([
|
||||
SelectFilter::make('status')
|
||||
->options([
|
||||
'pending' => 'Pending',
|
||||
'processing' => 'Processing',
|
||||
'resolved' => 'Resolved',
|
||||
'closed' => 'Closed',
|
||||
]),
|
||||
SelectFilter::make('tags')
|
||||
->relationship('tags', 'name'),
|
||||
])
|
||||
->defaultSort('created_at', 'desc')
|
||||
->headerActions([])
|
||||
->recordActions([]);
|
||||
}
|
||||
}
|
||||
50
app/Filament/Resources/Customers/Schemas/CustomerForm.php
Normal file
50
app/Filament/Resources/Customers/Schemas/CustomerForm.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\Customers\Schemas;
|
||||
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Schema;
|
||||
|
||||
class CustomerForm
|
||||
{
|
||||
public static function configure(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->components([
|
||||
Section::make('Customer Information')
|
||||
->schema([
|
||||
TextInput::make('name')
|
||||
->required()
|
||||
->maxLength(255),
|
||||
TextInput::make('email')
|
||||
->email()
|
||||
->maxLength(255),
|
||||
TextInput::make('phone')
|
||||
->tel()
|
||||
->maxLength(255),
|
||||
Textarea::make('address')
|
||||
->columnSpanFull(),
|
||||
Select::make('status')
|
||||
->options([
|
||||
'active' => 'Active',
|
||||
'inactive' => 'Inactive',
|
||||
])
|
||||
->default('active')
|
||||
->required(),
|
||||
])
|
||||
->columns(2),
|
||||
|
||||
Section::make('Owned Products')
|
||||
->schema([
|
||||
Select::make('products')
|
||||
->relationship('products', 'name')
|
||||
->multiple()
|
||||
->preload()
|
||||
->columnSpanFull(),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
61
app/Filament/Resources/Customers/Tables/CustomersTable.php
Normal file
61
app/Filament/Resources/Customers/Tables/CustomersTable.php
Normal file
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\Customers\Tables;
|
||||
|
||||
use Filament\Actions\BulkActionGroup;
|
||||
use Filament\Actions\DeleteBulkAction;
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class CustomersTable
|
||||
{
|
||||
public static function configure(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
TextColumn::make('name')
|
||||
->searchable()
|
||||
->sortable(),
|
||||
TextColumn::make('email')
|
||||
->searchable()
|
||||
->toggleable(),
|
||||
TextColumn::make('phone')
|
||||
->searchable()
|
||||
->toggleable(),
|
||||
TextColumn::make('status')
|
||||
->badge()
|
||||
->color(fn(string $state): string => match ($state) {
|
||||
'active' => 'success',
|
||||
'inactive' => 'gray',
|
||||
default => 'gray',
|
||||
}),
|
||||
TextColumn::make('products_count')
|
||||
->counts('products')
|
||||
->label('Products'),
|
||||
TextColumn::make('feedbacks_count')
|
||||
->counts('feedbacks')
|
||||
->label('Feedbacks'),
|
||||
TextColumn::make('created_at')
|
||||
->dateTime()
|
||||
->sortable()
|
||||
->toggleable(),
|
||||
])
|
||||
->filters([
|
||||
SelectFilter::make('status')
|
||||
->options([
|
||||
'active' => 'Active',
|
||||
'inactive' => 'Inactive',
|
||||
]),
|
||||
])
|
||||
->recordActions([
|
||||
EditAction::make(),
|
||||
])
|
||||
->toolbarActions([
|
||||
BulkActionGroup::make([
|
||||
DeleteBulkAction::make(),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
58
app/Filament/Resources/Feedback/FeedbackResource.php
Normal file
58
app/Filament/Resources/Feedback/FeedbackResource.php
Normal file
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\Feedback;
|
||||
|
||||
use App\Filament\Resources\Feedback\Pages\CreateFeedback;
|
||||
use App\Filament\Resources\Feedback\Pages\EditFeedback;
|
||||
use App\Filament\Resources\Feedback\Pages\ListFeedback;
|
||||
use App\Filament\Resources\Feedback\RelationManagers\InteractionsRelationManager;
|
||||
use App\Filament\Resources\Feedback\Schemas\FeedbackForm;
|
||||
use App\Filament\Resources\Feedback\Tables\FeedbackTable;
|
||||
use App\Models\Feedback;
|
||||
use BackedEnum;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Support\Icons\Heroicon;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class FeedbackResource extends Resource
|
||||
{
|
||||
protected static ?string $model = Feedback::class;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedChatBubbleLeftRight;
|
||||
|
||||
protected static ?string $pluralLabel = 'Feedbacks';
|
||||
|
||||
protected static ?int $navigationSort = 1;
|
||||
|
||||
public static function getNavigationGroup(): ?string
|
||||
{
|
||||
return 'Customer Care';
|
||||
}
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return FeedbackForm::configure($schema);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return FeedbackTable::configure($table);
|
||||
}
|
||||
|
||||
public static function getRelations(): array
|
||||
{
|
||||
return [
|
||||
InteractionsRelationManager::class,
|
||||
];
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => ListFeedback::route('/'),
|
||||
'create' => CreateFeedback::route('/create'),
|
||||
'edit' => EditFeedback::route('/{record}/edit'),
|
||||
];
|
||||
}
|
||||
}
|
||||
52
app/Filament/Resources/Feedback/Pages/CreateFeedback.php
Normal file
52
app/Filament/Resources/Feedback/Pages/CreateFeedback.php
Normal file
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\Feedback\Pages;
|
||||
|
||||
use App\Filament\Resources\Feedback\FeedbackResource;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class CreateFeedback extends CreateRecord
|
||||
{
|
||||
protected static string $resource = FeedbackResource::class;
|
||||
|
||||
protected function mutateFormDataBeforeCreate(array $data): array
|
||||
{
|
||||
if (($data['is_general'] ?? false) === true) {
|
||||
$data['customer_product_id'] = null;
|
||||
}
|
||||
unset($data['is_general'], $data['attachment_collection']);
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
protected function afterCreate(): void
|
||||
{
|
||||
$feedback = $this->getRecord();
|
||||
$attachments = $this->data['attachments'] ?? [];
|
||||
$collection = $this->data['attachment_collection'] ?? 'general';
|
||||
|
||||
$feedback->interactions()->create([
|
||||
'user_id' => auth()->id(),
|
||||
'type' => 'note',
|
||||
'content' => 'Feedback created via ' . ($feedback->feedbackChannel?->name ?? 'unknown channel'),
|
||||
'changes' => ['status' => ['old' => null, 'new' => $feedback->status]],
|
||||
]);
|
||||
|
||||
if (! empty($attachments)) {
|
||||
$disk = Storage::disk('local');
|
||||
foreach ($attachments as $path) {
|
||||
$feedback->attachments()->create([
|
||||
'uuid' => (string) \Illuminate\Support\Str::uuid(),
|
||||
'user_id' => auth()->id(),
|
||||
'name' => basename($path),
|
||||
'path' => $path,
|
||||
'disk' => 'local',
|
||||
'collection' => $collection,
|
||||
'mime_type' => $disk->mimeType($path),
|
||||
'size' => $disk->size($path),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
193
app/Filament/Resources/Feedback/Pages/EditFeedback.php
Normal file
193
app/Filament/Resources/Feedback/Pages/EditFeedback.php
Normal file
@@ -0,0 +1,193 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\Feedback\Pages;
|
||||
|
||||
use App\Filament\Resources\Feedback\FeedbackResource;
|
||||
use App\Models\Department;
|
||||
use App\Services\ClosingService;
|
||||
use App\Services\TransferService;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Forms\Components\FileUpload;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Support\Facades\App;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class EditFeedback extends EditRecord
|
||||
{
|
||||
protected static string $resource = FeedbackResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
$this->transferDepartmentAction(),
|
||||
$this->closeTicketAction(),
|
||||
DeleteAction::make(),
|
||||
];
|
||||
}
|
||||
|
||||
protected function transferDepartmentAction(): Action
|
||||
{
|
||||
return Action::make('transferDepartment')
|
||||
->label('Transfer Department')
|
||||
->icon('heroicon-o-arrows-right-left')
|
||||
->color('warning')
|
||||
->visible(fn (): bool => auth()->user()->hasRole(['admin', 'manager']))
|
||||
->form([
|
||||
Select::make('to_department_id')
|
||||
->label('Target Department')
|
||||
->options(Department::pluck('name', 'id')->toArray())
|
||||
->required()
|
||||
->searchable(),
|
||||
Select::make('new_assignee_id')
|
||||
->label('Assign To (optional)')
|
||||
->options(function (\Filament\Forms\Get $get) {
|
||||
$deptId = $get('to_department_id');
|
||||
if (! $deptId) {
|
||||
return [];
|
||||
}
|
||||
$dept = Department::with('manager')->find($deptId);
|
||||
|
||||
return \App\Models\User::pluck('name', 'id')->toArray();
|
||||
})
|
||||
->searchable()
|
||||
->nullable(),
|
||||
Textarea::make('reason')
|
||||
->label('Reason')
|
||||
->required()
|
||||
->rows(3),
|
||||
FileUpload::make('transfer_attachments')
|
||||
->label('Attachments')
|
||||
->multiple()
|
||||
->disk('local')
|
||||
->directory('feedback-attachments')
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->action(function (array $data): void {
|
||||
$feedback = $this->getRecord();
|
||||
$toDepartment = Department::findOrFail($data['to_department_id']);
|
||||
$sender = auth()->user();
|
||||
|
||||
$transferService = App::make(TransferService::class);
|
||||
$log = $transferService->transfer(
|
||||
$feedback,
|
||||
$toDepartment,
|
||||
$data['reason'],
|
||||
$sender,
|
||||
$data['new_assignee_id'] ?? null,
|
||||
);
|
||||
|
||||
if (! empty($data['transfer_attachments'])) {
|
||||
$fileService = App::make(FileService::class);
|
||||
$fileService->upload(
|
||||
$data['transfer_attachments'],
|
||||
'general',
|
||||
$feedback,
|
||||
$sender->id,
|
||||
);
|
||||
}
|
||||
|
||||
Notification::make()
|
||||
->title('Transferred successfully')
|
||||
->success()
|
||||
->send();
|
||||
|
||||
$this->redirect($this->getResource()::getUrl('edit', ['record' => $feedback]));
|
||||
});
|
||||
}
|
||||
|
||||
protected function closeTicketAction(): Action
|
||||
{
|
||||
return Action::make('closeTicket')
|
||||
->label('Close Ticket')
|
||||
->icon('heroicon-o-check-circle')
|
||||
->color('success')
|
||||
->visible(fn (): bool => $this->getRecord()->status !== 'closed' && auth()->user()->hasRole(['admin', 'manager']))
|
||||
->requiresConfirmation()
|
||||
->modalHeading('Close Ticket')
|
||||
->modalDescription('Bạn có chắc muốn đóng phiếu này? Hành động này KHÔNG thể hoàn tác.')
|
||||
->modalSubmitActionLabel('Close')
|
||||
->action(function (): void {
|
||||
$feedback = $this->getRecord();
|
||||
$actor = auth()->user();
|
||||
|
||||
$closingService = App::make(ClosingService::class);
|
||||
|
||||
try {
|
||||
$closingService->close($feedback, $actor);
|
||||
|
||||
Notification::make()
|
||||
->title('Ticket closed successfully')
|
||||
->success()
|
||||
->send();
|
||||
|
||||
$this->redirect($this->getResource()::getUrl('edit', ['record' => $feedback]));
|
||||
} catch (\Symfony\Component\HttpKernel\Exception\HttpException $e) {
|
||||
Notification::make()
|
||||
->title($e->getMessage())
|
||||
->danger()
|
||||
->send();
|
||||
} catch (\Illuminate\Validation\ValidationException $e) {
|
||||
Notification::make()
|
||||
->title($e->getMessage())
|
||||
->danger()
|
||||
->send();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function getFooter(): ?View
|
||||
{
|
||||
return view('filament.resources.feedback.similar-cases');
|
||||
}
|
||||
|
||||
protected function mutateFormDataBeforeSave(array $data): array
|
||||
{
|
||||
if (($data['is_general'] ?? false) === true) {
|
||||
$data['customer_product_id'] = null;
|
||||
}
|
||||
unset($data['is_general'], $data['attachment_collection']);
|
||||
|
||||
$newStatus = $data['status'] ?? null;
|
||||
if ($newStatus === 'closed' && $this->getRecord()->status !== 'closed') {
|
||||
$closingService = App::make(ClosingService::class);
|
||||
$closingService->close($this->getRecord(), auth()->user());
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
protected function afterSave(): void
|
||||
{
|
||||
$attachments = $this->data['attachments'] ?? [];
|
||||
$collection = $this->data['attachment_collection'] ?? 'general';
|
||||
|
||||
if (! empty($attachments)) {
|
||||
$disk = Storage::disk('local');
|
||||
$feedback = $this->getRecord();
|
||||
foreach ($attachments as $path) {
|
||||
$feedback->attachments()->create([
|
||||
'uuid' => (string) \Illuminate\Support\Str::uuid(),
|
||||
'user_id' => auth()->id(),
|
||||
'name' => basename($path),
|
||||
'path' => $path,
|
||||
'disk' => 'local',
|
||||
'collection' => $collection,
|
||||
'mime_type' => $disk->mimeType($path),
|
||||
'size' => $disk->size($path),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected function mutateFormDataBeforeFill(array $data): array
|
||||
{
|
||||
$data['is_general'] = $data['customer_product_id'] === null;
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
19
app/Filament/Resources/Feedback/Pages/ListFeedback.php
Normal file
19
app/Filament/Resources/Feedback/Pages/ListFeedback.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\Feedback\Pages;
|
||||
|
||||
use App\Filament\Resources\Feedback\FeedbackResource;
|
||||
use Filament\Actions\CreateAction;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListFeedback extends ListRecords
|
||||
{
|
||||
protected static string $resource = FeedbackResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
CreateAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\Feedback\RelationManagers;
|
||||
|
||||
use App\Models\Department;
|
||||
use App\Models\FeedbackInteraction;
|
||||
use App\Models\User;
|
||||
use App\Services\ClosingService;
|
||||
use App\Services\FileService;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\CreateAction;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Filament\Forms\Components\FileUpload;
|
||||
use Filament\Forms\Components\RichEditor;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\RelationManagers\RelationManager;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Support\Facades\App;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||
|
||||
class InteractionsRelationManager extends RelationManager
|
||||
{
|
||||
protected static string $relationship = 'interactions';
|
||||
|
||||
protected static ?string $title = 'Interaction History';
|
||||
|
||||
public function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->components([
|
||||
Select::make('type')
|
||||
->options([
|
||||
'note' => 'Note',
|
||||
'status_change' => 'Status Change',
|
||||
'assignment' => 'Assignment / Forward',
|
||||
])
|
||||
->default('note')
|
||||
->required()
|
||||
->live(),
|
||||
|
||||
TextInput::make('title')
|
||||
->maxLength(255)
|
||||
->visible(fn ($get): bool => $get('type') === 'note'),
|
||||
|
||||
RichEditor::make('content')
|
||||
->required()
|
||||
->columnSpanFull(),
|
||||
|
||||
Select::make('new_status')
|
||||
->label('New Status')
|
||||
->options(fn (): array => auth()->user()->hasRole(['admin', 'manager'])
|
||||
? ['pending' => 'Pending', 'processing' => 'Processing', 'resolved' => 'Resolved', 'closed' => 'Closed']
|
||||
: ['pending' => 'Pending', 'processing' => 'Processing', 'resolved' => 'Resolved'],
|
||||
)
|
||||
->visible(fn ($get): bool => $get('type') === 'status_change'),
|
||||
|
||||
Select::make('new_assignee')
|
||||
->label('Assign To')
|
||||
->options(User::pluck('name', 'id')->toArray())
|
||||
->searchable()
|
||||
->visible(fn ($get): bool => $get('type') === 'assignment'),
|
||||
|
||||
Select::make('department_id')
|
||||
->label('Target Department')
|
||||
->options(Department::pluck('name', 'id')->toArray())
|
||||
->searchable()
|
||||
->nullable()
|
||||
->visible(fn ($get): bool => $get('type') === 'assignment'),
|
||||
|
||||
FileUpload::make('attachments')
|
||||
->label('Attachments')
|
||||
->multiple()
|
||||
->disk('local')
|
||||
->directory('feedback-attachments')
|
||||
->columnSpanFull(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->recordTitleAttribute('content')
|
||||
->columns([
|
||||
TextColumn::make('created_at')
|
||||
->dateTime('d/m/Y H:i')
|
||||
->sortable(),
|
||||
TextColumn::make('user.name')
|
||||
->label('By'),
|
||||
TextColumn::make('type')
|
||||
->badge()
|
||||
->formatStateUsing(fn (string $state): string => match ($state) {
|
||||
'note' => 'Note',
|
||||
'status_change' => 'Status Change',
|
||||
'assignment' => 'Assignment',
|
||||
default => $state,
|
||||
})
|
||||
->color(fn (string $state): string => match ($state) {
|
||||
'note' => 'gray',
|
||||
'status_change' => 'info',
|
||||
'assignment' => 'warning',
|
||||
default => 'gray',
|
||||
}),
|
||||
TextColumn::make('content')
|
||||
->html()
|
||||
->limit(80)
|
||||
->wrap(),
|
||||
TextColumn::make('changes')
|
||||
->label('Details')
|
||||
->formatStateUsing(function (?array $state): string {
|
||||
if (! $state) return '';
|
||||
$lines = [];
|
||||
foreach ($state as $field => $values) {
|
||||
if (is_array($values)) {
|
||||
$lines[] = "<b>{$field}</b>: {$values['old']} → {$values['new']}";
|
||||
}
|
||||
}
|
||||
return implode('<br>', $lines);
|
||||
})
|
||||
->html(),
|
||||
TextColumn::make('attachments_count')
|
||||
->label('Files')
|
||||
->counts('attachments')
|
||||
->badge()
|
||||
->color(fn (int $state): string => $state > 0 ? 'primary' : 'gray'),
|
||||
])
|
||||
->defaultSort('created_at', 'desc')
|
||||
->recordActions([
|
||||
Action::make('viewAttachments')
|
||||
->label('View Attachments')
|
||||
->icon('heroicon-o-paper-clip')
|
||||
->modalHeading('Attachments')
|
||||
->modalContent(function (FeedbackInteraction $record): \Illuminate\Contracts\View\View {
|
||||
$fileService = App::make(FileService::class);
|
||||
$attachments = $record->attachments;
|
||||
$links = $attachments->map(function ($a) use ($fileService) {
|
||||
return [
|
||||
'name' => $a->name,
|
||||
'url' => $fileService->getTemporaryUrl($a, 60),
|
||||
'size' => $a->size ? round($a->size / 1024, 1) . ' KB' : 'N/A',
|
||||
'collection' => $a->collection,
|
||||
];
|
||||
});
|
||||
|
||||
return view('filament.resources.feedback.attachment-links', [
|
||||
'links' => $links,
|
||||
]);
|
||||
})
|
||||
->visible(fn (FeedbackInteraction $record): bool => $record->attachments()->count() > 0),
|
||||
])
|
||||
->headerActions([
|
||||
CreateAction::make()
|
||||
->label('Add Interaction')
|
||||
->icon('heroicon-o-plus')
|
||||
->mutateFormDataUsing(function (array $data, $livewire): array {
|
||||
$data['user_id'] = auth()->id();
|
||||
$data['feedback_id'] = $livewire->getOwnerRecord()->id;
|
||||
|
||||
if ($data['type'] === 'status_change' && isset($data['new_status'])) {
|
||||
$feedback = $livewire->getOwnerRecord();
|
||||
|
||||
if ($data['new_status'] === 'closed') {
|
||||
$this->handleClosingViaService($feedback, auth()->user(), $data);
|
||||
}
|
||||
|
||||
$data['changes'] = [
|
||||
'status' => ['old' => $feedback->status, 'new' => $data['new_status']],
|
||||
];
|
||||
$feedback->update(['status' => $data['new_status']]);
|
||||
unset($data['new_status']);
|
||||
}
|
||||
|
||||
if ($data['type'] === 'assignment' && isset($data['new_assignee'])) {
|
||||
$feedback = $livewire->getOwnerRecord();
|
||||
$oldAssignee = $feedback->assignedTo?->name ?? 'Unassigned';
|
||||
$newAssignee = User::find($data['new_assignee'])?->name ?? 'Unknown';
|
||||
$data['changes'] = [
|
||||
'assignment' => ['old' => $oldAssignee, 'new' => $newAssignee],
|
||||
];
|
||||
$update = ['assigned_to' => $data['new_assignee']];
|
||||
if (! empty($data['department_id'])) {
|
||||
$update['current_department_id'] = $data['department_id'];
|
||||
$dept = Department::find($data['department_id']);
|
||||
$data['changes']['department'] = [
|
||||
'old' => $feedback->currentDepartment?->name ?? 'N/A',
|
||||
'new' => $dept?->name ?? 'N/A',
|
||||
];
|
||||
}
|
||||
$feedback->update($update);
|
||||
unset($data['new_assignee'], $data['department_id']);
|
||||
}
|
||||
|
||||
$data['_attachments'] = $data['attachments'] ?? [];
|
||||
unset($data['attachments'], $data['title']);
|
||||
return $data;
|
||||
})
|
||||
->after(function (FeedbackInteraction $record, array $data): void {
|
||||
$attachments = $data['_attachments'] ?? [];
|
||||
|
||||
if (! empty($attachments)) {
|
||||
$disk = Storage::disk('local');
|
||||
foreach ($attachments as $path) {
|
||||
$record->attachments()->create([
|
||||
'uuid' => (string) \Illuminate\Support\Str::uuid(),
|
||||
'feedback_id' => $record->feedback_id,
|
||||
'user_id' => auth()->id(),
|
||||
'name' => basename($path),
|
||||
'path' => $path,
|
||||
'disk' => 'local',
|
||||
'collection' => 'general',
|
||||
'mime_type' => $disk->mimeType($path),
|
||||
'size' => $disk->size($path),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
if ($record->type === 'assignment') {
|
||||
Notification::make()
|
||||
->title('Feedback assigned successfully')
|
||||
->success()
|
||||
->send();
|
||||
}
|
||||
if ($record->type === 'status_change') {
|
||||
Notification::make()
|
||||
->title('Status updated successfully')
|
||||
->success()
|
||||
->send();
|
||||
}
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
protected function handleClosingViaService($feedback, $actor, array &$data): void
|
||||
{
|
||||
try {
|
||||
$closingService = App::make(ClosingService::class);
|
||||
$closingService->close($feedback, $actor);
|
||||
} catch (HttpException $e) {
|
||||
Notification::make()
|
||||
->title($e->getMessage())
|
||||
->danger()
|
||||
->send();
|
||||
unset($data['new_status']);
|
||||
throw new \Filament\Exceptions\SilentActionException;
|
||||
} catch (ValidationException $e) {
|
||||
Notification::make()
|
||||
->title(collect($e->errors())->flatten()->first())
|
||||
->danger()
|
||||
->send();
|
||||
unset($data['new_status']);
|
||||
throw new \Filament\Exceptions\SilentActionException;
|
||||
}
|
||||
}
|
||||
}
|
||||
122
app/Filament/Resources/Feedback/Schemas/FeedbackForm.php
Normal file
122
app/Filament/Resources/Feedback/Schemas/FeedbackForm.php
Normal file
@@ -0,0 +1,122 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\Feedback\Schemas;
|
||||
|
||||
use App\Models\CustomerProduct;
|
||||
use App\Models\Department;
|
||||
use Filament\Forms\Components\FileUpload;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Schema;
|
||||
|
||||
class FeedbackForm
|
||||
{
|
||||
public static function configure(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->components([
|
||||
Section::make('Feedback Information')
|
||||
->schema([
|
||||
Select::make('customer_id')
|
||||
->relationship('customer', 'name')
|
||||
->searchable()
|
||||
->preload()
|
||||
->required()
|
||||
->live(),
|
||||
|
||||
Toggle::make('is_general')
|
||||
->label('General feedback (not related to a product)')
|
||||
->default(false)
|
||||
->live(),
|
||||
|
||||
Select::make('customer_product_id')
|
||||
->label('Product')
|
||||
->options(function ($get): array {
|
||||
$customerId = $get('customer_id');
|
||||
if (! $customerId) {
|
||||
return [];
|
||||
}
|
||||
return CustomerProduct::where('customer_id', $customerId)
|
||||
->with('product')
|
||||
->get()
|
||||
->pluck('product.name', 'id')
|
||||
->toArray();
|
||||
})
|
||||
->visible(fn ($get): bool => ! $get('is_general'))
|
||||
->nullable()
|
||||
->searchable(),
|
||||
|
||||
Select::make('feedback_channel_id')
|
||||
->relationship('feedbackChannel', 'name')
|
||||
->required()
|
||||
->preload(),
|
||||
|
||||
TextInput::make('title')
|
||||
->required()
|
||||
->maxLength(255),
|
||||
|
||||
Textarea::make('content')
|
||||
->required()
|
||||
->rows(6)
|
||||
->columnSpanFull(),
|
||||
|
||||
Select::make('attachment_collection')
|
||||
->label('Attachment Collection')
|
||||
->options([
|
||||
'general' => 'General',
|
||||
'proof_of_resolution' => 'Proof of Resolution',
|
||||
'customer_evidence' => 'Customer Evidence',
|
||||
])
|
||||
->default('general'),
|
||||
|
||||
FileUpload::make('attachments')
|
||||
->label('Attachments')
|
||||
->multiple()
|
||||
->disk('local')
|
||||
->directory('feedback-attachments')
|
||||
->columnSpanFull()
|
||||
->dehydrated(false),
|
||||
|
||||
Select::make('tags')
|
||||
->relationship('tags', 'name')
|
||||
->multiple()
|
||||
->preload()
|
||||
->createOptionForm([
|
||||
TextInput::make('name')->required(),
|
||||
TextInput::make('slug')->required(),
|
||||
TextInput::make('color')->type('color'),
|
||||
]),
|
||||
|
||||
Select::make('status')
|
||||
->options([
|
||||
'pending' => 'Pending',
|
||||
'processing' => 'Processing',
|
||||
'resolved' => 'Resolved',
|
||||
'closed' => 'Closed',
|
||||
])
|
||||
->default('pending')
|
||||
->required(),
|
||||
|
||||
Select::make('assigned_to')
|
||||
->relationship('assignedTo', 'name')
|
||||
->searchable()
|
||||
->preload()
|
||||
->nullable(),
|
||||
|
||||
Select::make('current_department_id')
|
||||
->label('Department')
|
||||
->options(Department::pluck('name', 'id')->toArray())
|
||||
->searchable()
|
||||
->nullable(),
|
||||
|
||||
Toggle::make('is_escalated')
|
||||
->label('Escalated')
|
||||
->visible(fn (): bool => auth()->user()?->hasRole(['admin', 'manager']) ?? false),
|
||||
])
|
||||
->columns(2),
|
||||
]);
|
||||
}
|
||||
}
|
||||
86
app/Filament/Resources/Feedback/Tables/FeedbackTable.php
Normal file
86
app/Filament/Resources/Feedback/Tables/FeedbackTable.php
Normal file
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\Feedback\Tables;
|
||||
|
||||
use Filament\Actions\BulkActionGroup;
|
||||
use Filament\Actions\DeleteBulkAction;
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Tables\Columns\IconColumn;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class FeedbackTable
|
||||
{
|
||||
public static function configure(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
TextColumn::make('title')
|
||||
->searchable()
|
||||
->sortable(),
|
||||
TextColumn::make('customer.name')
|
||||
->searchable()
|
||||
->sortable(),
|
||||
TextColumn::make('customerProduct.product.name')
|
||||
->label('Product')
|
||||
->placeholder('General')
|
||||
->sortable(),
|
||||
TextColumn::make('feedbackChannel.name')
|
||||
->label('Channel'),
|
||||
TextColumn::make('tags.name')
|
||||
->badge()
|
||||
->separator(','),
|
||||
TextColumn::make('status')
|
||||
->badge()
|
||||
->color(fn(string $state): string => match ($state) {
|
||||
'pending' => 'warning',
|
||||
'processing' => 'info',
|
||||
'resolved' => 'success',
|
||||
'closed' => 'gray',
|
||||
default => 'gray',
|
||||
}),
|
||||
TextColumn::make('currentDepartment.name')
|
||||
->label('Department')
|
||||
->toggleable(),
|
||||
TextColumn::make('assignedTo.name')
|
||||
->label('Assigned To')
|
||||
->toggleable(),
|
||||
IconColumn::make('is_escalated')
|
||||
->label('Escalated')
|
||||
->boolean()
|
||||
->toggleable(),
|
||||
TextColumn::make('created_at')
|
||||
->dateTime()
|
||||
->sortable()
|
||||
->toggleable(),
|
||||
])
|
||||
->filters([
|
||||
SelectFilter::make('status')
|
||||
->options([
|
||||
'pending' => 'Pending',
|
||||
'processing' => 'Processing',
|
||||
'resolved' => 'Resolved',
|
||||
'closed' => 'Closed',
|
||||
]),
|
||||
SelectFilter::make('feedback_channel_id')
|
||||
->relationship('feedbackChannel', 'name')
|
||||
->label('Channel'),
|
||||
SelectFilter::make('current_department_id')
|
||||
->relationship('currentDepartment', 'name')
|
||||
->label('Department'),
|
||||
SelectFilter::make('assigned_to')
|
||||
->relationship('assignedTo', 'name')
|
||||
->label('Assigned To'),
|
||||
])
|
||||
->recordActions([
|
||||
EditAction::make(),
|
||||
])
|
||||
->toolbarActions([
|
||||
BulkActionGroup::make([
|
||||
DeleteBulkAction::make(),
|
||||
]),
|
||||
])
|
||||
->defaultSort('created_at', 'desc');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\FeedbackChannels;
|
||||
|
||||
use App\Filament\Resources\FeedbackChannels\Pages\CreateFeedbackChannel;
|
||||
use App\Filament\Resources\FeedbackChannels\Pages\EditFeedbackChannel;
|
||||
use App\Filament\Resources\FeedbackChannels\Pages\ListFeedbackChannels;
|
||||
use App\Filament\Resources\FeedbackChannels\Schemas\FeedbackChannelForm;
|
||||
use App\Filament\Resources\FeedbackChannels\Tables\FeedbackChannelsTable;
|
||||
use App\Models\FeedbackChannel;
|
||||
use BackedEnum;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Support\Icons\Heroicon;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class FeedbackChannelResource extends Resource
|
||||
{
|
||||
protected static ?string $model = FeedbackChannel::class;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedInboxStack;
|
||||
|
||||
protected static ?string $pluralLabel = 'Channels';
|
||||
|
||||
protected static ?int $navigationSort = 3;
|
||||
|
||||
public static function getNavigationGroup(): ?string
|
||||
{
|
||||
return 'Management';
|
||||
}
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return FeedbackChannelForm::configure($schema);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return FeedbackChannelsTable::configure($table);
|
||||
}
|
||||
|
||||
public static function getRelations(): array
|
||||
{
|
||||
return [
|
||||
//
|
||||
];
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => ListFeedbackChannels::route('/'),
|
||||
'create' => CreateFeedbackChannel::route('/create'),
|
||||
'edit' => EditFeedbackChannel::route('/{record}/edit'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\FeedbackChannels\Pages;
|
||||
|
||||
use App\Filament\Resources\FeedbackChannels\FeedbackChannelResource;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
|
||||
class CreateFeedbackChannel extends CreateRecord
|
||||
{
|
||||
protected static string $resource = FeedbackChannelResource::class;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\FeedbackChannels\Pages;
|
||||
|
||||
use App\Filament\Resources\FeedbackChannels\FeedbackChannelResource;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
|
||||
class EditFeedbackChannel extends EditRecord
|
||||
{
|
||||
protected static string $resource = FeedbackChannelResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
DeleteAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\FeedbackChannels\Pages;
|
||||
|
||||
use App\Filament\Resources\FeedbackChannels\FeedbackChannelResource;
|
||||
use Filament\Actions\CreateAction;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListFeedbackChannels extends ListRecords
|
||||
{
|
||||
protected static string $resource = FeedbackChannelResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
CreateAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\FeedbackChannels\Schemas;
|
||||
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Schema;
|
||||
|
||||
class FeedbackChannelForm
|
||||
{
|
||||
public static function configure(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->components([
|
||||
Section::make()
|
||||
->schema([
|
||||
TextInput::make('name')
|
||||
->required()
|
||||
->maxLength(255),
|
||||
TextInput::make('slug')
|
||||
->required()
|
||||
->maxLength(255)
|
||||
->unique(ignoreRecord: true),
|
||||
TextInput::make('icon')
|
||||
->maxLength(255)
|
||||
->hint('Heroicon name or emoji'),
|
||||
Toggle::make('is_active')
|
||||
->default(true),
|
||||
])
|
||||
->columns(2),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\FeedbackChannels\Tables;
|
||||
|
||||
use Filament\Actions\BulkActionGroup;
|
||||
use Filament\Actions\DeleteBulkAction;
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Tables\Columns\IconColumn;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class FeedbackChannelsTable
|
||||
{
|
||||
public static function configure(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
TextColumn::make('name')
|
||||
->searchable()
|
||||
->sortable(),
|
||||
TextColumn::make('slug')
|
||||
->searchable(),
|
||||
IconColumn::make('is_active')
|
||||
->boolean()
|
||||
->label('Active'),
|
||||
TextColumn::make('feedbacks_count')
|
||||
->counts('feedbacks')
|
||||
->label('Feedbacks'),
|
||||
TextColumn::make('created_at')
|
||||
->dateTime()
|
||||
->sortable()
|
||||
->toggleable(),
|
||||
])
|
||||
->filters([
|
||||
//
|
||||
])
|
||||
->recordActions([
|
||||
EditAction::make(),
|
||||
])
|
||||
->toolbarActions([
|
||||
BulkActionGroup::make([
|
||||
DeleteBulkAction::make(),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
55
app/Filament/Resources/FeedbackTags/FeedbackTagResource.php
Normal file
55
app/Filament/Resources/FeedbackTags/FeedbackTagResource.php
Normal file
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\FeedbackTags;
|
||||
|
||||
use App\Filament\Resources\FeedbackTags\Pages\CreateFeedbackTag;
|
||||
use App\Filament\Resources\FeedbackTags\Pages\EditFeedbackTag;
|
||||
use App\Filament\Resources\FeedbackTags\Pages\ListFeedbackTags;
|
||||
use App\Filament\Resources\FeedbackTags\Schemas\FeedbackTagForm;
|
||||
use App\Filament\Resources\FeedbackTags\Tables\FeedbackTagsTable;
|
||||
use App\Models\FeedbackTag;
|
||||
use BackedEnum;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Support\Icons\Heroicon;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class FeedbackTagResource extends Resource
|
||||
{
|
||||
protected static ?string $model = FeedbackTag::class;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedTag;
|
||||
|
||||
protected static ?string $pluralLabel = 'Tags';
|
||||
|
||||
public static function getNavigationGroup(): ?string
|
||||
{
|
||||
return 'Management';
|
||||
}
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return FeedbackTagForm::configure($schema);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return FeedbackTagsTable::configure($table);
|
||||
}
|
||||
|
||||
public static function getRelations(): array
|
||||
{
|
||||
return [
|
||||
//
|
||||
];
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => ListFeedbackTags::route('/'),
|
||||
'create' => CreateFeedbackTag::route('/create'),
|
||||
'edit' => EditFeedbackTag::route('/{record}/edit'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\FeedbackTags\Pages;
|
||||
|
||||
use App\Filament\Resources\FeedbackTags\FeedbackTagResource;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
|
||||
class CreateFeedbackTag extends CreateRecord
|
||||
{
|
||||
protected static string $resource = FeedbackTagResource::class;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\FeedbackTags\Pages;
|
||||
|
||||
use App\Filament\Resources\FeedbackTags\FeedbackTagResource;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
|
||||
class EditFeedbackTag extends EditRecord
|
||||
{
|
||||
protected static string $resource = FeedbackTagResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
DeleteAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\FeedbackTags\Pages;
|
||||
|
||||
use App\Filament\Resources\FeedbackTags\FeedbackTagResource;
|
||||
use Filament\Actions\CreateAction;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListFeedbackTags extends ListRecords
|
||||
{
|
||||
protected static string $resource = FeedbackTagResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
CreateAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\FeedbackTags\Schemas;
|
||||
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Schemas\Schema;
|
||||
|
||||
class FeedbackTagForm
|
||||
{
|
||||
public static function configure(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->components([
|
||||
TextInput::make('name')
|
||||
->required(),
|
||||
TextInput::make('slug')
|
||||
->required(),
|
||||
TextInput::make('color'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\FeedbackTags\Tables;
|
||||
|
||||
use Filament\Actions\BulkActionGroup;
|
||||
use Filament\Actions\DeleteBulkAction;
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class FeedbackTagsTable
|
||||
{
|
||||
public static function configure(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
TextColumn::make('name')
|
||||
->searchable(),
|
||||
TextColumn::make('slug')
|
||||
->searchable(),
|
||||
TextColumn::make('color')
|
||||
->searchable(),
|
||||
TextColumn::make('created_at')
|
||||
->dateTime()
|
||||
->sortable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
TextColumn::make('updated_at')
|
||||
->dateTime()
|
||||
->sortable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
])
|
||||
->filters([
|
||||
//
|
||||
])
|
||||
->recordActions([
|
||||
EditAction::make(),
|
||||
])
|
||||
->toolbarActions([
|
||||
BulkActionGroup::make([
|
||||
DeleteBulkAction::make(),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
11
app/Filament/Resources/Products/Pages/CreateProduct.php
Normal file
11
app/Filament/Resources/Products/Pages/CreateProduct.php
Normal file
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\Products\Pages;
|
||||
|
||||
use App\Filament\Resources\Products\ProductResource;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
|
||||
class CreateProduct extends CreateRecord
|
||||
{
|
||||
protected static string $resource = ProductResource::class;
|
||||
}
|
||||
19
app/Filament/Resources/Products/Pages/EditProduct.php
Normal file
19
app/Filament/Resources/Products/Pages/EditProduct.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\Products\Pages;
|
||||
|
||||
use App\Filament\Resources\Products\ProductResource;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
|
||||
class EditProduct extends EditRecord
|
||||
{
|
||||
protected static string $resource = ProductResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
DeleteAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
19
app/Filament/Resources/Products/Pages/ListProducts.php
Normal file
19
app/Filament/Resources/Products/Pages/ListProducts.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\Products\Pages;
|
||||
|
||||
use App\Filament\Resources\Products\ProductResource;
|
||||
use Filament\Actions\CreateAction;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListProducts extends ListRecords
|
||||
{
|
||||
protected static string $resource = ProductResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
CreateAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
57
app/Filament/Resources/Products/ProductResource.php
Normal file
57
app/Filament/Resources/Products/ProductResource.php
Normal file
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\Products;
|
||||
|
||||
use App\Filament\Resources\Products\Pages\CreateProduct;
|
||||
use App\Filament\Resources\Products\Pages\EditProduct;
|
||||
use App\Filament\Resources\Products\Pages\ListProducts;
|
||||
use App\Filament\Resources\Products\Schemas\ProductForm;
|
||||
use App\Filament\Resources\Products\Tables\ProductsTable;
|
||||
use App\Models\Product;
|
||||
use BackedEnum;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Support\Icons\Heroicon;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class ProductResource extends Resource
|
||||
{
|
||||
protected static ?string $model = Product::class;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedBuildingStorefront;
|
||||
|
||||
protected static ?string $pluralLabel = 'Products';
|
||||
|
||||
protected static ?int $navigationSort = 1;
|
||||
|
||||
public static function getNavigationGroup(): ?string
|
||||
{
|
||||
return 'Management';
|
||||
}
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return ProductForm::configure($schema);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return ProductsTable::configure($table);
|
||||
}
|
||||
|
||||
public static function getRelations(): array
|
||||
{
|
||||
return [
|
||||
//
|
||||
];
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => ListProducts::route('/'),
|
||||
'create' => CreateProduct::route('/create'),
|
||||
'edit' => EditProduct::route('/{record}/edit'),
|
||||
];
|
||||
}
|
||||
}
|
||||
36
app/Filament/Resources/Products/Schemas/ProductForm.php
Normal file
36
app/Filament/Resources/Products/Schemas/ProductForm.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\Products\Schemas;
|
||||
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Schema;
|
||||
|
||||
class ProductForm
|
||||
{
|
||||
public static function configure(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->components([
|
||||
Section::make()
|
||||
->schema([
|
||||
TextInput::make('name')
|
||||
->required()
|
||||
->maxLength(255),
|
||||
Textarea::make('description')
|
||||
->columnSpanFull(),
|
||||
Select::make('status')
|
||||
->options([
|
||||
'active' => 'Active',
|
||||
'inactive' => 'Inactive',
|
||||
'sold_out' => 'Sold Out',
|
||||
])
|
||||
->default('active')
|
||||
->required(),
|
||||
])
|
||||
->columns(2),
|
||||
]);
|
||||
}
|
||||
}
|
||||
57
app/Filament/Resources/Products/Tables/ProductsTable.php
Normal file
57
app/Filament/Resources/Products/Tables/ProductsTable.php
Normal file
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\Products\Tables;
|
||||
|
||||
use Filament\Actions\BulkActionGroup;
|
||||
use Filament\Actions\DeleteBulkAction;
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class ProductsTable
|
||||
{
|
||||
public static function configure(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
TextColumn::make('name')
|
||||
->searchable()
|
||||
->sortable(),
|
||||
TextColumn::make('status')
|
||||
->badge()
|
||||
->color(fn(string $state): string => match ($state) {
|
||||
'active' => 'success',
|
||||
'inactive' => 'gray',
|
||||
'sold_out' => 'danger',
|
||||
default => 'gray',
|
||||
}),
|
||||
TextColumn::make('description')
|
||||
->limit(50)
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
TextColumn::make('customers_count')
|
||||
->counts('customers')
|
||||
->label('Owners'),
|
||||
TextColumn::make('created_at')
|
||||
->dateTime()
|
||||
->sortable()
|
||||
->toggleable(),
|
||||
])
|
||||
->filters([
|
||||
SelectFilter::make('status')
|
||||
->options([
|
||||
'active' => 'Active',
|
||||
'inactive' => 'Inactive',
|
||||
'sold_out' => 'Sold Out',
|
||||
]),
|
||||
])
|
||||
->recordActions([
|
||||
EditAction::make(),
|
||||
])
|
||||
->toolbarActions([
|
||||
BulkActionGroup::make([
|
||||
DeleteBulkAction::make(),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
59
app/Filament/Widgets/AvgProcessingTimeWidget.php
Normal file
59
app/Filament/Widgets/AvgProcessingTimeWidget.php
Normal file
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Widgets;
|
||||
|
||||
use App\Models\Department;
|
||||
use App\Models\Feedback;
|
||||
use Filament\Widgets\StatsOverviewWidget as BaseWidget;
|
||||
use Filament\Widgets\StatsOverviewWidget\Stat;
|
||||
|
||||
class AvgProcessingTimeWidget extends BaseWidget
|
||||
{
|
||||
protected function getStats(): array
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
$query = Feedback::query()
|
||||
->whereNotNull('assigned_to')
|
||||
->whereNotNull('updated_at');
|
||||
|
||||
if ($user->hasRole('manager')) {
|
||||
$deptIds = Department::where('manager_id', $user->id)->pluck('id');
|
||||
$query->whereIn('current_department_id', $deptIds);
|
||||
}
|
||||
|
||||
$totalResolved = (clone $query)->whereIn('status', ['resolved', 'closed'])->count();
|
||||
|
||||
$avgMinutes = (clone $query)
|
||||
->whereIn('status', ['resolved', 'closed'])
|
||||
->selectRaw('AVG(strftime("%s", updated_at) - strftime("%s", created_at)) / 60 as avg_minutes')
|
||||
->value('avg_minutes');
|
||||
|
||||
$totalPending = (clone $query)->whereNotIn('status', ['resolved', 'closed'])->count();
|
||||
|
||||
$escalatedCount = (clone $query)->where('is_escalated', true)->count();
|
||||
|
||||
return [
|
||||
Stat::make('Resolved Tickets', (string) $totalResolved)
|
||||
->description('Total resolved/closed tickets')
|
||||
->color('success'),
|
||||
|
||||
Stat::make('Avg Processing Time', $avgMinutes ? round($avgMinutes) . ' min' : 'N/A')
|
||||
->description('Average time from creation to resolution')
|
||||
->color('warning'),
|
||||
|
||||
Stat::make('Pending Tickets', (string) $totalPending)
|
||||
->description('Still in progress')
|
||||
->color('danger'),
|
||||
|
||||
Stat::make('Escalated', (string) $escalatedCount)
|
||||
->description('Tickets marked as escalated')
|
||||
->color('gray'),
|
||||
];
|
||||
}
|
||||
|
||||
public static function canView(): bool
|
||||
{
|
||||
return auth()->user()?->hasRole(['admin', 'manager']) ?? false;
|
||||
}
|
||||
}
|
||||
59
app/Filament/Widgets/HandlerPerformanceWidget.php
Normal file
59
app/Filament/Widgets/HandlerPerformanceWidget.php
Normal file
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Widgets;
|
||||
|
||||
use App\Models\Department;
|
||||
use App\Models\Feedback;
|
||||
use Filament\Widgets\ChartWidget;
|
||||
|
||||
class HandlerPerformanceWidget extends ChartWidget
|
||||
{
|
||||
protected ?string $heading = 'Avg Processing Time by Handler';
|
||||
|
||||
protected int|string|array $columnSpan = 'full';
|
||||
|
||||
protected function getType(): string
|
||||
{
|
||||
return 'bar';
|
||||
}
|
||||
|
||||
protected function getData(): array
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
$query = Feedback::query()
|
||||
->whereIn('status', ['resolved', 'closed'])
|
||||
->whereNotNull('assigned_to');
|
||||
|
||||
if ($user->hasRole('manager')) {
|
||||
$deptIds = Department::where('manager_id', $user->id)->pluck('id');
|
||||
$query->whereIn('current_department_id', $deptIds);
|
||||
}
|
||||
|
||||
$handlers = (clone $query)
|
||||
->select('assigned_to')
|
||||
->selectRaw('AVG(strftime("%s", updated_at) - strftime("%s", created_at)) / 60 as avg_minutes')
|
||||
->groupBy('assigned_to')
|
||||
->with('assignedTo')
|
||||
->get();
|
||||
|
||||
$labels = $handlers->map(fn ($h) => $h->assignedTo?->name ?? 'Unknown')->toArray();
|
||||
$data = $handlers->map(fn ($h) => round($h->avg_minutes, 1))->toArray();
|
||||
|
||||
return [
|
||||
'datasets' => [
|
||||
[
|
||||
'label' => 'Avg Time (minutes)',
|
||||
'data' => $data,
|
||||
'backgroundColor' => '#3b82f6',
|
||||
],
|
||||
],
|
||||
'labels' => $labels,
|
||||
];
|
||||
}
|
||||
|
||||
public static function canView(): bool
|
||||
{
|
||||
return auth()->user()?->hasRole(['admin', 'manager']) ?? false;
|
||||
}
|
||||
}
|
||||
59
app/Filament/Widgets/ResolvedTicketsWidget.php
Normal file
59
app/Filament/Widgets/ResolvedTicketsWidget.php
Normal file
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Widgets;
|
||||
|
||||
use App\Filament\Resources\Feedback\FeedbackResource;
|
||||
use App\Models\Department;
|
||||
use App\Models\Feedback;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Table;
|
||||
use Filament\Widgets\TableWidget as BaseWidget;
|
||||
|
||||
class ResolvedTicketsWidget extends BaseWidget
|
||||
{
|
||||
protected int|string|array $columnSpan = 'full';
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->query(
|
||||
Feedback::query()
|
||||
->where('status', 'resolved')
|
||||
->when(
|
||||
auth()->user()->hasRole('manager'),
|
||||
function ($q) {
|
||||
$deptIds = Department::where('manager_id', auth()->id())->pluck('id');
|
||||
return $q->whereIn('current_department_id', $deptIds);
|
||||
}
|
||||
)
|
||||
)
|
||||
->columns([
|
||||
TextColumn::make('id')
|
||||
->label('#')
|
||||
->sortable(),
|
||||
TextColumn::make('title')
|
||||
->label('Ticket')
|
||||
->searchable()
|
||||
->url(fn (Feedback $record): string => FeedbackResource::getUrl('edit', ['record' => $record])),
|
||||
TextColumn::make('customer.name')
|
||||
->label('Customer')
|
||||
->searchable(),
|
||||
TextColumn::make('assignedTo.name')
|
||||
->label('Handler'),
|
||||
TextColumn::make('currentDepartment.name')
|
||||
->label('Department'),
|
||||
TextColumn::make('updated_at')
|
||||
->label('Resolved Date')
|
||||
->dateTime('d/m/Y')
|
||||
->sortable(),
|
||||
])
|
||||
->defaultSort('updated_at', 'desc')
|
||||
->heading('Tickets Awaiting Closure (Resolved)')
|
||||
->description('Tickets that have been resolved and are pending manager review for closure.');
|
||||
}
|
||||
|
||||
public static function canView(): bool
|
||||
{
|
||||
return auth()->user()?->hasRole(['admin', 'manager']) ?? false;
|
||||
}
|
||||
}
|
||||
24
app/Models/Customer.php
Normal file
24
app/Models/Customer.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
#[Fillable(['name', 'email', 'phone', 'address', 'status'])]
|
||||
class Customer extends Model
|
||||
{
|
||||
public function products(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Product::class, 'customer_product')
|
||||
->withPivot('purchase_date')
|
||||
->withTimestamps();
|
||||
}
|
||||
|
||||
public function feedbacks(): HasMany
|
||||
{
|
||||
return $this->hasMany(Feedback::class);
|
||||
}
|
||||
}
|
||||
40
app/Models/CustomerProduct.php
Normal file
40
app/Models/CustomerProduct.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class CustomerProduct extends Model
|
||||
{
|
||||
protected $table = 'customer_product';
|
||||
|
||||
protected $fillable = [
|
||||
'customer_id',
|
||||
'product_id',
|
||||
'purchase_date',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'purchase_date' => 'date',
|
||||
];
|
||||
}
|
||||
|
||||
public function customer(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Customer::class);
|
||||
}
|
||||
|
||||
public function product(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Product::class);
|
||||
}
|
||||
|
||||
public function feedbacks(): HasMany
|
||||
{
|
||||
return $this->hasMany(Feedback::class);
|
||||
}
|
||||
}
|
||||
32
app/Models/Department.php
Normal file
32
app/Models/Department.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
#[Fillable(['name', 'manager_id'])]
|
||||
class Department extends Model
|
||||
{
|
||||
public function manager(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'manager_id');
|
||||
}
|
||||
|
||||
public function feedbacks(): HasMany
|
||||
{
|
||||
return $this->hasMany(Feedback::class, 'current_department_id');
|
||||
}
|
||||
|
||||
public function transferLogsFrom(): HasMany
|
||||
{
|
||||
return $this->hasMany(TicketTransferLog::class, 'from_department_id');
|
||||
}
|
||||
|
||||
public function transferLogsTo(): HasMany
|
||||
{
|
||||
return $this->hasMany(TicketTransferLog::class, 'to_department_id');
|
||||
}
|
||||
}
|
||||
58
app/Models/Feedback.php
Normal file
58
app/Models/Feedback.php
Normal file
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
#[Fillable(['customer_id', 'customer_product_id', 'feedback_channel_id', 'assigned_to', 'title', 'content', 'status', 'current_department_id', 'is_escalated'])]
|
||||
class Feedback extends Model
|
||||
{
|
||||
public function customer(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Customer::class);
|
||||
}
|
||||
|
||||
public function customerProduct(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(CustomerProduct::class, 'customer_product_id');
|
||||
}
|
||||
|
||||
public function feedbackChannel(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(FeedbackChannel::class);
|
||||
}
|
||||
|
||||
public function assignedTo(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'assigned_to');
|
||||
}
|
||||
|
||||
public function currentDepartment(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Department::class, 'current_department_id');
|
||||
}
|
||||
|
||||
public function transferLogs(): HasMany
|
||||
{
|
||||
return $this->hasMany(TicketTransferLog::class);
|
||||
}
|
||||
|
||||
public function interactions(): HasMany
|
||||
{
|
||||
return $this->hasMany(FeedbackInteraction::class)->latest();
|
||||
}
|
||||
|
||||
public function attachments(): HasMany
|
||||
{
|
||||
return $this->hasMany(FeedbackAttachment::class);
|
||||
}
|
||||
|
||||
public function tags(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(FeedbackTag::class, 'feedback_feedback_tag');
|
||||
}
|
||||
}
|
||||
36
app/Models/FeedbackAttachment.php
Normal file
36
app/Models/FeedbackAttachment.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
#[Fillable(['uuid', 'feedback_id', 'feedback_interaction_id', 'user_id', 'name', 'path', 'disk', 'collection', 'mime_type', 'size'])]
|
||||
class FeedbackAttachment extends Model
|
||||
{
|
||||
public function feedback(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Feedback::class);
|
||||
}
|
||||
|
||||
public function interaction(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(FeedbackInteraction::class, 'feedback_interaction_id');
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function scopeByCollection($query, string $collection)
|
||||
{
|
||||
return $query->where('collection', $collection);
|
||||
}
|
||||
|
||||
public function scopeByDisk($query, string $disk)
|
||||
{
|
||||
return $query->where('disk', $disk);
|
||||
}
|
||||
}
|
||||
23
app/Models/FeedbackChannel.php
Normal file
23
app/Models/FeedbackChannel.php
Normal file
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
#[Fillable(['name', 'slug', 'icon', 'is_active'])]
|
||||
class FeedbackChannel extends Model
|
||||
{
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'is_active' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
public function feedbacks(): HasMany
|
||||
{
|
||||
return $this->hasMany(Feedback::class);
|
||||
}
|
||||
}
|
||||
34
app/Models/FeedbackInteraction.php
Normal file
34
app/Models/FeedbackInteraction.php
Normal file
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
#[Fillable(['feedback_id', 'user_id', 'type', 'content', 'changes'])]
|
||||
class FeedbackInteraction extends Model
|
||||
{
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'changes' => 'array',
|
||||
];
|
||||
}
|
||||
|
||||
public function feedback(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Feedback::class);
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function attachments(): HasMany
|
||||
{
|
||||
return $this->hasMany(FeedbackAttachment::class, 'feedback_interaction_id');
|
||||
}
|
||||
}
|
||||
16
app/Models/FeedbackTag.php
Normal file
16
app/Models/FeedbackTag.php
Normal file
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
|
||||
#[Fillable(['name', 'slug', 'color'])]
|
||||
class FeedbackTag extends Model
|
||||
{
|
||||
public function feedbacks(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Feedback::class, 'feedback_feedback_tag');
|
||||
}
|
||||
}
|
||||
18
app/Models/Product.php
Normal file
18
app/Models/Product.php
Normal file
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
|
||||
#[Fillable(['name', 'description', 'status'])]
|
||||
class Product extends Model
|
||||
{
|
||||
public function customers(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Customer::class, 'customer_product')
|
||||
->withPivot('purchase_date')
|
||||
->withTimestamps();
|
||||
}
|
||||
}
|
||||
31
app/Models/TicketTransferLog.php
Normal file
31
app/Models/TicketTransferLog.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
#[Fillable(['feedback_id', 'from_department_id', 'to_department_id', 'sender_id', 'reason'])]
|
||||
class TicketTransferLog extends Model
|
||||
{
|
||||
public function feedback(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Feedback::class);
|
||||
}
|
||||
|
||||
public function fromDepartment(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Department::class, 'from_department_id');
|
||||
}
|
||||
|
||||
public function toDepartment(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Department::class, 'to_department_id');
|
||||
}
|
||||
|
||||
public function sender(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'sender_id');
|
||||
}
|
||||
}
|
||||
@@ -2,26 +2,25 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
// use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Database\Factories\UserFactory;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Attributes\Hidden;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Spatie\Permission\Traits\HasRoles;
|
||||
use Illuminate\Contracts\Auth\Access\Authorizable;
|
||||
use Filament\Models\Contracts\FilamentUser;
|
||||
use Filament\Panel;
|
||||
|
||||
#[Fillable(['name', 'email', 'password'])]
|
||||
#[Fillable(['name', 'email', 'password', 'role'])]
|
||||
#[Hidden(['password', 'remember_token'])]
|
||||
class User extends Authenticatable
|
||||
class User extends Authenticatable implements FilamentUser
|
||||
{
|
||||
/** @use HasFactory<UserFactory> */
|
||||
use HasFactory, Notifiable;
|
||||
use HasFactory, Notifiable, HasRoles;
|
||||
|
||||
/**
|
||||
* Get the attributes that should be cast.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
@@ -29,4 +28,24 @@ class User extends Authenticatable
|
||||
'password' => 'hashed',
|
||||
];
|
||||
}
|
||||
|
||||
public function canAccessPanel(Panel $panel): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function assignedFeedbacks(): HasMany
|
||||
{
|
||||
return $this->hasMany(Feedback::class, 'assigned_to');
|
||||
}
|
||||
|
||||
public function isAdmin(): bool
|
||||
{
|
||||
return $this->hasRole('admin');
|
||||
}
|
||||
|
||||
public function isManager(): bool
|
||||
{
|
||||
return $this->hasRole('manager');
|
||||
}
|
||||
}
|
||||
|
||||
46
app/Notifications/TicketClosed.php
Normal file
46
app/Notifications/TicketClosed.php
Normal file
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
use App\Models\Feedback;
|
||||
use App\Models\User;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Notifications\Notification;
|
||||
|
||||
class TicketClosed extends Notification
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public function __construct(
|
||||
public Feedback $feedback,
|
||||
public User $actor,
|
||||
) {}
|
||||
|
||||
public function via(object $notifiable): array
|
||||
{
|
||||
return ['database', 'mail'];
|
||||
}
|
||||
|
||||
public function toMail(object $notifiable): MailMessage
|
||||
{
|
||||
return (new MailMessage)
|
||||
->subject(__('Ticket #{id} đã được đóng', ['id' => $this->feedback->id]))
|
||||
->line(__('Ticket: :title', ['title' => $this->feedback->title]))
|
||||
->line(__('Đóng bởi: :name', ['name' => $this->actor->name]))
|
||||
->action(__('Xem Ticket'), url('/admin/feedbacks/' . $this->feedback->id . '/edit'));
|
||||
}
|
||||
|
||||
public function toDatabase(object $notifiable): array
|
||||
{
|
||||
return [
|
||||
'message' => __('Ticket #{id} ":title" đã được đóng bởi :actor.', [
|
||||
'id' => $this->feedback->id,
|
||||
'title' => $this->feedback->title,
|
||||
'actor' => $this->actor->name,
|
||||
]),
|
||||
'feedback_id' => $this->feedback->id,
|
||||
'actor_name' => $this->actor->name,
|
||||
];
|
||||
}
|
||||
}
|
||||
51
app/Notifications/TicketTransferred.php
Normal file
51
app/Notifications/TicketTransferred.php
Normal file
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
use App\Models\Feedback;
|
||||
use App\Models\TicketTransferLog;
|
||||
use App\Models\User;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Notifications\Notification;
|
||||
|
||||
class TicketTransferred extends Notification
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public function __construct(
|
||||
public Feedback $feedback,
|
||||
public TicketTransferLog $transferLog,
|
||||
public User $sender,
|
||||
) {}
|
||||
|
||||
public function via(object $notifiable): array
|
||||
{
|
||||
return ['database', 'mail'];
|
||||
}
|
||||
|
||||
public function toMail(object $notifiable): MailMessage
|
||||
{
|
||||
return (new MailMessage)
|
||||
->subject(__('Ticket #{id} đã được chuyển đến phòng của bạn', ['id' => $this->feedback->id]))
|
||||
->line(__('Ticket: :title', ['title' => $this->feedback->title]))
|
||||
->line(__('Người chuyển: :name', ['name' => $this->sender->name]))
|
||||
->line(__('Lý do: :reason', ['reason' => $this->transferLog->reason]))
|
||||
->action(__('Xem Ticket'), url('/admin/feedbacks/' . $this->feedback->id . '/edit'));
|
||||
}
|
||||
|
||||
public function toDatabase(object $notifiable): array
|
||||
{
|
||||
return [
|
||||
'message' => __('Ticket #{id} ":title" đã được chuyển đến phòng của bạn bởi :sender.', [
|
||||
'id' => $this->feedback->id,
|
||||
'title' => $this->feedback->title,
|
||||
'sender' => $this->sender->name,
|
||||
]),
|
||||
'feedback_id' => $this->feedback->id,
|
||||
'sender_name' => $this->sender->name,
|
||||
'reason' => $this->transferLog->reason,
|
||||
'to_department_id' => $this->transferLog->to_department_id,
|
||||
];
|
||||
}
|
||||
}
|
||||
44
app/Policies/CustomerPolicy.php
Normal file
44
app/Policies/CustomerPolicy.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\Customer;
|
||||
use App\Models\User;
|
||||
|
||||
class CustomerPolicy
|
||||
{
|
||||
public function viewAny(User $user): bool
|
||||
{
|
||||
return $user->hasRole(['admin', 'manager', 'staff']);
|
||||
}
|
||||
|
||||
public function view(User $user, Customer $customer): bool
|
||||
{
|
||||
return $user->hasRole(['admin', 'manager', 'staff']);
|
||||
}
|
||||
|
||||
public function create(User $user): bool
|
||||
{
|
||||
return $user->hasRole(['admin', 'manager']);
|
||||
}
|
||||
|
||||
public function update(User $user, Customer $customer): bool
|
||||
{
|
||||
return $user->hasRole(['admin', 'manager']);
|
||||
}
|
||||
|
||||
public function delete(User $user, Customer $customer): bool
|
||||
{
|
||||
return $user->hasRole('admin');
|
||||
}
|
||||
|
||||
public function restore(User $user, Customer $customer): bool
|
||||
{
|
||||
return $user->hasRole('admin');
|
||||
}
|
||||
|
||||
public function forceDelete(User $user, Customer $customer): bool
|
||||
{
|
||||
return $user->hasRole('admin');
|
||||
}
|
||||
}
|
||||
44
app/Policies/FeedbackChannelPolicy.php
Normal file
44
app/Policies/FeedbackChannelPolicy.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\FeedbackChannel;
|
||||
use App\Models\User;
|
||||
|
||||
class FeedbackChannelPolicy
|
||||
{
|
||||
public function viewAny(User $user): bool
|
||||
{
|
||||
return in_array($user->role, ['admin', 'manager', 'staff']);
|
||||
}
|
||||
|
||||
public function view(User $user, FeedbackChannel $feedbackChannel): bool
|
||||
{
|
||||
return in_array($user->role, ['admin', 'manager', 'staff']);
|
||||
}
|
||||
|
||||
public function create(User $user): bool
|
||||
{
|
||||
return in_array($user->role, ['admin', 'manager']);
|
||||
}
|
||||
|
||||
public function update(User $user, FeedbackChannel $feedbackChannel): bool
|
||||
{
|
||||
return in_array($user->role, ['admin', 'manager']);
|
||||
}
|
||||
|
||||
public function delete(User $user, FeedbackChannel $feedbackChannel): bool
|
||||
{
|
||||
return $user->role === 'admin';
|
||||
}
|
||||
|
||||
public function restore(User $user, FeedbackChannel $feedbackChannel): bool
|
||||
{
|
||||
return $user->role === 'admin';
|
||||
}
|
||||
|
||||
public function forceDelete(User $user, FeedbackChannel $feedbackChannel): bool
|
||||
{
|
||||
return $user->role === 'admin';
|
||||
}
|
||||
}
|
||||
44
app/Policies/FeedbackPolicy.php
Normal file
44
app/Policies/FeedbackPolicy.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\Feedback;
|
||||
use App\Models\User;
|
||||
|
||||
class FeedbackPolicy
|
||||
{
|
||||
public function viewAny(User $user): bool
|
||||
{
|
||||
return $user->hasRole(['admin', 'manager', 'staff']);
|
||||
}
|
||||
|
||||
public function view(User $user, Feedback $feedback): bool
|
||||
{
|
||||
return $user->hasRole(['admin', 'manager', 'staff']);
|
||||
}
|
||||
|
||||
public function create(User $user): bool
|
||||
{
|
||||
return $user->hasRole(['admin', 'manager', 'staff']);
|
||||
}
|
||||
|
||||
public function update(User $user, Feedback $feedback): bool
|
||||
{
|
||||
return $user->hasRole(['admin', 'manager', 'staff']);
|
||||
}
|
||||
|
||||
public function delete(User $user, Feedback $feedback): bool
|
||||
{
|
||||
return $user->hasRole(['admin', 'manager']);
|
||||
}
|
||||
|
||||
public function restore(User $user, Feedback $feedback): bool
|
||||
{
|
||||
return $user->hasRole('admin');
|
||||
}
|
||||
|
||||
public function forceDelete(User $user, Feedback $feedback): bool
|
||||
{
|
||||
return $user->hasRole('admin');
|
||||
}
|
||||
}
|
||||
44
app/Policies/ProductPolicy.php
Normal file
44
app/Policies/ProductPolicy.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\Product;
|
||||
use App\Models\User;
|
||||
|
||||
class ProductPolicy
|
||||
{
|
||||
public function viewAny(User $user): bool
|
||||
{
|
||||
return $user->hasRole(['admin', 'manager', 'staff']);
|
||||
}
|
||||
|
||||
public function view(User $user, Product $product): bool
|
||||
{
|
||||
return $user->hasRole(['admin', 'manager', 'staff']);
|
||||
}
|
||||
|
||||
public function create(User $user): bool
|
||||
{
|
||||
return $user->hasRole(['admin', 'manager']);
|
||||
}
|
||||
|
||||
public function update(User $user, Product $product): bool
|
||||
{
|
||||
return $user->hasRole(['admin', 'manager']);
|
||||
}
|
||||
|
||||
public function delete(User $user, Product $product): bool
|
||||
{
|
||||
return $user->hasRole('admin');
|
||||
}
|
||||
|
||||
public function restore(User $user, Product $product): bool
|
||||
{
|
||||
return $user->hasRole('admin');
|
||||
}
|
||||
|
||||
public function forceDelete(User $user, Product $product): bool
|
||||
{
|
||||
return $user->hasRole('admin');
|
||||
}
|
||||
}
|
||||
65
app/Providers/Filament/AdminPanelProvider.php
Normal file
65
app/Providers/Filament/AdminPanelProvider.php
Normal file
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
namespace App\Providers\Filament;
|
||||
|
||||
use Filament\Http\Middleware\Authenticate;
|
||||
use Filament\Http\Middleware\AuthenticateSession;
|
||||
use Filament\Http\Middleware\DisableBladeIconComponents;
|
||||
use Filament\Http\Middleware\DispatchServingFilamentEvent;
|
||||
use Filament\Navigation\NavigationGroup;
|
||||
use Filament\Pages\Dashboard;
|
||||
use Filament\Panel;
|
||||
use Filament\PanelProvider;
|
||||
use Filament\Support\Colors\Color;
|
||||
use Filament\Widgets\AccountWidget;
|
||||
use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
|
||||
use Illuminate\Cookie\Middleware\EncryptCookies;
|
||||
use Illuminate\Foundation\Http\Middleware\PreventRequestForgery;
|
||||
use Illuminate\Routing\Middleware\SubstituteBindings;
|
||||
use Illuminate\Session\Middleware\StartSession;
|
||||
use Illuminate\View\Middleware\ShareErrorsFromSession;
|
||||
|
||||
class AdminPanelProvider extends PanelProvider
|
||||
{
|
||||
public function panel(Panel $panel): Panel
|
||||
{
|
||||
return $panel
|
||||
->default()
|
||||
->id('admin')
|
||||
->path('admin')
|
||||
->login()
|
||||
->brandName('AfterSales CRM')
|
||||
->colors([
|
||||
'primary' => Color::Blue,
|
||||
])
|
||||
->discoverResources(in: app_path('Filament/Resources'), for: 'App\Filament\Resources')
|
||||
->discoverPages(in: app_path('Filament/Pages'), for: 'App\Filament\Pages')
|
||||
->pages([
|
||||
Dashboard::class,
|
||||
])
|
||||
->discoverWidgets(in: app_path('Filament/Widgets'), for: 'App\Filament\Widgets')
|
||||
->widgets([
|
||||
AccountWidget::class,
|
||||
])
|
||||
->navigationGroups([
|
||||
NavigationGroup::make()
|
||||
->label('Customer Care'),
|
||||
NavigationGroup::make()
|
||||
->label('Management'),
|
||||
])
|
||||
->middleware([
|
||||
EncryptCookies::class,
|
||||
AddQueuedCookiesToResponse::class,
|
||||
StartSession::class,
|
||||
AuthenticateSession::class,
|
||||
ShareErrorsFromSession::class,
|
||||
PreventRequestForgery::class,
|
||||
SubstituteBindings::class,
|
||||
DisableBladeIconComponents::class,
|
||||
DispatchServingFilamentEvent::class,
|
||||
])
|
||||
->authMiddleware([
|
||||
Authenticate::class,
|
||||
]);
|
||||
}
|
||||
}
|
||||
32
app/Rules/RequireProofOfResolution.php
Normal file
32
app/Rules/RequireProofOfResolution.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Rules;
|
||||
|
||||
use App\Models\Feedback;
|
||||
use App\Services\FileService;
|
||||
use Closure;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Support\Facades\App;
|
||||
|
||||
class RequireProofOfResolution implements ValidationRule
|
||||
{
|
||||
protected Feedback $feedback;
|
||||
|
||||
public function __construct(Feedback $feedback)
|
||||
{
|
||||
$this->feedback = $feedback;
|
||||
}
|
||||
|
||||
public function validate(string $attribute, mixed $value, Closure $fail): void
|
||||
{
|
||||
if ($value !== 'closed') {
|
||||
return;
|
||||
}
|
||||
|
||||
$fileService = App::make(FileService::class);
|
||||
|
||||
if (! $fileService->hasCollection($this->feedback, 'proof_of_resolution')) {
|
||||
$fail(__('Yêu cầu cung cấp tài liệu bằng chứng để hoàn tất quy trình.'));
|
||||
}
|
||||
}
|
||||
}
|
||||
74
app/Services/ClosingService.php
Normal file
74
app/Services/ClosingService.php
Normal file
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Department;
|
||||
use App\Models\Feedback;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\App;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||
|
||||
class ClosingService
|
||||
{
|
||||
/**
|
||||
* Close a feedback. Throws if staff tries to close or no proof exists.
|
||||
*/
|
||||
public function close(Feedback $feedback, User $actor): void
|
||||
{
|
||||
if ($feedback->status === 'closed') {
|
||||
throw ValidationException::withMessages([
|
||||
'status' => __('Phiếu này đã được đóng.'),
|
||||
]);
|
||||
}
|
||||
|
||||
if ($actor->hasRole('staff')) {
|
||||
throw new HttpException(403, __('Chỉ lãnh đạo cấp phòng mới có quyền đóng phiếu.'));
|
||||
}
|
||||
|
||||
if ($actor->hasRole('manager') && ! $actor->hasRole('admin')) {
|
||||
$managedDeptIds = Department::where('manager_id', $actor->id)->pluck('id');
|
||||
if (! $managedDeptIds->contains($feedback->current_department_id)) {
|
||||
throw new HttpException(403, __('Bạn chỉ có thể đóng phiếu thuộc phòng ban mình quản lý.'));
|
||||
}
|
||||
}
|
||||
|
||||
$fileService = App::make(FileService::class);
|
||||
|
||||
if (! $fileService->hasCollection($feedback, 'proof_of_resolution')) {
|
||||
throw ValidationException::withMessages([
|
||||
'status' => __('Yêu cầu cung cấp tài liệu bằng chứng để hoàn tất quy trình.'),
|
||||
]);
|
||||
}
|
||||
|
||||
$feedback->update(['status' => 'closed']);
|
||||
|
||||
$this->notifyStakeholders($feedback, $actor);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a feedback. Any role can resolve.
|
||||
*/
|
||||
public function resolve(Feedback $feedback, User $actor): void
|
||||
{
|
||||
if (in_array($feedback->status, ['closed', 'resolved'])) {
|
||||
throw ValidationException::withMessages([
|
||||
'status' => __('Phiếu này đã được xử lý hoặc đóng.'),
|
||||
]);
|
||||
}
|
||||
|
||||
$feedback->update(['status' => 'resolved']);
|
||||
}
|
||||
|
||||
protected function notifyStakeholders(Feedback $feedback, User $actor): void
|
||||
{
|
||||
if ($feedback->assignedTo) {
|
||||
$feedback->assignedTo->notify(new \App\Notifications\TicketClosed($feedback, $actor));
|
||||
}
|
||||
|
||||
$department = $feedback->currentDepartment;
|
||||
if ($department && $department->manager && $department->manager->id !== ($feedback->assignedTo?->id ?? null)) {
|
||||
$department->manager->notify(new \App\Notifications\TicketClosed($feedback, $actor));
|
||||
}
|
||||
}
|
||||
}
|
||||
125
app/Services/FileService.php
Normal file
125
app/Services/FileService.php
Normal file
@@ -0,0 +1,125 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\FeedbackAttachment;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class FileService
|
||||
{
|
||||
protected array $allowedMimes = [
|
||||
'image/jpeg',
|
||||
'image/jpg',
|
||||
'image/png',
|
||||
'application/pdf',
|
||||
'video/mp4',
|
||||
'video/quicktime',
|
||||
'video/mov',
|
||||
];
|
||||
|
||||
protected int $maxSize = 20480;
|
||||
|
||||
protected string $defaultDisk = 'local';
|
||||
|
||||
/**
|
||||
* Upload files and create FeedbackAttachment records.
|
||||
*/
|
||||
public function upload(
|
||||
array $files,
|
||||
string $collection,
|
||||
Model $model,
|
||||
int $userId,
|
||||
?int $interactionId = null,
|
||||
string $disk = 'local',
|
||||
): array {
|
||||
$records = [];
|
||||
|
||||
foreach ($files as $file) {
|
||||
if (! $file instanceof UploadedFile || ! $file->isValid()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->validate($file);
|
||||
|
||||
$path = $file->store('feedback-attachments', $disk);
|
||||
|
||||
$records[] = FeedbackAttachment::create([
|
||||
'uuid' => (string) Str::uuid(),
|
||||
'feedback_id' => $model instanceof \App\Models\Feedback ? $model->id : null,
|
||||
'feedback_interaction_id' => $interactionId,
|
||||
'user_id' => $userId,
|
||||
'name' => $file->getClientOriginalName(),
|
||||
'path' => $path,
|
||||
'disk' => $disk,
|
||||
'collection' => $collection,
|
||||
'mime_type' => $file->getMimeType(),
|
||||
'size' => $file->getSize(),
|
||||
]);
|
||||
}
|
||||
|
||||
return $records;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a temporary URL for a file on a private disk.
|
||||
*/
|
||||
public function getTemporaryUrl(FeedbackAttachment $attachment, int $minutes = 10): string
|
||||
{
|
||||
if ($attachment->disk === 'public') {
|
||||
return Storage::disk('public')->url($attachment->path);
|
||||
}
|
||||
|
||||
return URL::temporarySignedRoute(
|
||||
'storage.local',
|
||||
now()->addMinutes($minutes),
|
||||
['path' => $attachment->path]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all attachments of a specific collection for a model.
|
||||
*/
|
||||
public function getByCollection(Model $model, string $collection)
|
||||
{
|
||||
$query = FeedbackAttachment::byCollection($collection);
|
||||
|
||||
if ($model instanceof \App\Models\Feedback) {
|
||||
$query->where('feedback_id', $model->id);
|
||||
}
|
||||
|
||||
return $query->orderBy('created_at', 'desc')->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a model has at least one attachment in the given collection.
|
||||
*/
|
||||
public function hasCollection(Model $model, string $collection): bool
|
||||
{
|
||||
return $this->getByCollection($model, $collection)->isNotEmpty();
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate file type and size.
|
||||
*/
|
||||
protected function validate(UploadedFile $file): void
|
||||
{
|
||||
$errors = [];
|
||||
|
||||
if (! in_array($file->getMimeType(), $this->allowedMimes)) {
|
||||
$errors['file'] = __('File type :type is not allowed. Allowed: jpg, png, pdf, mp4, mov.', ['type' => $file->getMimeType()]);
|
||||
}
|
||||
|
||||
if ($file->getSize() > $this->maxSize * 1024) {
|
||||
$errors['file'] = __('File size exceeds :max KB.', ['max' => $this->maxSize]);
|
||||
}
|
||||
|
||||
if (! empty($errors)) {
|
||||
throw ValidationException::withMessages($errors);
|
||||
}
|
||||
}
|
||||
}
|
||||
57
app/Services/TransferService.php
Normal file
57
app/Services/TransferService.php
Normal file
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Department;
|
||||
use App\Models\Feedback;
|
||||
use App\Models\TicketTransferLog;
|
||||
use App\Models\User;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class TransferService
|
||||
{
|
||||
/**
|
||||
* Transfer a feedback to another department.
|
||||
*/
|
||||
public function transfer(
|
||||
Feedback $feedback,
|
||||
Department $toDepartment,
|
||||
string $reason,
|
||||
User $sender,
|
||||
?int $newHandlerId = null,
|
||||
): TicketTransferLog {
|
||||
if (empty(trim($reason))) {
|
||||
throw ValidationException::withMessages([
|
||||
'reason' => __('Vui lòng nhập lý do chuyển tiếp.'),
|
||||
]);
|
||||
}
|
||||
|
||||
$fromDepartmentId = $feedback->current_department_id;
|
||||
|
||||
$log = TicketTransferLog::create([
|
||||
'feedback_id' => $feedback->id,
|
||||
'from_department_id' => $fromDepartmentId,
|
||||
'to_department_id' => $toDepartment->id,
|
||||
'sender_id' => $sender->id,
|
||||
'reason' => $reason,
|
||||
]);
|
||||
|
||||
$feedback->update([
|
||||
'current_department_id' => $toDepartment->id,
|
||||
'assigned_to' => $newHandlerId ?? $toDepartment->manager_id ?? null,
|
||||
]);
|
||||
|
||||
$this->notifyDepartmentManager($toDepartment, $feedback, $log, $sender);
|
||||
|
||||
return $log;
|
||||
}
|
||||
|
||||
protected function notifyDepartmentManager(Department $department, Feedback $feedback, TicketTransferLog $log, User $sender): void
|
||||
{
|
||||
$manager = $department->manager;
|
||||
|
||||
if ($manager) {
|
||||
$manager->notify(new \App\Notifications\TicketTransferred($feedback, $log, $sender));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user